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%
5.3 KB · 116 lines tsx
Raw Blame History
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