From c25822dee07b6f0fcf117c1816126821376e0d58 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 13:20:55 -0400 Subject: [PATCH] Phase 22b: Interactive Race Story Canvas --- frontend/src/components/RaceStoryCanvas.tsx | 175 ++++++++++++++++++-- frontend/src/styles/app.css | 33 ++++ 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index e40ba23..8fd940a 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -1,3 +1,4 @@ +import { useState, useMemo, useRef, useCallback } from 'react' import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' @@ -8,18 +9,26 @@ interface Props { positions: PositionSample[] laps: Lap[] datasets: Record + race_control?: any[] + pit_stops?: any[] } } export function RaceStoryCanvas({ data }: Props) { - const { results, starting_grid: grid, positions, datasets } = data + const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [] } = data const hasPositions = datasets['positions']?.status === 'available' + const [scrubTime, setScrubTime] = useState(null) + const [hoverDriver, setHoverDriver] = useState(null) + const svgRef = useRef(null) + // Position Evolution Chart Logic - const allTimes = [...new Set(positions.map((p) => p.date))].sort() + const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const hasChartData = hasPositions && allTimes.length > 0 let chartContent = null + let displayResults = results + if (hasChartData) { const tMin = new Date(allTimes[0]).getTime() const tMax = new Date(allTimes[allTimes.length - 1]).getTime() @@ -37,6 +46,36 @@ export function RaceStoryCanvas({ data }: Props) { samples.sort((a, b) => a.t - b.t) } + const getInterpPos = (samples: {t: number, pos: number}[], t: number) => { + if (!samples || samples.length === 0) return null + if (t <= samples[0].t) return samples[0].pos + if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos + for (let i = 0; i < samples.length - 1; i++) { + if (samples[i].t <= t && samples[i+1].t >= t) { + const dt = samples[i+1].t - samples[i].t + if (dt === 0) return samples[i].pos + const frac = (t - samples[i].t) / dt + return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac + } + } + return null + } + + if (scrubTime !== null) { + const currentPos = new Map() + for (const [dNum, samples] of byDriver.entries()) { + const pos = getInterpPos(samples, scrubTime) + if (pos !== null) { + currentPos.set(dNum, pos) + } + } + displayResults = [...results].sort((a, b) => { + const posA = currentPos.get(a.driver_number) ?? 999 + const posB = currentPos.get(b.driver_number) ?? 999 + return posA - posB + }) + } + const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2) const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour])) const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym])) @@ -53,14 +92,66 @@ export function RaceStoryCanvas({ data }: Props) { const toX = (t: number) => PL + t * plotW const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH + // Safety Car / VSC periods + const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = [] + let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null + const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + + for (const msg of rc) { + const t = new Date(msg.date).getTime() + const m = msg.message?.toUpperCase() || '' + const cat = msg.category?.toUpperCase() || '' + + if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') { + if (!activeSC) activeSC = { start: t, type: 'VSC' } + } else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') { + if (!activeSC) activeSC = { start: t, type: 'SC' } + } else if (m.includes('TRACK CLEAR') || m.includes('CLEAR')) { + if (activeSC) { + scPeriods.push({ start: activeSC.start, end: t, type: activeSC.type }) + activeSC = null + } + } + } + if (activeSC) { + scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type }) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (!svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const t = Math.max(0, Math.min(1, (x - PL) / plotW)) + setScrubTime(t) + } + chartContent = (
+ {scPeriods.map((sc, i) => { + const startT = (sc.start - tMin) / tRange + const endT = sc.end ? (sc.end - tMin) / tRange : 1 + const x1 = toX(Math.max(0, startT)) + const x2 = toX(Math.min(1, endT)) + if (x2 <= PL || x1 >= W - PR) return null + return ( + + ) + })} + {Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => ( `${toX(s.t)},${toY(s.pos)}`).join(' ') const last = samples[samples.length - 1] + const isHovered = hoverDriver === dNum + const isFaded = hoverDriver !== null && !isHovered + + const driverPits = pit_stops.filter(p => p.driver_number === dNum) return ( - + setHoverDriver(dNum)} + onMouseLeave={() => setHoverDriver(null)} + > - {samples.map((s, i) => ( - - ))} + + {driverPits.map((p, i) => { + const t = (new Date(p.date).getTime() - tMin) / tRange + if (t < 0 || t > 1) return null + const pos = getInterpPos(samples, t) + if (pos === null) return null + return ( + + ) + })} + {last && ( {acronymByDriver.get(dNum) ?? dNum} @@ -118,6 +237,31 @@ export function RaceStoryCanvas({ data }: Props) { ) })} + + {scrubTime !== null && ( + + )} + + setScrubTime(null)} + style={{ cursor: 'crosshair', touchAction: 'none' }} + />
) @@ -134,17 +278,24 @@ export function RaceStoryCanvas({ data }: Props) { )} - {results.length > 0 && ( + {displayResults.length > 0 && (
- {results.map((r, i) => { + {displayResults.map((r, i) => { const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 const isWinner = i === 0 && r.position === 1 const pClass = r.position === 1 ? 'rs-pos-p1' : r.position === 2 ? 'rs-pos-p2' : r.position === 3 ? 'rs-pos-p3' : '' return ( -
+
setHoverDriver(r.driver_number)} + onMouseLeave={() => setHoverDriver(null)} + >
-
{r.position}
+
+ {scrubTime !== null ? i + 1 : r.position} +