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 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
|
||||||
(macroSignal.source || '') === 'btc_bottom_reversal'
|
macroLabelTextRef.current =
|
||||||
? 'Macro bottom'
|
(macroSignal.source || '') === 'btc_bottom_reversal'
|
||||||
: 'Funding reversal'
|
? 'Macro bottom'
|
||||||
const clamped = Math.max(76, Math.min((containerRef.current?.clientWidth ?? x) - 76, x))
|
: 'Funding reversal'
|
||||||
macroLabelRef.current.style.left = `${clamped}px`
|
repositionMacroRef.current?.()
|
||||||
macroLabelRef.current.style.opacity = '1'
|
} else {
|
||||||
}
|
macroBucketTimeRef.current = null
|
||||||
} else {
|
repositionMacroRef.current?.()
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fittedRef.current) {
|
if (!fittedRef.current) {
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user