/**
* 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 (
Unavailable
);
}
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 ;
const m = max ?? d3max(data, (d) => d.value) ?? 1;
return (
{data.map((d, i) => (
{d.href ?
{d.label} :
{d.label} }
{showValue &&
{valueFormat(d.value)} }
))}
);
}
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 ;
const W = 640;
const H = height;
const pad = { l: 36, r: 8, t: 8, b: 22 };
const x = scaleBand().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 (
{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;
}