diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js index 561b8bb..f452527 100644 --- a/src/static/formation_overlay.js +++ b/src/static/formation_overlay.js @@ -321,87 +321,134 @@ class FormationOverlay { /** * Calculate infinite line endpoints that extend to viewport edges. + * Handles anchor points outside the visible range by calculating in chart coordinates first. * @param {Object} point1 - {time, price} * @param {Object} point2 - {time, price} * @returns {{start: {x, y}, end: {x, y}}|null} */ _getInfiniteLineEndpoints(point1, point2) { - if (!this.svg) return null; + if (!this.svg || !this.chart || !this.candleSeries) return null; const width = this.svg.clientWidth || this.container.clientWidth; const height = this.svg.clientHeight || this.container.clientHeight; - // Convert anchor points to pixels - const p1 = this._chartToPixel(point1.time, point1.price); - const p2 = this._chartToPixel(point2.time, point2.price); + // Get visible range in chart coordinates + const timeScale = this.chart.timeScale(); + const visibleRange = timeScale.getVisibleRange(); + if (!visibleRange) return null; - if (!p1 || !p2) return null; + const visiblePriceRange = this.candleSeries.priceScale().getVisiblePriceRange?.(); - // Calculate line parameters - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; + // Get viewport boundaries in chart coordinates + const leftTime = visibleRange.from; + const rightTime = visibleRange.to; - // Handle vertical line - if (Math.abs(dx) < 0.001) { + // 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) + 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: p1.x, y: 0 }, - end: { x: p1.x, y: height } + start: { x: x, y: 0 }, + end: { x: x, y: height } }; } - // Handle horizontal line - if (Math.abs(dy) < 0.001) { + // 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: p1.y }, - end: { x: width, y: p1.y } + start: { x: 0, y: y }, + end: { x: width, y: y } }; } - // Calculate slope - const slope = dy / dx; + // Calculate price/time slope + const slope = dp / dt; // price change per time unit - // Calculate y-intercept (y = mx + b => b = y - mx) - const b = p1.y - slope * p1.x; + // 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; - // Left edge (x = 0) - const yLeft = b; - if (yLeft >= 0 && yLeft <= height) { - 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 (x = width) - const yRight = slope * width + b; - if (yRight >= 0 && yRight <= height) { - intersections.push({ x: width, y: yRight }); + // Right edge (time = rightTime) + if (priceAtRight >= Math.min(topPrice, bottomPrice) && priceAtRight <= Math.max(topPrice, bottomPrice)) { + chartIntersections.push({ time: rightTime, price: priceAtRight }); } - // Top edge (y = 0) - const xTop = -b / slope; - if (xTop >= 0 && xTop <= width) { - intersections.push({ x: xTop, y: 0 }); + // Top edge (price = topPrice) + if (timeAtTop >= leftTime && timeAtTop <= rightTime) { + chartIntersections.push({ time: timeAtTop, price: topPrice }); } - // Bottom edge (y = height) - const xBottom = (height - b) / slope; - if (xBottom >= 0 && xBottom <= width) { - intersections.push({ x: xBottom, y: height }); + // Bottom edge (price = bottomPrice) + if (timeAtBottom >= leftTime && timeAtBottom <= rightTime) { + chartIntersections.push({ time: timeAtBottom, price: bottomPrice }); } - // We need exactly 2 intersections - if (intersections.length < 2) { - // Fallback: just use the anchor points - return { start: p1, end: p2 }; + // 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; } // Sort by x to get consistent start/end - intersections.sort((a, b) => a.x - b.x); + pixelIntersections.sort((a, b) => a.x - b.x); return { - start: intersections[0], - end: intersections[intersections.length - 1] + start: pixelIntersections[0], + end: pixelIntersections[pixelIntersections.length - 1] }; }