Files
personal-website/src/components/RaceReplay.tsx
T

266 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowUpRight, ChevronLeft, ChevronRight, CloudRain, FlagTriangleRight, Pause, Play, RotateCcw } from "lucide-react";
import rawData from "~/lib/race-replay-data.json";
type Driver = {
id: string;
number: number;
code: string;
name: string;
team: string;
color: string;
grid: number;
finish: number;
status: string;
};
type Timing = { id: string; position: number; seconds: number };
type RaceData = {
race: { title: string; year: number; date: string; circuit: string; location: string; laps: number };
drivers: Driver[];
laps: { number: number; timings: Timing[] }[];
pitStops: { id: string; lap: number; seconds: number }[];
events: { lap: number; kind: string; title: string; description: string }[];
track: [number, number][];
corners: { number: number; x: number; y: number }[];
drsZones: { number: number; track: [number, number][] }[];
sources: Record<string, string>;
};
const data = rawData as unknown as RaceData;
const driverById = new Map(data.drivers.map((driver) => [driver.id, driver]));
const trackSegments = data.track.slice(1).map((point, index) => Math.hypot(point[0] - data.track[index]![0], point[1] - data.track[index]![1]));
const trackDistances = [0];
for (const segment of trackSegments) trackDistances.push(trackDistances.at(-1)! + segment);
const trackLength = trackDistances.at(-1)!;
const trackPath = data.track.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" ");
const drsPaths = data.drsZones.map((zone) => ({
number: zone.number,
path: zone.track.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" "),
middle: zone.track[Math.floor(zone.track.length / 2)]!,
}));
const elapsedByDriver = new Map<string, number[]>();
for (const driver of data.drivers) elapsedByDriver.set(driver.id, [0]);
for (const lap of data.laps) {
for (const timing of lap.timings) {
const elapsed = elapsedByDriver.get(timing.id)!;
elapsed.push(Math.round((elapsed.at(-1)! + timing.seconds) * 1000) / 1000);
}
}
const leaderCrossings = [0];
for (let lap = 1; lap <= data.race.laps; lap++) {
leaderCrossings.push(Math.min(...[...elapsedByDriver.values()].map((times) => times[lap] ?? Infinity)));
}
function pointOnTrack(progress: number): [number, number] {
const distance = (((progress % 1) + 1) % 1) * trackLength;
let low = 0;
let high = trackSegments.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (trackDistances[middle + 1]! < distance) low = middle + 1;
else high = middle;
}
const fraction = (distance - trackDistances[low]!) / (trackSegments[low] ?? 1);
const start = data.track[low]!;
const end = data.track[low + 1]!;
return [start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction];
}
function formatTime(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours ? `${hours}:` : ""}${String(minutes).padStart(hours ? 2 : 1, "0")}:${String(secs).padStart(2, "0")}`;
}
function formatLapTime(seconds: number) {
return `${Math.floor(seconds / 60)}:${(seconds % 60).toFixed(3).padStart(6, "0")}`;
}
function inactiveLabel(driver: Driver) {
if (driver.status === "Did not start") return "DNS";
if (driver.status === "Disqualified") return "DSQ";
return "OUT";
}
function positionAtLap(driverId: string, lap: number) {
if (lap === 0) {
const driver = driverById.get(driverId);
return driver?.status === "Did not start" ? null : driver?.grid ?? null;
}
return data.laps[lap - 1]?.timings.find((timing) => timing.id === driverId)?.position ?? null;
}
function carProgress(driverId: string, seconds: number, grid: number) {
const times = elapsedByDriver.get(driverId)!;
if (seconds > times.at(-1)! + 2) return null;
let low = 0;
let high = times.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (times[middle]! <= seconds) low = middle;
else high = middle - 1;
}
if (low >= times.length - 1) return seconds >= times.at(-1)! ? 0 : null;
const fraction = (seconds - times[low]!) / (times[low + 1]! - times[low]!);
const gridOffset = Math.max(0, 1 - seconds / 12) * grid * 0.008;
return fraction - gridOffset;
}
function PositionChart({ driver }: { driver: Driver }) {
const positions = [driver.grid, ...data.laps.map((lap) => lap.timings.find((timing) => timing.id === driver.id)?.position).filter((position): position is number => position !== undefined)];
if (positions.length === 1) return <p className="replay-no-laps">No race laps recorded.</p>;
const points = positions.map((position, index) => `${(index / Math.max(positions.length - 1, 1)) * 284 + 8},${((position - 1) / 19) * 73 + 8}`).join(" ");
return <div className="replay-chart" aria-label={`${driver.name} race position by lap, from P${driver.grid} to P${driver.finish}`} role="img">
<div className="replay-chart-axis"><span>P1</span><span>P20</span></div>
<svg viewBox="0 0 300 90" preserveAspectRatio="none" aria-hidden="true">
<line x1="8" x2="292" y1="8" y2="8" />
<line x1="8" x2="292" y1="81" y2="81" />
<polyline points={points} fill="none" stroke={driver.color} strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className="replay-chart-bottom"><span>START</span><span>FINISH</span></div>
</div>;
}
export function RaceReplay() {
const [progress, setProgress] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const [selectedId, setSelectedId] = useState("max_verstappen");
const [showAllCars, setShowAllCars] = useState(true);
const progressRef = useRef(0);
useEffect(() => {
if (!playing) return;
let frame = 0;
let last = 0;
function tick(now: number) {
if (last && now - last >= 35) {
const elapsed = Math.min(now - last, 100);
progressRef.current = Math.min(data.race.laps, progressRef.current + elapsed / 1000 * 0.8 * speed);
setProgress(progressRef.current);
if (progressRef.current >= data.race.laps) setPlaying(false);
last = now;
} else if (!last) last = now;
frame = requestAnimationFrame(tick);
}
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [playing, speed]);
const completedLap = Math.min(data.race.laps, Math.floor(progress));
const displayLap = Math.min(data.race.laps, completedLap + (progress < data.race.laps ? 1 : 0));
const lapFraction = progress - completedLap;
const elapsed = completedLap === data.race.laps
? leaderCrossings.at(-1)!
: leaderCrossings[completedLap]! + (leaderCrossings[completedLap + 1]! - leaderCrossings[completedLap]!) * lapFraction;
const selected = driverById.get(selectedId)!;
const selectedStatus = inactiveLabel(selected);
const selectedPosition = positionAtLap(selectedId, completedLap);
const activeEvent = [...data.events].reverse().find((event) => event.lap <= displayLap);
const lapTimings = completedLap ? data.laps[completedLap - 1]!.timings : [];
const timingById = new Map(lapTimings.map((timing) => [timing.id, timing]));
const orderedDrivers = [...data.drivers].sort((a, b) => {
if (!completedLap && a.status === "Did not start") return b.status === "Did not start" ? a.grid - b.grid : 1;
if (!completedLap && b.status === "Did not start") return -1;
const aPosition = timingById.get(a.id)?.position;
const bPosition = timingById.get(b.id)?.position;
if (aPosition !== undefined && bPosition !== undefined) return aPosition - bPosition;
if (aPosition !== undefined) return -1;
if (bPosition !== undefined) return 1;
return completedLap ? a.finish - b.finish : a.grid - b.grid;
});
const visibleCars = useMemo(() => data.drivers.filter((driver) => driver.status !== "Did not start" && (showAllCars || driver.id === selectedId)), [showAllCars, selectedId]);
const selectedPits = data.pitStops.filter((pit) => pit.id === selectedId);
const validLaps = data.laps.flatMap((lap) => lap.timings.filter((timing) => timing.id === selectedId && timing.seconds < 200).map((timing) => timing.seconds));
const bestLap = validLaps.length ? Math.min(...validLaps) : null;
function jumpTo(lap: number) {
setPlaying(false);
progressRef.current = Math.max(0, Math.min(data.race.laps, lap));
setProgress(progressRef.current);
}
return <div className="race-replay">
<div className="replay-wrap">
<header className="replay-intro">
<div className="replay-intro-copy">
<div className="replay-eyebrow"><span className="replay-live-dot" /> RACE REPLAY <span className="replay-eyebrow-divider">/</span> 2024 ARCHIVE <span className="replay-eyebrow-divider">/</span> ROUND 21</div>
<h1>São Paulo <em>24</em><span className="replay-title-period">.</span></h1>
<p>One very wet Sunday at Interlagos. Scrub the race, follow a driver, and watch the order change lap by lap.</p>
<div className="replay-intro-meta"><span><FlagTriangleRight size={15} /> INTERLAGOS, BRAZIL</span><span><CloudRain size={16} /> WET RACE</span><span>03 NOV 2024</span></div>
</div>
<div className="replay-headline-stat"><span>THE COMEBACK</span><strong><small>P</small>17 <i></i> <small>P</small>1</strong><p>Max Verstappen · Red Bull Racing</p></div>
</header>
<div className="replay-dashboard">
<section className="replay-map-panel" aria-labelledby="map-title">
<div className="replay-panel-heading"><div><p>01 / CIRCUIT VIEW</p><h2 id="map-title">Autódromo José Carlos Pace</h2></div><span className="replay-track-tag">4.309 KM · ANTI-CLOCKWISE</span></div>
<div className="replay-map-stage">
<div className="replay-stage-top"><span>INTERLAGOS / SÃO PAULO</span><span>24° 42 S &nbsp; 46° 41 W</span></div>
<svg className="replay-circuit" viewBox="210 0 580 740" role="img" aria-label={`FIA-oriented Interlagos circuit map with two DRS zones and cars at lap ${displayLap}`}>
<defs><filter id="car-glow" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="8" /></filter></defs>
<path d={trackPath} className="replay-track-shadow" />
<path d={trackPath} className="replay-track-outer" />
{drsPaths.map((zone) => <g key={zone.number} className="replay-drs-zone">
<path d={zone.path} />
<text x={zone.middle[0] + (zone.number === 1 ? 29 : -29)} y={zone.middle[1]} textAnchor={zone.number === 1 ? "start" : "end"}>DRS {zone.number}</text>
</g>)}
{data.corners.filter((corner) => [1, 3, 4, 6, 8, 12, 13, 15].includes(corner.number)).map((corner) => <g key={corner.number} className="replay-corner" transform={`translate(${corner.x} ${corner.y})`}><circle r="4" /><text x="12" y="-10">T{corner.number}</text></g>)}
{visibleCars.map((driver) => {
const onTrack = carProgress(driver.id, elapsed, driver.grid);
if (onTrack === null) return null;
const [x, y] = pointOnTrack(onTrack);
const focused = driver.id === selectedId;
return <g key={driver.id} transform={`translate(${x} ${y})`} className={focused ? "replay-car is-focused" : "replay-car"}>
{focused && <circle r="19" fill={driver.color} opacity=".35" filter="url(#car-glow)" />}
<circle r={focused ? 9 : 5.5} fill={driver.color} stroke="#15151e" strokeWidth={focused ? 2 : 1.5} />
{focused && <text y="-23" textAnchor="middle">{driver.code}</text>}
</g>;
})}
<g transform={`translate(${data.track[0]![0]} ${data.track[0]![1]})`} className="replay-start-marker"><rect x="-3" y="-14" width="6" height="28" /><text x="20" y="-15">START / FINISH</text></g>
</svg>
<div className="replay-map-legend"><span><i /> SELECTED DRIVER</span><span className="replay-drs-legend"><i /> DRS ZONES · DISABLED IN WET RACE</span><button type="button" onClick={() => setShowAllCars((value) => !value)} aria-pressed={showAllCars}>{showAllCars ? "SHOWING ALL CARS" : "SHOW ALL CARS"}</button></div>
{activeEvent && <div className={`replay-stage-event replay-stage-event--${activeEvent.kind}`}><span>LAP {String(activeEvent.lap).padStart(2, "0")}</span><strong>{activeEvent.title}</strong></div>}
</div>
<div className="replay-playback">
<div className="replay-playback-top"><div><span>RACE CLOCK</span><strong>{formatTime(elapsed)}</strong></div><div className="replay-lap-readout"><span>CURRENT LAP</span><strong>{String(displayLap).padStart(2, "0")} <small>/ {data.race.laps}</small></strong></div></div>
<div className="replay-transport">
<button className="replay-play-button" type="button" onClick={() => { if (progress >= data.race.laps) { progressRef.current = 0; setProgress(0); } setPlaying((value) => !value); }} aria-label={playing ? "Pause replay" : "Play replay"}>{playing ? <Pause fill="currentColor" size={20} /> : <Play fill="currentColor" size={20} />}</button>
<button className="replay-step-button" type="button" onClick={() => jumpTo(Math.floor(progress) - 1)} aria-label="Previous lap"><ChevronLeft size={21} /></button>
<div className="replay-scrubber"><input type="range" min="0" max={data.race.laps} step="0.05" value={progress} onChange={(event) => { setPlaying(false); progressRef.current = Number(event.target.value); setProgress(progressRef.current); }} aria-label="Replay lap" style={{ "--replay-progress": `${progress / data.race.laps * 100}%` } as React.CSSProperties} /><div className="replay-timeline-ticks" aria-hidden="true">{data.events.map((event) => <span key={`${event.lap}-${event.kind}`} className={`is-${event.kind}`} style={{ left: `${event.lap / data.race.laps * 100}%` }} />)}</div></div>
<button className="replay-step-button" type="button" onClick={() => jumpTo(Math.floor(progress) + 1)} aria-label="Next lap"><ChevronRight size={21} /></button>
<button className="replay-reset-button" type="button" onClick={() => jumpTo(0)} aria-label="Reset replay"><RotateCcw size={18} /></button>
<div className="replay-speed" aria-label="Playback speed">{[1, 2, 4].map((option) => <button key={option} type="button" aria-pressed={speed === option} onClick={() => setSpeed(option)}>{option}×</button>)}</div>
</div>
<p>Cars are interpolated from recorded lap times on an OpenF1 circuit trace. Order updates at each timing line; dots are not live GPS.</p>
</div>
</section>
<aside className="replay-sidebar" aria-label="Race classification and driver details">
<div className="replay-sidebar-head"><span>02 / LIVE ORDER</span><strong>LAP {String(displayLap).padStart(2, "0")}</strong></div>
<div className="replay-leaderboard">
{orderedDrivers.map((driver) => {
const position = driver.status === "Did not start" ? undefined : completedLap ? timingById.get(driver.id)?.position : driver.grid;
const change = position ? driver.grid - position : 0;
return <button key={driver.id} type="button" className={`replay-driver-row ${driver.id === selectedId ? "is-selected" : ""} ${!position ? "is-out" : ""}`} onClick={() => setSelectedId(driver.id)} aria-pressed={driver.id === selectedId}>
<span className="replay-row-position">{position ? String(position).padStart(2, "0") : "—"}</span>
<span className="replay-row-color" style={{ backgroundColor: driver.color }} />
<span className="replay-row-identity"><strong>{driver.code}</strong><small>{driver.name}</small></span>
<span className={`replay-row-change ${change > 0 ? "is-up" : change < 0 ? "is-down" : ""}`}>{position ? (change > 0 ? `+${change}` : change) : inactiveLabel(driver)}</span>
</button>;
})}
</div>
<div className="replay-driver-focus"><p>DRIVER FOCUS <span>#{selected.number}</span></p><div className="replay-focus-head"><div><h3>{selected.name}</h3><span>{selected.team}</span></div><span className="replay-focus-position" style={{ color: selected.color }}>{selectedPosition ? `P${selectedPosition}` : selectedStatus}</span></div><div className="replay-focus-stats"><div><span>GRID</span><strong>P{selected.grid}</strong></div><div><span>NOW</span><strong>{selectedPosition ? `P${selectedPosition}` : selectedStatus}</strong></div><div><span>BEST LAP</span><strong>{bestLap === null ? "—" : formatLapTime(bestLap)}</strong></div><div><span>PIT STOPS</span><strong>{selectedPits.length}</strong></div></div><PositionChart driver={selected} /></div>
</aside>
</div>
<section className="replay-moments" aria-labelledby="moments-title"><div className="replay-moments-title"><div><p>03 / RACE CONTROL</p><h2 id="moments-title">The moments that moved it.</h2></div><span>SELECT A MOMENT TO JUMP</span></div><div className="replay-moments-track">{data.events.map((event) => <button type="button" key={`${event.lap}-${event.title}`} className={`replay-moment ${displayLap === event.lap ? "is-active" : ""}`} onClick={() => jumpTo(event.kind === "finish" ? data.race.laps : event.lap - 0.01)}><span>LAP {String(event.lap).padStart(2, "0")} <i className={`is-${event.kind}`} /></span><strong>{event.title}</strong><p>{event.description}</p><span className="replay-moment-arrow"></span></button>)}</div></section>
<div className="replay-sources"><span>RACE REPLAY / AN INDEPENDENT EXPERIMENT BY SEAN OCONNOR</span><div>DATA: <a href={data.sources.laps} target="_blank" rel="noreferrer">JOLPICA <ArrowUpRight size={12} /></a> <a href={data.sources.track} target="_blank" rel="noreferrer">OPENF1 <ArrowUpRight size={12} /></a> <a href={data.sources.corners} target="_blank" rel="noreferrer">MULTIVIEWER <ArrowUpRight size={12} /></a> <a href={data.sources.circuitMap} target="_blank" rel="noreferrer">FIA MAP <ArrowUpRight size={12} /></a></div></div>
</div>
</div>;
}