spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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