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:
@@ -20,6 +20,18 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
const macroPriceLineRef = useRef<unknown>(null)
|
||||
const macroVerticalRef = 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 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 =
|
||||
// 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'
|
||||
const clamped = Math.max(76, Math.min((containerRef.current?.clientWidth ?? x) - 76, x))
|
||||
macroLabelRef.current.style.left = `${clamped}px`
|
||||
macroLabelRef.current.style.opacity = '1'
|
||||
}
|
||||
repositionMacroRef.current?.()
|
||||
} 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'
|
||||
macroBucketTimeRef.current = null
|
||||
repositionMacroRef.current?.()
|
||||
}
|
||||
|
||||
if (!fittedRef.current) {
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user