"use client"; import { useEffect, useId, useRef, useState } from "react"; import "./bar-race.css"; /** * Bar Race * A year of bookings as a race. Each month the destinations re-sort by how * many trips they sold; press play and the bars grow, overtake and slide * past each other in real time, values counting as they go, with the month * watermarked behind. Colour is the region, which never changes; the name * on every bar does the identifying. */ export type Racer = { name: string; region: 0 | 1 | 2; values: number[] }; // cumulative or monthly — shown as given type Props = { racers: Racer[]; months: string[]; regions: [string, string, string]; show?: number; title?: string; theme?: "light" | "dark"; motion?: "full" | "reduced"; className?: string }; const ROW = 38, GAP = 8; export function BarRace({ racers, months, regions, show = 8, title = "Bookings by destination", theme = "light", motion = "full", className = "" }: Props) { const id = useId(); const last = months.length - 1; const [t, setT] = useState(0); const [playing, setPlaying] = useState(false); const [view, setView] = useState<"chart" | "table">("chart"); const tRef = useRef(0); tRef.current = t; const wrapRef = useRef(null); const started = useRef(false); const reduced = () => motion === "reduced" || (typeof window !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches); // Start playing the first time it scrolls into view. useEffect(() => { const el = wrapRef.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting && !started.current) { started.current = true; if (reduced()) setT(last); else setPlaying(true); } }, { threshold: 0.4 }); io.observe(el); return () => io.disconnect(); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!playing) return; if (tRef.current >= last) setT(0); let raf = 0, prev = performance.now(); const step = (now: number) => { const dt = Math.min(0.05, (now - prev) / 1000); prev = now; if (document.hidden) { raf = requestAnimationFrame(step); return; } const next = Math.min(last, tRef.current + dt / 1.15); setT(next); if (next >= last) { setPlaying(false); return; } raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [playing]); // eslint-disable-line react-hooks/exhaustive-deps // Values eased between months; ranks from the eased values, then each bar's // row eased toward its rank so overtakes slide rather than snap. const i = Math.min(last - 1, Math.floor(t)), f = t - i; const ease = f * f * (3 - 2 * f); const vals = racers.map((r) => (last === 0 ? r.values[0] : r.values[i] + (r.values[i + 1] - r.values[i]) * ease)); const order = vals.map((v, k) => ({ v, k })).sort((a, b) => b.v - a.v); const rank = new Array(racers.length); order.forEach((o, r) => (rank[o.k] = r)); const rowPos = useRef(racers.map((_, k) => k)); const smoothed = racers.map((_, k) => { const p = rowPos.current[k]; const target = rank[k]; const nv = reduced() ? target : p + (target - p) * 0.22; return Math.abs(nv - target) < 0.002 ? target : nv; }); rowPos.current = smoothed; const max = Math.max(...vals) * 1.04; const month = months[Math.round(t)]; const leader = racers[order[0].k]; const H = show * (ROW + GAP); const summary = `${title}, ${months[0]}–${months[last]}. In ${month}, ${leader.name} leads with ${Math.round(order[0].v).toLocaleString("en-GB")} bookings.`; const scale = (() => { const raw = max / 5, p = Math.pow(10, Math.floor(Math.log10(raw))), m = raw / p; const s = (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p; return Array.from({ length: Math.floor(max / s) + 1 }, (_, k) => k * s); })(); return (

{title}

{leader.name} leads in {month} — {Math.round(order[0].v).toLocaleString("en-GB")} bookings.

{view === "table" && (
{months.map((m) => )}{racers.map((r) => {r.values.map((v, k) => )})}
{summary}
Destination{m}
{r.name}{Math.round(v).toLocaleString("en-GB")}
)}
{ setPlaying(false); setT(Number(e.target.value)); }} onPointerUp={() => setT((v) => Math.round(v))} onKeyDown={(e) => { if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); setPlaying(false); setT((v) => Math.min(last, Math.floor(v + 1e-6) + 1)); } if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); setPlaying(false); setT((v) => Math.max(0, Math.ceil(v - 1e-6) - 1)); } }} />
); }