HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1import { extent, max, min } from 'd3-array';2import { scaleLinear, scaleLog, scaleTime } from 'd3-scale';3import { area, curveMonotoneX, curveStepAfter, line } from 'd3-shape';4import type { ReactNode } from 'react';5import { cn } from '@/lib/cn';6import { fmtCompact, fmtDeltaPct } from '@/lib/format';78/*9 Pure-SVG, server-safe charts. Colours come from CSS variables (--series-1..8, --accent, --ink-3, --rule) so they10 follow the theme. Every chart accepts `className` and sizes to its container via viewBox + width 100%.11 Interactive variants (hover crosshair, brushing) live in client files next to this one and reuse `lineLayout()`.12*/1314export type Point = { x: number | Date; y: number };1516/**17 * Sparkline. `variant="trend"` adds a delta label (last vs first, %) coloured positive/negative — pass `invert` when18 * lower is better (prices) so a drop reads as positive.19 */20export function Sparkline({21 values,22 width = 120,23 height = 28,24 className,25 stroke = 'var(--accent)',26 fill = true,27 strokeWidth = 1.5,28 variant = 'line',29 invert = false,30 format,31 title,32}: {33 values: number[];34 width?: number;35 height?: number;36 className?: string;37 stroke?: string;38 fill?: boolean;39 strokeWidth?: number;40 variant?: 'line' | 'trend';41 invert?: boolean;42 format?: (v: number) => string;43 title?: string;44}) {45 const clean = values.filter((v) => Number.isFinite(v));46 if (clean.length < 2) return <span className={cn('inline-block text-xs text-ink-3', className)}>—</span>;47 const x = scaleLinear().domain([0, clean.length - 1]).range([1, width - 1]);48 const [lo, hi] = extent(clean) as [number, number];49 const y = scaleLinear().domain([lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi]).range([height - 2, 2]);50 const l = line<number>().x((_, i) => x(i)).y((d) => y(d)).curve(curveMonotoneX);51 const a = area<number>().x((_, i) => x(i)).y0(height).y1((d) => y(d)).curve(curveMonotoneX);52 const first = clean[0] as number;53 const last = clean[clean.length - 1] as number;54 const delta = fmtDeltaPct(first, last);55 const up = last > first;56 const good = invert ? !up : up;57 const svg = (58 <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} className={cn('overflow-visible', variant === 'line' && className)} role={title ? 'img' : undefined} aria-label={title} aria-hidden={title ? undefined : true}>59 {fill && <path d={a(clean) ?? ''} fill={stroke} opacity={0.1} />}60 <path d={l(clean) ?? ''} fill="none" stroke={stroke} strokeWidth={strokeWidth} />61 <circle cx={x(clean.length - 1)} cy={y(last)} r={2} fill={stroke} />62 </svg>63 );64 if (variant === 'line') return svg;65 return (66 <span className={cn('inline-flex items-center gap-2', className)}>67 {svg}68 <span className="tnum flex flex-col leading-tight">69 {format && <span className="text-sm font-medium text-ink">{format(last)}</span>}70 {delta && <span className={cn('text-[11px] font-medium', last === first ? 'text-ink-3' : good ? 'text-positive' : 'text-danger')}>{delta}</span>}71 </span>72 </span>73 );74}7576/** Vertical bars (counts per bucket). Labels shown for ≤ 16 bars or every nth. */77export 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 }) {78 if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;79 const w = 600;80 const pad = { l: 0, r: 0, t: 14, b: showLabels ? 18 : 2 };81 const m = max(data, (d) => d.value) ?? 0;82 const y = scaleLinear().domain([0, m || 1]).range([height - pad.b, pad.t]);83 const bw = (w - pad.l - pad.r) / data.length;84 const every = Math.ceil(data.length / 12);85 return (86 <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label="Bar chart">87 <line x1={0} x2={w} y1={height - pad.b} y2={height - pad.b} stroke="var(--rule-strong)" />88 {data.map((d, i) => {89 const h = height - pad.b - y(d.value);90 return (91 <g key={d.label + i}>92 <rect x={pad.l + i * bw + bw * 0.15} y={y(d.value)} width={bw * 0.7} height={Math.max(0, h)} fill={color} opacity={0.9} />93 {d.value > 0 && data.length <= 24 && (94 <text x={pad.l + i * bw + bw / 2} y={y(d.value) - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="tnum">95 {format(d.value)}96 </text>97 )}98 {showLabels && i % every === 0 && (99 <text x={pad.l + i * bw + bw / 2} y={height - 5} textAnchor="middle" fontSize={9} fill="var(--ink-3)">100 {d.label}101 </text>102 )}103 </g>104 );105 })}106 </svg>107 );108}109110/** Horizontal bars with labels — for rankings (top orgs, categories). Uses HTML for crisp text. */111export 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 }) {112 if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;113 const m = (maxOverride ?? max(data, (d) => d.value) ?? 0) || 1;114 return (115 <ol className={cn('space-y-1.5', className)}>116 {data.map((d) => {117 const link = d.href ?? href?.(d);118 const label = link ? (119 <a href={link} className="truncate text-ink hover:text-accent hover:underline">120 {d.label}121 </a>122 ) : (123 <span className="truncate text-ink">{d.label}</span>124 );125 return (126 <li key={d.label} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 text-sm">127 <div className="flex min-w-0 items-baseline gap-2">128 {label}129 {d.sub && <span className="truncate text-xs text-ink-3">{d.sub}</span>}130 </div>131 <span className="tnum text-xs text-ink-2">{format(d.value)}</span>132 <div className="col-span-2 h-1 rounded-sm bg-surface-2">133 <div className="h-full rounded-sm" style={{ width: `${(100 * d.value) / m}%`, background: color }} />134 </div>135 </li>136 );137 })}138 </ol>139 );140}141142export type Series = { name: string; color?: string; points: Point[] };143144export interface LineChartProps {145 series: Series[];146 height?: number;147 className?: string;148 yFormat?: (v: number) => string;149 xTime?: boolean;150 yLabel?: string;151 showDots?: boolean;152 yDomain?: [number, number];153 yScale?: 'linear' | 'log';154 /** Step interpolation (prices, statuses): the value holds until the next point. */155 step?: boolean;156 /** Extra SVG layers (crosshair, tooltips) rendered inside the chart — used by `InteractiveLineChart`. */157 children?: ReactNode;158 /** Layout width in viewBox units (default 720). */159 width?: number;160}161162export const LINE_W = 720;163export const LINE_PAD = { l: 44, r: 16, t: 12, b: 24 };164165export type LineLayout = {166 w: number;167 height: number;168 pad: typeof LINE_PAD;169 x: (v: number) => number;170 y: (v: number) => number;171 /** Inverse of x for hover: viewBox x → time/number. */172 xInvert: (px: number) => number;173 series: { name: string; color: string; pts: { x: number; y: number }[] }[];174 ticksY: number[];175 ticksX: number[];176 fmtX: (v: number) => string;177 log: boolean;178 xTime: boolean;179};180181const toNum = (v: number | Date) => (v instanceof Date ? v.getTime() : Number(v));182183/** Shared scale/layout computation for LineChart and its interactive wrapper. Returns null with < 2 usable points. */184export function lineLayout({ series, height = 220, xTime = true, yDomain, yScale = 'linear', width = LINE_W }: Pick<LineChartProps, 'series' | 'height' | 'xTime' | 'yDomain' | 'yScale' | 'width'>): LineLayout | null {185 const log = yScale === 'log';186 const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0);187 const all = series.flatMap((s) => s.points).filter(keep);188 if (all.length < 2) return null;189 const w = width;190 const pad = LINE_PAD;191 const xs = all.map((p) => toNum(p.x));192 const [x0, x1] = extent(xs) as [number, number];193 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]);194 const ys = all.map((p) => p.y);195 let [y0, y1] = yDomain ?? (extent(ys) as [number, number]);196 if (y0 === y1) {197 y0 = y0 * 0.9;198 y1 = y1 * 1.1 || 1;199 }200 if (!yDomain && !log) y0 = Math.min(0, y0);201 if (log) {202 y0 = Math.max(Number.EPSILON, y0 <= 0 ? (min(ys.filter((v) => v > 0)) as number) : y0) / 1.25;203 y1 = y1 * 1.25;204 }205 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]);206 const x = (v: number) => (xTime ? (xScale as ReturnType<typeof scaleTime>)(new Date(v)) : (xScale as ReturnType<typeof scaleLinear>)(v)) as number;207 const xInvert = (px: number) => {208 const inv = (xScale as { invert: (p: number) => Date | number }).invert(px);209 return inv instanceof Date ? inv.getTime() : Number(inv);210 };211 const ticksY = log ? logTicks(y0, y1) : (yScaleFn as ReturnType<typeof scaleLinear>).ticks(4);212 const ticksX = (xScale as { ticks: (n: number) => (Date | number)[] }).ticks(5).map(toNum);213 const fmtX = (v: number) => (xTime ? new Date(v).toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v));214 return {215 w,216 height,217 pad,218 x,219 y: (v: number) => yScaleFn(v) as number,220 xInvert,221 series: series.map((s, i) => ({222 name: s.name,223 color: s.color ?? `var(--series-${(i % 8) + 1})`,224 pts: s.points225 .filter(keep)226 .map((p) => ({ x: toNum(p.x), y: p.y }))227 .sort((a, b) => a.x - b.x),228 })),229 ticksY,230 ticksX,231 fmtX,232 log,233 xTime,234 };235}236237/**238 * Multi-series line chart with time or linear x axis, light grid, end labels.239 * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices240 * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0).241 * `step` switches to step-after interpolation (see `StepChart`). Pass `children` to draw extra layers.242 */243export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear', step = false, children, width }: LineChartProps) {244 const L = lineLayout({ series, height, xTime, yDomain, yScale, width });245 if (!L) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>;246 const l = line<{ x: number; y: number }>()247 .x((p) => L.x(p.x))248 .y((p) => L.y(p.y))249 .curve(step ? curveStepAfter : curveMonotoneX);250 return (251 <svg viewBox={`0 0 ${L.w} ${L.height}`} className={cn('block w-full', className)} role="img" aria-label={yLabel ?? 'Line chart'}>252 {yLabel && <title>{yLabel}</title>}253 {L.ticksY.map((t) => (254 <g key={t}>255 <line x1={L.pad.l} x2={L.w - L.pad.r} y1={L.y(t)} y2={L.y(t)} stroke="var(--rule)" />256 <text x={L.pad.l - 6} y={L.y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum">257 {yFormat(t)}258 </text>259 </g>260 ))}261 {L.ticksX.map((t, i) => (262 <text key={i} x={L.x(t)} y={L.height - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">263 {L.fmtX(t)}264 </text>265 ))}266 {L.series.map((s) => {267 if (s.pts.length === 0) return null;268 const last = s.pts[s.pts.length - 1] as { x: number; y: number };269 return (270 <g key={s.name}>271 <path d={l(s.pts) ?? ''} fill="none" stroke={s.color} strokeWidth={1.75} />272 {(showDots || s.pts.length < 12) && s.pts.map((p, j) => <circle key={j} cx={L.x(p.x)} cy={L.y(p.y)} r={2.2} fill={s.color} />)}273 <circle cx={L.x(last.x)} cy={L.y(last.y)} r={3} fill={s.color} />274 </g>275 );276 })}277 {children}278 </svg>279 );280}281282/** Step chart = LineChart in step mode (prices, statuses). */283export function StepChart(props: Omit<LineChartProps, 'step'>) {284 return <LineChart {...props} step />;285}286287/** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */288export function logTicks(lo: number, hi: number): number[] {289 const out: number[] = [];290 for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e);291 if (out.length < 2) return [lo, hi];292 return out;293}294295export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) {296 return (297 <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>298 {series.map((s, i) => (299 <li key={s.name} className="flex items-center gap-1.5">300 <span className="inline-block h-[3px] w-4 rounded-sm" style={{ background: s.color ?? `var(--series-${(i % 8) + 1})` }} />301 {s.name}302 </li>303 ))}304 </ul>305 );306}307308/** Step chart (price history): keeps a value until the next change. */309export function stepPoints(rows: { at: string; value: number | null }[]): Point[] {310 const out: Point[] = [];311 for (const r of rows) {312 if (r.value === null || !Number.isFinite(r.value)) continue;313 const t = new Date(r.at);314 if (Number.isNaN(t.getTime())) continue;315 const prev = out[out.length - 1];316 if (prev) out.push({ x: new Date(t.getTime() - 1), y: prev.y });317 out.push({ x: t, y: r.value });318 }319 return out;320}321322/* ------------------------------------------------------------------------------------------------------------ Heatmap */323export type HeatCell = { value: number | null; label?: string; href?: string; title?: string };324325/**326 * Matrix heatmap (server-safe HTML table): row/column labels, sticky headers inside `.table-scroll`, colour scale from tokens327 * (`color-mix` of `--accent` — or a per-column `colors`) between `min` and `max` (defaults: matrix extent). Empty cells read "—".328 * `direction="lower"` inverts the scale for lower-is-better metrics.329 */330export function Heatmap({331 rows,332 cols,333 cell,334 format = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1)),335 min: minOverride,336 max: maxOverride,337 color = 'var(--accent)',338 direction = 'higher',339 className,340 caption,341 rowHeader = '',342}: {343 rows: { key: string; label: string; href?: string; sub?: string }[];344 cols: { key: string; label: string; href?: string; sub?: string }[];345 cell: (rowKey: string, colKey: string) => HeatCell | null | undefined;346 format?: (v: number) => string;347 min?: number;348 max?: number;349 color?: string;350 direction?: 'higher' | 'lower';351 className?: string;352 caption?: string;353 rowHeader?: string;354}) {355 const values: number[] = [];356 for (const r of rows) for (const c of cols) {357 const v = cell(r.key, c.key)?.value;358 if (v !== null && v !== undefined && Number.isFinite(v)) values.push(v);359 }360 if (!rows.length || !cols.length || !values.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;361 const lo = minOverride ?? (min(values) as number);362 const hi = maxOverride ?? (max(values) as number);363 const t = (v: number) => {364 const r = hi === lo ? 1 : (v - lo) / (hi - lo);365 const k = direction === 'lower' ? 1 - r : r;366 return Math.max(0, Math.min(1, k));367 };368 return (369 <div className={cn('table-scroll', className)}>370 <table className="heatmap">371 {caption && <caption className="sr-only">{caption}</caption>}372 <thead>373 <tr>374 <th scope="col" className="text-left">{rowHeader}</th>375 {cols.map((c) => (376 <th key={c.key} scope="col">377 {c.href ? (378 <a href={c.href} className="hover:text-accent">379 {c.label}380 </a>381 ) : (382 c.label383 )}384 {c.sub && <span className="block text-[10px] font-normal text-ink-3">{c.sub}</span>}385 </th>386 ))}387 </tr>388 </thead>389 <tbody>390 {rows.map((r) => (391 <tr key={r.key}>392 <th scope="row">393 {r.href ? (394 <a href={r.href} className="hover:text-accent">395 {r.label}396 </a>397 ) : (398 r.label399 )}400 {r.sub && <span className="block text-[10px] font-normal text-ink-3">{r.sub}</span>}401 </th>402 {cols.map((c) => {403 const h = cell(r.key, c.key);404 const v = h?.value;405 if (v === null || v === undefined || !Number.isFinite(v))406 return (407 <td key={c.key} className="empty" title={h?.title}>408 —409 </td>410 );411 const k = t(v);412 const pct = Math.round(8 + k * 72);413 const style = { background: `color-mix(in srgb, ${color} ${pct}%, var(--surface))`, color: k > 0.62 ? 'var(--accent-ink)' : 'var(--ink)' };414 const text = h?.label ?? format(v);415 return (416 <td key={c.key} style={style} title={h?.title ?? `${r.label} · ${c.label}: ${text}`}>417 {h?.href ? <a href={h.href}>{text}</a> : text}418 </td>419 );420 })}421 </tr>422 ))}423 </tbody>424 </table>425 </div>426 );427}428