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%
4.1 KB · 84 lines tsx
Raw Blame History
1import { fmtValue } from '@/lib/format';23export interface Series {4  name: string;5  points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>;6  dashed?: boolean; // e.g. estimated vs observed7}89const SERIES_COLORS = ['var(--color-series-1)', 'var(--color-series-2)', 'var(--color-series-3)', 'var(--color-series-4)', 'var(--color-series-5)', 'var(--color-series-6)', 'var(--color-series-7)', 'var(--color-series-8)'];1011/**12 * Time-series line chart in pure SVG: years on x, values on y, optional confidence band.13 * Zero-based y axis; light grid; every series has a text legend (colour is never the only carrier).14 */15export function LineChart({ series, unit, ariaLabel, height = 220 }: { series: Series[]; unit?: string | null; ariaLabel: string; height?: number }) {16  const all = series.flatMap((s) => s.points);17  if (all.length === 0) return null;18  const xs = all.map((p) => p.x);19  const ys = all.flatMap((p) => [p.y, p.lo ?? p.y, p.hi ?? p.y]);20  const xMin = Math.min(...xs);21  const xMax = Math.max(...xs);22  const yMax = Math.max(...ys, Number.EPSILON) * 1.08;23  const width = 640;24  const pad = { l: 56, r: 12, t: 10, b: 28 };25  const iw = width - pad.l - pad.r;26  const ih = height - pad.t - pad.b;27  const sx = (x: number) => pad.l + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw);28  const sy = (y: number) => pad.t + ih - (y / yMax) * ih;29  const yTicks = 4;30  const xTickCount = Math.min(8, xMax - xMin + 1);31  const xTicks = Array.from({ length: xTickCount }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / Math.max(1, xTickCount - 1)));32  return (33    <figure className="w-full">34      <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block">35        <title>{ariaLabel}</title>36        {Array.from({ length: yTicks + 1 }, (_, i) => {37          const v = (yMax / yTicks) * i;38          const y = sy(v);39          return (40            <g key={i}>41              <line x1={pad.l} x2={width - pad.r} y1={y} y2={y} stroke="var(--color-rule)" strokeWidth="1" />42              <text x={pad.l - 6} y={y + 4} textAnchor="end" fontSize="11" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>43                {fmtValue(v, unit === 'count' ? 'count' : unit)}44              </text>45            </g>46          );47        })}48        {xTicks.map((x) => (49          <text key={x} x={sx(x)} y={height - 8} textAnchor="middle" fontSize="11" fill="var(--color-ink-3)">50            {x}51          </text>52        ))}53        {series.map((s, si) => {54          const pts = [...s.points].sort((a, b) => a.x - b.x);55          const color = SERIES_COLORS[si % SERIES_COLORS.length];56          const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' ');57          const band = pts.filter((p) => p.lo != null && p.hi != null);58          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;59          return (60            <g key={s.name}>61              {bandPath ? <path d={bandPath} fill={color} opacity="0.12" /> : null}62              <path d={d} fill="none" stroke={color} strokeWidth="1.75" strokeDasharray={s.dashed ? '4 3' : undefined} />63              {pts.map((p) => (64                <circle key={p.x} cx={sx(p.x)} cy={sy(p.y)} r="2.5" fill={color}>65                  <title>{`${s.name} — ${p.x}: ${fmtValue(p.y, unit)}`}</title>66                </circle>67              ))}68            </g>69          );70        })}71      </svg>72      <figcaption className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-ink-2">73        {series.map((s, si) => (74          <span key={s.name} className="inline-flex items-center gap-1.5">75            <span className="inline-block h-[2px] w-4" style={{ background: SERIES_COLORS[si % SERIES_COLORS.length], borderTop: s.dashed ? '2px dashed' : undefined }} aria-hidden />76            {s.name}77            {s.dashed ? ' (estimated)' : ''}78          </span>79        ))}80      </figcaption>81    </figure>82  );83}84