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:
parent
2d01ee07eb
commit
d7398569a8
|
|
@ -167,13 +167,11 @@ 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._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
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
|
||||
const visiblePriceRange = this.candleSeries.priceScale().getVisiblePriceRange?.();
|
||||
|
||||
// Get viewport boundaries in chart coordinates
|
||||
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);
|
||||
|
||||
if (topPrice === null || bottomPrice === null) return null;
|
||||
|
||||
// Calculate line slope in chart coordinates (price per time unit)
|
||||
// Calculate slope (price per time unit)
|
||||
const dt = point2.time - point1.time;
|
||||
const dp = point2.price - 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;
|
||||
return {
|
||||
start: { x: x, y: 0 },
|
||||
end: { x: x, y: height }
|
||||
};
|
||||
}
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
return {
|
||||
start: { x: 0, y: y },
|
||||
end: { x: width, y: y }
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate price/time slope
|
||||
const slope = dp / dt; // price change per time unit
|
||||
|
||||
// Find prices at viewport time edges using line equation: price = slope * (time - t1) + p1
|
||||
// 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;
|
||||
|
||||
// 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 });
|
||||
p1 = this._chartToPixel(leftTime, priceAtLeft);
|
||||
p2 = this._chartToPixel(rightTime, priceAtRight);
|
||||
}
|
||||
|
||||
// Right edge (time = rightTime)
|
||||
if (priceAtRight >= Math.min(topPrice, bottomPrice) && priceAtRight <= Math.max(topPrice, bottomPrice)) {
|
||||
chartIntersections.push({ time: rightTime, price: priceAtRight });
|
||||
if (!p1 || !p2) return null;
|
||||
}
|
||||
|
||||
// Top edge (price = topPrice)
|
||||
if (timeAtTop >= leftTime && timeAtTop <= rightTime) {
|
||||
chartIntersections.push({ time: timeAtTop, price: topPrice });
|
||||
// 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: p1.x, y: 0 },
|
||||
end: { x: p1.x, y: height }
|
||||
};
|
||||
}
|
||||
|
||||
// Bottom edge (price = bottomPrice)
|
||||
if (timeAtBottom >= leftTime && timeAtBottom <= rightTime) {
|
||||
chartIntersections.push({ time: timeAtBottom, price: bottomPrice });
|
||||
// Handle horizontal line
|
||||
if (Math.abs(dy) < 0.001) {
|
||||
return {
|
||||
start: { x: 0, y: p1.y },
|
||||
end: { x: width, y: p1.y }
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Calculate slope in pixel space
|
||||
const slope = dy / dx;
|
||||
const b = p1.y - slope * p1.x; // y-intercept
|
||||
|
||||
// Find intersections with viewport edges
|
||||
const intersections = [];
|
||||
|
||||
// Left edge (x = 0)
|
||||
const yLeft = b;
|
||||
if (yLeft >= 0 && yLeft <= height) {
|
||||
intersections.push({ x: 0, y: yLeft });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Right edge (x = width)
|
||||
const yRight = slope * width + b;
|
||||
if (yRight >= 0 && yRight <= height) {
|
||||
intersections.push({ x: width, y: yRight });
|
||||
}
|
||||
|
||||
// Top edge (y = 0)
|
||||
const xTop = -b / slope;
|
||||
if (xTop >= 0 && xTop <= width) {
|
||||
intersections.push({ x: xTop, y: 0 });
|
||||
}
|
||||
|
||||
// Bottom edge (y = height)
|
||||
const xBottom = (height - b) / slope;
|
||||
if (xBottom >= 0 && xBottom <= width) {
|
||||
intersections.push({ x: xBottom, y: height });
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue