Add project previews and race replay

This commit is contained in:
2026-09-22 20:38:43 -04:00
parent 8b0caae14f
commit 5519480058
27 changed files with 1124 additions and 140 deletions
+265
View File
@@ -0,0 +1,265 @@
"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>;
}