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 = () => {
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) {
// Check if chart view has changed
if (this._hasViewChanged()) {
this._updateAllFormations();
}
this._updateAllFormations();
}
this._animationFrameId = requestAnimationFrame(sync);
@ -183,7 +181,8 @@ class FormationOverlay {
/**
* 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}
*/
_hasViewChanged() {
@ -194,7 +193,6 @@ class FormationOverlay {
const timeRange = timeScale.getVisibleLogicalRange();
if (!timeRange) return false;
// Store only stable primitives for quick equality checks.
const nextRange = {
from: Number(timeRange.from),
to: Number(timeRange.to)
@ -209,7 +207,6 @@ class FormationOverlay {
return true;
}
} catch (e) {
// Fallback: always update if there's an error
return true;
}
@ -321,7 +318,7 @@ class FormationOverlay {
/**
* 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} point2 - {time, price}
* @returns {{start: {x, y}, end: {x, y}}|null}
@ -332,123 +329,105 @@ class FormationOverlay {
const width = this.svg.clientWidth || this.container.clientWidth;
const height = this.svg.clientHeight || this.container.clientHeight;
// Get visible range in chart coordinates
const timeScale = this.chart.timeScale();
const visibleRange = timeScale.getVisibleRange();
if (!visibleRange) return null;
// Try direct conversion first (works when both points are in visible range)
let p1 = this._chartToPixel(point1.time, point1.price);
let p2 = this._chartToPixel(point2.time, point2.price);
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 rightTime = visibleRange.to;
const leftTime = visibleRange.from;
const rightTime = visibleRange.to;
// Estimate price range from top/bottom of viewport
const topPrice = this.candleSeries.coordinateToPrice(0);
const bottomPrice = this.candleSeries.coordinateToPrice(height);
// Calculate slope (price per time unit)
const dt = point2.time - point1.time;
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)
const dt = point2.time - point1.time;
const dp = point2.price - point1.price;
// Get two points within visible range using the line equation
const priceAtLeft = slope * (leftTime - point1.time) + point1.price;
const priceAtRight = slope * (rightTime - point1.time) + point1.price;
// Handle vertical line (same time, different prices) - shouldn't happen normally
if (Math.abs(dt) < 1) {
const x = timeScale.timeToCoordinate(point1.time);
if (x === null) return null;
p1 = this._chartToPixel(leftTime, priceAtLeft);
p2 = this._chartToPixel(rightTime, priceAtRight);
}
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 {
start: { x: x, y: 0 },
end: { x: x, y: height }
start: { x: p1.x, y: 0 },
end: { x: p1.x, y: height }
};
}
// Handle horizontal line (same price) - common case
if (Math.abs(dp) < 0.001) {
const y = this.candleSeries.priceToCoordinate(point1.price);
if (y === null) return null;
// Handle horizontal line
if (Math.abs(dy) < 0.001) {
return {
start: { x: 0, y: y },
end: { x: width, y: y }
start: { x: 0, y: p1.y },
end: { x: width, y: p1.y }
};
}
// Calculate price/time slope
const slope = dp / dt; // price change per time unit
// Calculate slope in pixel space
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
const priceAtLeft = slope * (leftTime - point1.time) + point1.price;
const priceAtRight = slope * (rightTime - point1.time) + point1.price;
// Find intersections with viewport edges
const intersections = [];
// Find times at viewport price edges (top and bottom)
// time = (price - p1) / slope + t1
const timeAtTop = (topPrice - point1.price) / slope + point1.time;
const timeAtBottom = (bottomPrice - point1.price) / slope + point1.time;
// 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 });
// Left edge (x = 0)
const yLeft = b;
if (yLeft >= 0 && yLeft <= height) {
intersections.push({ x: 0, y: yLeft });
}
// Right edge (time = rightTime)
if (priceAtRight >= Math.min(topPrice, bottomPrice) && priceAtRight <= Math.max(topPrice, bottomPrice)) {
chartIntersections.push({ time: rightTime, price: priceAtRight });
// Right edge (x = width)
const yRight = slope * width + b;
if (yRight >= 0 && yRight <= height) {
intersections.push({ x: width, y: yRight });
}
// Top edge (price = topPrice)
if (timeAtTop >= leftTime && timeAtTop <= rightTime) {
chartIntersections.push({ time: timeAtTop, price: topPrice });
// Top edge (y = 0)
const xTop = -b / slope;
if (xTop >= 0 && xTop <= width) {
intersections.push({ x: xTop, y: 0 });
}
// Bottom edge (price = bottomPrice)
if (timeAtBottom >= leftTime && timeAtBottom <= rightTime) {
chartIntersections.push({ time: timeAtBottom, price: bottomPrice });
// Bottom edge (y = height)
const xBottom = (height - b) / slope;
if (xBottom >= 0 && xBottom <= width) {
intersections.push({ x: xBottom, y: height });
}
// Remove duplicates (can happen at corners)
const uniqueIntersections = [];
for (const pt of chartIntersections) {
const isDupe = uniqueIntersections.some(
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;
// We need exactly 2 intersections for a line crossing the viewport
if (intersections.length < 2) {
// Fallback: use the calculated points
return { start: p1, end: p2 };
}
// 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 {
start: pixelIntersections[0],
end: pixelIntersections[pixelIntersections.length - 1]
start: intersections[0],
end: intersections[intersections.length - 1]
};
}
@ -1149,10 +1128,9 @@ class FormationOverlay {
* Update all rendered formations (called when chart view changes).
*/
_updateAllFormations() {
// IMPORTANT: iterate over a snapshot, because renderFormation mutates renderedFormations.
// Mutating a Map while iterating it can lead to effectively infinite loops.
// Use in-place updates to prevent flashing
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
if (this.drawingMode && this.currentPoints.length > 0) {
@ -1169,6 +1147,95 @@ class FormationOverlay {
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.
* @param {string} tblKey - Formation tbl_key
@ -1393,8 +1460,8 @@ class FormationOverlay {
// Update formation data
this.dragFormation.lines_json = JSON.stringify(linesData);
// Re-render the formation
this.renderFormation(this.dragFormation);
// Update positions in place (avoids flashing from remove/recreate)
this._updateFormationPositionsInPlace(this.dragFormation);
}
/**