'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 } = 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 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]) return (
) }