import { fmtValue } from '@/lib/format'; export interface ExplorerChartSeries { name: string; points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>; dashed?: boolean; // estimate_type ≠ observed /** Fixed colour slot (0-based) so the same cancer keeps its hue across panels and after filtering. */ slot?: number; } /** * Categorical palette of the Data explorer: eight hues assigned in fixed order (never cycled — beyond * eight series the page switches to small multiples). Light and dark values are separate steps of the * same hues, both validated for colour-vision-deficiency separation and contrast against the paper * surfaces (`--color-paper` #fafaf7 / #151514). Colour is never the only carrier: every series has a text * legend entry, the tooltip names the series, and the observations table lists every value. */ export const EXPLORER_PALETTE_LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'] as const; export const EXPLORER_PALETTE_DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'] as const; export const MAX_PALETTE_SLOTS = EXPLORER_PALETTE_LIGHT.length; const PALETTE_CSS = `.ci-explorer-chart{${EXPLORER_PALETTE_LIGHT.map((c, i) => `--ex-${i}:${c};`).join('')}} :root[data-theme='dark'] .ci-explorer-chart,:root:not([data-theme='light']):not([data-theme='dark']).ci-system-dark .ci-explorer-chart{${EXPLORER_PALETTE_DARK.map((c, i) => `--ex-${i}:${c};`).join('')}} @media (prefers-color-scheme: dark){:root:not([data-theme='light']) .ci-explorer-chart{${EXPLORER_PALETTE_DARK.map((c, i) => `--ex-${i}:${c};`).join('')}}}`; export function slotColor(slot: number): string { return `var(--ex-${Math.max(0, Math.min(MAX_PALETTE_SLOTS - 1, slot))})`; } /** * Time-series chart in pure SVG (server-rendered): years on x, values on y from zero, optional 95 % CI * band, dashed lines for estimated values, 8 px hit targets with a native tooltip per point. * `yMax` lets several panels share one axis (small multiples); the axis range is always stated by the caller. */ export function ExplorerChart({ series, unit, ariaLabel, height = 240, yMax, compact = false }: { series: ExplorerChartSeries[]; unit?: string | null; ariaLabel: string; height?: number; yMax?: number; compact?: boolean }) { const all = series.flatMap((s) => s.points); if (all.length === 0) return null; const xs = all.map((p) => p.x); const ys = all.flatMap((p) => [p.y, p.lo ?? p.y, p.hi ?? p.y]); const xMin = Math.min(...xs); const xMax = Math.max(...xs); const top = Math.max(yMax ?? 0, ...ys, Number.EPSILON) * 1.08; const width = 640; const pad = { l: 60, r: 14, t: 12, b: 28 }; const iw = width - pad.l - pad.r; const ih = height - pad.t - pad.b; const sx = (x: number) => pad.l + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw); const sy = (y: number) => pad.t + ih - (y / top) * ih; const yTicks = 4; const xTickCount = Math.min(compact ? 5 : 8, xMax - xMin + 1); const xTicks = Array.from({ length: xTickCount }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / Math.max(1, xTickCount - 1))); const fmt = (v: number) => fmtValue(v, unit); return (
{ariaLabel} {Array.from({ length: yTicks + 1 }, (_, i) => { const v = (top / yTicks) * i; const y = sy(v); return ( {fmt(v)} ); })} {xTicks.map((x) => ( {x} ))} {series.map((s, si) => { const pts = [...s.points].sort((a, b) => a.x - b.x); const color = slotColor(s.slot ?? si); const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); const band = pts.filter((p) => p.lo != null && p.hi != null); const bandPath = band.length > 1 ? `${band.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.hi!).toFixed(1)}`).join(' ')} ${[...band].reverse().map((p) => `L${sx(p.x).toFixed(1)},${sy(p.lo!).toFixed(1)}`).join(' ')} Z` : null; return ( {bandPath ? : null} {pts.map((p) => ( {`${s.name} — ${p.x}: ${fmt(p.y)}${p.lo != null && p.hi != null ? ` (95% CI ${fmt(p.lo)}–${fmt(p.hi)})` : ''}${s.dashed ? ' · estimated' : ''}`} ))} ); })} {series.length > 1 || !compact ? (
{series.map((s, si) => ( {s.name} {s.dashed ? ' (estimated)' : ''} ))}
) : null}
); }