done
This commit is contained in:
+106
-101
@@ -3,56 +3,64 @@
|
||||
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[]
|
||||
externalSelectedId?: number | null
|
||||
onSelectPost?: (id: number | null) => void
|
||||
}
|
||||
|
||||
export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPanelProps) {
|
||||
const { asset, timeframe, setAsset, setTimeframe, selectedPostId, setSelectedPost } = useDashboardStore()
|
||||
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost }: ChartPanelProps) {
|
||||
const { timeframe } = 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 selectedPostIdRef = useRef(externalSelectedId)
|
||||
selectedPostIdRef.current = externalSelectedId
|
||||
const timeframeRef = useRef(timeframe)
|
||||
timeframeRef.current = timeframe
|
||||
const onSelectRef = useRef(onSelectPost)
|
||||
onSelectRef.current = onSelectPost
|
||||
|
||||
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
|
||||
// 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
|
||||
|
||||
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: '#050505' },
|
||||
textColor: '#555555',
|
||||
background: { color: colors.background },
|
||||
textColor: colors.textColor,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#111111' },
|
||||
horzLines: { color: '#111111' },
|
||||
vertLines: { color: colors.gridColor },
|
||||
horzLines: { color: colors.gridColor },
|
||||
},
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
rightPriceScale: { borderColor: '#1a1a1a' },
|
||||
rightPriceScale: { borderColor: colors.borderColor },
|
||||
timeScale: {
|
||||
borderColor: '#1a1a1a',
|
||||
borderColor: colors.borderColor,
|
||||
timeVisible: true,
|
||||
rightOffset: 5,
|
||||
barSpacing: 10,
|
||||
@@ -61,47 +69,54 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
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',
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderUpColor: '#26a69a',
|
||||
borderDownColor: '#ef5350',
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350',
|
||||
})
|
||||
|
||||
// @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 }) => {
|
||||
// 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 allPosts = postsRef.current
|
||||
let closest: TrumpPost | null = null
|
||||
let closestDiff = Infinity
|
||||
const bucketByTf: Record<string, number> = {
|
||||
'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
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
})
|
||||
.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0))
|
||||
|
||||
if (inBucket.length === 0) {
|
||||
onSelectRef.current?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
const currentIdx = inBucket.findIndex((p) => p.id === selectedPostIdRef.current)
|
||||
if (currentIdx >= 0) {
|
||||
const nextIdx = currentIdx + 1
|
||||
if (nextIdx >= inBucket.length) {
|
||||
onSelectRef.current?.(null)
|
||||
} else {
|
||||
onSelectRef.current?.(inBucket[nextIdx].id)
|
||||
}
|
||||
} else {
|
||||
setSelectedPost(null)
|
||||
onSelectRef.current?.(inBucket[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,9 +127,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
}
|
||||
return () => { ro.disconnect() }
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -130,7 +143,7 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Update candles + markers whenever data changes
|
||||
// Update candles + markers
|
||||
useEffect(() => {
|
||||
const series = seriesRef.current
|
||||
const chart = chartRef.current
|
||||
@@ -147,7 +160,6 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
close: c.close,
|
||||
})))
|
||||
|
||||
// Show markers for all posts within visible time range
|
||||
const minTime = sorted[0].time
|
||||
const maxTime = sorted[sorted.length - 1].time
|
||||
|
||||
@@ -158,20 +170,44 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
const bucketByTf: Record<string, number> = {
|
||||
'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<number, typeof visible>()
|
||||
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)
|
||||
}
|
||||
@@ -181,45 +217,14 @@ export default function ChartPanel({ posts = MOCK_POSTS, candles = [] }: ChartPa
|
||||
chart.timeScale().fitContent()
|
||||
fittedRef.current = true
|
||||
}
|
||||
}, [candles, posts, selectedPostId])
|
||||
}, [candles, posts, externalSelectedId])
|
||||
|
||||
// Reset fit flag on timeframe/asset switch
|
||||
useEffect(() => {
|
||||
fittedRef.current = false
|
||||
}, [asset, timeframe])
|
||||
useEffect(() => { fittedRef.current = false }, [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>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user