'use client' import { useEffect, useRef } 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 fittedRef = 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', } } // 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 chart = createChart(containerRef.current, { width: containerRef.current.clientWidth, height: 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 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 }) } }) 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 }, }) }) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'], }) }) return () => { destroyed = true ro?.disconnect() themeObserver?.disconnect() fittedRef.current = false if (chartRef.current) { // @ts-expect-error lightweight-charts type chartRef.current.remove() chartRef.current = null seriesRef.current = null } } }, []) // Update candles + markers useEffect(() => { const series = seriesRef.current const chart = chartRef.current if (!series || !chart || candles.length === 0) 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, }))) 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 }) 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 visible) { 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) if (!fittedRef.current) { // @ts-expect-error lightweight-charts type chart.timeScale().fitContent() fittedRef.current = true } }, [candles, posts, externalSelectedId]) useEffect(() => { fittedRef.current = false }, [timeframe]) // 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 if (!series) 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 (
) }