"use client"; import { useEffect, useRef, useState } from "react"; import "./isotype-stats.css"; /** * Isotype Stats * Counting in people, the way Otto Neurath's charts did: each figure stands * for a fixed number, a row of figures is a quantity you can see at a * glance, and the last figure is cut to the fraction left over. The figures * are stamped in row by row as the chart scrolls into view; switching the * period stamps in the new figures and lets the lost ones fade. */ export type IsoRow = { label: string; note?: string; values: Record }; type Props = { rows: IsoRow[]; /** Period keys in order, e.g. ["2024", "2025"]. The last is shown first. */ periods: string[]; /** How many people one figure stands for. */ unit: number; /** Plural noun for the legend, e.g. "weekly riders". */ noun: string; title?: string; theme?: "paper" | "night"; motion?: "full" | "reduced"; className?: string; }; const fmt = (n: number) => Math.round(n).toLocaleString("en-US"); function Figure() { return ( ); } export function IsotypeStats({ rows, periods, unit, noun, title, theme = "paper", motion = "full", className = "" }: Props) { const root = useRef(null); const [period, setPeriod] = useState(periods[periods.length - 1]); const [on, setOn] = useState(false); // Change is only ever against the period before; the earliest has none. const prev: string | undefined = periods[periods.indexOf(period) - 1]; const slots = Math.ceil(Math.max(...rows.flatMap((r) => periods.map((p) => r.values[p] ?? 0))) / unit); useEffect(() => { const el = root.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setOn(true); io.disconnect(); } }, { threshold: 0.3 }); io.observe(el); return () => io.disconnect(); }, []); const total = rows.reduce((s, r) => s + (r.values[period] ?? 0), 0); const totalPrev = prev ? rows.reduce((s, r) => s + (r.values[prev] ?? 0), 0) : 0; return (

{fmt(total)} {noun}

{prev ? `${total >= totalPrev ? "+" : "−"}${Math.abs(((total - totalPrev) / totalPrev) * 100).toFixed(1)}% on ${prev}` : `First year counted · ${period}`}

{periods.map((p) => ( ))}
    {rows.map((r, ri) => { const v = r.values[period] ?? 0; const was = prev ? r.values[prev] ?? 0 : 0; const n = v / unit; const change = prev && was ? ((v - was) / was) * 100 : null; return (
  • = 0 ? "up" : "down"} ${Math.abs(change).toFixed(0)}% on ${prev}`}`} style={{ ["--ri" as string]: ri }} >
  • ); })}

= {fmt(unit)} {noun}

{/* Tables ignore overflow, so the hidden table sits inside a hidden box. */}
{periods.map((p) => ( ))} {rows.map((r) => ( {periods.map((p) => ( ))} ))}
{title ?? "Pictogram chart"}, {noun}
Route{p}
{r.label}{fmt(r.values[p] ?? 0)}
); }