226 lines
7.0 KiB
TypeScript
226 lines
7.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
import type { TrumpPost, Candle } from '@/types'
|
|
import { useDashboardStore } from '@/store/dashboard'
|
|
import Pill from '@/components/ui/Pill'
|
|
import PostCards, { MOCK_POSTS } from './PostCards'
|
|
import ExpandDetail from './ExpandDetail'
|
|
|
|
interface ChartPanelProps {
|
|
posts?: TrumpPost[]
|
|
candles?: Candle[]
|
|
}
|
|
|
|
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
|
|
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore()
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const chartRef = useRef<unknown>(null)
|
|
const seriesRef = useRef<unknown>(null)
|
|
const fittedRef = useRef(false)
|
|
// Keep latest posts/selectedPostId accessible inside chart callbacks without re-subscribing
|
|
const postsRef = useRef(posts)
|
|
postsRef.current = posts
|
|
const selectedPostIdRef = useRef(selectedPostId)
|
|
selectedPostIdRef.current = selectedPostId
|
|
|
|
const assets: Array<'BTC' | 'ETH'> = ['BTC', 'ETH']
|
|
const timeframes: Array<'5m' | '15m' | '1H' | '4H' | '1D' | '1W'> = ['5m', '15m', '1H', '4H', '1D', '1W']
|
|
|
|
const selectedPost = posts.find((p) => p.id === selectedPostId) ?? null
|
|
|
|
// Create chart once on mount
|
|
useEffect(() => {
|
|
if (!containerRef.current || typeof window === 'undefined') return
|
|
|
|
let destroyed = false
|
|
|
|
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
|
|
if (destroyed || !containerRef.current) return
|
|
|
|
const chart = createChart(containerRef.current, {
|
|
width: containerRef.current.clientWidth,
|
|
height: 360,
|
|
layout: {
|
|
background: { color: '#050505' },
|
|
textColor: '#555555',
|
|
},
|
|
grid: {
|
|
vertLines: { color: '#111111' },
|
|
horzLines: { color: '#111111' },
|
|
},
|
|
crosshair: { mode: CrosshairMode.Normal },
|
|
rightPriceScale: { borderColor: '#1a1a1a' },
|
|
timeScale: {
|
|
borderColor: '#1a1a1a',
|
|
timeVisible: true,
|
|
rightOffset: 5,
|
|
barSpacing: 10,
|
|
},
|
|
handleScroll: true,
|
|
handleScale: true,
|
|
})
|
|
|
|
// @ts-expect-error lightweight-charts type
|
|
chartRef.current = chart
|
|
|
|
const series = chart.addCandlestickSeries({
|
|
upColor: '#4ade80',
|
|
downColor: '#ef4444',
|
|
borderUpColor: '#4ade80',
|
|
borderDownColor: '#ef4444',
|
|
wickUpColor: '#4ade80',
|
|
wickDownColor: '#ef4444',
|
|
})
|
|
|
|
// @ts-expect-error lightweight-charts type
|
|
seriesRef.current = series
|
|
|
|
// Click on chart: find nearest post marker within 2-bar tolerance
|
|
chart.subscribeClick((param: { time?: number | string }) => {
|
|
if (!param.time) return
|
|
const clickTime = typeof param.time === 'number' ? param.time : 0
|
|
if (!clickTime) return
|
|
|
|
const allPosts = postsRef.current
|
|
let closest: TrumpPost | null = null
|
|
let closestDiff = Infinity
|
|
|
|
for (const p of allPosts) {
|
|
if (!p.published_at) continue
|
|
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
|
|
const diff = Math.abs(pt - clickTime)
|
|
if (diff < closestDiff) {
|
|
closestDiff = diff
|
|
closest = p
|
|
}
|
|
}
|
|
|
|
// Only select if click is within 2 hours of a post (7200 seconds)
|
|
if (closest && closestDiff <= 7200) {
|
|
const newId = closest.id === selectedPostIdRef.current ? null : closest.id
|
|
setSelectedPost(newId)
|
|
} else {
|
|
setSelectedPost(null)
|
|
}
|
|
})
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
if (containerRef.current && !destroyed) {
|
|
chart.applyOptions({ width: containerRef.current.clientWidth })
|
|
}
|
|
})
|
|
ro.observe(containerRef.current)
|
|
|
|
return () => {
|
|
ro.disconnect()
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
destroyed = true
|
|
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 whenever data changes
|
|
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,
|
|
})))
|
|
|
|
// Show markers for all posts within visible time range
|
|
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
|
|
})
|
|
|
|
if (visible.length > 0) {
|
|
const markers = [...visible]
|
|
.sort((a, b) => new Date(a.published_at).getTime() - new Date(b.published_at).getTime())
|
|
.map((p) => ({
|
|
time: Math.floor(new Date(p.published_at).getTime() / 1000) as number,
|
|
position: 'aboveBar' as const,
|
|
color: p.id === selectedPostId
|
|
? '#fb923c'
|
|
: p.sentiment === 'bearish'
|
|
? '#ef4444'
|
|
: '#f97316',
|
|
shape: 'circle' as const,
|
|
text: '',
|
|
size: p.id === selectedPostId ? 2 : 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, selectedPostId])
|
|
|
|
// Reset fit flag on timeframe/asset switch
|
|
useEffect(() => {
|
|
fittedRef.current = false
|
|
}, [asset, timeframe])
|
|
|
|
return (
|
|
<div className="flex-1 min-w-0 bg-[#0a0a0a] border border-[#141414] rounded-[10px] p-4 flex flex-col gap-3">
|
|
{/* Controls */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{assets.map((a) => (
|
|
<Pill key={a} active={asset === a} onClick={() => setAsset(a)}>
|
|
{a}
|
|
</Pill>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{timeframes.map((tf) => (
|
|
<Pill key={tf} active={timeframe === tf} onClick={() => setTimeframe(tf as '4H' | '1D' | '1W')}>
|
|
{tf}
|
|
</Pill>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full rounded-lg overflow-hidden cursor-crosshair"
|
|
style={{ height: 360, background: '#050505' }}
|
|
/>
|
|
|
|
{/* Selected post detail — shown immediately below chart */}
|
|
<ExpandDetail post={selectedPost} />
|
|
|
|
{/* Post cards list */}
|
|
<PostCards posts={posts} />
|
|
</div>
|
|
)
|
|
}
|