import { extent, max, min } from 'd3-array'; import { scaleLinear, scaleLog, scaleTime } from 'd3-scale'; import { area, curveMonotoneX, curveStepAfter, line } from 'd3-shape'; import type { ReactNode } from 'react'; import { cn } from '@/lib/cn'; import { fmtCompact, fmtDeltaPct } from '@/lib/format'; /* Pure-SVG, server-safe charts. Colours come from CSS variables (--series-1..8, --accent, --ink-3, --rule) so they follow the theme. Every chart accepts `className` and sizes to its container via viewBox + width 100%. Interactive variants (hover crosshair, brushing) live in client files next to this one and reuse `lineLayout()`. */ export type Point = { x: number | Date; y: number }; /** * Sparkline. `variant="trend"` adds a delta label (last vs first, %) coloured positive/negative — pass `invert` when * lower is better (prices) so a drop reads as positive. */ export function Sparkline({ values, width = 120, height = 28, className, stroke = 'var(--accent)', fill = true, strokeWidth = 1.5, variant = 'line', invert = false, format, title, }: { values: number[]; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; strokeWidth?: number; variant?: 'line' | 'trend'; invert?: boolean; format?: (v: number) => string; title?: string; }) { const clean = values.filter((v) => Number.isFinite(v)); if (clean.length < 2) return —; const x = scaleLinear().domain([0, clean.length - 1]).range([1, width - 1]); const [lo, hi] = extent(clean) as [number, number]; const y = scaleLinear().domain([lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi]).range([height - 2, 2]); const l = line().x((_, i) => x(i)).y((d) => y(d)).curve(curveMonotoneX); const a = area().x((_, i) => x(i)).y0(height).y1((d) => y(d)).curve(curveMonotoneX); const first = clean[0] as number; const last = clean[clean.length - 1] as number; const delta = fmtDeltaPct(first, last); const up = last > first; const good = invert ? !up : up; const svg = ( {fill && } ); if (variant === 'line') return svg; return ( {svg} {format && {format(last)}} {delta && {delta}} ); } /** Vertical bars (counts per bucket). Labels shown for ≤ 16 bars or every nth. */ export function Bars({ data, height = 120, className, color = 'var(--series-1)', showLabels = true, format = fmtCompact }: { data: { label: string; value: number }[]; height?: number; className?: string; color?: string; showLabels?: boolean; format?: (v: number) => string }) { if (!data.length) return

No data

; const w = 600; const pad = { l: 0, r: 0, t: 14, b: showLabels ? 18 : 2 }; const m = max(data, (d) => d.value) ?? 0; const y = scaleLinear().domain([0, m || 1]).range([height - pad.b, pad.t]); const bw = (w - pad.l - pad.r) / data.length; const every = Math.ceil(data.length / 12); return ( {data.map((d, i) => { const h = height - pad.b - y(d.value); return ( {d.value > 0 && data.length <= 24 && ( {format(d.value)} )} {showLabels && i % every === 0 && ( {d.label} )} ); })} ); } /** Horizontal bars with labels — for rankings (top orgs, categories). Uses HTML for crisp text. */ export function HBars({ data, className, color = 'var(--series-1)', format = fmtCompact, max: maxOverride, href }: { data: { label: string; value: number; sub?: string; href?: string }[]; className?: string; color?: string; format?: (v: number) => string; max?: number; href?: (d: { label: string }) => string | undefined }) { if (!data.length) return

No data

; const m = (maxOverride ?? max(data, (d) => d.value) ?? 0) || 1; return (
    {data.map((d) => { const link = d.href ?? href?.(d); const label = link ? ( {d.label} ) : ( {d.label} ); return (
  1. {label} {d.sub && {d.sub}}
    {format(d.value)}
  2. ); })}
); } export type Series = { name: string; color?: string; points: Point[] }; export interface LineChartProps { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number]; yScale?: 'linear' | 'log'; /** Step interpolation (prices, statuses): the value holds until the next point. */ step?: boolean; /** Extra SVG layers (crosshair, tooltips) rendered inside the chart — used by `InteractiveLineChart`. */ children?: ReactNode; /** Layout width in viewBox units (default 720). */ width?: number; } export const LINE_W = 720; export const LINE_PAD = { l: 44, r: 16, t: 12, b: 24 }; export type LineLayout = { w: number; height: number; pad: typeof LINE_PAD; x: (v: number) => number; y: (v: number) => number; /** Inverse of x for hover: viewBox x → time/number. */ xInvert: (px: number) => number; series: { name: string; color: string; pts: { x: number; y: number }[] }[]; ticksY: number[]; ticksX: number[]; fmtX: (v: number) => string; log: boolean; xTime: boolean; }; const toNum = (v: number | Date) => (v instanceof Date ? v.getTime() : Number(v)); /** Shared scale/layout computation for LineChart and its interactive wrapper. Returns null with < 2 usable points. */ export function lineLayout({ series, height = 220, xTime = true, yDomain, yScale = 'linear', width = LINE_W }: Pick): LineLayout | null { const log = yScale === 'log'; const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0); const all = series.flatMap((s) => s.points).filter(keep); if (all.length < 2) return null; const w = width; const pad = LINE_PAD; const xs = all.map((p) => toNum(p.x)); const [x0, x1] = extent(xs) as [number, number]; const xScale = xTime ? scaleTime().domain([new Date(x0), new Date(x1 === x0 ? x0 + 86400000 : x1)]).range([pad.l, w - pad.r]) : scaleLinear().domain([x0, x1 === x0 ? x0 + 1 : x1]).range([pad.l, w - pad.r]); const ys = all.map((p) => p.y); let [y0, y1] = yDomain ?? (extent(ys) as [number, number]); if (y0 === y1) { y0 = y0 * 0.9; y1 = y1 * 1.1 || 1; } if (!yDomain && !log) y0 = Math.min(0, y0); if (log) { y0 = Math.max(Number.EPSILON, y0 <= 0 ? (min(ys.filter((v) => v > 0)) as number) : y0) / 1.25; y1 = y1 * 1.25; } const yScaleFn = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); const x = (v: number) => (xTime ? (xScale as ReturnType)(new Date(v)) : (xScale as ReturnType)(v)) as number; const xInvert = (px: number) => { const inv = (xScale as { invert: (p: number) => Date | number }).invert(px); return inv instanceof Date ? inv.getTime() : Number(inv); }; const ticksY = log ? logTicks(y0, y1) : (yScaleFn as ReturnType).ticks(4); const ticksX = (xScale as { ticks: (n: number) => (Date | number)[] }).ticks(5).map(toNum); const fmtX = (v: number) => (xTime ? new Date(v).toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); return { w, height, pad, x, y: (v: number) => yScaleFn(v) as number, xInvert, series: series.map((s, i) => ({ name: s.name, color: s.color ?? `var(--series-${(i % 8) + 1})`, pts: s.points .filter(keep) .map((p) => ({ x: toNum(p.x), y: p.y })) .sort((a, b) => a.x - b.x), })), ticksY, ticksX, fmtX, log, xTime, }; } /** * Multi-series line chart with time or linear x axis, light grid, end labels. * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0). * `step` switches to step-after interpolation (see `StepChart`). Pass `children` to draw extra layers. */ export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear', step = false, children, width }: LineChartProps) { const L = lineLayout({ series, height, xTime, yDomain, yScale, width }); if (!L) return

