spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { fmtValue } from '@/lib/format';23export interface ExplorerChartSeries {4 name: string;5 points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>;6 dashed?: boolean; // estimate_type ≠ observed7 /** Fixed colour slot (0-based) so the same cancer keeps its hue across panels and after filtering. */8 slot?: number;9}1011/**12 * Categorical palette of the Data explorer: eight hues assigned in fixed order (never cycled — beyond13 * eight series the page switches to small multiples). Light and dark values are separate steps of the14 * same hues, both validated for colour-vision-deficiency separation and contrast against the paper15 * surfaces (`--color-paper` #fafaf7 / #151514). Colour is never the only carrier: every series has a text16 * legend entry, the tooltip names the series, and the observations table lists every value.17 */18export const EXPLORER_PALETTE_LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'] as const;19export const EXPLORER_PALETTE_DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'] as const;20export const MAX_PALETTE_SLOTS = EXPLORER_PALETTE_LIGHT.length;2122const PALETTE_CSS = `.ci-explorer-chart{${EXPLORER_PALETTE_LIGHT.map((c, i) => `--ex-${i}:${c};`).join('')}}23: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('')}}24@media (prefers-color-scheme: dark){:root:not([data-theme='light']) .ci-explorer-chart{${EXPLORER_PALETTE_DARK.map((c, i) => `--ex-${i}:${c};`).join('')}}}`;2526export function slotColor(slot: number): string {27 return `var(--ex-${Math.max(0, Math.min(MAX_PALETTE_SLOTS - 1, slot))})`;28}2930/**31 * Time-series chart in pure SVG (server-rendered): years on x, values on y from zero, optional 95 % CI32 * band, dashed lines for estimated values, 8 px hit targets with a native tooltip per point.33 * `yMax` lets several panels share one axis (small multiples); the axis range is always stated by the caller.34 */35export function ExplorerChart({ series, unit, ariaLabel, height = 240, yMax, compact = false }: { series: ExplorerChartSeries[]; unit?: string | null; ariaLabel: string; height?: number; yMax?: number; compact?: boolean }) {36 const all = series.flatMap((s) => s.points);37 if (all.length === 0) return null;38 const xs = all.map((p) => p.x);39 const ys = all.flatMap((p) => [p.y, p.lo ?? p.y, p.hi ?? p.y]);40 const xMin = Math.min(...xs);41 const xMax = Math.max(...xs);42 const top = Math.max(yMax ?? 0, ...ys, Number.EPSILON) * 1.08;43 const width = 640;44 const pad = { l: 60, r: 14, t: 12, b: 28 };45 const iw = width - pad.l - pad.r;46 const ih = height - pad.t - pad.b;47 const sx = (x: number) => pad.l + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw);48 const sy = (y: number) => pad.t + ih - (y / top) * ih;49 const yTicks = 4;50 const xTickCount = Math.min(compact ? 5 : 8, xMax - xMin + 1);51 const xTicks = Array.from({ length: xTickCount }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / Math.max(1, xTickCount - 1)));52 const fmt = (v: number) => fmtValue(v, unit);53 return (54 <figure className="ci-explorer-chart w-full">55 <style href="ci-explorer-chart-palette" precedence="default">56 {PALETTE_CSS}57 </style>58 <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block">59 <title>{ariaLabel}</title>60 {Array.from({ length: yTicks + 1 }, (_, i) => {61 const v = (top / yTicks) * i;62 const y = sy(v);63 return (64 <g key={i}>65 <line x1={pad.l} x2={width - pad.r} y1={y} y2={y} stroke="var(--color-rule)" strokeWidth="1" />66 <text x={pad.l - 6} y={y + 4} textAnchor="end" fontSize="11" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>67 {fmt(v)}68 </text>69 </g>70 );71 })}72 <line x1={pad.l} x2={width - pad.r} y1={sy(0)} y2={sy(0)} stroke="var(--color-rule-strong)" strokeWidth="1" />73 {xTicks.map((x) => (74 <text key={x} x={sx(x)} y={height - 8} textAnchor="middle" fontSize="11" fill="var(--color-ink-3)">75 {x}76 </text>77 ))}78 {series.map((s, si) => {79 const pts = [...s.points].sort((a, b) => a.x - b.x);80 const color = slotColor(s.slot ?? si);81 const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' ');82 const band = pts.filter((p) => p.lo != null && p.hi != null);83 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;84 return (85 <g key={s.name}>86 {bandPath ? <path d={bandPath} fill={color} opacity="0.12" /> : null}87 <path d={d} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeDasharray={s.dashed ? '5 3' : undefined} />88 {pts.map((p) => (89 <g key={p.x}>90 <circle cx={sx(p.x)} cy={sy(p.y)} r="2" fill={color} stroke="var(--color-paper)" strokeWidth="1" />91 <circle cx={sx(p.x)} cy={sy(p.y)} r="5" fill="transparent">92 <title>{`${s.name} — ${p.x}: ${fmt(p.y)}${p.lo != null && p.hi != null ? ` (95% CI ${fmt(p.lo)}–${fmt(p.hi)})` : ''}${s.dashed ? ' · estimated' : ''}`}</title>93 </circle>94 </g>95 ))}96 </g>97 );98 })}99 </svg>100 {series.length > 1 || !compact ? (101 <figcaption className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-ink-2">102 {series.map((s, si) => (103 <span key={s.name} className="inline-flex items-center gap-1.5">104 <span className="inline-block h-0 w-4 border-t-2" style={{ borderColor: slotColor(s.slot ?? si), borderTopStyle: s.dashed ? 'dashed' : 'solid' }} aria-hidden />105 {s.name}106 {s.dashed ? ' (estimated)' : ''}107 </span>108 ))}109 </figcaption>110 ) : null}111 </figure>112 );113}114