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:
parent
ee199022fa
commit
18d773320c
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue