mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Compare commits
4 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2617d39bb | ||
|
|
5970649a9c | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 |
@@ -2,12 +2,15 @@ import type {
|
|||||||
ArticleContent,
|
ArticleContent,
|
||||||
CarDataSample,
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
|
EnrichedGrid,
|
||||||
|
EnrichedResult,
|
||||||
LapsComparisonResponse,
|
LapsComparisonResponse,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
LiveSessionMeta,
|
LiveSessionMeta,
|
||||||
Meeting,
|
Meeting,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
RaceHub,
|
RaceHub,
|
||||||
|
ReplayFramesResponse,
|
||||||
Session,
|
Session,
|
||||||
TrackOutline,
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
@@ -40,7 +43,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
return fetchMeetings(year, 'openf1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
||||||
|
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
}
|
}
|
||||||
@@ -48,6 +55,47 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
|||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
||||||
|
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const results = await res.json()
|
||||||
|
return Array.isArray(results) ? results : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
||||||
|
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const grid = await res.json()
|
||||||
|
return Array.isArray(grid) ? grid : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
||||||
|
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||||
|
return data as TrackOutline
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReplayFrames(
|
||||||
|
sessionKey: number,
|
||||||
|
intervalMs = 5000,
|
||||||
|
): Promise<ReplayFramesResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
session_key: String(sessionKey),
|
||||||
|
interval_ms: String(intervalMs),
|
||||||
|
})
|
||||||
|
const res = await fetch(`/api/v1/replay/frames?${params}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export function Nav() {
|
|||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||||
|
Preview
|
||||||
|
</Link>
|
||||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||||
Championship
|
Championship
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session } from '../types'
|
||||||
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||||
|
import { ReplayTrackMap } from './ReplayTrackMap'
|
||||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,28 +15,97 @@ interface Props {
|
|||||||
race_control?: any[]
|
race_control?: any[]
|
||||||
pit_stops?: any[]
|
pit_stops?: any[]
|
||||||
session?: Session
|
session?: Session
|
||||||
|
meeting?: Meeting
|
||||||
|
drivers?: Driver[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceStoryCanvas({ data }: Props) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
const {
|
||||||
|
results,
|
||||||
|
starting_grid: grid,
|
||||||
|
positions,
|
||||||
|
datasets,
|
||||||
|
race_control = [],
|
||||||
|
pit_stops = [],
|
||||||
|
laps = [],
|
||||||
|
session,
|
||||||
|
meeting,
|
||||||
|
drivers = [],
|
||||||
|
} = data
|
||||||
const hasPositions = datasets['positions']?.status === 'available'
|
const hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||||
|
const [mapOpen, setMapOpen] = useState(false)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
// Position Evolution Chart Logic
|
||||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
const hasChartData = hasPositions && allTimes.length > 0
|
||||||
|
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)
|
||||||
|
|
||||||
|
const replayQuery = useQuery({
|
||||||
|
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||||
|
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||||
|
enabled: mapOpen && Boolean(session?.session_key),
|
||||||
|
})
|
||||||
|
const outlineQuery = useQuery({
|
||||||
|
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||||
|
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||||
|
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 chartContent = null
|
||||||
let displayResults = results
|
let displayResults = results
|
||||||
|
|
||||||
if (hasChartData) {
|
if (hasChartData && chartTiming) {
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
const { tMin, tRange } = chartTiming
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
|
||||||
const tRange = Math.max(tMax - tMin, 1)
|
|
||||||
|
|
||||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
for (const p of positions) {
|
||||||
@@ -137,6 +209,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
setIsPlaying(false)
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
const x = e.clientX - rect.left
|
||||||
@@ -288,24 +361,78 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => setScrubTime(null)}
|
onPointerLeave={() => {
|
||||||
|
if (!isPlaying) setScrubTime(null)
|
||||||
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<div className="rs-replay-tools" aria-label="Race replay controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setScrubTime((current) => current ?? 0)
|
||||||
|
setIsPlaying((current) => !current)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? 'Pause' : 'Play'}
|
||||||
|
</button>
|
||||||
|
<div className="rs-speed-group" aria-label="Playback speed">
|
||||||
|
{[1, 10, 30].map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
type="button"
|
||||||
|
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
||||||
|
onClick={() => setPlaybackSpeed(speed)}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
||||||
|
onClick={() => setMapOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="race-story-canvas">
|
<div className="race-story-canvas">
|
||||||
{hasChartData ? (
|
<div className="rs-replay-shell">
|
||||||
chartContent
|
<div className="rs-replay-main">
|
||||||
) : (
|
{hasChartData ? (
|
||||||
<div className="analysis-notice">
|
chartContent
|
||||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
) : (
|
||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
<div className="analysis-notice">
|
||||||
|
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||||
|
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{mapOpen && (
|
||||||
|
<div className="rs-replay-map-slot">
|
||||||
|
{circuitKey > 0 && outlineYear > 0 ? (
|
||||||
|
<ReplayTrackMap
|
||||||
|
outline={outlineQuery.data}
|
||||||
|
replay={replayQuery.data}
|
||||||
|
tMs={replayTMs}
|
||||||
|
drivers={drivers}
|
||||||
|
results={results}
|
||||||
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||||
|
error={outlineQuery.isError || replayQuery.isError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div className="rs-field-list">
|
||||||
|
|||||||
133
frontend/src/components/ReplayTrackMap.tsx
Normal file
133
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
|
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||||
|
import '../styles/replay-map.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
outline?: TrackOutline | null
|
||||||
|
replay?: ReplayFramesResponse | null
|
||||||
|
tMs: number
|
||||||
|
drivers: Driver[]
|
||||||
|
results: EnrichedResult[]
|
||||||
|
loading?: boolean
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplayTrackMap({
|
||||||
|
outline,
|
||||||
|
replay,
|
||||||
|
tMs,
|
||||||
|
drivers,
|
||||||
|
results,
|
||||||
|
loading = false,
|
||||||
|
error = false,
|
||||||
|
}: Props) {
|
||||||
|
const [pinned, setPinned] = useState<string | null>(null)
|
||||||
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
const driverInfo = useMemo(() => {
|
||||||
|
const info = new Map<string, { label: string; color: string }>()
|
||||||
|
for (const driver of drivers) {
|
||||||
|
info.set(String(driver.driver_number), {
|
||||||
|
label: driver.name_acronym || String(driver.driver_number),
|
||||||
|
color: normalizeColor(driver.team_colour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const result of results) {
|
||||||
|
const key = String(result.driver_number)
|
||||||
|
if (!info.has(key)) {
|
||||||
|
info.set(key, {
|
||||||
|
label: result.name_acronym || key,
|
||||||
|
color: normalizeColor(result.team_colour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}, [drivers, results])
|
||||||
|
|
||||||
|
const cars = useMemo(() => {
|
||||||
|
if (!outline?.bounds || !replay?.frames?.length) return []
|
||||||
|
const positions = interpolateReplayCars(replay.frames, tMs)
|
||||||
|
return Object.entries(positions)
|
||||||
|
.map(([number, car]) => ({
|
||||||
|
number,
|
||||||
|
svg: replayCarToSvg(car, outline.bounds),
|
||||||
|
info: driverInfo.get(number),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(a.number) - Number(b.number))
|
||||||
|
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">loading replay GPS...</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">replay GPS unavailable for this session</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outline || !outlinePath) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">track outline unavailable for this session</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replay?.frames?.length || cars.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-empty">historical GPS unavailable for this session</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-stage">
|
||||||
|
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
|
||||||
|
<path className="replay-map-outline-shadow" d={outlinePath} />
|
||||||
|
<path className="replay-map-outline" d={outlinePath} />
|
||||||
|
{cars.map((car) => {
|
||||||
|
const label = car.info?.label ?? car.number
|
||||||
|
const selected = pinned === car.number
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={car.number}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${label} replay position`}
|
||||||
|
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
|
||||||
|
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
|
||||||
|
onClick={() => setPinned(selected ? null : car.number)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setPinned(selected ? null : car.number)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
|
||||||
|
<text y="0.85">{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(color: string | undefined): string {
|
||||||
|
const raw = color?.trim()
|
||||||
|
if (!raw) return '#777777'
|
||||||
|
return raw.startsWith('#') ? raw : `#${raw}`
|
||||||
|
}
|
||||||
123
frontend/src/lib/preview.ts
Normal file
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||||
|
import {
|
||||||
|
currentAndNextSession,
|
||||||
|
nextUpcomingMeeting,
|
||||||
|
sessionStartTime,
|
||||||
|
sortSessionsByStart,
|
||||||
|
} from './schedule'
|
||||||
|
|
||||||
|
export const RACE_WIN_POINTS = 25
|
||||||
|
export const SPRINT_WIN_POINTS = 8
|
||||||
|
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
|
||||||
|
|
||||||
|
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||||
|
const active = meetings.filter((m) => !m.is_cancelled)
|
||||||
|
return nextUpcomingMeeting(active, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPriorYearMeetingByCircuit(
|
||||||
|
priorYearMeetings: Meeting[],
|
||||||
|
circuitKey: number | undefined,
|
||||||
|
): Meeting | null {
|
||||||
|
if (!circuitKey) return null
|
||||||
|
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRaceSession(sessions: Session[]): Session | null {
|
||||||
|
const sorted = sortSessionsByStart(sessions)
|
||||||
|
return (
|
||||||
|
sorted.find((s) => {
|
||||||
|
const type = s.session_type?.toLowerCase() ?? ''
|
||||||
|
return type.includes('race') && !type.includes('sprint')
|
||||||
|
}) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSprintWeekend(sessions: Session[]): boolean {
|
||||||
|
return sessions.some((s) => {
|
||||||
|
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
|
||||||
|
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodiumPlace {
|
||||||
|
position: number
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolePosition {
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
|
||||||
|
return results
|
||||||
|
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((r) => ({
|
||||||
|
position: r.position,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
full_name: r.full_name,
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
|
||||||
|
const pole = grid.find((g) => g.position === 1)
|
||||||
|
if (!pole) return null
|
||||||
|
return {
|
||||||
|
name_acronym: pole.name_acronym,
|
||||||
|
full_name: pole.full_name,
|
||||||
|
team_name: pole.team_name,
|
||||||
|
team_colour: pole.team_colour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitleFightDriver {
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_colour: string
|
||||||
|
points: number
|
||||||
|
position: number
|
||||||
|
gapToLeader: number
|
||||||
|
gapAfterRaceWin: number
|
||||||
|
gapAfterSprintWeekendMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
|
||||||
|
if (hub.drivers.length === 0) return []
|
||||||
|
const leader = hub.drivers[0]
|
||||||
|
return hub.drivers.slice(0, 3).map((d) => ({
|
||||||
|
name_acronym: d.name_acronym,
|
||||||
|
full_name: d.full_name,
|
||||||
|
team_colour: d.team_colour,
|
||||||
|
points: d.points,
|
||||||
|
position: d.position,
|
||||||
|
gapToLeader: leader.points - d.points,
|
||||||
|
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
|
||||||
|
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
|
||||||
|
const sorted = sortSessionsByStart(sessions)
|
||||||
|
const { next } = currentAndNextSession(sorted, now)
|
||||||
|
if (next) return next
|
||||||
|
for (const session of sorted) {
|
||||||
|
const start = sessionStartTime(session)
|
||||||
|
if (start && start > now) return session
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPointsGap(gap: number): string {
|
||||||
|
if (gap === 0) return 'LEADER'
|
||||||
|
const sign = gap > 0 ? '+' : ''
|
||||||
|
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
|
||||||
|
}
|
||||||
75
frontend/src/lib/replay.ts
Normal file
75
frontend/src/lib/replay.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
|
||||||
|
import { canvasToSvg, normalizeRawPoint } from './trackmap'
|
||||||
|
|
||||||
|
export interface ReplayFramePair {
|
||||||
|
previous: ReplayFrame | null
|
||||||
|
next: ReplayFrame | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupReplayFramePair(
|
||||||
|
frames: ReadonlyArray<ReplayFrame>,
|
||||||
|
tMs: number,
|
||||||
|
): ReplayFramePair {
|
||||||
|
if (frames.length === 0) return { previous: null, next: null }
|
||||||
|
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
|
||||||
|
const last = frames[frames.length - 1]
|
||||||
|
if (tMs >= last.t) return { previous: last, next: last }
|
||||||
|
|
||||||
|
let lo = 0
|
||||||
|
let hi = frames.length - 1
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = Math.floor((lo + hi) / 2)
|
||||||
|
const frame = frames[mid]
|
||||||
|
if (frame.t === tMs) return { previous: frame, next: frame }
|
||||||
|
if (frame.t < tMs) {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolateReplayCars(
|
||||||
|
frames: ReadonlyArray<ReplayFrame>,
|
||||||
|
tMs: number,
|
||||||
|
): Record<string, ReplayCarPosition> {
|
||||||
|
const { previous, next } = lookupReplayFramePair(frames, tMs)
|
||||||
|
if (!previous && !next) return {}
|
||||||
|
if (!previous) return next?.cars ?? {}
|
||||||
|
if (!next || previous.t === next.t) return previous.cars
|
||||||
|
|
||||||
|
const span = next.t - previous.t
|
||||||
|
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
|
||||||
|
const cars: Record<string, ReplayCarPosition> = {}
|
||||||
|
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
|
||||||
|
|
||||||
|
for (const number of numbers) {
|
||||||
|
const before = previous.cars[number]
|
||||||
|
const after = next.cars[number]
|
||||||
|
if (before && after) {
|
||||||
|
cars[number] = {
|
||||||
|
x: before.x + (after.x - before.x) * fraction,
|
||||||
|
y: before.y + (after.y - before.y) * fraction,
|
||||||
|
}
|
||||||
|
} else if (before) {
|
||||||
|
cars[number] = before
|
||||||
|
} else if (after) {
|
||||||
|
cars[number] = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cars
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayCarToSvg(
|
||||||
|
car: ReplayCarPosition,
|
||||||
|
bounds: TrackBounds,
|
||||||
|
): ReplayCarPosition {
|
||||||
|
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
return Math.min(1, Math.max(0, value))
|
||||||
|
}
|
||||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchMeetings,
|
||||||
|
fetchResults,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchSessions,
|
||||||
|
fetchStartingGrid,
|
||||||
|
fetchTrackOutline,
|
||||||
|
} from '../api'
|
||||||
|
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import {
|
||||||
|
buildTitleFightContext,
|
||||||
|
countdownTargetSession,
|
||||||
|
extractPodium,
|
||||||
|
extractPole,
|
||||||
|
findPriorYearMeetingByCircuit,
|
||||||
|
findRaceSession,
|
||||||
|
formatPointsGap,
|
||||||
|
isSprintWeekend,
|
||||||
|
pickPreviewMeeting,
|
||||||
|
RACE_WIN_POINTS,
|
||||||
|
SPRINT_WEEKEND_MAX_POINTS,
|
||||||
|
} from '../lib/preview'
|
||||||
|
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
|
||||||
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import type { Meeting, Session, TrackOutline } from '../types'
|
||||||
|
import '../styles/preview.css'
|
||||||
|
|
||||||
|
function SectionState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
empty?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="preview-section-state">Loading…</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="preview-section-state error">
|
||||||
|
{error instanceof Error ? error.message : 'Failed to load'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (empty) {
|
||||||
|
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackOutlineCard({
|
||||||
|
outline,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
outline: TrackOutline | null | undefined
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="preview-card" data-testid="preview-track-card">
|
||||||
|
<h2 className="preview-card-title">Circuit</h2>
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={!outline || !outlinePath}
|
||||||
|
emptyMessage="Track outline unavailable for this circuit"
|
||||||
|
>
|
||||||
|
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
||||||
|
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
|
||||||
|
<path className="preview-track-shadow" d={outlinePath} />
|
||||||
|
<path className="preview-track-outline" d={outlinePath} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastYearCard({
|
||||||
|
year,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
podium,
|
||||||
|
pole,
|
||||||
|
isFirstTime,
|
||||||
|
}: {
|
||||||
|
year: number | null
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
podium: ReturnType<typeof extractPodium>
|
||||||
|
pole: ReturnType<typeof extractPole>
|
||||||
|
isFirstTime: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="preview-card" data-testid="preview-last-year-card">
|
||||||
|
<h2 className="preview-card-title">Last year here</h2>
|
||||||
|
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={isFirstTime || podium.length === 0}
|
||||||
|
emptyMessage={
|
||||||
|
isFirstTime
|
||||||
|
? 'First time on the calendar — no prior race at this circuit'
|
||||||
|
: 'No race results available for the previous visit'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="preview-podium">
|
||||||
|
{podium.map((place) => (
|
||||||
|
<div key={place.position} className="preview-podium-row">
|
||||||
|
<span className="preview-podium-pos">P{place.position}</span>
|
||||||
|
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
|
||||||
|
{place.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="preview-podium-team">{place.team_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pole && (
|
||||||
|
<p className="preview-pole">
|
||||||
|
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
|
||||||
|
({pole.team_name})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleFightCard({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
drivers,
|
||||||
|
sprintWeekend,
|
||||||
|
season,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
drivers: ReturnType<typeof buildTitleFightContext>
|
||||||
|
sprintWeekend: boolean
|
||||||
|
season: number | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
|
||||||
|
<h2 className="preview-card-title">Title fight context</h2>
|
||||||
|
{season != null && <p className="preview-card-sub">{season} drivers' championship · top 3</p>}
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={drivers.length === 0}
|
||||||
|
emptyMessage="Championship standings not available"
|
||||||
|
>
|
||||||
|
<table className="preview-title-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th>Pts</th>
|
||||||
|
<th>Gap</th>
|
||||||
|
<th>If win (+{RACE_WIN_POINTS})</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<tr key={d.name_acronym}>
|
||||||
|
<td>
|
||||||
|
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
|
||||||
|
</td>
|
||||||
|
<td className="mono">{d.points}</td>
|
||||||
|
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
|
||||||
|
<td className="mono">
|
||||||
|
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{sprintWeekend && (
|
||||||
|
<p className="preview-sprint-note" data-testid="preview-sprint-note">
|
||||||
|
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
|
||||||
|
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewHeader({
|
||||||
|
meeting,
|
||||||
|
sessions,
|
||||||
|
countdownSession,
|
||||||
|
now,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
meeting: Meeting
|
||||||
|
sessions: Session[]
|
||||||
|
countdownSession: Session | null
|
||||||
|
now: Date
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="preview-header"
|
||||||
|
data-testid="preview-header"
|
||||||
|
style={{ ['--preview-accent' as string]: accent }}
|
||||||
|
>
|
||||||
|
<div className="preview-header-accent" />
|
||||||
|
<div className="preview-header-body">
|
||||||
|
<div className="preview-header-top">
|
||||||
|
<div>
|
||||||
|
<h1 className="preview-gp-name">
|
||||||
|
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
|
||||||
|
{meeting.meeting_name}
|
||||||
|
</h1>
|
||||||
|
<p className="preview-circuit">
|
||||||
|
{meeting.circuit_short_name}
|
||||||
|
{meeting.location ? ` · ${meeting.location}` : ''}
|
||||||
|
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{countdownTarget && (
|
||||||
|
<div className="preview-countdown" data-testid="preview-countdown">
|
||||||
|
<span className="preview-countdown-label">
|
||||||
|
{countdownSession?.session_name ?? 'Next session'}
|
||||||
|
</span>
|
||||||
|
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="preview-schedule">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{session.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RacePreviewPage() {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const nowDate = useMemo(() => new Date(now), [now])
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', latestSeason, 'auto'],
|
||||||
|
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewMeeting = useMemo(
|
||||||
|
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||||
|
[meetingsQuery.data, nowDate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionsQuery = useQuery({
|
||||||
|
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||||
|
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||||
|
enabled: previewMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = useMemo(
|
||||||
|
() => sortSessionsByStart(sessionsQuery.data ?? []),
|
||||||
|
[sessionsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
|
||||||
|
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
|
||||||
|
|
||||||
|
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
|
||||||
|
|
||||||
|
const priorMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', priorYear, 'auto'],
|
||||||
|
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||||
|
enabled: priorYear != null && previewMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorMeeting = useMemo(
|
||||||
|
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
|
||||||
|
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
|
||||||
|
)
|
||||||
|
|
||||||
|
const priorSessionsQuery = useQuery({
|
||||||
|
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||||
|
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||||
|
enabled: priorMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorRaceSession = useMemo(
|
||||||
|
() => findRaceSession(priorSessionsQuery.data ?? []),
|
||||||
|
[priorSessionsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const priorResultsQuery = useQuery({
|
||||||
|
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||||
|
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||||
|
enabled: priorRaceSession != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorGridQuery = useQuery({
|
||||||
|
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||||
|
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||||
|
enabled: priorRaceSession != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackOutlineQuery = useQuery({
|
||||||
|
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||||
|
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||||
|
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
|
||||||
|
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
|
||||||
|
const titleFight = useMemo(
|
||||||
|
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
|
||||||
|
[championshipQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const accent = countryAccent(previewMeeting)
|
||||||
|
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||||
|
|
||||||
|
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||||
|
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||||
|
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||||
|
return (
|
||||||
|
<div className="page error-box" data-testid="preview-error">
|
||||||
|
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewMeeting) {
|
||||||
|
return (
|
||||||
|
<div className="preview-page" data-testid="preview-season-over">
|
||||||
|
<div className="preview-empty">
|
||||||
|
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
|
||||||
|
<h1 className="preview-empty-title">Season complete</h1>
|
||||||
|
<p className="preview-empty-sub">
|
||||||
|
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
|
||||||
|
published.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{titleFight.length > 0 && (
|
||||||
|
<TitleFightCard
|
||||||
|
loading={championshipQuery.isLoading}
|
||||||
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||||
|
drivers={titleFight}
|
||||||
|
sprintWeekend={false}
|
||||||
|
season={latestSeason}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview-page" data-testid="preview-page">
|
||||||
|
<PreviewHeader
|
||||||
|
meeting={previewMeeting}
|
||||||
|
sessions={sessions}
|
||||||
|
countdownSession={countdownSession}
|
||||||
|
now={nowDate}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="preview-grid">
|
||||||
|
<TrackOutlineCard
|
||||||
|
outline={trackOutlineQuery.data}
|
||||||
|
loading={trackOutlineQuery.isLoading}
|
||||||
|
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LastYearCard
|
||||||
|
year={priorMeeting?.year ?? priorYear}
|
||||||
|
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||||
|
error={
|
||||||
|
priorMeetingsQuery.isError
|
||||||
|
? (priorMeetingsQuery.error as Error)
|
||||||
|
: priorResultsQuery.isError
|
||||||
|
? (priorResultsQuery.error as Error)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
podium={podium}
|
||||||
|
pole={pole}
|
||||||
|
isFirstTime={isFirstTimeCircuit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TitleFightCard
|
||||||
|
loading={championshipQuery.isLoading}
|
||||||
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||||
|
drivers={titleFight}
|
||||||
|
sprintWeekend={sprintWeekend}
|
||||||
|
season={latestSeason}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
|||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
|
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
@@ -70,6 +71,12 @@ export const briefingRoute = createRoute({
|
|||||||
component: BriefingPage,
|
component: BriefingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const previewRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/preview',
|
||||||
|
component: RacePreviewPage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
commandCenterRoute,
|
commandCenterRoute,
|
||||||
raceHubRoute,
|
raceHubRoute,
|
||||||
@@ -78,6 +85,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
championshipRoute,
|
championshipRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
|
previewRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
309
frontend/src/styles/preview.css
Normal file
309
frontend/src/styles/preview.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
.preview-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s6);
|
||||||
|
padding: var(--s6);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-height: 320px;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--s8);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-eyebrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-accent {
|
||||||
|
width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--preview-accent, var(--gp-accent));
|
||||||
|
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-gp-name {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-gp-flag {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circuit {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown-value {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-time {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-sub {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin: calc(-1 * var(--s2)) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--preview-accent, var(--gp-accent));
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-shadow {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(0, 0, 0, 0.5);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-pos {
|
||||||
|
width: 1.5rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-code {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-team {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pole {
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pole strong {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table th,
|
||||||
|
.preview-title-table td {
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table td.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-sprint-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-state.error {
|
||||||
|
color: var(--red, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.preview-page {
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
frontend/src/styles/replay-map.css
Normal file
161
frontend/src/styles/replay-map.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
.rs-replay-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-top: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tool-btn,
|
||||||
|
.rs-speed-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tool-btn:hover,
|
||||||
|
.rs-speed-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tool-btn.active,
|
||||||
|
.rs-speed-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--red);
|
||||||
|
background: rgba(230, 36, 41, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-speed-group {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-map-slot {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-map-placeholder {
|
||||||
|
min-height: 244px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--s5);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-panel {
|
||||||
|
min-height: 244px;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 244px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 244px;
|
||||||
|
display: block;
|
||||||
|
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-outline-shadow,
|
||||||
|
.replay-map-outline {
|
||||||
|
fill: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-outline-shadow {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-outline {
|
||||||
|
stroke: var(--text-2);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car circle {
|
||||||
|
stroke: var(--bg);
|
||||||
|
stroke-width: 0.8;
|
||||||
|
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car text {
|
||||||
|
fill: #fff;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 2.6px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-anchor: middle;
|
||||||
|
pointer-events: none;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: rgba(0, 0, 0, 0.75);
|
||||||
|
stroke-width: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car:not(.replay-car-pinned) text {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car:hover text,
|
||||||
|
.replay-car:focus-visible text,
|
||||||
|
.replay-car-pinned text {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car-pinned circle {
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-empty {
|
||||||
|
min-height: 244px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--s5);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.rs-replay-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
276
frontend/src/test/RacePreviewPage.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchMeetings: vi.fn(),
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
|
fetchResults: vi.fn(),
|
||||||
|
fetchStartingGrid: vi.fn(),
|
||||||
|
fetchTrackOutline: vi.fn(),
|
||||||
|
fetchChampionshipHub: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchSeasons,
|
||||||
|
fetchMeetings,
|
||||||
|
fetchSessions,
|
||||||
|
fetchResults,
|
||||||
|
fetchStartingGrid,
|
||||||
|
fetchTrackOutline,
|
||||||
|
fetchChampionshipHub,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchMeetings = vi.mocked(fetchMeetings)
|
||||||
|
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||||
|
const mockFetchResults = vi.mocked(fetchResults)
|
||||||
|
const mockFetchStartingGrid = vi.mocked(fetchStartingGrid)
|
||||||
|
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
||||||
|
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||||
|
|
||||||
|
const upcomingMeeting: Meeting = {
|
||||||
|
meeting_key: 100,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'Monaco GP',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 10,
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2099-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2099-05-24T23:59:59+00:00',
|
||||||
|
year: 2099,
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorMeeting: Meeting = {
|
||||||
|
...upcomingMeeting,
|
||||||
|
meeting_key: 90,
|
||||||
|
year: 2098,
|
||||||
|
date_start: '2098-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2098-05-24T23:59:59+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: Session[] = [
|
||||||
|
{
|
||||||
|
session_key: 1,
|
||||||
|
session_name: 'FP1',
|
||||||
|
session_type: 'Practice',
|
||||||
|
meeting_key: 100,
|
||||||
|
date_start: '2099-05-22T10:00:00+00:00',
|
||||||
|
date_end: '2099-05-22T11:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 2,
|
||||||
|
session_name: 'Sprint',
|
||||||
|
session_type: 'Sprint',
|
||||||
|
meeting_key: 100,
|
||||||
|
date_start: '2099-05-23T10:00:00+00:00',
|
||||||
|
date_end: '2099-05-23T11:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 3,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 100,
|
||||||
|
date_start: '2099-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2099-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorRaceSession: Session = {
|
||||||
|
session_key: 50,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 90,
|
||||||
|
date_start: '2098-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2098-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 200,
|
||||||
|
position: 1,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
poles: 4,
|
||||||
|
form: [25],
|
||||||
|
cumulative: [200],
|
||||||
|
teammate_wins: 9,
|
||||||
|
teammate_losses: 1,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2099,
|
||||||
|
round: 5,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 19,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1'],
|
||||||
|
drivers: [
|
||||||
|
hubDriver({}),
|
||||||
|
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||||
|
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 100,
|
||||||
|
gap_to_leader: 0,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 50,
|
||||||
|
meeting_key: 90,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const grid: EnrichedGrid[] = [
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
session_key: 50,
|
||||||
|
meeting_key: 90,
|
||||||
|
lap_duration: 70,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 10,
|
||||||
|
points: [
|
||||||
|
{ x: 0.1, y: 0.2 },
|
||||||
|
{ x: 0.5, y: 0.5 },
|
||||||
|
{ x: 0.9, y: 0.8 },
|
||||||
|
],
|
||||||
|
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RacePreviewPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RacePreviewPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchSeasons.mockResolvedValue([2099])
|
||||||
|
mockFetchChampionshipHub.mockResolvedValue(hub)
|
||||||
|
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||||
|
mockFetchResults.mockResolvedValue(results)
|
||||||
|
mockFetchStartingGrid.mockResolvedValue(grid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders upcoming race preview with sections', async () => {
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2099) return [upcomingMeeting]
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
|
if (meetingKey === 100) return sessions
|
||||||
|
if (meetingKey === 90) return [priorRaceSession]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-countdown')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
||||||
|
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||||
|
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('preview-title-fight-card')).toHaveTextContent('NOR')
|
||||||
|
expect(screen.getByTestId('preview-sprint-note')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders season-over state when no upcoming meeting', async () => {
|
||||||
|
mockFetchMeetings.mockResolvedValue([
|
||||||
|
{
|
||||||
|
...upcomingMeeting,
|
||||||
|
date_start: '2020-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2020-05-24T23:59:59+00:00',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-season-over')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Season complete')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('preview-title-fight-card')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows first-time circuit empty state', async () => {
|
||||||
|
const newCircuit: Meeting = {
|
||||||
|
...upcomingMeeting,
|
||||||
|
meeting_key: 200,
|
||||||
|
circuit_key: 999,
|
||||||
|
meeting_name: 'New GP',
|
||||||
|
}
|
||||||
|
|
||||||
|
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||||
|
if (year === 2099) return [newCircuit]
|
||||||
|
if (year === 2098) return [priorMeeting]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
mockFetchSessions.mockResolvedValue(sessions)
|
||||||
|
mockFetchTrackOutline.mockResolvedValue(null)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
135
frontend/src/test/RaceStoryCanvas.test.tsx
Normal file
135
frontend/src/test/RaceStoryCanvas.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
|
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchReplayFrames: vi.fn(),
|
||||||
|
fetchTrackOutline: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||||
|
|
||||||
|
const mockFetchReplayFrames = vi.mocked(fetchReplayFrames)
|
||||||
|
const mockFetchTrackOutline = vi.mocked(fetchTrackOutline)
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 1,
|
||||||
|
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay: ReplayFramesResponse = {
|
||||||
|
session_key: 99,
|
||||||
|
interval_ms: 5000,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const raceHub: RaceHub = {
|
||||||
|
source: 'local',
|
||||||
|
session_key: 99,
|
||||||
|
datasets: {
|
||||||
|
positions: { status: 'available', source: 'local', count: 2 },
|
||||||
|
},
|
||||||
|
meeting: {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Monaco Grand Prix',
|
||||||
|
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 1,
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00Z',
|
||||||
|
date_end: '2025-05-25T00:00:00Z',
|
||||||
|
year: 2025,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
session_key: 99,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
circuit_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2025-05-25T13:00:00Z',
|
||||||
|
date_end: '2025-05-25T15:00:00Z',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
},
|
||||||
|
drivers: [],
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: null,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 99,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
starting_grid: [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
session_key: 99,
|
||||||
|
meeting_key: 1,
|
||||||
|
lap_duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stints: [],
|
||||||
|
pit_stops: [],
|
||||||
|
positions: [
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:00:00Z', position: 1 },
|
||||||
|
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
|
||||||
|
],
|
||||||
|
race_control: [],
|
||||||
|
weather: [],
|
||||||
|
laps: [],
|
||||||
|
chapters: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCanvas() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RaceStoryCanvas data={raceHub} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RaceStoryCanvas replay map', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchReplayFrames.mockResolvedValue(replay)
|
||||||
|
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches replay frames lazily when the map panel opens', async () => {
|
||||||
|
renderCanvas()
|
||||||
|
|
||||||
|
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
||||||
|
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
||||||
|
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
56
frontend/src/test/ReplayTrackMap.test.tsx
Normal file
56
frontend/src/test/ReplayTrackMap.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ReplayTrackMap } from '../components/ReplayTrackMap'
|
||||||
|
import type { EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
const outline: TrackOutline = {
|
||||||
|
circuit_key: 1,
|
||||||
|
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay: ReplayFramesResponse = {
|
||||||
|
session_key: 99,
|
||||||
|
interval_ms: 5000,
|
||||||
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
|
frames: [
|
||||||
|
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
|
||||||
|
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: null,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 99,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ReplayTrackMap', () => {
|
||||||
|
it('renders an empty state when replay frames are missing', () => {
|
||||||
|
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||||
|
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders car labels from result metadata', () => {
|
||||||
|
render(<ReplayTrackMap outline={outline} replay={replay} tMs={2500} drivers={[]} results={results} />)
|
||||||
|
expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
226
frontend/src/test/preview.test.ts
Normal file
226
frontend/src/test/preview.test.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||||
|
import {
|
||||||
|
buildTitleFightContext,
|
||||||
|
countdownTargetSession,
|
||||||
|
extractPodium,
|
||||||
|
extractPole,
|
||||||
|
findPriorYearMeetingByCircuit,
|
||||||
|
findRaceSession,
|
||||||
|
formatPointsGap,
|
||||||
|
isSprintWeekend,
|
||||||
|
pickPreviewMeeting,
|
||||||
|
RACE_WIN_POINTS,
|
||||||
|
SPRINT_WEEKEND_MAX_POINTS,
|
||||||
|
} from '../lib/preview'
|
||||||
|
|
||||||
|
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'Monaco GP',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_key: 10,
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2026-05-22T00:00:00+00:00',
|
||||||
|
date_end: '2026-05-24T23:59:59+00:00',
|
||||||
|
year: 2026,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||||
|
session_key: 100,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2026-05-24T13:00:00+00:00',
|
||||||
|
date_end: '2026-05-24T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 200,
|
||||||
|
position: 1,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
poles: 4,
|
||||||
|
form: [25],
|
||||||
|
cumulative: [200],
|
||||||
|
teammate_wins: 9,
|
||||||
|
teammate_losses: 1,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('preview lib', () => {
|
||||||
|
it('picks the next upcoming meeting', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const meetings = [
|
||||||
|
meeting({ meeting_key: 1, date_start: '2026-05-22T00:00:00+00:00' }),
|
||||||
|
meeting({ meeting_key: 2, meeting_name: 'Spain', date_start: '2026-06-12T00:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)?.meeting_key).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no upcoming meeting exists', () => {
|
||||||
|
const now = new Date('2027-01-01T00:00:00Z')
|
||||||
|
const meetings = [meeting({ date_start: '2026-05-22T00:00:00+00:00' })]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips cancelled meetings', () => {
|
||||||
|
const now = new Date('2026-01-01T00:00:00Z')
|
||||||
|
const meetings = [meeting({ is_cancelled: true })]
|
||||||
|
expect(pickPreviewMeeting(meetings, now)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches prior-year meeting by circuit_key', () => {
|
||||||
|
const prior = [
|
||||||
|
meeting({ meeting_key: 50, year: 2025, circuit_key: 10 }),
|
||||||
|
meeting({ meeting_key: 51, year: 2025, circuit_key: 20, meeting_name: 'Spain' }),
|
||||||
|
]
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, 10)?.meeting_key).toBe(50)
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, 99)).toBeNull()
|
||||||
|
expect(findPriorYearMeetingByCircuit(prior, undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds the grand prix race session', () => {
|
||||||
|
const sessions = [
|
||||||
|
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00' }),
|
||||||
|
session({ session_key: 2, session_name: 'Sprint', session_type: 'Sprint', date_start: '2026-05-23T10:00:00+00:00' }),
|
||||||
|
session({ session_key: 3, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
expect(findRaceSession(sessions)?.session_key).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects sprint weekends', () => {
|
||||||
|
const sprint = [
|
||||||
|
session({ session_type: 'Practice' }),
|
||||||
|
session({ session_name: 'Sprint', session_type: 'Sprint' }),
|
||||||
|
session({ session_type: 'Race' }),
|
||||||
|
]
|
||||||
|
const normal = [session({ session_type: 'Practice' }), session({ session_type: 'Race' })]
|
||||||
|
expect(isSprintWeekend(sprint)).toBe(true)
|
||||||
|
expect(isSprintWeekend(normal)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts podium and pole', () => {
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 100,
|
||||||
|
gap_to_leader: 0,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 2,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 101,
|
||||||
|
gap_to_leader: 1,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 18,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 16,
|
||||||
|
position: 3,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'e8002d',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: 102,
|
||||||
|
gap_to_leader: 2,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 15,
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const grid: EnrichedGrid[] = [
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
session_key: 1,
|
||||||
|
meeting_key: 1,
|
||||||
|
lap_duration: 70,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const podium = extractPodium(results)
|
||||||
|
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'NOR', 'LEC'])
|
||||||
|
expect(extractPole(grid)?.name_acronym).toBe('NOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds title fight gaps for top 3', () => {
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2026,
|
||||||
|
round: 5,
|
||||||
|
total_rounds: 24,
|
||||||
|
rounds_left: 19,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1'],
|
||||||
|
drivers: [
|
||||||
|
hubDriver({ points: 200, position: 1 }),
|
||||||
|
hubDriver({ driver_number: 4, name_acronym: 'NOR', points: 160, position: 2 }),
|
||||||
|
hubDriver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }),
|
||||||
|
],
|
||||||
|
teams: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = buildTitleFightContext(hub)
|
||||||
|
expect(rows).toHaveLength(3)
|
||||||
|
expect(rows[0].gapToLeader).toBe(0)
|
||||||
|
expect(rows[1].gapToLeader).toBe(40)
|
||||||
|
expect(rows[1].gapAfterRaceWin).toBe(15)
|
||||||
|
expect(rows[2].gapAfterSprintWeekendMax).toBe(200 - (120 + SPRINT_WEEKEND_MAX_POINTS))
|
||||||
|
expect(RACE_WIN_POINTS).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats points gaps', () => {
|
||||||
|
expect(formatPointsGap(0)).toBe('LEADER')
|
||||||
|
expect(formatPointsGap(40)).toBe('+40')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks countdown target as next future session', () => {
|
||||||
|
const sessions = [
|
||||||
|
session({ session_key: 1, session_name: 'FP1', session_type: 'Practice', date_start: '2026-05-22T10:00:00+00:00', date_end: '2026-05-22T11:00:00+00:00' }),
|
||||||
|
session({ session_key: 2, session_name: 'Race', session_type: 'Race', date_start: '2026-05-24T13:00:00+00:00', date_end: '2026-05-24T15:00:00+00:00' }),
|
||||||
|
]
|
||||||
|
const now = new Date('2026-05-23T12:00:00Z')
|
||||||
|
expect(countdownTargetSession(sessions, now)?.session_key).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
frontend/src/test/replay.test.ts
Normal file
33
frontend/src/test/replay.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ReplayFrame, TrackBounds } from '../types'
|
||||||
|
import { interpolateReplayCars, lookupReplayFramePair, replayCarToSvg } from '../lib/replay'
|
||||||
|
|
||||||
|
const frames: ReplayFrame[] = [
|
||||||
|
{ t: 0, cars: { '1': { x: 0, y: 0 } } },
|
||||||
|
{ t: 5000, cars: { '1': { x: 10, y: 20 }, '4': { x: 40, y: 80 } } },
|
||||||
|
{ t: 10000, cars: { '1': { x: 20, y: 40 } } },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('replay helpers', () => {
|
||||||
|
it('looks up boundary frame pairs', () => {
|
||||||
|
expect(lookupReplayFramePair(frames, -1)).toEqual({ previous: frames[0], next: frames[0] })
|
||||||
|
expect(lookupReplayFramePair(frames, 10000)).toEqual({ previous: frames[2], next: frames[2] })
|
||||||
|
expect(lookupReplayFramePair([], 1000)).toEqual({ previous: null, next: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates cars between frames and keeps one-sided samples visible', () => {
|
||||||
|
const cars = interpolateReplayCars(frames, 2500)
|
||||||
|
expect(cars['1']).toEqual({ x: 5, y: 10 })
|
||||||
|
expect(cars['4']).toEqual({ x: 40, y: 80 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps interpolation outside the replay range', () => {
|
||||||
|
expect(interpolateReplayCars(frames, -500)['1']).toEqual({ x: 0, y: 0 })
|
||||||
|
expect(interpolateReplayCars(frames, 12000)['1']).toEqual({ x: 20, y: 40 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps replay GPS through the shared track-map coordinate transform', () => {
|
||||||
|
const bounds: TrackBounds = { minX: 0, maxX: 100, minY: 0, maxY: 200 }
|
||||||
|
expect(replayCarToSvg({ x: 50, y: 50 }, bounds)).toEqual({ x: 50, y: 75 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ export interface Meeting {
|
|||||||
country_name: string
|
country_name: string
|
||||||
country_code: string
|
country_code: string
|
||||||
country_flag: string
|
country_flag: string
|
||||||
|
circuit_key?: number
|
||||||
circuit_short_name: string
|
circuit_short_name: string
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -23,6 +24,7 @@ export interface Session {
|
|||||||
session_key: number
|
session_key: number
|
||||||
session_name: string
|
session_name: string
|
||||||
session_type: string
|
session_type: string
|
||||||
|
circuit_key?: number
|
||||||
meeting_key: number
|
meeting_key: number
|
||||||
date_start: string
|
date_start: string
|
||||||
date_end: string
|
date_end: string
|
||||||
@@ -358,6 +360,23 @@ export interface TrackOutline {
|
|||||||
bounds: TrackBounds
|
bounds: TrackBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplayCarPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFrame {
|
||||||
|
t: number
|
||||||
|
cars: Record<string, ReplayCarPosition>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayFramesResponse {
|
||||||
|
session_key: number
|
||||||
|
interval_ms: number
|
||||||
|
start_time: string
|
||||||
|
frames: ReplayFrame[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChampHubDriver {
|
export interface ChampHubDriver {
|
||||||
driver_number: number
|
driver_number: number
|
||||||
name_acronym: string
|
name_acronym: string
|
||||||
|
|||||||
259
internal/web/replay.go
Normal file
259
internal/web/replay.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultReplayIntervalMS = 5000
|
||||||
|
maxReplayFrames = 3000
|
||||||
|
replayFetchConcurrency = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type replayDataClient interface {
|
||||||
|
GetDriversForSession(sessionKey int) ([]models.Driver, error)
|
||||||
|
GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayFramesResponse struct {
|
||||||
|
SessionKey int `json:"session_key"`
|
||||||
|
Interval int `json:"interval_ms"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
Frames []replayFrame `json:"frames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayFrame struct {
|
||||||
|
T int64 `json:"t"`
|
||||||
|
Cars map[string]replayCar `json:"cars"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayCar struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
|
||||||
|
if err != nil || sessionKey == 0 {
|
||||||
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalMS := defaultReplayIntervalMS
|
||||||
|
rawInterval := r.URL.Query().Get("interval_ms")
|
||||||
|
if rawInterval != "" {
|
||||||
|
parsed, err := strconv.Atoi(rawInterval)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid interval_ms", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsed > intervalMS {
|
||||||
|
intervalMS = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
|
||||||
|
if intervalMS < defaultReplayIntervalMS {
|
||||||
|
intervalMS = defaultReplayIntervalMS
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := replayFramesResponse{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Interval: intervalMS,
|
||||||
|
Frames: []replayFrame{},
|
||||||
|
}
|
||||||
|
|
||||||
|
drivers, err := client.GetDriversForSession(sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
driverNumbers := uniqueDriverNumbers(drivers)
|
||||||
|
if len(driverNumbers) == 0 {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
||||||
|
if err != nil && len(series) == 0 {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
start, ok := earliestReplayLocationTime(series)
|
||||||
|
if !ok {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
resp.StartTime = start.Format(time.RFC3339Nano)
|
||||||
|
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
||||||
|
seen := make(map[int]bool, len(drivers))
|
||||||
|
numbers := make([]int, 0, len(drivers))
|
||||||
|
for _, driver := range drivers {
|
||||||
|
if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[driver.DriverNumber] = true
|
||||||
|
numbers = append(numbers, driver.DriverNumber)
|
||||||
|
}
|
||||||
|
sort.Ints(numbers)
|
||||||
|
return numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
|
||||||
|
type result struct {
|
||||||
|
driver int
|
||||||
|
locs []models.Location
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, replayFetchConcurrency)
|
||||||
|
results := make(chan result, len(driverNumbers))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, driverNumber := range driverNumbers {
|
||||||
|
driverNumber := driverNumber
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
results <- result{driver: driverNumber, err: ctx.Err()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locs, err := client.GetLocation(sessionKey, driverNumber)
|
||||||
|
results <- result{driver: driverNumber, locs: locs, err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
series := make(map[int][]models.Location, len(driverNumbers))
|
||||||
|
var joined error
|
||||||
|
for result := range results {
|
||||||
|
if result.err != nil {
|
||||||
|
joined = errors.Join(joined, result.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(result.locs) > 0 {
|
||||||
|
series[result.driver] = result.locs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return series, joined
|
||||||
|
}
|
||||||
|
|
||||||
|
func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
|
||||||
|
var start time.Time
|
||||||
|
for _, locs := range series {
|
||||||
|
for _, loc := range locs {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if start.IsZero() || t.Before(start) {
|
||||||
|
start = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start.IsZero() {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return start, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
|
||||||
|
type accumulator struct {
|
||||||
|
t int64
|
||||||
|
cars map[string]replayCar
|
||||||
|
nearest map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := int64(intervalMS)
|
||||||
|
framesByIndex := make(map[int]*accumulator)
|
||||||
|
|
||||||
|
for driverNumber, locs := range series {
|
||||||
|
driverKey := strconv.Itoa(driverNumber)
|
||||||
|
for _, loc := range locs {
|
||||||
|
if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offset := t.Sub(start).Milliseconds()
|
||||||
|
if offset < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index := int((offset + interval/2) / interval)
|
||||||
|
if index < 0 || index >= maxReplayFrames {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frameT := int64(index) * interval
|
||||||
|
distance := absInt64(offset - frameT)
|
||||||
|
|
||||||
|
acc, ok := framesByIndex[index]
|
||||||
|
if !ok {
|
||||||
|
acc = &accumulator{
|
||||||
|
t: frameT,
|
||||||
|
cars: make(map[string]replayCar),
|
||||||
|
nearest: make(map[string]int64),
|
||||||
|
}
|
||||||
|
framesByIndex[index] = acc
|
||||||
|
}
|
||||||
|
if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc.nearest[driverKey] = distance
|
||||||
|
acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes := make([]int, 0, len(framesByIndex))
|
||||||
|
for index, acc := range framesByIndex {
|
||||||
|
if len(acc.cars) > 0 {
|
||||||
|
indexes = append(indexes, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(indexes)
|
||||||
|
|
||||||
|
frames := make([]replayFrame, 0, len(indexes))
|
||||||
|
for _, index := range indexes {
|
||||||
|
acc := framesByIndex[index]
|
||||||
|
frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
|
||||||
|
}
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
func absInt64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFiniteFloat(v float64) bool {
|
||||||
|
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
||||||
|
}
|
||||||
181
internal/web/replay_test.go
Normal file
181
internal/web/replay_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeReplayClient struct {
|
||||||
|
drivers []models.Driver
|
||||||
|
locs map[int][]models.Location
|
||||||
|
err error
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
inFlight int
|
||||||
|
maxInFlight int
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.drivers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.inFlight++
|
||||||
|
if f.inFlight > f.maxInFlight {
|
||||||
|
f.maxInFlight = f.inFlight
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
if f.delay > 0 {
|
||||||
|
time.Sleep(f.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
f.inFlight--
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
return f.locs[driverNumber], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
||||||
|
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||||
|
client := &fakeReplayClient{
|
||||||
|
drivers: []models.Driver{
|
||||||
|
{DriverNumber: 1},
|
||||||
|
{DriverNumber: 4},
|
||||||
|
{DriverNumber: 16},
|
||||||
|
},
|
||||||
|
locs: map[int][]models.Location{
|
||||||
|
1: {
|
||||||
|
{Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
|
||||||
|
{Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
|
||||||
|
{Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
{Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
|
||||||
|
},
|
||||||
|
16: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
||||||
|
t.Fatalf("response metadata = %+v", resp)
|
||||||
|
}
|
||||||
|
if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
|
||||||
|
t.Fatalf("start_time = %q", resp.StartTime)
|
||||||
|
}
|
||||||
|
if len(resp.Frames) != 2 {
|
||||||
|
t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
|
||||||
|
}
|
||||||
|
if _, ok := resp.Frames[0].Cars["16"]; ok {
|
||||||
|
t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
|
||||||
|
}
|
||||||
|
if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
|
||||||
|
t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
|
||||||
|
}
|
||||||
|
if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
|
||||||
|
t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
|
||||||
|
}
|
||||||
|
if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
|
||||||
|
t.Fatalf("frame 1 car 4 = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
|
||||||
|
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||||
|
locs := make([]models.Location, maxReplayFrames+250)
|
||||||
|
for i := range locs {
|
||||||
|
locs[i] = models.Location{
|
||||||
|
Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
|
||||||
|
X: float64(i),
|
||||||
|
Y: float64(i * 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := &fakeReplayClient{
|
||||||
|
drivers: []models.Driver{{DriverNumber: 1}},
|
||||||
|
locs: map[int][]models.Location{1: locs},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Frames) > maxReplayFrames {
|
||||||
|
t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
|
||||||
|
}
|
||||||
|
if len(resp.Frames) != maxReplayFrames {
|
||||||
|
t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
|
||||||
|
drivers := make([]models.Driver, 10)
|
||||||
|
locs := make(map[int][]models.Location, len(drivers))
|
||||||
|
now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||||
|
for i := range drivers {
|
||||||
|
number := i + 1
|
||||||
|
drivers[i] = models.Driver{DriverNumber: number}
|
||||||
|
locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
|
||||||
|
}
|
||||||
|
client := &fakeReplayClient{
|
||||||
|
drivers: drivers,
|
||||||
|
locs: locs,
|
||||||
|
delay: 5 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
||||||
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
|
}
|
||||||
|
if client.maxInFlight > replayFetchConcurrency {
|
||||||
|
t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
|
||||||
|
srv := testServer(t, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleReplayFrames(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("missing session_key status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
srv.handleReplayFrames(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid interval status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
||||||
|
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
||||||
|
}}
|
||||||
|
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(resp)
|
||||||
|
if err != nil || len(body) == 0 {
|
||||||
|
t.Fatalf("marshal response = %d bytes, %v", len(body), err)
|
||||||
|
}
|
||||||
|
if resp.Interval != defaultReplayIntervalMS {
|
||||||
|
t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||||
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
||||||
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
||||||
|
mux.HandleFunc("/api/v1/replay/frames", s.handleReplayFrames)
|
||||||
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
||||||
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
||||||
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
||||||
|
|||||||
Reference in New Issue
Block a user