318 lines
9.2 KiB
TypeScript
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>
|
|
)
|
|
}
|