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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-10 23:06:18 -03:00
parent ee199022fa
commit 18d773320c
1 changed files with 103 additions and 52 deletions

View File

@ -222,7 +222,8 @@ class FormationOverlay {
// Click handler for drawing // Click handler for drawing
this.container.addEventListener('click', (e) => { this.container.addEventListener('click', (e) => {
if (this.drawingMode) { 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) { if (coords) {
this._handleDrawingClick(coords); this._handleDrawingClick(coords);
} }
@ -231,7 +232,8 @@ class FormationOverlay {
// Mouse move for temp line preview // Mouse move for temp line preview
this.container.addEventListener('mousemove', (e) => { 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) // Channel preview (parallel line following mouse)
if (this.drawingMode === 'channel' && this.channelStep === 1 && coords) { 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. * Convert pixel coordinates to chart time/price.
* @param {number} x - Pixel X * @param {number} x - Pixel X
@ -994,8 +1014,8 @@ class FormationOverlay {
const lineColor = (isChannel && line.isSecondary) ? this.channelSecondaryColor : color; const lineColor = (isChannel && line.isSecondary) ? this.channelSecondaryColor : color;
// Draw the infinite line with a wider invisible hit area // Draw the infinite line with a wider invisible hit area
const lineHitArea = this._createLineHitArea(line.point1, line.point2, 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); const lineEl = this._createLine(line.point1, line.point2, lineColor, formation.tbl_key, index);
if (lineHitArea) { if (lineHitArea) {
formationGroup.appendChild(lineHitArea); formationGroup.appendChild(lineHitArea);
@ -1080,9 +1100,10 @@ class FormationOverlay {
* @param {Object} point1 - {time, price} * @param {Object} point1 - {time, price}
* @param {Object} point2 - {time, price} * @param {Object} point2 - {time, price}
* @param {string} tblKey - Formation tbl_key * @param {string} tblKey - Formation tbl_key
* @param {number} lineIndex - Index of line in formation
* @returns {SVGLineElement|null} * @returns {SVGLineElement|null}
*/ */
_createLineHitArea(point1, point2, tblKey) { _createLineHitArea(point1, point2, tblKey, lineIndex) {
const endpoints = this._getInfiniteLineEndpoints(point1, point2); const endpoints = this._getInfiniteLineEndpoints(point1, point2);
if (!endpoints) return null; if (!endpoints) return null;
@ -1094,6 +1115,8 @@ class FormationOverlay {
line.setAttribute('stroke', 'transparent'); line.setAttribute('stroke', 'transparent');
line.setAttribute('stroke-width', 20); // Wide hit area line.setAttribute('stroke-width', 20); // Wide hit area
line.setAttribute('data-tbl-key', tblKey); 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.setAttribute('data-hit-area', 'true');
line.style.cursor = 'pointer'; line.style.cursor = 'pointer';
@ -1106,9 +1129,10 @@ class FormationOverlay {
* @param {Object} point2 - {time, price} * @param {Object} point2 - {time, price}
* @param {string} color - Line color * @param {string} color - Line color
* @param {string} tblKey - Formation tbl_key * @param {string} tblKey - Formation tbl_key
* @param {number} lineIndex - Index of line in formation
* @returns {SVGLineElement|null} * @returns {SVGLineElement|null}
*/ */
_createLine(point1, point2, color, tblKey) { _createLine(point1, point2, color, tblKey, lineIndex) {
const endpoints = this._getInfiniteLineEndpoints(point1, point2); const endpoints = this._getInfiniteLineEndpoints(point1, point2);
if (!endpoints) return null; if (!endpoints) return null;
@ -1120,6 +1144,8 @@ class FormationOverlay {
line.setAttribute('stroke', color); line.setAttribute('stroke', color);
line.setAttribute('stroke-width', 2); line.setAttribute('stroke-width', 2);
line.setAttribute('data-tbl-key', tblKey); 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-time', point1.time);
line.setAttribute('data-point1-price', point1.price); line.setAttribute('data-point1-price', point1.price);
line.setAttribute('data-point2-time', point2.time); line.setAttribute('data-point2-time', point2.time);
@ -1225,37 +1251,33 @@ class FormationOverlay {
const linesData = JSON.parse(formation.lines_json || '{}'); const linesData = JSON.parse(formation.lines_json || '{}');
const lines = linesData.lines || []; 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 // Update line positions
let lineIdx = 0;
lines.forEach((line, index) => { lines.forEach((line, index) => {
const endpoints = this._getInfiniteLineEndpoints(line.point1, line.point2); const endpoints = this._getInfiniteLineEndpoints(line.point1, line.point2);
if (endpoints) { if (!endpoints) return;
// Update hit area (if exists)
if (lineElements[lineIdx]) { const hitLine = data.group.querySelector(
lineElements[lineIdx].setAttribute('x1', endpoints.start.x); `line[data-line-index="${index}"][data-line-role="hit"]`
lineElements[lineIdx].setAttribute('y1', endpoints.start.y); );
lineElements[lineIdx].setAttribute('x2', endpoints.end.x); const mainLine = data.group.querySelector(
lineElements[lineIdx].setAttribute('y2', endpoints.end.y); `line[data-line-index="${index}"][data-line-role="main"]`
lineIdx++; );
}
// Update visible line (if exists) if (hitLine) {
if (lineElements[lineIdx]) { hitLine.setAttribute('x1', endpoints.start.x);
lineElements[lineIdx].setAttribute('x1', endpoints.start.x); hitLine.setAttribute('y1', endpoints.start.y);
lineElements[lineIdx].setAttribute('y1', endpoints.start.y); hitLine.setAttribute('x2', endpoints.end.x);
lineElements[lineIdx].setAttribute('x2', endpoints.end.x); hitLine.setAttribute('y2', endpoints.end.y);
lineElements[lineIdx].setAttribute('y2', endpoints.end.y);
lineIdx++;
} }
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 // Update anchor positions by stable selector (avoids NodeList order drift).
// Use _getAnchorPixelPosition which handles anchors outside visible range
let anchorIdx = 0;
lines.forEach((line, index) => { lines.forEach((line, index) => {
const isChannel = formation.formation_type === 'channel'; const isChannel = formation.formation_type === 'channel';
const centerPoint = line.center || { const centerPoint = line.center || {
@ -1264,33 +1286,49 @@ class FormationOverlay {
}; };
if (isChannel && line.isSecondary) { if (isChannel && line.isSecondary) {
// Secondary line: only center anchor // Secondary line: channel offset anchor only.
const pixel = this._getAnchorPixelPosition(centerPoint, line.point1, line.point2); const pixel = this._getAnchorPixelPosition(centerPoint, line.point1, line.point2);
if (pixel && circleElements[anchorIdx]) { const offsetAnchor = data.group.querySelector(
circleElements[anchorIdx].setAttribute('cx', pixel.x); `circle[data-line-index="${index}"][data-point-key="channel_offset"]`
circleElements[anchorIdx].setAttribute('cy', pixel.y); );
anchorIdx++; 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 { } 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 p1Pixel = this._getAnchorPixelPosition(line.point1, line.point1, line.point2);
const centerPixel = this._getAnchorPixelPosition(centerPoint, line.point1, line.point2); const centerPixel = this._getAnchorPixelPosition(centerPoint, line.point1, line.point2);
const p2Pixel = this._getAnchorPixelPosition(line.point2, 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]) { if (p1Pixel && anchorPoint1) {
circleElements[anchorIdx].setAttribute('cx', p1Pixel.x); anchorPoint1.setAttribute('cx', p1Pixel.x);
circleElements[anchorIdx].setAttribute('cy', p1Pixel.y); anchorPoint1.setAttribute('cy', p1Pixel.y);
anchorIdx++; anchorPoint1.setAttribute('data-time', line.point1.time);
anchorPoint1.setAttribute('data-price', line.point1.price);
} }
if (centerPixel && circleElements[anchorIdx]) { if (centerPixel && anchorCenter) {
circleElements[anchorIdx].setAttribute('cx', centerPixel.x); anchorCenter.setAttribute('cx', centerPixel.x);
circleElements[anchorIdx].setAttribute('cy', centerPixel.y); anchorCenter.setAttribute('cy', centerPixel.y);
anchorIdx++; anchorCenter.setAttribute('data-time', centerPoint.time);
anchorCenter.setAttribute('data-price', centerPoint.price);
} }
if (p2Pixel && circleElements[anchorIdx]) { if (p2Pixel && anchorPoint2) {
circleElements[anchorIdx].setAttribute('cx', p2Pixel.x); anchorPoint2.setAttribute('cx', p2Pixel.x);
circleElements[anchorIdx].setAttribute('cy', p2Pixel.y); anchorPoint2.setAttribute('cy', p2Pixel.y);
anchorIdx++; anchorPoint2.setAttribute('data-time', line.point2.time);
anchorPoint2.setAttribute('data-price', line.point2.price);
} }
} }
}); });
@ -1332,12 +1370,19 @@ class FormationOverlay {
const prevData = this.renderedFormations.get(this.selectedTblKey); const prevData = this.renderedFormations.get(this.selectedTblKey);
if (prevData) { if (prevData) {
prevData.elements.forEach(el => { 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); el.setAttribute('stroke', prevData.formation.color || this.defaultColor);
} }
if (el.tagName === 'circle') { if (el.tagName === 'circle') {
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); el.setAttribute('fill', prevData.formation.color || this.defaultColor);
} }
}
}); });
} }
} }
@ -1348,7 +1393,7 @@ class FormationOverlay {
const data = this.renderedFormations.get(tblKey); const data = this.renderedFormations.get(tblKey);
if (data) { if (data) {
data.elements.forEach(el => { data.elements.forEach(el => {
if (el.tagName === 'line') { if (el.tagName === 'line' && el.getAttribute('data-line-role') === 'main') {
el.setAttribute('stroke', this.selectedColor); el.setAttribute('stroke', this.selectedColor);
} }
if (el.tagName === 'circle') { if (el.tagName === 'circle') {
@ -1546,6 +1591,12 @@ class FormationOverlay {
if (data) { if (data) {
data.formation = this.dragFormation; 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; this.isDragging = false;