Fix formation overlay sync to update on every RAF frame

- Remove hasViewChanged check from RAF loop
- Update formations on every animation frame for smooth chart sync
- This ensures formations stay positioned correctly when:
  - Time scale changes (horizontal scroll/zoom)
  - Price scale changes (vertical scroll/zoom)
- Simplify _hasViewChanged method (kept for potential future optimization)
- Clean up debug logging

The previous implementation only updated when time range changed,
causing formations to drift when the price scale was modified.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-10 22:38:16 -03:00
parent 2d01ee07eb
commit d7398569a8
1 changed files with 169 additions and 102 deletions

View File

@ -167,12 +167,10 @@ class FormationOverlay {
const sync = () => { const sync = () => {
if (!this._loopRunning) return; if (!this._loopRunning) return;
// Skip work when there's nothing to redraw. // Always update formations on every frame for smooth sync with chart.
// The update is lightweight - just updating SVG element positions.
if (this.renderedFormations.size > 0 || this.drawingMode) { if (this.renderedFormations.size > 0 || this.drawingMode) {
// Check if chart view has changed this._updateAllFormations();
if (this._hasViewChanged()) {
this._updateAllFormations();
}
} }
this._animationFrameId = requestAnimationFrame(sync); this._animationFrameId = requestAnimationFrame(sync);
@ -183,7 +181,8 @@ class FormationOverlay {
/** /**
* Check if the chart view (time/price range) has changed. * Check if the chart view (time/price range) has changed.
* Uses time scale visible range - in v5, price scale methods differ. * Note: Currently unused - we update on every RAF frame for smooth sync.
* Kept for potential future optimization.
* @returns {boolean} * @returns {boolean}
*/ */
_hasViewChanged() { _hasViewChanged() {
@ -194,7 +193,6 @@ class FormationOverlay {
const timeRange = timeScale.getVisibleLogicalRange(); const timeRange = timeScale.getVisibleLogicalRange();
if (!timeRange) return false; if (!timeRange) return false;
// Store only stable primitives for quick equality checks.
const nextRange = { const nextRange = {
from: Number(timeRange.from), from: Number(timeRange.from),
to: Number(timeRange.to) to: Number(timeRange.to)
@ -209,7 +207,6 @@ class FormationOverlay {
return true; return true;
} }
} catch (e) { } catch (e) {
// Fallback: always update if there's an error
return true; return true;
} }
@ -321,7 +318,7 @@ class FormationOverlay {
/** /**
* Calculate infinite line endpoints that extend to viewport edges. * Calculate infinite line endpoints that extend to viewport edges.
* Handles anchor points outside the visible range by calculating in chart coordinates first. * Uses a robust approach that works even when anchor points are outside visible range.
* @param {Object} point1 - {time, price} * @param {Object} point1 - {time, price}
* @param {Object} point2 - {time, price} * @param {Object} point2 - {time, price}
* @returns {{start: {x, y}, end: {x, y}}|null} * @returns {{start: {x, y}, end: {x, y}}|null}
@ -332,123 +329,105 @@ class FormationOverlay {
const width = this.svg.clientWidth || this.container.clientWidth; const width = this.svg.clientWidth || this.container.clientWidth;
const height = this.svg.clientHeight || this.container.clientHeight; const height = this.svg.clientHeight || this.container.clientHeight;
// Get visible range in chart coordinates // Try direct conversion first (works when both points are in visible range)
const timeScale = this.chart.timeScale(); let p1 = this._chartToPixel(point1.time, point1.price);
const visibleRange = timeScale.getVisibleRange(); let p2 = this._chartToPixel(point2.time, point2.price);
if (!visibleRange) return null;
const visiblePriceRange = this.candleSeries.priceScale().getVisiblePriceRange?.(); // If direct conversion fails, clamp times to visible range and calculate prices
if (!p1 || !p2) {
const timeScale = this.chart.timeScale();
const visibleRange = timeScale.getVisibleRange();
if (!visibleRange) return null;
// Get viewport boundaries in chart coordinates const leftTime = visibleRange.from;
const leftTime = visibleRange.from; const rightTime = visibleRange.to;
const rightTime = visibleRange.to;
// Estimate price range from top/bottom of viewport // Calculate slope (price per time unit)
const topPrice = this.candleSeries.coordinateToPrice(0); const dt = point2.time - point1.time;
const bottomPrice = this.candleSeries.coordinateToPrice(height); const dp = point2.price - point1.price;
if (topPrice === null || bottomPrice === null) return null; if (Math.abs(dt) < 1) {
// Near-vertical line - use midpoint time
const midTime = (leftTime + rightTime) / 2;
p1 = this._chartToPixel(midTime, point1.price);
p2 = this._chartToPixel(midTime, point2.price);
} else {
const slope = dp / dt;
// Calculate line slope in chart coordinates (price per time unit) // Get two points within visible range using the line equation
const dt = point2.time - point1.time; const priceAtLeft = slope * (leftTime - point1.time) + point1.price;
const dp = point2.price - point1.price; const priceAtRight = slope * (rightTime - point1.time) + point1.price;
// Handle vertical line (same time, different prices) - shouldn't happen normally p1 = this._chartToPixel(leftTime, priceAtLeft);
if (Math.abs(dt) < 1) { p2 = this._chartToPixel(rightTime, priceAtRight);
const x = timeScale.timeToCoordinate(point1.time); }
if (x === null) return null;
if (!p1 || !p2) return null;
}
// Now we have two valid pixel points - extend to viewport edges
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
// Handle vertical line
if (Math.abs(dx) < 0.001) {
return { return {
start: { x: x, y: 0 }, start: { x: p1.x, y: 0 },
end: { x: x, y: height } end: { x: p1.x, y: height }
}; };
} }
// Handle horizontal line (same price) - common case // Handle horizontal line
if (Math.abs(dp) < 0.001) { if (Math.abs(dy) < 0.001) {
const y = this.candleSeries.priceToCoordinate(point1.price);
if (y === null) return null;
return { return {
start: { x: 0, y: y }, start: { x: 0, y: p1.y },
end: { x: width, y: y } end: { x: width, y: p1.y }
}; };
} }
// Calculate price/time slope // Calculate slope in pixel space
const slope = dp / dt; // price change per time unit const slope = dy / dx;
const b = p1.y - slope * p1.x; // y-intercept
// Find prices at viewport time edges using line equation: price = slope * (time - t1) + p1 // Find intersections with viewport edges
const priceAtLeft = slope * (leftTime - point1.time) + point1.price; const intersections = [];
const priceAtRight = slope * (rightTime - point1.time) + point1.price;
// Find times at viewport price edges (top and bottom) // Left edge (x = 0)
// time = (price - p1) / slope + t1 const yLeft = b;
const timeAtTop = (topPrice - point1.price) / slope + point1.time; if (yLeft >= 0 && yLeft <= height) {
const timeAtBottom = (bottomPrice - point1.price) / slope + point1.time; intersections.push({ x: 0, y: yLeft });
// Collect valid intersection points in chart coordinates
const chartIntersections = [];
// Left edge (time = leftTime)
if (priceAtLeft >= Math.min(topPrice, bottomPrice) && priceAtLeft <= Math.max(topPrice, bottomPrice)) {
chartIntersections.push({ time: leftTime, price: priceAtLeft });
} }
// Right edge (time = rightTime) // Right edge (x = width)
if (priceAtRight >= Math.min(topPrice, bottomPrice) && priceAtRight <= Math.max(topPrice, bottomPrice)) { const yRight = slope * width + b;
chartIntersections.push({ time: rightTime, price: priceAtRight }); if (yRight >= 0 && yRight <= height) {
intersections.push({ x: width, y: yRight });
} }
// Top edge (price = topPrice) // Top edge (y = 0)
if (timeAtTop >= leftTime && timeAtTop <= rightTime) { const xTop = -b / slope;
chartIntersections.push({ time: timeAtTop, price: topPrice }); if (xTop >= 0 && xTop <= width) {
intersections.push({ x: xTop, y: 0 });
} }
// Bottom edge (price = bottomPrice) // Bottom edge (y = height)
if (timeAtBottom >= leftTime && timeAtBottom <= rightTime) { const xBottom = (height - b) / slope;
chartIntersections.push({ time: timeAtBottom, price: bottomPrice }); if (xBottom >= 0 && xBottom <= width) {
intersections.push({ x: xBottom, y: height });
} }
// Remove duplicates (can happen at corners) // We need exactly 2 intersections for a line crossing the viewport
const uniqueIntersections = []; if (intersections.length < 2) {
for (const pt of chartIntersections) { // Fallback: use the calculated points
const isDupe = uniqueIntersections.some( return { start: p1, end: p2 };
existing => Math.abs(existing.time - pt.time) < 1 && Math.abs(existing.price - pt.price) < 0.01
);
if (!isDupe) {
uniqueIntersections.push(pt);
}
}
// We need at least 2 intersections
if (uniqueIntersections.length < 2) {
// Line doesn't cross visible viewport - try using anchor points directly
const p1 = this._chartToPixel(point1.time, point1.price);
const p2 = this._chartToPixel(point2.time, point2.price);
if (p1 && p2) {
return { start: p1, end: p2 };
}
return null;
}
// Convert chart intersections to pixel coordinates
const pixelIntersections = [];
for (const pt of uniqueIntersections) {
const pixel = this._chartToPixel(pt.time, pt.price);
if (pixel) {
pixelIntersections.push(pixel);
}
}
if (pixelIntersections.length < 2) {
return null;
} }
// Sort by x to get consistent start/end // Sort by x to get consistent start/end
pixelIntersections.sort((a, b) => a.x - b.x); intersections.sort((a, b) => a.x - b.x);
return { return {
start: pixelIntersections[0], start: intersections[0],
end: pixelIntersections[pixelIntersections.length - 1] end: intersections[intersections.length - 1]
}; };
} }
@ -1149,10 +1128,9 @@ class FormationOverlay {
* Update all rendered formations (called when chart view changes). * Update all rendered formations (called when chart view changes).
*/ */
_updateAllFormations() { _updateAllFormations() {
// IMPORTANT: iterate over a snapshot, because renderFormation mutates renderedFormations. // Use in-place updates to prevent flashing
// Mutating a Map while iterating it can lead to effectively infinite loops.
const formationsSnapshot = Array.from(this.renderedFormations.values()).map(item => item.formation); const formationsSnapshot = Array.from(this.renderedFormations.values()).map(item => item.formation);
formationsSnapshot.forEach(formation => this.renderFormation(formation)); formationsSnapshot.forEach(formation => this._updateFormationPositionsInPlace(formation));
// Update temp elements if drawing // Update temp elements if drawing
if (this.drawingMode && this.currentPoints.length > 0) { if (this.drawingMode && this.currentPoints.length > 0) {
@ -1169,6 +1147,95 @@ class FormationOverlay {
this.renderFormation(formation); this.renderFormation(formation);
} }
/**
* Update formation positions in place (without removing/recreating elements).
* Used during dragging to prevent flashing.
* @param {Object} formation - Formation data with updated lines_json
*/
_updateFormationPositionsInPlace(formation) {
const data = this.renderedFormations.get(formation.tbl_key);
if (!data || !data.group) {
// Fallback to full render if no existing elements
this.renderFormation(formation);
return;
}
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++;
}
}
});
// Update anchor positions
let anchorIdx = 0;
lines.forEach((line, index) => {
const isChannel = formation.formation_type === 'channel';
const centerPoint = line.center || {
time: Math.floor((line.point1.time + line.point2.time) / 2),
price: (line.point1.price + line.point2.price) / 2
};
if (isChannel && line.isSecondary) {
// Secondary line: only center anchor
const pixel = this._chartToPixel(centerPoint.time, centerPoint.price);
if (pixel && circleElements[anchorIdx]) {
circleElements[anchorIdx].setAttribute('cx', pixel.x);
circleElements[anchorIdx].setAttribute('cy', pixel.y);
anchorIdx++;
}
} else {
// Primary/single line: 3 anchors (point1, center, point2)
const p1Pixel = this._chartToPixel(line.point1.time, line.point1.price);
const centerPixel = this._chartToPixel(centerPoint.time, centerPoint.price);
const p2Pixel = this._chartToPixel(line.point2.time, line.point2.price);
if (p1Pixel && circleElements[anchorIdx]) {
circleElements[anchorIdx].setAttribute('cx', p1Pixel.x);
circleElements[anchorIdx].setAttribute('cy', p1Pixel.y);
anchorIdx++;
}
if (centerPixel && circleElements[anchorIdx]) {
circleElements[anchorIdx].setAttribute('cx', centerPixel.x);
circleElements[anchorIdx].setAttribute('cy', centerPixel.y);
anchorIdx++;
}
if (p2Pixel && circleElements[anchorIdx]) {
circleElements[anchorIdx].setAttribute('cx', p2Pixel.x);
circleElements[anchorIdx].setAttribute('cy', p2Pixel.y);
anchorIdx++;
}
}
});
// Update the stored formation data
data.formation = formation;
}
/** /**
* Remove a formation from the overlay. * Remove a formation from the overlay.
* @param {string} tblKey - Formation tbl_key * @param {string} tblKey - Formation tbl_key
@ -1393,8 +1460,8 @@ class FormationOverlay {
// Update formation data // Update formation data
this.dragFormation.lines_json = JSON.stringify(linesData); this.dragFormation.lines_json = JSON.stringify(linesData);
// Re-render the formation // Update positions in place (avoids flashing from remove/recreate)
this.renderFormation(this.dragFormation); this._updateFormationPositionsInPlace(this.dragFormation);
} }
/** /**