diff --git a/components/dashboard/ChartPanel.tsx b/components/dashboard/ChartPanel.tsx index 5ef1432..116da15 100644 --- a/components/dashboard/ChartPanel.tsx +++ b/components/dashboard/ChartPanel.tsx @@ -20,6 +20,18 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI const macroPriceLineRef = useRef(null) const macroVerticalRef = useRef(null) const macroLabelRef = useRef(null) + // Macro overlay state. The vertical line + label are absolutely-positioned + // DOM nodes (lightweight-charts has no first-class "vertical marker" + // primitive), so we have to manually re-project signal time → pixel X + // every time the visible range / chart width changes. Without this the + // line stays anchored to the SCREEN while the candles pan beneath it, + // which looks exactly like a draggable / floating line. + const macroBucketTimeRef = useRef(null) + const macroLabelTextRef = useRef('') + // Imperative reposition fn — set up by the chart-creation effect, called + // from BOTH the data effect (when the signal changes) AND from a + // subscribeVisibleTimeRangeChange callback (when the user pans / zooms). + const repositionMacroRef = useRef<(() => void) | null>(null) const fittedRef = useRef(false) const postsRef = useRef(posts) postsRef.current = posts @@ -65,7 +77,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI vertical.style.opacity = '0' vertical.style.pointerEvents = 'none' vertical.style.zIndex = '2' - vertical.style.transition = 'left 160ms ease, opacity 120ms ease' + // NO transition on `left` — the reposition callback fires on every + // pan/zoom frame; a 160ms ease there makes the line lag visibly + // behind the candles and looks exactly like the original bug. + vertical.style.transition = 'opacity 120ms ease' containerRef.current.appendChild(vertical) macroVerticalRef.current = vertical @@ -88,7 +103,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI label.style.opacity = '0' label.style.transform = 'translateX(-50%)' label.style.zIndex = '3' - label.style.transition = 'left 160ms ease, opacity 120ms ease' + // Same reasoning as the vertical line: no transition on `left` — + // panning must keep the label glued to its timestamp without lag. + label.style.transition = 'opacity 120ms ease' containerRef.current.appendChild(label) macroLabelRef.current = label @@ -128,6 +145,49 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI seriesRef.current = series + // Re-project macro signal time → pixel X on every visible-range + // change (pan / zoom / data update / resize). Without this the line + // is set ONCE in the data effect and then stays stuck at a fixed + // pixel column while the user scrolls the chart — the bug the user + // reported as "this line is draggable". + const reposition = () => { + const c = chartRef.current as any + const vert = macroVerticalRef.current + const lbl = macroLabelRef.current + const container = containerRef.current + if (!c || !vert || !container) return + const bucket = macroBucketTimeRef.current + if (bucket == null) { + vert.style.opacity = '0' + if (lbl) lbl.style.opacity = '0' + return + } + const x = c.timeScale?.().timeToCoordinate?.(bucket) + const w = container.clientWidth + // Hide (don't clamp) when the signal time is OUTSIDE the visible + // range. Clamping to the edge would put the "MACRO BOTTOM" label + // on candles that have nothing to do with the signal — actively + // misleading. + if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > w) { + vert.style.opacity = '0' + if (lbl) lbl.style.opacity = '0' + return + } + vert.style.left = `${x}px` + vert.style.opacity = '1' + if (lbl) { + lbl.textContent = macroLabelTextRef.current + const clamped = Math.max(76, Math.min(w - 76, x)) + lbl.style.left = `${clamped}px` + lbl.style.opacity = '1' + } + } + repositionMacroRef.current = reposition + // lightweight-charts fires this on user pan, wheel zoom, programmatic + // setVisibleRange, and on each new candle. We piggy-back on it for + // every reposition trigger we care about. + chart.timeScale().subscribeVisibleTimeRangeChange(reposition) + chart.subscribeClick((param: any) => { if (!param.time) return const clickTime = typeof param.time === 'number' ? param.time : 0 @@ -167,6 +227,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI ro = new ResizeObserver(() => { if (containerRef.current && !destroyed) { chart.applyOptions({ width: containerRef.current.clientWidth }) + // Width change → new pixel coordinates → re-project the macro + // marker too, otherwise it'd drift relative to the candles on + // window resize / sidebar toggle. + repositionMacroRef.current?.() } }) ro.observe(containerRef.current) @@ -330,26 +394,20 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI }) } - const x = (chart as any).timeScale?.().timeToCoordinate?.(bucketTime) - if (typeof x === 'number' && Number.isFinite(x)) { - macroVerticalRef.current.style.left = `${x}px` - macroVerticalRef.current.style.opacity = '1' - if (macroLabelRef.current) { - macroLabelRef.current.textContent = - (macroSignal.source || '') === 'btc_bottom_reversal' - ? 'Macro bottom' - : 'Funding reversal' - const clamped = Math.max(76, Math.min((containerRef.current?.clientWidth ?? x) - 76, x)) - macroLabelRef.current.style.left = `${clamped}px` - macroLabelRef.current.style.opacity = '1' - } - } else { - macroVerticalRef.current.style.opacity = '0' - if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0' - } - } else if (macroVerticalRef.current) { - macroVerticalRef.current.style.opacity = '0' - if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0' + // Hand off positioning to the shared reposition fn — it knows how + // to re-project on every pan/zoom too, not just on data update. + // (Before this fix, the line was positioned ONCE here and never + // moved with the chart; it looked like a draggable floating element + // because the candles slid out from under it.) + macroBucketTimeRef.current = bucketTime + macroLabelTextRef.current = + (macroSignal.source || '') === 'btc_bottom_reversal' + ? 'Macro bottom' + : 'Funding reversal' + repositionMacroRef.current?.() + } else { + macroBucketTimeRef.current = null + repositionMacroRef.current?.() } if (!fittedRef.current) { diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.