Not enough history

; const l = line<{ x: number; y: number }>() .x((p) => L.x(p.x)) .y((p) => L.y(p.y)) .curve(step ? curveStepAfter : curveMonotoneX); return ( {yLabel && {yLabel}} {L.ticksY.map((t) => ( {yFormat(t)} ))} {L.ticksX.map((t, i) => ( {L.fmtX(t)} ))} {L.series.map((s) => { if (s.pts.length === 0) return null; const last = s.pts[s.pts.length - 1] as { x: number; y: number }; return ( {(showDots || s.pts.length < 12) && s.pts.map((p, j) => )} ); })} {children} ); } /** Step chart = LineChart in step mode (prices, statuses). */ export function StepChart(props: Omit) { return ; } /** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */ export function logTicks(lo: number, hi: number): number[] { const out: number[] = []; for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e); if (out.length < 2) return [lo, hi]; return out; } export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) { return (
    {series.map((s, i) => (
  • {s.name}
  • ))}
); } /** Step chart (price history): keeps a value until the next change. */ export function stepPoints(rows: { at: string; value: number | null }[]): Point[] { const out: Point[] = []; for (const r of rows) { if (r.value === null || !Number.isFinite(r.value)) continue; const t = new Date(r.at); if (Number.isNaN(t.getTime())) continue; const prev = out[out.length - 1]; if (prev) out.push({ x: new Date(t.getTime() - 1), y: prev.y }); out.push({ x: t, y: r.value }); } return out; } /* ------------------------------------------------------------------------------------------------------------ Heatmap */ export type HeatCell = { value: number | null; label?: string; href?: string; title?: string }; /** * Matrix heatmap (server-safe HTML table): row/column labels, sticky headers inside `.table-scroll`, colour scale from tokens * (`color-mix` of `--accent` — or a per-column `colors`) between `min` and `max` (defaults: matrix extent). Empty cells read "—". * `direction="lower"` inverts the scale for lower-is-better metrics. */ export function Heatmap({ rows, cols, cell, format = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1)), min: minOverride, max: maxOverride, color = 'var(--accent)', direction = 'higher', className, caption, rowHeader = '', }: { rows: { key: string; label: string; href?: string; sub?: string }[]; cols: { key: string; label: string; href?: string; sub?: string }[]; cell: (rowKey: string, colKey: string) => HeatCell | null | undefined; format?: (v: number) => string; min?: number; max?: number; color?: string; direction?: 'higher' | 'lower'; className?: string; caption?: string; rowHeader?: string; }) { const values: number[] = []; for (const r of rows) for (const c of cols) { const v = cell(r.key, c.key)?.value; if (v !== null && v !== undefined && Number.isFinite(v)) values.push(v); } if (!rows.length || !cols.length || !values.length) return

No data

; const lo = minOverride ?? (min(values) as number); const hi = maxOverride ?? (max(values) as number); const t = (v: number) => { const r = hi === lo ? 1 : (v - lo) / (hi - lo); const k = direction === 'lower' ? 1 - r : r; return Math.max(0, Math.min(1, k)); }; return (
{caption && } {cols.map((c) => ( ))} {rows.map((r) => ( {cols.map((c) => { const h = cell(r.key, c.key); const v = h?.value; if (v === null || v === undefined || !Number.isFinite(v)) return ( ); const k = t(v); const pct = Math.round(8 + k * 72); const style = { background: `color-mix(in srgb, ${color} ${pct}%, var(--surface))`, color: k > 0.62 ? 'var(--accent-ink)' : 'var(--ink)' }; const text = h?.label ?? format(v); return ( ); })} ))}
{caption}
{rowHeader} {c.href ? ( {c.label} ) : ( c.label )} {c.sub && {c.sub}}
{r.href ? ( {r.label} ) : ( r.label )} {r.sub && {r.sub}} — {h?.href ? {text} : text}
); }