From 18d773320c0b4ffc73c576bf7aca6c3e31ce6340 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 10 Mar 2026 23:06:18 -0300 Subject: [PATCH] Fix draft formation not preserving drag changes on save When dragging a draft formation's anchors, the changes were being stored in dragFormation and renderedFormations, but not synced back to draftFormation. When completeDrawing() was called to save, it used draftFormation.lines_json which still had the original horizontal line data. Now _endDrag() checks if the dragged formation is the draft (tbl_key === draftTblKey) and syncs lines_json back to draftFormation. Co-Authored-By: Claude Opus 4.5 --- src/static/formation_overlay.js | 155 +++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 52 deletions(-) diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js index 76afd38..9414b1d 100644 --- a/src/static/formation_overlay.js +++ b/src/static/formation_overlay.js @@ -222,7 +222,8 @@ class FormationOverlay { // Click handler for drawing this.container.addEventListener('click', (e) => { if (this.drawingMode) { - const coords = this._pixelToChart(e.offsetX, e.offsetY); + const local = this._eventToLocalCoords(e); + const coords = local ? this._pixelToChart(local.x, local.y) : null; if (coords) { this._handleDrawingClick(coords); } @@ -231,7 +232,8 @@ class FormationOverlay { // Mouse move for temp line preview this.container.addEventListener('mousemove', (e) => { - const coords = this._pixelToChart(e.offsetX, e.offsetY); + const local = this._eventToLocalCoords(e); + const coords = local ? this._pixelToChart(local.x, local.y) : null; // Channel preview (parallel line following mouse) if (this.drawingMode === 'channel' && this.channelStep === 1 && coords) { @@ -270,6 +272,24 @@ class FormationOverlay { }); } + /** + * Convert a mouse event to coordinates relative to the chart container. + * Uses client coordinates to avoid target-relative offset drift. + * @param {MouseEvent} event + * @returns {{x:number, y:number}|null} + */ + _eventToLocalCoords(event) { + if (!this.container || typeof event.clientX !== 'number' || typeof event.clientY !== 'number') { + return null; + } + + const rect = this.container.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + } + /** * Convert pixel coordinates to chart time/price. * @param {number} x - Pixel X @@ -994,8 +1014,8 @@ class FormationOverlay { const lineColor = (isChannel && line.isSecondary) ? this.channelSecondaryColor : color; // Draw the infinite line with a wider invisible hit area - const lineHitArea = this._createLineHitArea(line.point1, line.point2, formation.tbl_key); - const lineEl = this._createLine(line.point1, line.point2, lineColor, formation.tbl_key); + const lineHitArea = this._createLineHitArea(line.point1, line.point2, formation.tbl_key, index); + const lineEl = this._createLine(line.point1, line.point2, lineColor, formation.tbl_key, index); if (lineHitArea) { formationGroup.appendChild(lineHitArea); @@ -1080,9 +1100,10 @@ class FormationOverlay { * @param {Object} point1 - {time, price} * @param {Object} point2 - {time, price} * @param {string} tblKey - Formation tbl_key + * @param {number} lineIndex - Index of line in formation * @returns {SVGLineElement|null} */ - _createLineHitArea(point1, point2, tblKey) { + _createLineHitArea(point1, point2, tblKey, lineIndex) { const endpoints = this._getInfiniteLineEndpoints(point1, point2); if (!endpoints) return null; @@ -1094,6 +1115,8 @@ class FormationOverlay { line.setAttribute('stroke', 'transparent'); line.setAttribute('stroke-width', 20); // Wide hit area line.setAttribute('data-tbl-key', tblKey); + line.setAttribute('data-line-index', String(lineIndex)); + line.setAttribute('data-line-role', 'hit'); line.setAttribute('data-hit-area', 'true'); line.style.cursor = 'pointer'; @@ -1106,9 +1129,10 @@ class FormationOverlay { * @param {Object} point2 - {time, price} * @param {string} color - Line color * @param {string} tblKey - Formation tbl_key + * @param {number} lineIndex - Index of line in formation * @returns {SVGLineElement|null} */ - _createLine(point1, point2, color, tblKey) { + _createLine(point1, point2, color, tblKey, lineIndex) { const endpoints = this._getInfiniteLineEndpoints(point1, point2); if (!endpoints) return null; @@ -1120,6 +1144,8 @@ class FormationOverlay { line.setAttribute('stroke', color); line.setAttribute('stroke-width', 2); line.setAttribute('data-tbl-key', tblKey); + line.setAttribute('data-line-index', String(lineIndex)); + line.setAttribute('data-line-role', 'main'); line.setAttribute('data-point1-time', point1.time); line.setAttribute('data-point1-price', point1.price); line.setAttribute('data-point2-time', point2.time); @@ -1225,37 +1251,33 @@ class FormationOverlay { const linesData = JSON.parse(formation.lines_json || '{}'); const lines = linesData.lines || []; - // Find existing line elements (both visible lines and hit areas) - const lineElements = data.group.querySelectorAll('line'); - const circleElements = data.group.querySelectorAll('circle'); - // Update line positions - let lineIdx = 0; lines.forEach((line, index) => { const endpoints = this._getInfiniteLineEndpoints(line.point1, line.point2); - if (endpoints) { - // Update hit area (if exists) - if (lineElements[lineIdx]) { - lineElements[lineIdx].setAttribute('x1', endpoints.start.x); - lineElements[lineIdx].setAttribute('y1', endpoints.start.y); - lineElements[lineIdx].setAttribute('x2', endpoints.end.x); - lineElements[lineIdx].setAttribute('y2', endpoints.end.y); - lineIdx++; - } - // Update visible line (if exists) - if (lineElements[lineIdx]) { - lineElements[lineIdx].setAttribute('x1', endpoints.start.x); - lineElements[lineIdx].setAttribute('y1', endpoints.start.y); - lineElements[lineIdx].setAttribute('x2', endpoints.end.x); - lineElements[lineIdx].setAttribute('y2', endpoints.end.y); - lineIdx++; - } + if (!endpoints) return; + + const hitLine = data.group.querySelector( + `line[data-line-index="${index}"][data-line-role="hit"]` + ); + const mainLine = data.group.querySelector( + `line[data-line-index="${index}"][data-line-role="main"]` + ); + + if (hitLine) { + hitLine.setAttribute('x1', endpoints.start.x); + hitLine.setAttribute('y1', endpoints.start.y); + hitLine.setAttribute('x2', endpoints.end.x); + hitLine.setAttribute('y2', endpoints.end.y); + } + if (mainLine) { + mainLine.setAttribute('x1', endpoints.start.x); + mainLine.setAttribute('y1', endpoints.start.y); + mainLine.setAttribute('x2', endpoints.end.x); + mainLine.setAttribute('y2', endpoints.end.y); } }); - // Update anchor positions - // Use _getAnchorPixelPosition which handles anchors outside visible range - let anchorIdx = 0; + // Update anchor positions by stable selector (avoids NodeList order drift). lines.forEach((line, index) => { const isChannel = formation.formation_type === 'channel'; const centerPoint = line.center || { @@ -1264,33 +1286,49 @@ class FormationOverlay { }; if (isChannel && line.isSecondary) { - // Secondary line: only center anchor + // Secondary line: channel offset anchor only. const pixel = this._getAnchorPixelPosition(centerPoint, line.point1, line.point2); - if (pixel && circleElements[anchorIdx]) { - circleElements[anchorIdx].setAttribute('cx', pixel.x); - circleElements[anchorIdx].setAttribute('cy', pixel.y); - anchorIdx++; + const offsetAnchor = data.group.querySelector( + `circle[data-line-index="${index}"][data-point-key="channel_offset"]` + ); + if (pixel && offsetAnchor) { + offsetAnchor.setAttribute('cx', pixel.x); + offsetAnchor.setAttribute('cy', pixel.y); + offsetAnchor.setAttribute('data-time', centerPoint.time); + offsetAnchor.setAttribute('data-price', centerPoint.price); } } else { - // Primary/single line: 3 anchors (point1, center, point2) + // Primary/single line: point1, center, point2 anchors. const p1Pixel = this._getAnchorPixelPosition(line.point1, line.point1, line.point2); const centerPixel = this._getAnchorPixelPosition(centerPoint, line.point1, line.point2); const p2Pixel = this._getAnchorPixelPosition(line.point2, line.point1, line.point2); + const anchorPoint1 = data.group.querySelector( + `circle[data-line-index="${index}"][data-point-key="point1"]` + ); + const anchorCenter = data.group.querySelector( + `circle[data-line-index="${index}"][data-point-key="center"]` + ); + const anchorPoint2 = data.group.querySelector( + `circle[data-line-index="${index}"][data-point-key="point2"]` + ); - if (p1Pixel && circleElements[anchorIdx]) { - circleElements[anchorIdx].setAttribute('cx', p1Pixel.x); - circleElements[anchorIdx].setAttribute('cy', p1Pixel.y); - anchorIdx++; + if (p1Pixel && anchorPoint1) { + anchorPoint1.setAttribute('cx', p1Pixel.x); + anchorPoint1.setAttribute('cy', p1Pixel.y); + anchorPoint1.setAttribute('data-time', line.point1.time); + anchorPoint1.setAttribute('data-price', line.point1.price); } - if (centerPixel && circleElements[anchorIdx]) { - circleElements[anchorIdx].setAttribute('cx', centerPixel.x); - circleElements[anchorIdx].setAttribute('cy', centerPixel.y); - anchorIdx++; + if (centerPixel && anchorCenter) { + anchorCenter.setAttribute('cx', centerPixel.x); + anchorCenter.setAttribute('cy', centerPixel.y); + anchorCenter.setAttribute('data-time', centerPoint.time); + anchorCenter.setAttribute('data-price', centerPoint.price); } - if (p2Pixel && circleElements[anchorIdx]) { - circleElements[anchorIdx].setAttribute('cx', p2Pixel.x); - circleElements[anchorIdx].setAttribute('cy', p2Pixel.y); - anchorIdx++; + if (p2Pixel && anchorPoint2) { + anchorPoint2.setAttribute('cx', p2Pixel.x); + anchorPoint2.setAttribute('cy', p2Pixel.y); + anchorPoint2.setAttribute('data-time', line.point2.time); + anchorPoint2.setAttribute('data-price', line.point2.price); } } }); @@ -1332,11 +1370,18 @@ class FormationOverlay { const prevData = this.renderedFormations.get(this.selectedTblKey); if (prevData) { prevData.elements.forEach(el => { - if (el.tagName === 'line') { + if (el.tagName === 'line' && el.getAttribute('data-line-role') === 'main') { el.setAttribute('stroke', prevData.formation.color || this.defaultColor); } if (el.tagName === 'circle') { - el.setAttribute('fill', prevData.formation.color || this.defaultColor); + const pointKey = el.getAttribute('data-point-key'); + if (pointKey === 'center') { + el.setAttribute('fill', '#28a745'); + } else if (pointKey === 'channel_offset') { + el.setAttribute('fill', this.channelSecondaryColor); + } else { + el.setAttribute('fill', prevData.formation.color || this.defaultColor); + } } }); } @@ -1348,7 +1393,7 @@ class FormationOverlay { const data = this.renderedFormations.get(tblKey); if (data) { data.elements.forEach(el => { - if (el.tagName === 'line') { + if (el.tagName === 'line' && el.getAttribute('data-line-role') === 'main') { el.setAttribute('stroke', this.selectedColor); } if (el.tagName === 'circle') { @@ -1546,6 +1591,12 @@ class FormationOverlay { if (data) { data.formation = this.dragFormation; } + + // If dragging the draft formation, sync changes back to draftFormation + // so that completeDrawing() uses the modified positions + if (this.dragFormation.tbl_key === this.draftTblKey && this.draftFormation) { + this.draftFormation.lines_json = this.dragFormation.lines_json; + } } this.isDragging = false;