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,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue