import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { LineChart } from 'lucide-react' import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types' import { fetchReplayFrames, fetchTrackOutline } from '../api' import { ReplayTrackMap } from './ReplayTrackMap' import { ChapterStrip } from './ChapterStrip' import { EmptyStateCard } from './EmptyStateCard' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' import { chapterBandFill, chapterEndScrub, chapterStartScrub, chapterTourDurations, deCollideYPositions, decimatedPositionLabels, } from '../lib/chapters' import { isReplayMapAvailable } from '../lib/replayMap' import '../styles/race-story.css' const CHAPTER_TOUR_MS = 90_000 const CHART_W = 640 const CHART_H = 180 const CHART_PL = 40 const CHART_PR = 48 const CHART_PT = 8 const CHART_PB = 20 interface ChartTiming { tMin: number tMax: number tRange: number } interface Props { data: { results: EnrichedResult[] starting_grid: EnrichedGrid[] positions: PositionSample[] laps: Lap[] datasets: Record race_control?: any[] pit_stops?: any[] session?: Session meeting?: Meeting drivers?: Driver[] chapters?: Chapter[] } } /** * Position samples are recorded for the entire session, including the pre-race * grid period. Use the winning driver's laps to define the race window so * that the x-axis starts at lights-out rather than at the earliest sample. */ function raceChartTiming( positions: PositionSample[], laps: Lap[], results: EnrichedResult[], ): ChartTiming | null { const positionTimes = positions .map((position) => new Date(position.date).getTime()) .filter(Number.isFinite) if (positionTimes.length === 0) return null const fallbackMin = Math.min(...positionTimes) const fallbackMax = Math.max(...positionTimes) const fallback = { tMin: fallbackMin, tMax: fallbackMax, tRange: Math.max(fallbackMax - fallbackMin, 1), } const winner = results.find((result) => result.position === 1) if (!winner) return fallback const winnerLaps = laps .filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0) .map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() })) .filter((lap) => Number.isFinite(lap.start)) .sort((a, b) => a.lap_number - b.lap_number) if (winnerLaps.length === 0) return fallback const firstLap = winnerLaps[0] const lastLap = winnerLaps[winnerLaps.length - 1] const finalLapDuration = lastLap.lap_duration ?? 0 const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0) if (tMax <= firstLap.start) return fallback return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start } } export function RaceStoryCanvas({ data }: Props) { const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [], session, meeting, drivers = [], chapters = [], } = data const hasPositions = datasets['positions']?.status === 'available' const [scrubTime, setScrubTime] = useState(null) const [hoverDriver, setHoverDriver] = useState(null) const [mapOpen, setMapOpen] = useState(false) const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(10) const [chapterTourActive, setChapterTourActive] = useState(false) const [tourChapterIndex, setTourChapterIndex] = useState(null) const [selectedChapterIndex, setSelectedChapterIndex] = useState(null) const svgRef = useRef(null) const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results]) const hasChartData = hasPositions && chartTiming !== null const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0 const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0) const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0 const replayQuery = useQuery({ queryKey: ['replay-frames', session?.session_key, 5000], queryFn: () => fetchReplayFrames(session!.session_key, 5000), enabled: canProbeMap, }) const outlineQuery = useQuery({ queryKey: ['track-outline', circuitKey, outlineYear], queryFn: () => fetchTrackOutline(circuitKey, outlineYear), enabled: canProbeMap, }) const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading) const mapAvailable = useMemo( () => canProbeMap && isReplayMapAvailable( replayQuery.data, outlineQuery.data, replayQuery.isError || outlineQuery.isError, ), [canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError], ) const showMapPanel = mapOpen && mapAvailable useEffect(() => { if (!mapAvailable && mapOpen) { setMapOpen(false) } }, [mapAvailable, mapOpen]) useEffect(() => { if (!isPlaying || !chartTiming) return let frame = 0 let last = performance.now() const tick = (now: number) => { const delta = now - last last = now setScrubTime((current) => { const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange) if (next >= 1) { setIsPlaying(false) } return next }) frame = requestAnimationFrame(tick) } frame = requestAnimationFrame(tick) return () => cancelAnimationFrame(frame) }, [chartTiming, isPlaying, playbackSpeed]) const stopChapterTour = () => { setChapterTourActive(false) setTourChapterIndex(null) } const jumpToChapter = (index: number, scrub: number) => { setIsPlaying(false) stopChapterTour() setSelectedChapterIndex(index) setScrubTime(scrub) } const clearChapterSelection = () => { setSelectedChapterIndex(null) } const toggleChapterTour = () => { if (chapterTourActive) { stopChapterTour() return } if (!chartTiming || chapters.length === 0) return setIsPlaying(false) setSelectedChapterIndex(null) setChapterTourActive(true) setTourChapterIndex(0) const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0 setScrubTime(startScrub) const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS) tourRef.current = { chapterIndex: 0, startedAt: performance.now(), durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length, startScrub, endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub, } } useEffect(() => { if (!chapterTourActive || !chartTiming || chapters.length === 0) return let frame = 0 const tick = (now: number) => { const state = tourRef.current const elapsed = now - state.startedAt const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1)) const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress setScrubTime(scrub) setTourChapterIndex(state.chapterIndex) if (progress >= 1) { const nextIndex = state.chapterIndex + 1 if (nextIndex >= chapters.length) { stopChapterTour() return } const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS) const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0 const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub tourRef.current = { chapterIndex: nextIndex, startedAt: now, durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length, startScrub, endScrub, } } frame = requestAnimationFrame(tick) } frame = requestAnimationFrame(tick) return () => cancelAnimationFrame(frame) }, [chapterTourActive, chartTiming, chapters]) useEffect(() => { if (!chapterTourActive) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { stopChapterTour() } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [chapterTourActive]) const replayTMs = useMemo(() => { const replay = replayQuery.data const frames = replay?.frames ?? [] if (frames.length === 0) return 0 const lastFrameT = frames[frames.length - 1].t const progress = scrubTime ?? 0 if (!chartTiming || !replay?.start_time) { return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT))) } const replayStart = new Date(replay.start_time).getTime() const chartTime = chartTiming.tMin + progress * chartTiming.tRange return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart))) }, [chartTiming, replayQuery.data, scrubTime]) let chartContent = null let displayResults = results if (hasChartData && chartTiming) { const { tMin, tRange } = chartTiming const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange)) const byDriver = new Map>() for (const p of positions) { const time = new Date(p.date).getTime() if (!Number.isFinite(time)) continue if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, []) byDriver.get(p.driver_number)!.push({ t: normaliseTime(time), 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 positionLabels = decimatedPositionLabels(maxPos) const W = CHART_W const H = CHART_H const PL = CHART_PL const PR = CHART_PR const PT = CHART_PT const PB = CHART_PB 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 (let i = 0; i < winnerLaps.length; i++) { const lap = winnerLaps[i] if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) { const lapStart = new Date(lap.date_start).getTime() const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN const lapEnd = lap.lap_duration && lap.lap_duration > 0 ? lapStart + lap.lap_duration * 1000 : nextLapStart if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) }) } } 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 chapterBands = chapters .map((chapter, index) => { const startT = chapterStartScrub(chapter, tMin, tRange) const endT = chapterEndScrub(chapter, tMin, tRange) if (startT === null) return null const end = endT ?? startT return { key: `${chapter.kind}-${index}`, start: Math.min(startT, end), end: Math.max(startT, end), fill: chapterBandFill(chapter.kind), } }) .filter((band): band is NonNullable => band !== null) const labelCandidates = Array.from(byDriver.entries()) .map(([dNum, samples]) => { const last = samples[samples.length - 1] if (!last) return null return { key: dNum, y: toY(last.pos) } }) .filter((item): item is { key: number; y: number } => item !== null) const labelYByDriver = deCollideYPositions(labelCandidates, 12) const handlePointerMove = (e: React.PointerEvent) => { setIsPlaying(false) stopChapterTour() clearChapterSelection() if (!svgRef.current) return const rect = svgRef.current.getBoundingClientRect() if (rect.width <= 0) return // Pointer coordinates are CSS pixels; convert them to the SVG viewBox // before comparing with the fixed chart margins and plot width. const x = ((e.clientX - rect.left) / rect.width) * W const t = Math.max(0, Math.min(1, (x - PL) / plotW)) setScrubTime(t) } chartContent = (
{chapterBands.map((band) => { const x1 = toX(Math.max(0, band.start)) const x2 = toX(Math.min(1, band.end)) if (x2 <= PL || x1 >= W - PR) return null return ( ) })} {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 ( ) })} {positionLabels.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 labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0 const driverPits = pit_stops.filter((p) => p.driver_number === dNum) return ( setHoverDriver(dNum)} onMouseLeave={() => setHoverDriver(null)} > {driverPits.map((p, i) => { const time = new Date(p.date).getTime() if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null const t = normaliseTime(time) const pos = getInterpPos(samples, t) if (pos === null) return null return ( ) })} {last && ( {acronymByDriver.get(dNum) ?? dNum} )} ) })} {scrubTime !== null && ( )} { if (!isPlaying) { clearChapterSelection() setScrubTime(null) } }} style={{ cursor: 'crosshair', touchAction: 'none' }} />
{[1, 10, 30].map((speed) => ( ))} {mapProbeSettled && mapAvailable && ( <> )}
) } return (
{hasChartData && chartTiming && chapters.length > 0 && ( )}
{hasChartData ? ( chartContent ) : ( This session does not have ingested position samples in{' '} /api/v1/race-hub. } testId="race-story-no-positions" className="race-story-empty-card" /> )}
{showMapPanel && (
)}
{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
) })}
)}
) }