SoftTraderFrontend/components/stock-chart/stock-chart-core.tsx
2026-03-20 11:10:13 -04:00

318 lines
9.2 KiB
TypeScript

'use client'
import { useMemo } from 'react'
import {
ComposedChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Cell,
Customized,
} from 'recharts'
import type { ChartPoint, TimeRangeConfig } from './types'
const GREEN = 'oklch(0.65 0.18 142)'
const RED = 'oklch(0.55 0.22 25)'
const GREEN_V = 'rgba(70, 180, 80, 0.45)'
const RED_V = 'rgba(220, 60, 50, 0.45)'
interface TooltipPayloadItem {
value: number | null
payload: ChartPoint
}
interface CustomTooltipProps {
active?: boolean
payload?: TooltipPayloadItem[]
label?: string
}
function formatVolume(v: number | null): string {
if (v === null) return '-'
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}K`
return v.toString()
}
function formatLocalTimestamp(ts: string): string {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return ts
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
interface SegmentedLineProps {
xAxisMap?: Record<string, { scale: (v: number) => number }>
yAxisMap?: Record<string, { scale: (v: number) => number }>
data?: ChartPoint[]
yAxisId?: string
}
function SegmentedLine({ xAxisMap, yAxisMap, data, yAxisId = 'pct' }: SegmentedLineProps) {
if (!xAxisMap || !yAxisMap || !data?.length) return null
const xScale = Object.values(xAxisMap)[0]?.scale
const yScale = yAxisMap[yAxisId]?.scale
if (!xScale || !yScale) return null
const segments: { color: string; d: string }[] = []
let currentColor: string | null = null
let currentPath = ''
for (let i = 0; i < data.length; i++) {
const pt = data[i]
if (pt.percentChange === null) {
if (currentPath) {
segments.push({ color: currentColor!, d: currentPath })
currentPath = ''
currentColor = null
}
continue
}
const x = xScale(pt.index)
const y = yScale(pt.percentChange)
const color = pt.percentChange >= 0 ? GREEN : RED
if (i === 0 || !currentPath) {
currentColor = color
currentPath = `M ${x} ${y}`
continue
}
const prev = data[i - 1]
if (prev.percentChange === null) {
currentColor = color
currentPath = `M ${x} ${y}`
continue
}
if ((prev.percentChange >= 0) !== (pt.percentChange >= 0)) {
const pct0 = prev.percentChange
const pct1 = pt.percentChange
const t = pct0 / (pct0 - pct1)
const x0 = xScale(prev.index)
const x1 = xScale(pt.index)
const xCross = x0 + t * (x1 - x0)
const yCross = yScale(0)
currentPath += ` L ${xCross} ${yCross}`
segments.push({ color: currentColor!, d: currentPath })
currentColor = color
currentPath = `M ${xCross} ${yCross} L ${x} ${y}`
} else {
currentPath += ` L ${x} ${y}`
}
}
if (currentPath) {
segments.push({ color: currentColor!, d: currentPath })
}
return (
<g>
{segments.map((seg, i) => (
<path
key={i}
d={seg.d}
stroke={seg.color}
strokeWidth={1.5}
fill="none"
strokeLinejoin="round"
strokeLinecap="round"
/>
))}
</g>
)
}
function CustomTooltip({ active, payload }: CustomTooltipProps) {
if (!active || !payload?.length) return null
const d = payload[0].payload as ChartPoint
if (d.price === null || d.percentChange === null) return null
const isUp = d.percentChange >= 0
return (
<div className="bg-card border border-border rounded-md px-3 py-2 text-xs font-mono shadow-xl min-w-[160px]">
<p className="text-muted-foreground mb-1">{formatLocalTimestamp(d.timestamp)}</p>
<p className="text-foreground font-semibold text-sm">${d.price.toFixed(2)}</p>
<p className={isUp ? 'text-[oklch(0.65_0.18_142)]' : 'text-[oklch(0.55_0.22_25)]'}>
{isUp ? '+' : ''}{d.percentChange.toFixed(2)}%
</p>
<p className="text-muted-foreground mt-1">{formatVolume(d.volume)} vol</p>
</div>
)
}
interface StockChartCoreProps {
data: ChartPoint[]
config: TimeRangeConfig
}
export function StockChartCore({ data, config }: StockChartCoreProps) {
const tickIndices = useMemo(() => new Set(config.tickInterval(data)), [data, config])
const ticks = useMemo(
() => data.filter((_, i) => tickIndices.has(i)).map((d) => d.index),
[data, tickIndices]
)
const priceValues = useMemo(
() => data.map((d) => d.price).filter((v): v is number => v !== null),
[data]
)
const pctValues = useMemo(
() => data.map((d) => d.percentChange).filter((v): v is number => v !== null),
[data]
)
const volValues = useMemo(
() => data.map((d) => d.volume).filter((v): v is number => v !== null),
[data]
)
const priceMin = priceValues.length ? Math.min(...priceValues) : 0
const priceMax = priceValues.length ? Math.max(...priceValues) : 0
const pricePad = (priceMax - priceMin) * 0.08 || 1
const pctMin = pctValues.length ? Math.min(...pctValues) : 0
const pctMax = pctValues.length ? Math.max(...pctValues) : 0
const pctPad = Math.max(Math.abs(pctMax - pctMin) * 0.08, 0.05)
const volMax = volValues.length ? Math.max(...volValues) : 0
return (
<div className="w-full flex flex-col gap-0">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart
data={data}
margin={{ top: 8, right: 64, left: 0, bottom: 0 }}
>
<CartesianGrid vertical={false} stroke="oklch(0.22 0 0)" strokeDasharray="0" />
<XAxis
dataKey="index"
type="number"
domain={[0, data.length - 1]}
ticks={ticks}
tickFormatter={(v: number) => {
const pt = data[v]
return pt ? config.formatLabel(pt.timestamp) : ''
}}
tick={{ fill: 'oklch(0.55 0 0)', fontSize: 11, fontFamily: 'var(--font-mono)' }}
axisLine={false}
tickLine={false}
tickMargin={8}
interval={0}
/>
<YAxis
yAxisId="pct"
orientation="left"
domain={[pctMin - pctPad, pctMax + pctPad]}
tickFormatter={(v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`}
tick={{ fill: 'oklch(0.55 0 0)', fontSize: 10, fontFamily: 'var(--font-mono)' }}
axisLine={false}
tickLine={false}
width={70}
/>
<YAxis
yAxisId="price"
orientation="right"
domain={[priceMin - pricePad, priceMax + pricePad]}
tickFormatter={(v: number) => `$${v.toFixed(2)}`}
tick={{ fill: 'oklch(0.55 0 0)', fontSize: 10, fontFamily: 'var(--font-mono)' }}
axisLine={false}
tickLine={false}
width={72}
/>
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'oklch(0.4 0 0)', strokeWidth: 1 }} />
<ReferenceLine
yAxisId="pct"
y={0}
stroke="oklch(0.45 0 0)"
strokeDasharray="3 3"
strokeWidth={1}
/>
<Bar yAxisId="pct" dataKey="percentChange" fill="none" isAnimationActive={false} />
<Customized component={<SegmentedLine data={data} yAxisId="pct" />} />
</ComposedChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={80}>
<ComposedChart
data={data}
margin={{ top: 0, right: 64, left: 0, bottom: 0 }}
barCategoryGap={0}
barGap={0}
>
<CartesianGrid vertical={false} stroke="oklch(0.22 0 0)" />
<XAxis
dataKey="index"
type="number"
domain={[0, data.length - 1]}
ticks={ticks}
tick={false}
axisLine={false}
tickLine={false}
/>
<YAxis
orientation="left"
tickFormatter={formatVolume}
tick={{ fill: 'oklch(0.55 0 0)', fontSize: 10, fontFamily: 'var(--font-mono)' }}
axisLine={false}
tickLine={false}
width={70}
tickCount={3}
domain={[0, volMax * 1.1 || 1]}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={false}
axisLine={false}
tickLine={false}
width={72}
/>
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'oklch(0.4 0 0)', strokeWidth: 1 }} />
<Bar dataKey="volume" isAnimationActive={false} maxBarSize={24}>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={(entry.percentChange ?? 0) >= 0 ? GREEN_V : RED_V}
fillOpacity={entry.volume === null ? 0 : 1}
/>
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
)
}