import { useState, useMemo, useRef, useCallback } from 'react' import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' interface Props { data: { results: EnrichedResult[] starting_grid: EnrichedGrid[] positions: PositionSample[] laps: Lap[] datasets: Record race_control?: any[] pit_stops?: any[] session?: Session } } export function RaceStoryCanvas({ data }: Props) { const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = 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 = 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() const tRange = Math.max(tMax - tMin, 1) const byDriver = new Map>() for (const p of positions) { if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, []) byDriver.get(p.driver_number)!.push({ t: (new Date(p.date).getTime() - tMin) / tRange, pos: p.position, }) } const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number)) for (const [dNum, samples] of byDriver.entries()) { samples.sort((a, b) => a.t - b.t) if (samples.length > 0 && !dnfSet.has(dNum)) { samples.push({ t: 1, pos: samples[samples.length - 1].pos }) } } 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])) const W = 640 const H = 180 const PL = 40 const PR = 48 const PT = 8 const PB = 20 const plotW = W - PL - PR const plotH = H - PT - PB const toX = (t: number) => PL + t * plotW const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH const winner = results.find(r => r.position === 1) const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : [] const lapTicks: { lap: number, t: number }[] = [] const lapInterval = winnerLaps.length < 30 ? 5 : 10 for (const lap of winnerLaps) { if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) { const t = (new Date(lap.date_start).getTime() - tMin) / tRange if (t >= 0 && t <= 1) { lapTicks.push({ lap: lap.lap_number, t }) } } } // 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) => ( P{pos} ))} {lapTicks.map(tick => ( L{tick.lap} ))} {Array.from(byDriver.entries()).map(([dNum, samples]) => { const colour = colorByDriver.get(dNum) const color = colour ? `#${colour}` : '#888' const pts = samples.map((s) => `${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)} > {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} )} ) })} {scrubTime !== null && ( )} setScrubTime(null)} style={{ cursor: 'crosshair', touchAction: 'none' }} />
) } return (
{hasChartData ? ( chartContent ) : (
Lap-by-lap positions not available. This session does not have ingested position samples in /api/v1/race-hub.
)} {displayResults.length > 0 && (
{displayResults.map((r, i) => { const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 const currentPos = scrubTime !== null ? i + 1 : r.position const isWinner = i === 0 && r.position === 1 const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : '' let currentPoints: number | string = r.points if (scrubTime !== null) { const isSprint = data.session?.session_type?.toLowerCase().includes('sprint') const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1] currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0 } return (
setHoverDriver(r.driver_number)} onMouseLeave={() => setHoverDriver(null)} >
{currentPos}
{r.name_acronym || r.driver_number} {r.team_name}
{gridDelta(currentPos, gridPos)} Grid
{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)} {isWinner ? 'Time' : 'Gap'}
0 ? 'var(--text)' : 'var(--text-3)' }}> {currentPoints} Pts
) })}
)}
) }