Files
trumpsignal-frontend/components/dashboard/ChartPanel.tsx
T
k d01adc4790 style(dashboard): unify Macro composite into one card + dark-mode fix
for Performance accent

User flagged the macro index box on the overview page as feeling
disjointed. Reshaped it from three stacked elements (band → track →
scale) into a single bordered card so it reads as ONE component.

  - JSX: wrap the three pieces in <div className="overview-macro-card">
  - CSS: new .overview-macro-card with tone-coloured rim (bull/bear/neutral)
  - Solid neutral-gray filled needle (was a hollow ring — looked like a
    placeholder); tone-coloured background + white inner ring + double
    shadow so it stands out on any gradient position
  - Removed the .overview-score-fill overlay — the gradient already
    encodes the spectrum; layering an opaque fill obscured it near 0
  - Thinner track (14px vs 22px), tighter scale labels, smaller pill
  - Added "TODAY · 8 INDICATORS" stamp next to the title — gives users
    a quick anchor of what they're looking at + freshness

Plus: dark-mode override for .overview-stat-card.accent (the Performance
card). It was using a cream gradient that floated as a glaring
out-of-theme block on dark mode. Mirrored the existing .kpi.accent dark
treatment so it stays visually grouped with the rest of the dashboard.

Also includes the in-flight overview rewrite from the other AI tool
(legacy /btc redirect to /macro, middleware.ts replacing proxy.ts for
Next.js routing, refactored several dashboard panels). TypeScript clean,
production build passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:25:59 +08:00

391 lines
14 KiB
TypeScript

'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<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)
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',
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'
vertical.style.transition = 'left 160ms ease, 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'
label.style.transition = 'left 160ms ease, opacity 120ms ease'
containerRef.current.appendChild(label)
macroLabelRef.current = label
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<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 })
}
})
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
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) 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<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)
// 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',
})
}
const x = (chart as any).timeScale?.().timeToCoordinate?.(bucketTime)
if (typeof x === 'number' && Number.isFinite(x)) {
macroVerticalRef.current.style.left = `${x}px`
macroVerticalRef.current.style.opacity = '1'
if (macroLabelRef.current) {
macroLabelRef.current.textContent =
(macroSignal.source || '') === 'btc_bottom_reversal'
? 'Macro bottom'
: 'Funding reversal'
const clamped = Math.max(76, Math.min((containerRef.current?.clientWidth ?? x) - 76, x))
macroLabelRef.current.style.left = `${clamped}px`
macroLabelRef.current.style.opacity = '1'
}
} else {
macroVerticalRef.current.style.opacity = '0'
if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0'
}
} else if (macroVerticalRef.current) {
macroVerticalRef.current.style.opacity = '0'
if (macroLabelRef.current) macroLabelRef.current.style.opacity = '0'
}
if (!fittedRef.current) {
// @ts-expect-error lightweight-charts type
chart.timeScale().fitContent()
fittedRef.current = true
}
}, [candles, posts, externalSelectedId, asset])
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 (
<div
ref={containerRef}
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
/>
)
}