fix(chart): MACRO BOTTOM marker now follows candles on pan / zoom / resize

User report: the dashed vertical line + "MACRO BOTTOM" label could be
visually "dragged" — it wasn't actually draggable, but it stayed glued
to a fixed pixel column on screen while the candles slid out from under
it on pan, so the label ended up pointing at unrelated candles.

Root cause: the line + label are absolutely-positioned DOM nodes (the
lightweight-charts library has no first-class vertical-marker primitive).
The data effect set their `left` ONCE via timeScale.timeToCoordinate()
and then never updated again. Every user interaction that changes the
visible range — pan, wheel zoom, programmatic setVisibleRange, window
resize — invalidates that pixel coordinate.

Fix:
  - Store the signal's bucketTime + label text in refs.
  - Hoist the "project bucketTime → pixel X" logic into a single
    `reposition()` fn captured in repositionMacroRef.
  - Subscribe to `timeScale().subscribeVisibleTimeRangeChange(reposition)`
    so it fires on every pan / zoom / data update.
  - Wire it into the ResizeObserver too (width change ⇒ new pixel space).
  - Re-run from the data effect whenever the signal changes.
  - Hide (opacity: 0) instead of clamping when the signal candle scrolls
    outside the visible range — a clamped label glued to the chart edge
    is actively misleading.
  - Drop `transition: left 160ms` on both nodes — that transition fires
    on every pan frame and makes the marker LAG behind the candles,
    reproducing the original visual bug for fast pans.

Verified via DOM scripting:
  pan right ⇒ label_left 744px → 344px (follows candles ✓)
  big right-pan past signal ⇒ opacity 1 → 0 (hides cleanly ✓)
  zoom out ⇒ opacity 0 → 1, label re-projects to new pixel space ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-27 11:34:26 +08:00
parent d01adc4790
commit 02b2aebbb7
2 changed files with 81 additions and 23 deletions
+76 -18
View File
@@ -20,6 +20,18 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
const macroPriceLineRef = useRef<unknown>(null) const macroPriceLineRef = useRef<unknown>(null)
const macroVerticalRef = useRef<HTMLDivElement | null>(null) const macroVerticalRef = useRef<HTMLDivElement | null>(null)
const macroLabelRef = useRef<HTMLDivElement | null>(null) const macroLabelRef = useRef<HTMLDivElement | null>(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<number | null>(null)
const macroLabelTextRef = useRef<string>('')
// 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 fittedRef = useRef(false)
const postsRef = useRef(posts) const postsRef = useRef(posts)
postsRef.current = posts postsRef.current = posts
@@ -65,7 +77,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
vertical.style.opacity = '0' vertical.style.opacity = '0'
vertical.style.pointerEvents = 'none' vertical.style.pointerEvents = 'none'
vertical.style.zIndex = '2' 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) containerRef.current.appendChild(vertical)
macroVerticalRef.current = vertical macroVerticalRef.current = vertical
@@ -88,7 +103,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
label.style.opacity = '0' label.style.opacity = '0'
label.style.transform = 'translateX(-50%)' label.style.transform = 'translateX(-50%)'
label.style.zIndex = '3' 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) containerRef.current.appendChild(label)
macroLabelRef.current = label macroLabelRef.current = label
@@ -128,6 +145,49 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
seriesRef.current = series 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) => { chart.subscribeClick((param: any) => {
if (!param.time) return if (!param.time) return
const clickTime = typeof param.time === 'number' ? param.time : 0 const clickTime = typeof param.time === 'number' ? param.time : 0
@@ -167,6 +227,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
ro = new ResizeObserver(() => { ro = new ResizeObserver(() => {
if (containerRef.current && !destroyed) { if (containerRef.current && !destroyed) {
chart.applyOptions({ width: containerRef.current.clientWidth }) 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) ro.observe(containerRef.current)
@@ -330,26 +394,20 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
}) })
} }
const x = (chart as any).timeScale?.().timeToCoordinate?.(bucketTime) // Hand off positioning to the shared reposition fn — it knows how
if (typeof x === 'number' && Number.isFinite(x)) { // to re-project on every pan/zoom too, not just on data update.
macroVerticalRef.current.style.left = `${x}px` // (Before this fix, the line was positioned ONCE here and never
macroVerticalRef.current.style.opacity = '1' // moved with the chart; it looked like a draggable floating element
if (macroLabelRef.current) { // because the candles slid out from under it.)
macroLabelRef.current.textContent = macroBucketTimeRef.current = bucketTime
macroLabelTextRef.current =
(macroSignal.source || '') === 'btc_bottom_reversal' (macroSignal.source || '') === 'btc_bottom_reversal'
? 'Macro bottom' ? 'Macro bottom'
: 'Funding reversal' : 'Funding reversal'
const clamped = Math.max(76, Math.min((containerRef.current?.clientWidth ?? x) - 76, x)) repositionMacroRef.current?.()
macroLabelRef.current.style.left = `${clamped}px`
macroLabelRef.current.style.opacity = '1'
}
} else { } else {
macroVerticalRef.current.style.opacity = '0' macroBucketTimeRef.current = null
if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0' repositionMacroRef.current?.()
}
} else if (macroVerticalRef.current) {
macroVerticalRef.current.style.opacity = '0'
if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0'
} }
if (!fittedRef.current) { if (!fittedRef.current) {
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.