spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { fmtValue } from '@/lib/format';23export interface TrendSeries {4 key: string;5 name: string;6 href?: string;7 points: Array<{ x: number; y: number }>;8 dashed?: boolean; // estimated / projected values9}1011/**12 * Eight-way colour set that stays distinguishable in print and for common colour-vision deficiencies;13 * shape (dash) and the legend label carry the meaning too — colour is never the only carrier.14 */15const TREND_COLORS = ['var(--color-series-1)', 'var(--color-series-2)', 'var(--color-series-5)', 'var(--color-series-4)', 'var(--color-series-3)', 'var(--color-series-7)', 'var(--color-series-6)', 'var(--color-series-8)'];1617/**18 * Multi-series yearly line chart in pure SVG for country trend panels (up to 8 series). Zero-based y axis,19 * light grid, year ticks, end-of-line labels when space allows, plus a text legend with the last value.20 */21export function TrendChart({ series, unit, ariaLabel, height = 300, yLabel }: { series: TrendSeries[]; unit?: string | null; ariaLabel: string; height?: number; yLabel?: string }) {22 const shown = series.filter((s) => s.points.length > 0).slice(0, TREND_COLORS.length);23 const all = shown.flatMap((s) => s.points);24 if (all.length === 0) return null;25 const xs = all.map((p) => p.x);26 const xMin = Math.min(...xs);27 const xMax = Math.max(...xs);28 const yMax = Math.max(...all.map((p) => p.y), Number.EPSILON) * 1.08;29 const width = 760;30 const pad = { l: 58, r: 16, t: 12, b: 30 };31 const iw = width - pad.l - pad.r;32 const ih = height - pad.t - pad.b;33 const sx = (x: number) => pad.l + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw);34 const sy = (y: number) => pad.t + ih - (y / yMax) * ih;35 const yTicks = 5;36 const span = xMax - xMin;37 const step = span <= 8 ? 1 : span <= 16 ? 2 : span <= 30 ? 5 : 10;38 const xTicks: number[] = [];39 for (let x = Math.ceil(xMin / step) * step; x <= xMax; x += step) xTicks.push(x);40 if (!xTicks.includes(xMin)) xTicks.unshift(xMin);41 if (!xTicks.includes(xMax)) xTicks.push(xMax);4243 return (44 <figure className="w-full">45 <div className="overflow-x-auto">46 <svg viewBox={`0 0 ${width} ${height}`} width="100%" role="img" aria-label={ariaLabel} className="block min-w-[520px]">47 <title>{ariaLabel}</title>48 {Array.from({ length: yTicks + 1 }, (_, i) => {49 const v = (yMax / yTicks) * i;50 const y = sy(v);51 return (52 <g key={i}>53 <line x1={pad.l} x2={width - pad.r} y1={y} y2={y} stroke="var(--color-rule)" strokeWidth="1" />54 <text x={pad.l - 6} y={y + 4} textAnchor="end" fontSize="11" fill="var(--color-ink-3)" style={{ fontVariantNumeric: 'tabular-nums' }}>55 {fmtValue(v, unit)}56 </text>57 </g>58 );59 })}60 {yLabel ? (61 <text x={pad.l} y={pad.t - 2} fontSize="10.5" fill="var(--color-ink-3)">62 {yLabel}63 </text>64 ) : null}65 {xTicks.map((x) => (66 <g key={x}>67 <line x1={sx(x)} x2={sx(x)} y1={pad.t + ih} y2={pad.t + ih + 4} stroke="var(--color-rule-strong)" />68 <text x={sx(x)} y={height - 9} textAnchor="middle" fontSize="11" fill="var(--color-ink-3)">69 {x}70 </text>71 </g>72 ))}73 {shown.map((s, si) => {74 const pts = [...s.points].sort((a, b) => a.x - b.x);75 const color = TREND_COLORS[si % TREND_COLORS.length];76 const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' ');77 return (78 <g key={s.key}>79 <path d={d} fill="none" stroke={color} strokeWidth="1.75" strokeDasharray={s.dashed ? '4 3' : undefined} strokeLinejoin="round" />80 {pts.map((p) => (81 <circle key={p.x} cx={sx(p.x)} cy={sy(p.y)} r="2" fill={color}>82 <title>{`${s.name} — ${p.x}: ${fmtValue(p.y, unit)}`}</title>83 </circle>84 ))}85 </g>86 );87 })}88 </svg>89 </div>90 <figcaption className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 text-[12px] text-ink-2 sm:grid-cols-2 lg:grid-cols-4">91 {shown.map((s, si) => {92 const last = [...s.points].sort((a, b) => a.x - b.x).at(-1);93 return (94 <span key={s.key} className="inline-flex min-w-0 items-baseline gap-1.5">95 <span className="inline-block h-[3px] w-4 shrink-0 self-center" style={{ background: s.dashed ? 'transparent' : TREND_COLORS[si % TREND_COLORS.length], borderTop: s.dashed ? `2px dashed ${TREND_COLORS[si % TREND_COLORS.length]}` : undefined }} aria-hidden />96 {s.href ? (97 <a className="ci-link truncate" href={s.href}>98 {s.name}99 </a>100 ) : (101 <span className="truncate">{s.name}</span>102 )}103 {last ? (104 <span className="ci-num shrink-0 text-ink-3">105 {fmtValue(last.y, unit)} <span className="text-[10.5px]">({last.x})</span>106 </span>107 ) : null}108 {s.dashed ? <span className="text-[10.5px] italic text-warn">estimated</span> : null}109 </span>110 );111 })}112 </figcaption>113 </figure>114 );115}116