'use client' import { useEffect, useRef, useState } from 'react' import type { TrumpPost, Candle } from '@/types' import { useDashboardStore } from '@/store/dashboard' interface ChartPanelProps { posts?: TrumpPost[] candles?: Candle[] externalSelectedId?: number | null onSelectPost?: (id: number | null) => void onSelectDayPosts?: (posts: TrumpPost[]) => void } export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost, onSelectDayPosts }: ChartPanelProps) { const { timeframe, asset, livePrices } = useDashboardStore() const containerRef = useRef(null) const chartRef = useRef(null) const seriesRef = useRef(null) 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) // Chart creation is async (dynamic import). If the candles fetch wins the // race (common — SWR serves them instantly from cache), the data effect // runs while seriesRef is still null and bails; nothing re-triggers it // afterwards, so the chart stays EMPTY and the 2s live-tick effect then // paints a single lone candle via series.update(). This state flips when // the chart actually exists so the data effect re-runs. const [chartReady, setChartReady] = useState(false) // The live-tick effect must never run before setData() — updating an empty // series creates a one-candle chart. const dataLoadedRef = useRef(false) const postsRef = useRef(posts) postsRef.current = posts const selectedPostIdRef = useRef(externalSelectedId) selectedPostIdRef.current = externalSelectedId const timeframeRef = useRef(timeframe) timeframeRef.current = timeframe const onSelectRef = useRef(onSelectPost) onSelectRef.current = onSelectPost const onSelectDayPostsRef = useRef(onSelectDayPosts) onSelectDayPostsRef.current = onSelectDayPosts // Detect current theme for chart colors function getChartColors() { const isDark = document.documentElement.dataset.theme === 'dark' return { background: isDark ? '#121212' : '#ffffff', textColor: isDark ? '#666666' : '#888888', gridColor: isDark ? '#1e1e1e' : '#f0ede8', borderColor: isDark ? '#2a2a2a' : '#e8e4de', macroAccent: isDark ? '#f59e0b' : '#b45309', macroAccentSoft: isDark ? 'rgba(245,158,11,0.24)' : 'rgba(180,83,9,0.18)', } } // Create chart once on mount useEffect(() => { if (!containerRef.current || typeof window === 'undefined') return let destroyed = false let ro: ResizeObserver | null = null let themeObserver: MutationObserver | null = null import('lightweight-charts').then(({ createChart, CrosshairMode }) => { if (destroyed || !containerRef.current) return const colors = getChartColors() const vertical = document.createElement('div') vertical.style.position = 'absolute' vertical.style.top = '0' vertical.style.bottom = '0' vertical.style.width = '0' vertical.style.borderLeft = `2px dashed ${colors.macroAccent}` vertical.style.opacity = '0' vertical.style.pointerEvents = 'none' vertical.style.zIndex = '2' // 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 const label = document.createElement('div') label.style.position = 'absolute' label.style.top = '14px' label.style.left = '0' label.style.padding = '5px 8px' label.style.borderRadius = '999px' label.style.border = `1px solid ${colors.macroAccentSoft}` label.style.background = colors.background label.style.boxShadow = '0 6px 24px rgba(15, 23, 42, 0.08)' label.style.color = colors.macroAccent label.style.fontSize = '11px' label.style.fontWeight = '700' label.style.letterSpacing = '0.04em' label.style.textTransform = 'uppercase' label.style.whiteSpace = 'nowrap' label.style.pointerEvents = 'none' label.style.opacity = '0' label.style.transform = 'translateX(-50%)' label.style.zIndex = '3' // 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 const chart = createChart(containerRef.current, { width: containerRef.current.clientWidth, // Follow the container, don't hardcode: .chart-wrap is 360px on // desktop but 300/240px behind !important media queries. A fixed // 360 overflows those and overflow:hidden cuts off the bottom of // the chart — lowest candles, markers, and the whole time axis. height: containerRef.current.clientHeight || 360, layout: { background: { color: colors.background }, textColor: colors.textColor, }, grid: { vertLines: { color: colors.gridColor }, horzLines: { color: colors.gridColor }, }, crosshair: { mode: CrosshairMode.Normal }, rightPriceScale: { borderColor: colors.borderColor }, timeScale: { borderColor: colors.borderColor, timeVisible: true, rightOffset: 5, barSpacing: 10, }, handleScroll: true, handleScale: true, }) chartRef.current = chart const series = chart.addCandlestickSeries({ upColor: '#26a69a', downColor: '#ef5350', borderUpColor: '#26a69a', borderDownColor: '#ef5350', wickUpColor: '#26a69a', wickDownColor: '#ef5350', }) seriesRef.current = series // Breathing room above/below the candles. Markers render ~10px outside // the bar's high/low; with the default tight bottom margin a crash // candle at the bottom of the autoscaled range gets its marker dots // clipped by the pane edge. series.priceScale().applyOptions({ scaleMargins: { top: 0.12, bottom: 0.16 }, }) setChartReady(true) // 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 if (!clickTime) return const bucketByTf: Record = { '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800, } const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600 const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs const inBucket = postsRef.current .filter((p) => { if (!p.published_at) return false const pt = Math.floor(new Date(p.published_at).getTime() / 1000) return Math.floor(pt / bucketSecs) * bucketSecs === clickBucket }) if (inBucket.length === 0) { onSelectRef.current?.(null) return } // Multiple posts in this candle bucket → show all of them in the right rail if (inBucket.length > 1 && onSelectDayPostsRef.current) { const sorted = [...inBucket].sort( (a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime() ) onSelectDayPostsRef.current(sorted) return } // Single post → show detail directly onSelectRef.current?.(inBucket[0].id) }) ro = new ResizeObserver(() => { if (containerRef.current && !destroyed) { chart.applyOptions({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight || 360, }) // 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) themeObserver = new MutationObserver(() => { const colors = getChartColors() chart.applyOptions({ layout: { background: { color: colors.background }, textColor: colors.textColor, }, grid: { vertLines: { color: colors.gridColor }, horzLines: { color: colors.gridColor }, }, rightPriceScale: { borderColor: colors.borderColor }, timeScale: { borderColor: colors.borderColor }, }) if (macroVerticalRef.current) { macroVerticalRef.current.style.borderLeft = `2px dashed ${colors.macroAccent}` } if (macroLabelRef.current) { macroLabelRef.current.style.color = colors.macroAccent macroLabelRef.current.style.borderColor = colors.macroAccentSoft macroLabelRef.current.style.background = colors.background } }) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'], }) }) return () => { destroyed = true ro?.disconnect() themeObserver?.disconnect() fittedRef.current = false dataLoadedRef.current = false setChartReady(false) if (chartRef.current) { // @ts-expect-error lightweight-charts type chartRef.current.remove() chartRef.current = null seriesRef.current = null } if (macroVerticalRef.current?.parentNode) { macroVerticalRef.current.parentNode.removeChild(macroVerticalRef.current) } if (macroLabelRef.current?.parentNode) { macroLabelRef.current.parentNode.removeChild(macroLabelRef.current) } macroVerticalRef.current = null macroLabelRef.current = null macroPriceLineRef.current = null } }, []) // Update candles + markers useEffect(() => { const series = seriesRef.current const chart = chartRef.current if (!series || !chart || candles.length === 0) { dataLoadedRef.current = false return } const sorted = [...candles].sort((a, b) => a.time - b.time) // @ts-expect-error lightweight-charts type series.setData(sorted.map((c) => ({ time: c.time as number, open: c.open, high: c.high, low: c.low, close: c.close, }))) dataLoadedRef.current = true const minTime = sorted[0].time const maxTime = sorted[sorted.length - 1].time const visible = posts.filter((p) => { if (!p.published_at) return false const t = Math.floor(new Date(p.published_at).getTime() / 1000) return t >= minTime && t <= maxTime }) // Markers show actionable signals only. Hold/filtered posts vastly // outnumber signals and their gray dots pile into unreadable clusters; // they stay browsable in the Recent-signals list instead. const markerPosts = visible.filter( (p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell', ) const bucketByTf: Record = { '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800, } const candleSpacing = sorted.length > 1 ? sorted[1].time - sorted[0].time : 300 const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing const bucketMap = new Map() for (const p of markerPosts) { const pt = Math.floor(new Date(p.published_at).getTime() / 1000) const bucket = Math.floor(pt / bucketSecs) * bucketSecs if (!bucketMap.has(bucket)) bucketMap.set(bucket, []) bucketMap.get(bucket)!.push(p) } bucketMap.forEach((ps) => ps.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0))) const markers = Array.from(bucketMap.entries()) .sort(([a], [b]) => a - b) .map(([bucketTime, bPosts]) => { const isSelected = bPosts.some((p) => p.id === externalSelectedId) const best = bPosts[0] const count = bPosts.length const signalColor = isSelected ? '#f59e0b' : best.signal === 'short' || best.signal === 'sell' ? '#ef5350' : best.signal === 'buy' ? '#26a69a' : '#aaaaaa' return { time: bucketTime as number, position: 'aboveBar' as const, color: signalColor, shape: 'circle' as const, text: count > 1 ? String(count) : '', size: isSelected ? 2 : count > 1 ? 1.5 : 1, } }) // @ts-expect-error lightweight-charts type series.setMarkers(markers) // Highlight the most recent visible Macro Vibes reversal signal on BTC. const visibleMacro = asset === 'BTC' ? visible .filter((p) => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short'), ) .sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime()) : [] const macroSignal = visibleMacro[0] ?? null const colors = getChartColors() const priceSeries = series as any if (macroPriceLineRef.current && priceSeries?.removePriceLine) { try { priceSeries.removePriceLine(macroPriceLineRef.current as any) } catch {} macroPriceLineRef.current = null } if (macroSignal && chart && macroVerticalRef.current) { const pt = Math.floor(new Date(macroSignal.published_at).getTime() / 1000) const bucketTime = Math.floor(pt / bucketSecs) * bucketSecs // Only draw the horizontal reference when we have a true signal-entry // price from the backend. Falling back to a bucket close made the line // drift across timeframe changes and looked like a bug because it was. const priceAtSignal = macroSignal.price_impact?.asset === 'BTC' && macroSignal.price_impact?.price_at_post ? macroSignal.price_impact.price_at_post : null if (priceAtSignal != null && priceSeries?.createPriceLine) { macroPriceLineRef.current = priceSeries.createPriceLine({ price: priceAtSignal, color: colors.macroAccent, lineWidth: 2, lineStyle: 2, axisLabelVisible: false, title: (macroSignal.source || '') === 'btc_bottom_reversal' ? 'Macro bottom' : 'Funding reversal', }) } // 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) { // @ts-expect-error lightweight-charts type chart.timeScale().fitContent() fittedRef.current = true } }, [candles, posts, externalSelectedId, asset, chartReady]) useEffect(() => { fittedRef.current = false }, [timeframe, asset]) // Live-tick the rightmost candle so the chart feels alive between REST polls. // lightweight-charts' `series.update()` either appends a new bar (newer time) // or in-place mutates the bar at that timestamp. We always keep `time` == // the last candle's bucket so the bar grows in place; high/low expand if // the live tick exceeds them. useEffect(() => { const series = seriesRef.current as any // dataLoadedRef: update() on a series that hasn't had setData() yet // creates a chart with a single lone candle. if (!series || !dataLoadedRef.current) return const live = livePrices[asset] if (live == null || !candles.length) return const last = candles[candles.length - 1] series.update({ time: last.time as number, open: last.open, high: Math.max(last.high, live), low: Math.min(last.low, live), close: live, }) }, [livePrices, asset, candles]) return (
) }