2026-07-11 18:17:15 -04:00
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
import { useQuery } from '@tanstack/react-query'
|
2026-07-12 13:23:07 -04:00
|
|
|
import { LineChart } from 'lucide-react'
|
2026-07-11 18:23:11 -04:00
|
|
|
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
2026-07-11 18:17:15 -04:00
|
|
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
|
|
|
|
import { ReplayTrackMap } from './ReplayTrackMap'
|
2026-07-11 18:23:11 -04:00
|
|
|
import { ChapterStrip } from './ChapterStrip'
|
2026-07-12 13:23:07 -04:00
|
|
|
import { EmptyStateCard } from './EmptyStateCard'
|
2026-05-25 13:10:47 -04:00
|
|
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
2026-07-12 13:23:07 -04:00
|
|
|
import {
|
|
|
|
|
chapterBandFill,
|
|
|
|
|
chapterEndScrub,
|
|
|
|
|
chapterStartScrub,
|
|
|
|
|
chapterTourDurations,
|
|
|
|
|
deCollideYPositions,
|
|
|
|
|
decimatedPositionLabels,
|
|
|
|
|
} from '../lib/chapters'
|
|
|
|
|
import { isReplayMapAvailable } from '../lib/replayMap'
|
|
|
|
|
import '../styles/race-story.css'
|
2026-07-11 18:23:11 -04:00
|
|
|
|
|
|
|
|
const CHAPTER_TOUR_MS = 90_000
|
2026-07-12 13:23:07 -04:00
|
|
|
const CHART_W = 640
|
|
|
|
|
const CHART_H = 180
|
|
|
|
|
const CHART_PL = 40
|
|
|
|
|
const CHART_PR = 48
|
|
|
|
|
const CHART_PT = 8
|
|
|
|
|
const CHART_PB = 20
|
2026-05-25 13:10:47 -04:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
data: {
|
|
|
|
|
results: EnrichedResult[]
|
|
|
|
|
starting_grid: EnrichedGrid[]
|
|
|
|
|
positions: PositionSample[]
|
|
|
|
|
laps: Lap[]
|
|
|
|
|
datasets: Record<string, any>
|
2026-05-25 13:20:55 -04:00
|
|
|
race_control?: any[]
|
|
|
|
|
pit_stops?: any[]
|
2026-05-25 13:44:51 -04:00
|
|
|
session?: Session
|
2026-07-11 18:17:15 -04:00
|
|
|
meeting?: Meeting
|
|
|
|
|
drivers?: Driver[]
|
2026-07-11 18:23:11 -04:00
|
|
|
chapters?: Chapter[]
|
2026-05-25 13:10:47 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function RaceStoryCanvas({ data }: Props) {
|
2026-07-11 18:17:15 -04:00
|
|
|
const {
|
|
|
|
|
results,
|
|
|
|
|
starting_grid: grid,
|
|
|
|
|
positions,
|
|
|
|
|
datasets,
|
|
|
|
|
race_control = [],
|
|
|
|
|
pit_stops = [],
|
|
|
|
|
laps = [],
|
|
|
|
|
session,
|
|
|
|
|
meeting,
|
|
|
|
|
drivers = [],
|
2026-07-11 18:23:11 -04:00
|
|
|
chapters = [],
|
2026-07-11 18:17:15 -04:00
|
|
|
} = data
|
2026-05-25 13:10:47 -04:00
|
|
|
const hasPositions = datasets['positions']?.status === 'available'
|
|
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
|
|
|
|
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
2026-07-11 18:17:15 -04:00
|
|
|
const [mapOpen, setMapOpen] = useState(false)
|
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
|
|
|
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
2026-07-11 18:23:11 -04:00
|
|
|
const [chapterTourActive, setChapterTourActive] = useState(false)
|
|
|
|
|
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
2026-05-25 13:20:55 -04:00
|
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
2026-07-11 18:23:11 -04:00
|
|
|
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
2026-05-25 13:20:55 -04:00
|
|
|
|
|
|
|
|
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
2026-05-25 13:10:47 -04:00
|
|
|
const hasChartData = hasPositions && allTimes.length > 0
|
2026-07-11 18:17:15 -04:00
|
|
|
const chartTiming = useMemo(() => {
|
|
|
|
|
if (!hasChartData) return null
|
|
|
|
|
const tMin = new Date(allTimes[0]).getTime()
|
|
|
|
|
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
|
|
|
|
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
|
|
|
|
}, [allTimes, hasChartData])
|
|
|
|
|
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
|
|
|
|
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
2026-07-12 13:23:07 -04:00
|
|
|
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
2026-07-11 18:17:15 -04:00
|
|
|
|
|
|
|
|
const replayQuery = useQuery({
|
|
|
|
|
queryKey: ['replay-frames', session?.session_key, 5000],
|
|
|
|
|
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
2026-07-12 13:23:07 -04:00
|
|
|
enabled: canProbeMap,
|
2026-07-11 18:17:15 -04:00
|
|
|
})
|
|
|
|
|
const outlineQuery = useQuery({
|
|
|
|
|
queryKey: ['track-outline', circuitKey, outlineYear],
|
|
|
|
|
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
2026-07-12 13:23:07 -04:00
|
|
|
enabled: canProbeMap,
|
2026-07-11 18:17:15 -04:00
|
|
|
})
|
|
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
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])
|
|
|
|
|
|
2026-07-11 18:17:15 -04:00
|
|
|
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])
|
|
|
|
|
|
2026-07-11 18:23:11 -04:00
|
|
|
const stopChapterTour = () => {
|
|
|
|
|
setChapterTourActive(false)
|
|
|
|
|
setTourChapterIndex(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const jumpToChapter = (index: number, scrub: number) => {
|
|
|
|
|
setIsPlaying(false)
|
|
|
|
|
stopChapterTour()
|
|
|
|
|
setScrubTime(scrub)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toggleChapterTour = () => {
|
|
|
|
|
if (chapterTourActive) {
|
|
|
|
|
stopChapterTour()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!chartTiming || chapters.length === 0) return
|
|
|
|
|
setIsPlaying(false)
|
|
|
|
|
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])
|
|
|
|
|
|
2026-07-11 18:17:15 -04:00
|
|
|
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])
|
2026-05-25 13:10:47 -04:00
|
|
|
|
|
|
|
|
let chartContent = null
|
2026-05-25 13:20:55 -04:00
|
|
|
let displayResults = results
|
|
|
|
|
|
2026-07-11 18:17:15 -04:00
|
|
|
if (hasChartData && chartTiming) {
|
|
|
|
|
const { tMin, tRange } = chartTiming
|
2026-05-25 13:10:47 -04:00
|
|
|
|
|
|
|
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-07-12 13:23:07 -04:00
|
|
|
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
|
2026-05-25 13:44:51 -04:00
|
|
|
for (const [dNum, samples] of byDriver.entries()) {
|
2026-05-25 13:10:47 -04:00
|
|
|
samples.sort((a, b) => a.t - b.t)
|
2026-05-25 13:44:51 -04:00
|
|
|
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
|
|
|
|
samples.push({ t: 1, pos: samples[samples.length - 1].pos })
|
|
|
|
|
}
|
2026-05-25 13:10:47 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
|
2026-05-25 13:20:55 -04:00
|
|
|
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++) {
|
2026-07-12 13:23:07 -04:00
|
|
|
if (samples[i].t <= t && samples[i + 1].t >= t) {
|
|
|
|
|
const dt = samples[i + 1].t - samples[i].t
|
2026-05-25 13:20:55 -04:00
|
|
|
if (dt === 0) return samples[i].pos
|
|
|
|
|
const frac = (t - samples[i].t) / dt
|
2026-07-12 13:23:07 -04:00
|
|
|
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
|
2026-05-25 13:20:55 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scrubTime !== null) {
|
|
|
|
|
const currentPos = new Map<number, number>()
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 13:10:47 -04:00
|
|
|
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]))
|
2026-07-12 13:23:07 -04:00
|
|
|
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
|
2026-05-25 13:10:47 -04:00
|
|
|
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
|
|
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
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 }[] = []
|
2026-05-25 13:44:51 -04:00
|
|
|
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:44:51 -04:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
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())
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
for (const msg of rc) {
|
|
|
|
|
const t = new Date(msg.date).getTime()
|
|
|
|
|
const m = msg.message?.toUpperCase() || ''
|
|
|
|
|
const cat = msg.category?.toUpperCase() || ''
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
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<typeof band> => 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)
|
|
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
2026-07-11 18:17:15 -04:00
|
|
|
setIsPlaying(false)
|
2026-07-11 18:23:11 -04:00
|
|
|
stopChapterTour()
|
2026-05-25 13:20:55 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 13:10:47 -04:00
|
|
|
chartContent = (
|
2026-07-12 13:23:07 -04:00
|
|
|
<div
|
|
|
|
|
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
|
|
|
|
|
data-testid="position-chart"
|
|
|
|
|
>
|
2026-05-25 13:10:47 -04:00
|
|
|
<svg
|
2026-05-25 13:20:55 -04:00
|
|
|
ref={svgRef}
|
2026-07-12 13:23:07 -04:00
|
|
|
className="rs-position-chart-svg"
|
2026-05-25 13:10:47 -04:00
|
|
|
viewBox={`0 0 ${W} ${H}`}
|
|
|
|
|
role="img"
|
|
|
|
|
aria-label="Position evolution chart"
|
|
|
|
|
>
|
2026-07-12 13:23:07 -04:00
|
|
|
{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 (
|
|
|
|
|
<rect
|
|
|
|
|
key={band.key}
|
|
|
|
|
x={x1}
|
|
|
|
|
y={PT}
|
|
|
|
|
width={Math.max(0, x2 - x1)}
|
|
|
|
|
height={plotH}
|
|
|
|
|
fill={band.fill}
|
|
|
|
|
data-testid="chapter-band"
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
{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 (
|
|
|
|
|
<rect
|
|
|
|
|
key={`sc-${i}`}
|
|
|
|
|
x={x1}
|
|
|
|
|
y={PT}
|
|
|
|
|
width={Math.max(0, x2 - x1)}
|
|
|
|
|
height={plotH}
|
|
|
|
|
fill={sc.type === 'SC' ? 'rgba(255, 153, 0, 0.15)' : 'rgba(255, 204, 0, 0.1)'}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
{positionLabels.map((pos) => (
|
2026-05-25 13:10:47 -04:00
|
|
|
<g key={pos}>
|
|
|
|
|
<line
|
|
|
|
|
x1={PL}
|
|
|
|
|
x2={W - PR}
|
|
|
|
|
y1={toY(pos)}
|
|
|
|
|
y2={toY(pos)}
|
2026-07-12 13:23:07 -04:00
|
|
|
className="rs-chart-grid-line"
|
2026-05-25 13:10:47 -04:00
|
|
|
/>
|
|
|
|
|
<text
|
|
|
|
|
x={PL - 4}
|
|
|
|
|
y={toY(pos) + 4}
|
|
|
|
|
textAnchor="end"
|
2026-07-12 13:23:07 -04:00
|
|
|
className="rs-chart-axis-label"
|
2026-05-25 13:10:47 -04:00
|
|
|
>
|
|
|
|
|
P{pos}
|
|
|
|
|
</text>
|
|
|
|
|
</g>
|
|
|
|
|
))}
|
|
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
{lapTicks.map((tick) => (
|
2026-05-25 13:44:51 -04:00
|
|
|
<g key={`lap-${tick.lap}`}>
|
2026-07-12 13:23:07 -04:00
|
|
|
<line
|
|
|
|
|
x1={toX(tick.t)}
|
|
|
|
|
x2={toX(tick.t)}
|
|
|
|
|
y1={H - PB}
|
|
|
|
|
y2={H - PB + 4}
|
|
|
|
|
className="rs-chart-lap-tick"
|
|
|
|
|
/>
|
|
|
|
|
<text
|
|
|
|
|
x={toX(tick.t)}
|
|
|
|
|
y={H - PB + 14}
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
className="rs-chart-axis-label"
|
|
|
|
|
>
|
2026-05-25 13:44:51 -04:00
|
|
|
L{tick.lap}
|
|
|
|
|
</text>
|
|
|
|
|
</g>
|
|
|
|
|
))}
|
|
|
|
|
|
2026-05-25 13:10:47 -04:00
|
|
|
{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]
|
2026-05-25 13:20:55 -04:00
|
|
|
const isHovered = hoverDriver === dNum
|
|
|
|
|
const isFaded = hoverDriver !== null && !isHovered
|
2026-07-12 13:23:07 -04:00
|
|
|
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
|
2026-05-25 13:20:55 -04:00
|
|
|
|
2026-07-12 13:23:07 -04:00
|
|
|
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
|
2026-05-25 13:10:47 -04:00
|
|
|
|
|
|
|
|
return (
|
2026-07-12 13:23:07 -04:00
|
|
|
<g
|
2026-05-25 13:20:55 -04:00
|
|
|
key={dNum}
|
|
|
|
|
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
|
|
|
|
|
onMouseEnter={() => setHoverDriver(dNum)}
|
|
|
|
|
onMouseLeave={() => setHoverDriver(null)}
|
|
|
|
|
>
|
2026-05-25 13:10:47 -04:00
|
|
|
<polyline
|
|
|
|
|
points={pts}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke={color}
|
2026-05-25 13:20:55 -04:00
|
|
|
strokeWidth={isHovered ? 3 : 2}
|
2026-05-25 13:10:47 -04:00
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeLinecap="round"
|
2026-05-25 13:20:55 -04:00
|
|
|
className="rs-driver-line"
|
2026-05-25 13:44:51 -04:00
|
|
|
pathLength={1}
|
2026-05-25 13:10:47 -04:00
|
|
|
/>
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
{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 (
|
2026-07-12 13:23:07 -04:00
|
|
|
<circle
|
|
|
|
|
key={`pit-${i}`}
|
|
|
|
|
cx={toX(t)}
|
|
|
|
|
cy={toY(pos)}
|
|
|
|
|
r={3}
|
|
|
|
|
fill="var(--bg)"
|
|
|
|
|
stroke={color}
|
2026-05-25 13:20:55 -04:00
|
|
|
strokeWidth={2}
|
|
|
|
|
className="rs-pit-dot"
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:10:47 -04:00
|
|
|
{last && (
|
|
|
|
|
<text
|
|
|
|
|
x={toX(last.t) + 6}
|
2026-07-12 13:23:07 -04:00
|
|
|
y={labelY + 4}
|
2026-05-25 13:10:47 -04:00
|
|
|
fill={color}
|
2026-05-25 13:20:55 -04:00
|
|
|
fontSize={isHovered ? 11 : 9}
|
2026-05-25 13:10:47 -04:00
|
|
|
fontFamily="var(--f-mono)"
|
|
|
|
|
fontWeight={700}
|
2026-05-25 13:20:55 -04:00
|
|
|
style={{ cursor: 'default' }}
|
2026-05-25 13:10:47 -04:00
|
|
|
>
|
|
|
|
|
{acronymByDriver.get(dNum) ?? dNum}
|
|
|
|
|
</text>
|
|
|
|
|
)}
|
|
|
|
|
</g>
|
|
|
|
|
)
|
|
|
|
|
})}
|
2026-05-25 13:20:55 -04:00
|
|
|
|
|
|
|
|
{scrubTime !== null && (
|
|
|
|
|
<line
|
|
|
|
|
x1={toX(scrubTime)}
|
|
|
|
|
x2={toX(scrubTime)}
|
|
|
|
|
y1={PT}
|
|
|
|
|
y2={H - PB}
|
|
|
|
|
stroke="var(--text)"
|
|
|
|
|
strokeWidth={1}
|
|
|
|
|
strokeDasharray="4 2"
|
|
|
|
|
className="rs-playhead"
|
|
|
|
|
style={{ pointerEvents: 'none' }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<rect
|
|
|
|
|
x={PL}
|
|
|
|
|
y={PT}
|
|
|
|
|
width={plotW}
|
|
|
|
|
height={plotH}
|
|
|
|
|
fill="transparent"
|
|
|
|
|
onPointerMove={handlePointerMove}
|
2026-07-11 18:17:15 -04:00
|
|
|
onPointerLeave={() => {
|
|
|
|
|
if (!isPlaying) setScrubTime(null)
|
|
|
|
|
}}
|
2026-05-25 13:20:55 -04:00
|
|
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
|
|
|
|
/>
|
2026-05-25 13:10:47 -04:00
|
|
|
</svg>
|
2026-07-12 13:23:07 -04:00
|
|
|
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
|
2026-07-11 18:17:15 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-12 13:23:07 -04:00
|
|
|
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
2026-07-11 18:17:15 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
setScrubTime((current) => current ?? 0)
|
2026-07-11 18:23:11 -04:00
|
|
|
stopChapterTour()
|
2026-07-11 18:17:15 -04:00
|
|
|
setIsPlaying((current) => !current)
|
|
|
|
|
}}
|
2026-07-12 13:23:07 -04:00
|
|
|
aria-pressed={isPlaying}
|
2026-07-11 18:17:15 -04:00
|
|
|
>
|
|
|
|
|
{isPlaying ? 'Pause' : 'Play'}
|
|
|
|
|
</button>
|
2026-07-12 13:23:07 -04:00
|
|
|
<span className="rs-segment-divider" aria-hidden />
|
|
|
|
|
{[1, 10, 30].map((speed) => (
|
|
|
|
|
<button
|
|
|
|
|
key={speed}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setPlaybackSpeed(speed)}
|
|
|
|
|
aria-pressed={playbackSpeed === speed}
|
|
|
|
|
>
|
|
|
|
|
{speed}x
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
{mapProbeSettled && mapAvailable && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="rs-segment-divider" aria-hidden />
|
2026-07-11 18:17:15 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-12 13:23:07 -04:00
|
|
|
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setMapOpen((current) => !current)}
|
|
|
|
|
aria-pressed={mapOpen}
|
|
|
|
|
data-testid="replay-map-toggle"
|
2026-07-11 18:17:15 -04:00
|
|
|
>
|
2026-07-12 13:23:07 -04:00
|
|
|
Map
|
2026-07-11 18:17:15 -04:00
|
|
|
</button>
|
2026-07-12 13:23:07 -04:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-07-11 18:17:15 -04:00
|
|
|
</div>
|
2026-05-25 13:10:47 -04:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="race-story-canvas">
|
2026-07-11 18:23:11 -04:00
|
|
|
{hasChartData && chartTiming && chapters.length > 0 && (
|
|
|
|
|
<ChapterStrip
|
|
|
|
|
chapters={chapters}
|
|
|
|
|
scrubTime={scrubTime}
|
|
|
|
|
tMin={chartTiming.tMin}
|
|
|
|
|
tRange={chartTiming.tRange}
|
|
|
|
|
tourActive={chapterTourActive}
|
|
|
|
|
tourChapterIndex={tourChapterIndex}
|
|
|
|
|
onChapterClick={jumpToChapter}
|
|
|
|
|
onTourToggle={toggleChapterTour}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-12 13:23:07 -04:00
|
|
|
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
2026-07-11 18:17:15 -04:00
|
|
|
<div className="rs-replay-main">
|
|
|
|
|
{hasChartData ? (
|
|
|
|
|
chartContent
|
|
|
|
|
) : (
|
2026-07-12 13:23:07 -04:00
|
|
|
<EmptyStateCard
|
|
|
|
|
icon={LineChart}
|
|
|
|
|
title="Lap-by-lap positions not available"
|
|
|
|
|
hint={
|
|
|
|
|
<>
|
|
|
|
|
This session does not have ingested position samples in{' '}
|
|
|
|
|
<code>/api/v1/race-hub</code>.
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
testId="race-story-no-positions"
|
|
|
|
|
className="race-story-empty-card"
|
|
|
|
|
/>
|
2026-07-11 18:17:15 -04:00
|
|
|
)}
|
2026-05-25 13:10:47 -04:00
|
|
|
</div>
|
2026-07-12 13:23:07 -04:00
|
|
|
{showMapPanel && (
|
|
|
|
|
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
|
|
|
|
<ReplayTrackMap
|
|
|
|
|
outline={outlineQuery.data}
|
|
|
|
|
replay={replayQuery.data}
|
|
|
|
|
tMs={replayTMs}
|
|
|
|
|
drivers={drivers}
|
|
|
|
|
results={results}
|
|
|
|
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
|
|
|
|
error={outlineQuery.isError || replayQuery.isError}
|
|
|
|
|
/>
|
2026-07-11 18:17:15 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-25 13:10:47 -04:00
|
|
|
|
2026-05-25 13:20:55 -04:00
|
|
|
{displayResults.length > 0 && (
|
2026-05-25 13:10:47 -04:00
|
|
|
<div className="rs-field-list">
|
2026-05-25 13:20:55 -04:00
|
|
|
{displayResults.map((r, i) => {
|
2026-05-25 13:10:47 -04:00
|
|
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
2026-05-25 13:44:51 -04:00
|
|
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
2026-05-25 13:10:47 -04:00
|
|
|
const isWinner = i === 0 && r.position === 1
|
2026-07-12 13:23:07 -04:00
|
|
|
const pClass =
|
|
|
|
|
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
|
|
|
|
|
2026-05-25 13:44:51 -04:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:10:47 -04:00
|
|
|
return (
|
2026-07-12 13:23:07 -04:00
|
|
|
<div
|
|
|
|
|
key={r.driver_number}
|
2026-05-25 13:20:55 -04:00
|
|
|
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
|
|
|
|
|
onMouseEnter={() => setHoverDriver(r.driver_number)}
|
|
|
|
|
onMouseLeave={() => setHoverDriver(null)}
|
|
|
|
|
>
|
2026-05-25 13:10:47 -04:00
|
|
|
<div className="rs-driver-left">
|
2026-07-12 13:23:07 -04:00
|
|
|
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
|
2026-05-25 13:10:47 -04:00
|
|
|
<div className="rs-driver-cell">
|
2026-07-12 13:23:07 -04:00
|
|
|
<div
|
|
|
|
|
className="rs-driver-color"
|
|
|
|
|
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
|
2026-05-25 13:10:47 -04:00
|
|
|
/>
|
|
|
|
|
<div className="rs-driver-identity">
|
|
|
|
|
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
|
|
|
|
|
<span className="rs-driver-team">{r.team_name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="rs-driver-right">
|
|
|
|
|
<div className="rs-metric">
|
|
|
|
|
<span>
|
2026-05-25 13:44:51 -04:00
|
|
|
<span className={gridDeltaClass(currentPos, gridPos)}>
|
|
|
|
|
{gridDelta(currentPos, gridPos)}
|
2026-05-25 13:10:47 -04:00
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="rs-metric-label">Grid</span>
|
|
|
|
|
</div>
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:44:51 -04:00
|
|
|
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
|
2026-05-25 13:10:47 -04:00
|
|
|
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
|
|
|
|
|
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
|
|
|
|
|
</div>
|
2026-07-12 13:23:07 -04:00
|
|
|
|
2026-05-25 13:10:47 -04:00
|
|
|
<div className="rs-metric" style={{ width: '40px' }}>
|
2026-05-25 13:44:51 -04:00
|
|
|
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
|
|
|
|
{currentPoints}
|
2026-05-25 13:10:47 -04:00
|
|
|
</span>
|
|
|
|
|
<span className="rs-metric-label">Pts</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|