/** * Small server-renderable SVG charts for account pages: one system (hairlines, tabular numbers, * neutral ink, accents only for gain/loss/index). No client JS required. */ import { cn } from '@/lib/format'; // Categorical identity: the four validated series tokens in fixed order; beyond four, fold into muted steps ('Other'). const PALETTE = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)', 'var(--ri-fg-muted)', 'var(--ri-fg-subtle)', 'var(--ri-border-strong)', 'var(--ri-border)']; export function Donut({ data, size = 120, thickness = 14, className, centerLabel, centerValue }: { data: Array<{ label: string; value: number }>; size?: number; thickness?: number; className?: string; centerLabel?: string; centerValue?: string }) { const total = data.reduce((a, d) => a + d.value, 0); const r = (size - thickness) / 2; const c = 2 * Math.PI * r; let offset = 0; return (
{total > 0 ? data.map((d, i) => { const len = (d.value / total) * c; const gap = data.length > 1 ? Math.min(2, len / 2) : 0; // 2px surface gap between segments const el = ; offset += len; return el; }) : null} {centerValue ? ( <> {centerValue} {centerLabel ? ( {centerLabel} ) : null} ) : null}
); } export interface SeriesPoint { date: string; value: number; } export function LineChart({ series, height = 180, className, formatY = (v: number) => v.toFixed(0), showArea = true, ariaLabel = 'Value over time' }: { series: Array<{ name: string; points: SeriesPoint[]; color?: string; dashed?: boolean }>; height?: number; className?: string; formatY?: (v: number) => string; showArea?: boolean; ariaLabel?: string }) { const all = series.flatMap((s) => s.points); if (all.length < 2) { return (
Not enough history yet — values are snapshotted daily.
); } const W = 640; const H = height; const padL = 44; const padR = 8; const padT = 8; const padB = 20; const dates = [...new Set(all.map((p) => p.date))].sort(); const t0 = new Date(dates[0]!).getTime(); const t1 = new Date(dates[dates.length - 1]!).getTime(); const min = Math.min(...all.map((p) => p.value)); const max = Math.max(...all.map((p) => p.value)); const span = max - min || max || 1; const yMin = min - span * 0.08; const yMax = max + span * 0.08; const x = (d: string) => padL + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - padL - padR); const y = (v: number) => padT + (1 - (v - yMin) / (yMax - yMin)) * (H - padT - padB); const ticks = [yMin + (yMax - yMin) * 0.1, (yMin + yMax) / 2, yMax - (yMax - yMin) * 0.1]; return ( {ticks.map((t) => ( {formatY(t)} ))} {series.map((s, i) => { const pts = [...s.points].sort((a, b) => a.date.localeCompare(b.date)); const d = pts.map((p, j) => `${j === 0 ? 'M' : 'L'}${x(p.date).toFixed(1)},${y(p.value).toFixed(1)}`).join(' '); const color = s.color ?? PALETTE[i % PALETTE.length]; const last = pts[pts.length - 1]!; return ( {showArea && i === 0 ? : null} ); })} {dates[0]} {dates[dates.length - 1]} ); } export function Sparkline({ points, width = 96, height = 24, positive }: { points: number[]; width?: number; height?: number; positive?: boolean | null }) { if (points.length < 2) return —; const min = Math.min(...points); const max = Math.max(...points); const span = max - min || 1; const d = points.map((v, i) => `${i === 0 ? 'M' : 'L'}${((i / (points.length - 1)) * width).toFixed(1)},${(height - 2 - ((v - min) / span) * (height - 4)).toFixed(1)}`).join(' '); const stroke = positive === null || positive === undefined ? 'var(--ri-fg-muted)' : positive ? 'var(--ri-gain)' : 'var(--ri-loss)'; return ( ); } export function Bars({ data, className, format = (v: number) => v.toFixed(0) }: { data: Array<{ label: string; value: number; tone?: 'gain' | 'loss' | 'neutral' }>; className?: string; format?: (v: number) => string }) { const max = Math.max(1, ...data.map((d) => Math.abs(d.value))); return ( ); } export function Progress({ value, className }: { value: number; className?: string }) { const v = Math.max(0, Math.min(1, value)); return ( = 1 ? 'bg-gain' : 'bg-index')} style={{ width: `${v * 100}%` }} /> ); }