SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
6.2 KB · 114 lines tsx
Raw Blame History
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