diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js index f452527..04ec67e 100644 --- a/src/static/formation_overlay.js +++ b/src/static/formation_overlay.js @@ -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); } /**