Add project previews and race replay
This commit is contained in:
@@ -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 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 O’CONNOR</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>;
|
||||
}
|
||||
Reference in New Issue
Block a user