491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
'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<HTMLDivElement>(null)
|
|
const chartRef = useRef<unknown>(null)
|
|
const seriesRef = useRef<unknown>(null)
|
|
const macroPriceLineRef = useRef<unknown>(null)
|
|
const macroVerticalRef = useRef<HTMLDivElement | null>(null)
|
|
const macroLabelRef = useRef<HTMLDivElement | null>(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<number | null>(null)
|
|
const macroLabelTextRef = useRef<string>('')
|
|
// 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<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
|
|
|
|
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<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 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 (
|
|
<div
|
|
ref={containerRef}
|
|
style={{ width: '100%', height: '100%', borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
|
|
/>
|
|
)
|
|
}
|