/** * Small, dependency-light SVG charts (server-component friendly, no client JS). One visual system: * hairline axes, tabular numbers, series colours from the design tokens, dark surfaces. * Every chart accepts `title` (visually hidden ) for accessibility and renders an "Unavailable" state for empty data. */ import { max as d3max } from 'd3-array'; import { scaleBand, scaleLinear } from 'd3-scale'; import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape'; import { cn } from '@/lib/cn'; import { fmtCompact, fmtInt } from '@/lib/format'; export const SERIES = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)', 'var(--series-4)', 'var(--series-5)', 'var(--series-6)', 'var(--series-7)', 'var(--series-8)']; function Empty({ className, h }: { className?: string; h: number }) { return ( <div className={cn('flex items-center justify-center rounded-md border border-dashed border-rule text-xs text-ink-3', className)} style={{ height: h }} role="img" aria-label="Chart unavailable"> Unavailable </div> ); } export interface BarDatum { label: string; value: number; color?: string; href?: string; } /** Horizontal bars with labels โ€” ranking lists. */ export function HBars({ data, className, max, valueFormat = fmtInt, barHeight = 26, showValue = true }: { data: BarDatum[]; className?: string; max?: number; valueFormat?: (v: number) => string; barHeight?: number; showValue?: boolean }) { if (!data.length) return <Empty className={className} h={120} />; const m = max ?? d3max(data, (d) => d.value) ?? 1; return ( <ul className={cn('space-y-1.5', className)}> {data.map((d, i) => ( <li key={`${d.label}-${i}`} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm" style={{ minHeight: barHeight }}> <div className="min-w-0"> <div className="flex items-baseline justify-between gap-2"> {d.href ? <a href={d.href} className="truncate text-ink hover:text-accent hover:underline">{d.label}</a> : <span className="truncate text-ink">{d.label}</span>} {showValue && <span className="tnum shrink-0 text-xs text-ink-2">{valueFormat(d.value)}</span>} </div> <div className="mt-1 h-[6px] w-full overflow-hidden rounded-full bg-plane-2"> <div className="h-full rounded-full" style={{ width: `${Math.max(1, (d.value / m) * 100)}%`, background: d.color ?? 'var(--series-1)' }} /> </div> </div> </li> ))} </ul> ); } export interface SeriesPoint { x: number | string; y: number; } /** Vertical bar chart (time series by year/month). */ export function Bars({ data, className, height = 160, color = 'var(--series-1)', title, xTicks = 6, yFormat = fmtCompact, highlightLast = false }: { data: SeriesPoint[]; className?: string; height?: number; color?: string; title: string; xTicks?: number; yFormat?: (v: number) => string; highlightLast?: boolean }) { if (!data.length) return <Empty className={className} h={height} />; const W = 640; const H = height; const pad = { l: 36, r: 8, t: 8, b: 22 }; const x = scaleBand<string>().domain(data.map((d) => String(d.x))).range([pad.l, W - pad.r]).paddingInner(0.25); const ymax = d3max(data, (d) => d.y) ?? 1; const y = scaleLinear().domain([0, ymax || 1]).nice().range([H - pad.b, pad.t]); const ticks = y.ticks(4); const every = Math.max(1, Math.ceil(data.length / xTicks)); return ( <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none"> <title>{title} {ticks.map((t) => ( {yFormat(t)} ))} {data.map((d, i) => ( ))} {data.map((d, i) => (i % every === 0 || i === data.length - 1) && ( {String(d.x).slice(0, 7)} ))} ); } /** Stacked vertical bars (e.g. launches by year by region). `keys` order = stack order; colours from SERIES. */ export function StackedBars({ data, keys, labels, className, height = 200, title, xTicks = 8 }: { data: Record[]; keys: string[]; labels?: Record; className?: string; height?: number; title: string; xTicks?: number }) { if (!data.length) return ; const W = 640; const H = height; const pad = { l: 36, r: 8, t: 8, b: 22 }; const xs = data.map((d) => String(d.x)); const x = scaleBand().domain(xs).range([pad.l, W - pad.r]).paddingInner(0.2); const totals = data.map((d) => keys.reduce((s, k) => s + (Number(d[k]) || 0), 0)); const y = scaleLinear().domain([0, d3max(totals) || 1]).nice().range([H - pad.b, pad.t]); const every = Math.max(1, Math.ceil(data.length / xTicks)); return (
{title} {y.ticks(4).map((t) => ( {fmtCompact(t)} ))} {data.map((d, i) => { let acc = 0; return keys.map((k, ki) => { const v = Number(d[k]) || 0; const y0 = y(acc); acc += v; const y1 = y(acc); return ; }); })} {xs.map((lab, i) => (i % every === 0 || i === xs.length - 1) && ( {lab.slice(0, 7)} ))}
    {keys.map((k, i) => (
  • {labels?.[k] ?? k}
  • ))}
); } /** Area/line chart for continuous series. */ export function AreaChart({ data, className, height = 160, color = 'var(--series-1)', title, yFormat = fmtCompact, xLabel, fill = true, yDomain }: { data: SeriesPoint[]; className?: string; height?: number; color?: string; title: string; yFormat?: (v: number) => string; xLabel?: (x: number | string) => string; fill?: boolean; yDomain?: [number, number] }) { if (data.length < 2) return ; const W = 640; const H = height; const pad = { l: 40, r: 8, t: 8, b: 22 }; const x = scaleLinear().domain([0, data.length - 1]).range([pad.l, W - pad.r]); const ys = data.map((d) => d.y); const lo = yDomain ? yDomain[0] : Math.min(...ys); const hi = yDomain ? yDomain[1] : Math.max(...ys); const padY = (hi - lo) * 0.1 || 1; const y = scaleLinear().domain([yDomain ? lo : lo - padY, yDomain ? hi : hi + padY]).nice().range([H - pad.b, pad.t]); const ln = d3line().x((_, i) => x(i)).y((d) => y(d.y)).curve(curveMonotoneX); const ar = d3area().x((_, i) => x(i)).y0(H - pad.b).y1((d) => y(d.y)).curve(curveMonotoneX); const every = Math.max(1, Math.ceil(data.length / 6)); const gid = `g${Math.abs(hashStr(title))}`; return ( {title} {y.ticks(4).map((t) => ( {yFormat(t)} ))} {fill && } {data.map((d, i) => (i % every === 0 || i === data.length - 1) && ( {xLabel ? xLabel(d.x) : String(d.x).slice(0, 7)} ))} ); } /** Donut with legend โ€” status/orbit distributions. */ export function Donut({ data, className, size = 140, title, total }: { data: BarDatum[]; className?: string; size?: number; title: string; total?: number }) { const sum = data.reduce((s, d) => s + d.value, 0); if (!sum) return ; const r = size / 2; const stroke = size * 0.16; const c = 2 * Math.PI * (r - stroke / 2); let acc = 0; return (
{title} {data.map((d, i) => { const frac = d.value / sum; const el = ; acc += frac; return el; })} {fmtCompact(total ?? sum)}
    {data.map((d, i) => (
  • {d.label} {fmtInt(d.value)} ยท {((d.value / sum) * 100).toFixed(0)}%
  • ))}
); } /** Tiny inline sparkline. */ export function Sparkline({ values, width = 90, height = 24, color = 'var(--accent)' }: { values: number[]; width?: number; height?: number; color?: string }) { if (values.length < 2) return โ€”; const x = scaleLinear().domain([0, values.length - 1]).range([1, width - 1]); const y = scaleLinear().domain([Math.min(...values), Math.max(...values) || 1]).range([height - 2, 2]); const d = d3line().x((_, i) => x(i)).y((v) => y(v)).curve(curveMonotoneX)(values) ?? ''; return ( ); } /** Histogram (altitude/inclination profiles): values already bucketed. */ export function Histogram({ data, className, height = 140, color = 'var(--series-1)', title, unit = '', xTicks = 8 }: { data: { bin: number; value: number }[]; className?: string; height?: number; color?: string; title: string; unit?: string; xTicks?: number }) { if (!data.length) return ; const W = 640; const H = height; const pad = { l: 36, r: 8, t: 6, b: 22 }; const bins = data.map((d) => d.bin); const step = bins.length > 1 ? Math.min(...bins.slice(1).map((b, i) => b - (bins[i] ?? 0)).filter((v) => v > 0)) || 1 : 1; const x = scaleLinear().domain([Math.min(...bins), Math.max(...bins) + step]).range([pad.l, W - pad.r]); const y = scaleLinear().domain([0, d3max(data, (d) => d.value) || 1]).nice().range([H - pad.b, pad.t]); const bw = Math.max(1, x(step) - x(0) - 1); return ( {title} {y.ticks(3).map((t) => ( {fmtCompact(t)} ))} {data.map((d) => ( ))} {x.ticks(xTicks).map((t) => ( {fmtCompact(t)}{unit} ))} ); } function hashStr(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h; }