import { extent, max } from 'd3-array'; import { scaleLinear, scaleLog, scaleTime } from 'd3-scale'; import { area, curveMonotoneX, line } from 'd3-shape'; import { cn } from '@/lib/cn'; import { fmtCompact } 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%. */ export type Point = { x: number | Date; y: number }; export function Sparkline({ values, width = 120, height = 28, className, stroke = 'var(--accent)', fill = true, strokeWidth = 1.5 }: { values: number[]; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; strokeWidth?: number }) { 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 last = clean[clean.length - 1] as number; return ( {fill && } ); } /** 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[] }; /** * 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). */ export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear' }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number]; yScale?: 'linear' | 'log' }) { 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

Not enough history

; const w = 720; const pad = { l: 44, r: 16, t: 12, b: 24 }; const xs = all.map((p) => (p.x instanceof Date ? p.x.getTime() : Number(p.x))); const [x0, x1] = extent(xs) as [number, number]; const x = 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 ? Math.min(...ys.filter((v) => v > 0)) : y0) / 1.25; y1 = y1 * 1.25; } const y = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number; const l = line().x(toX).y((p) => y(p.y)).curve(curveMonotoneX); const ticks = log ? logTicks(y0, y1) : y.ticks(4); const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5); const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); return ( {ticks.map((t) => ( {yFormat(t)} ))} {xt.map((t, i) => ( {fmtX(t)} ))} {series.map((s, i) => { const pts = s.points.filter(keep).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x))); if (pts.length === 0) return null; const color = s.color ?? `var(--series-${(i % 8) + 1})`; const last = pts[pts.length - 1] as Point; return ( {(showDots || pts.length < 12) && pts.map((p, j) => )} ); })} ); } /** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */ 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; }