mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
8 Commits
be7eb3c034
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2617d39bb | ||
|
|
5970649a9c | ||
|
|
7212eb9b44 | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 | ||
|
|
1782fd313c | ||
|
|
91199ff5c0 | ||
|
|
144e93ee44 |
@@ -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,16 +361,51 @@ 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">
|
||||||
|
<div className="rs-replay-shell">
|
||||||
|
<div className="rs-replay-main">
|
||||||
{hasChartData ? (
|
{hasChartData ? (
|
||||||
chartContent
|
chartContent
|
||||||
) : (
|
) : (
|
||||||
@@ -306,6 +414,25 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
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))
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
import { fetchLiveState } from '../api'
|
||||||
import type { LivePosition, LiveStreamData } from '../types'
|
import type { LivePosition, LiveStreamData } from '../types'
|
||||||
import {
|
import {
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
@@ -25,7 +25,6 @@ import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
|||||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||||
import { EventRail } from '../components/live/EventRail'
|
import { EventRail } from '../components/live/EventRail'
|
||||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||||
import { TrackMap } from '../components/live/TrackMap'
|
|
||||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||||
import { Archive, Radio } from 'lucide-react'
|
import { Archive, Radio } from 'lucide-react'
|
||||||
|
|
||||||
@@ -58,18 +57,6 @@ export function LiveTimingPage() {
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackOutlineQuery = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'live-track-outline',
|
|
||||||
snapshot?.Session?.MeetingName ?? '',
|
|
||||||
snapshot?.Session?.CircuitName ?? '',
|
|
||||||
],
|
|
||||||
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
|
|
||||||
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
|
|
||||||
staleTime: Infinity,
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
const nextLive = data.is_live && Boolean(data.data)
|
const nextLive = data.is_live && Boolean(data.data)
|
||||||
@@ -284,14 +271,6 @@ export function LiveTimingPage() {
|
|||||||
/>
|
/>
|
||||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||||
<TrackMap
|
|
||||||
outline={trackOutlineQuery.data}
|
|
||||||
positions={positions}
|
|
||||||
telemetry={snapshot.Telemetry}
|
|
||||||
drivers={snapshot.Drivers}
|
|
||||||
driverInfo={snapshot.DriverInfo}
|
|
||||||
loading={trackOutlineQuery.isLoading}
|
|
||||||
/>
|
|
||||||
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||||
<div className="live-columns">
|
<div className="live-columns">
|
||||||
<div className="live-tower-col">
|
<div className="live-tower-col">
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
|||||||
race_control: [],
|
race_control: [],
|
||||||
weather: [],
|
weather: [],
|
||||||
laps: [],
|
laps: [],
|
||||||
|
chapters: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -152,4 +152,15 @@ describe('LiveTimingPage archive mode', () => {
|
|||||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
||||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||||
|
renderPage({
|
||||||
|
is_live: true,
|
||||||
|
data: { ...archivedSnapshot, SessionStatus: 'Started' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
|
||||||
|
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
|||||||
is_pit_out_lap: false,
|
is_pit_out_lap: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chapters: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekend: Weekend = {
|
const weekend: Weekend = {
|
||||||
|
|||||||
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
|
||||||
@@ -89,6 +91,17 @@ export interface RaceHub {
|
|||||||
race_control: RaceControlMessage[]
|
race_control: RaceControlMessage[]
|
||||||
weather: WeatherSample[]
|
weather: WeatherSample[]
|
||||||
laps: Lap[]
|
laps: Lap[]
|
||||||
|
chapters: Chapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||||
|
title: string
|
||||||
|
start_lap: number
|
||||||
|
end_lap: number
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
driver_numbers: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stint {
|
export interface Stint {
|
||||||
@@ -347,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
|
||||||
|
|||||||
628
internal/chapters/chapters.go
Normal file
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindStart = "start"
|
||||||
|
KindSafetyCar = "safety_car"
|
||||||
|
KindVirtualSafetyCar = "virtual_safety_car"
|
||||||
|
KindRedFlag = "red_flag"
|
||||||
|
KindPitPhase = "pit_phase"
|
||||||
|
KindDecisiveSwing = "decisive_swing"
|
||||||
|
KindFinish = "finish"
|
||||||
|
|
||||||
|
pitPhaseWindowLaps = 3
|
||||||
|
pitPhaseShare = 0.30
|
||||||
|
minPitPhaseStops = 2
|
||||||
|
maxDecisiveSwings = 3
|
||||||
|
decisiveAfterLap = 5
|
||||||
|
structuralPriority = 110
|
||||||
|
flagPriority = 100
|
||||||
|
pitPhasePriority = 50
|
||||||
|
decisivePriority = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
type RaceControl = models.RaceControl
|
||||||
|
type PositionSample = models.Position
|
||||||
|
type Lap = models.Lap
|
||||||
|
|
||||||
|
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||||
|
type Chapter struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
StartLap int `json:"start_lap"`
|
||||||
|
EndLap int `json:"end_lap"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
DriverNumbers []int `json:"driver_numbers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||||
|
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||||
|
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||||
|
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||||
|
return []Chapter{}
|
||||||
|
}
|
||||||
|
if totalLaps <= 0 {
|
||||||
|
totalLaps = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
lapIndex := buildLapIndex(laps)
|
||||||
|
chapters := []Chapter{
|
||||||
|
{
|
||||||
|
Kind: KindStart,
|
||||||
|
Title: "Start",
|
||||||
|
StartLap: 1,
|
||||||
|
EndLap: minInt(1, totalLaps),
|
||||||
|
StartTime: lapIndex.lapStart(1),
|
||||||
|
EndTime: lapIndex.lapEnd(1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||||
|
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||||
|
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||||
|
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||||
|
|
||||||
|
return resolveConflicts(chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.LapNumber > totalLaps {
|
||||||
|
totalLaps = l.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, msg := range rc {
|
||||||
|
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||||
|
totalLaps = *msg.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalLaps
|
||||||
|
}
|
||||||
|
|
||||||
|
type lapIndex struct {
|
||||||
|
byLap map[int]string
|
||||||
|
events []lapEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type lapEvent struct {
|
||||||
|
lap int
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLapIndex(laps []Lap) lapIndex {
|
||||||
|
idx := lapIndex{byLap: map[int]string{}}
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||||
|
idx.byLap[l.LapNumber] = l.DateStart
|
||||||
|
}
|
||||||
|
at, ok := parseTime(l.DateStart)
|
||||||
|
if ok {
|
||||||
|
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(idx.events, func(i, j int) bool {
|
||||||
|
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||||
|
return idx.events[i].lap < idx.events[j].lap
|
||||||
|
}
|
||||||
|
return idx.events[i].at.Before(idx.events[j].at)
|
||||||
|
})
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapStart(lap int) string {
|
||||||
|
return idx.byLap[lap]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapEnd(lap int) string {
|
||||||
|
if v := idx.byLap[lap+1]; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return idx.byLap[lap]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx lapIndex) lapForTime(raw string) int {
|
||||||
|
at, ok := parseTime(raw)
|
||||||
|
if !ok || len(idx.events) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lap := 0
|
||||||
|
for _, event := range idx.events {
|
||||||
|
if event.at.After(at) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lap = event.lap
|
||||||
|
}
|
||||||
|
if lap == 0 {
|
||||||
|
return idx.events[0].lap
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|
||||||
|
type flagState struct {
|
||||||
|
startLap int
|
||||||
|
startTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||||
|
var chapters []Chapter
|
||||||
|
active := map[string]flagState{}
|
||||||
|
for _, msg := range rc {
|
||||||
|
kind, ok := flagKind(msg)
|
||||||
|
if !ok && greenFlagClear(msg) {
|
||||||
|
for activeKind, st := range active {
|
||||||
|
lap := messageLap(msg, idx)
|
||||||
|
if lap <= 0 {
|
||||||
|
lap = st.startLap
|
||||||
|
}
|
||||||
|
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: activeKind,
|
||||||
|
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||||
|
})
|
||||||
|
delete(active, activeKind)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := messageLap(msg, idx)
|
||||||
|
if lap <= 0 {
|
||||||
|
lap = 1
|
||||||
|
}
|
||||||
|
if flagCleared(msg) {
|
||||||
|
st, ok := active[kind]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: kind,
|
||||||
|
Title: flagTitle(kind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||||
|
})
|
||||||
|
delete(active, kind)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if flagStarted(msg) {
|
||||||
|
active[kind] = flagState{
|
||||||
|
startLap: clampLap(lap, 1, totalLaps),
|
||||||
|
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kind, st := range active {
|
||||||
|
endLap := totalLaps
|
||||||
|
chapters = append(chapters, Chapter{
|
||||||
|
Kind: kind,
|
||||||
|
Title: flagTitle(kind, st.startLap, endLap),
|
||||||
|
StartLap: st.startLap,
|
||||||
|
EndLap: endLap,
|
||||||
|
StartTime: st.startTime,
|
||||||
|
EndTime: idx.lapEnd(endLap),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagKind(msg RaceControl) (string, bool) {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||||
|
return KindVirtualSafetyCar, true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||||
|
return KindRedFlag, true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||||
|
return KindSafetyCar, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagStarted(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(text, "DEPLOY") ||
|
||||||
|
strings.Contains(text, "RED FLAG") ||
|
||||||
|
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||||
|
strings.Contains(text, "VSC") ||
|
||||||
|
strings.Contains(text, "SAFETY CAR") ||
|
||||||
|
string(msg.Flag) == string(models.FlagRed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagCleared(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||||
|
return strings.Contains(text, "CLEAR") ||
|
||||||
|
strings.Contains(text, "ENDING") ||
|
||||||
|
strings.Contains(text, "IN THIS LAP") ||
|
||||||
|
strings.Contains(text, "GREEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func greenFlagClear(msg RaceControl) bool {
|
||||||
|
text := upperText(string(msg.Flag), msg.Message)
|
||||||
|
return strings.Contains(text, "GREEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagTitle(kind string, startLap, endLap int) string {
|
||||||
|
name := "Flag period"
|
||||||
|
switch kind {
|
||||||
|
case KindSafetyCar:
|
||||||
|
name = "Safety Car"
|
||||||
|
case KindVirtualSafetyCar:
|
||||||
|
name = "Virtual Safety Car"
|
||||||
|
case KindRedFlag:
|
||||||
|
name = "Red Flag"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||||
|
type pitOut struct {
|
||||||
|
lap int
|
||||||
|
driver int
|
||||||
|
}
|
||||||
|
var stops []pitOut
|
||||||
|
for _, l := range laps {
|
||||||
|
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||||
|
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stops) < minPitPhaseStops {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(stops, func(i, j int) bool {
|
||||||
|
if stops[i].lap == stops[j].lap {
|
||||||
|
return stops[i].driver < stops[j].driver
|
||||||
|
}
|
||||||
|
return stops[i].lap < stops[j].lap
|
||||||
|
})
|
||||||
|
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||||
|
if needed < minPitPhaseStops {
|
||||||
|
needed = minPitPhaseStops
|
||||||
|
}
|
||||||
|
|
||||||
|
var windows []Chapter
|
||||||
|
for i := 0; i < len(stops); i++ {
|
||||||
|
start := stops[i].lap
|
||||||
|
end := start + pitPhaseWindowLaps - 1
|
||||||
|
drivers := map[int]bool{}
|
||||||
|
count := 0
|
||||||
|
for _, stop := range stops {
|
||||||
|
if stop.lap < start || stop.lap > end {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
drivers[stop.driver] = true
|
||||||
|
}
|
||||||
|
if count < needed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ch := Chapter{
|
||||||
|
Kind: KindPitPhase,
|
||||||
|
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||||
|
StartLap: start,
|
||||||
|
EndLap: end,
|
||||||
|
StartTime: idx.lapStart(start),
|
||||||
|
EndTime: idx.lapEnd(end),
|
||||||
|
DriverNumbers: sortedDriverNumbers(drivers),
|
||||||
|
}
|
||||||
|
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||||
|
last := &windows[len(windows)-1]
|
||||||
|
if ch.EndLap > last.EndLap {
|
||||||
|
last.EndLap = ch.EndLap
|
||||||
|
last.EndTime = idx.lapEnd(last.EndLap)
|
||||||
|
}
|
||||||
|
drivers := sliceToSet(last.DriverNumbers)
|
||||||
|
for _, driver := range ch.DriverNumbers {
|
||||||
|
drivers[driver] = true
|
||||||
|
}
|
||||||
|
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||||
|
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
windows = append(windows, ch)
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
type swingCandidate struct {
|
||||||
|
chapter Chapter
|
||||||
|
significance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||||
|
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
final := snapshots[totalLaps]
|
||||||
|
if len(final) == 0 {
|
||||||
|
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||||
|
if len(snapshots[lap]) > 0 {
|
||||||
|
final = snapshots[lap]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var candidates []swingCandidate
|
||||||
|
seenDriver := map[int]bool{}
|
||||||
|
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||||
|
prev := snapshots[lap-1]
|
||||||
|
curr := snapshots[lap]
|
||||||
|
if len(prev) == 0 || len(curr) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for driver, pos := range curr {
|
||||||
|
prevPos, ok := prev[driver]
|
||||||
|
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finalPos, ok := final[driver]
|
||||||
|
if !ok || finalPos > pos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||||
|
drivers := []int{driver}
|
||||||
|
if overtaken != 0 {
|
||||||
|
drivers = append(drivers, overtaken)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, swingCandidate{
|
||||||
|
chapter: Chapter{
|
||||||
|
Kind: KindDecisiveSwing,
|
||||||
|
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||||
|
StartLap: lap,
|
||||||
|
EndLap: lap,
|
||||||
|
StartTime: idx.lapStart(lap),
|
||||||
|
EndTime: idx.lapEnd(lap),
|
||||||
|
DriverNumbers: drivers,
|
||||||
|
},
|
||||||
|
significance: (prevPos-pos)*10 + (6 - pos),
|
||||||
|
})
|
||||||
|
seenDriver[driver] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].significance == candidates[j].significance {
|
||||||
|
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||||
|
}
|
||||||
|
return candidates[i].significance > candidates[j].significance
|
||||||
|
})
|
||||||
|
if len(candidates) > maxDecisiveSwings {
|
||||||
|
candidates = candidates[:maxDecisiveSwings]
|
||||||
|
}
|
||||||
|
out := make([]Chapter, 0, len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
out = append(out, c.chapter)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||||
|
byLap := map[int][]PositionSample{}
|
||||||
|
for _, p := range positions {
|
||||||
|
if p.Position <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lap := idx.lapForTime(p.Date)
|
||||||
|
if lap <= 0 || lap > totalLaps {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byLap[lap] = append(byLap[lap], p)
|
||||||
|
}
|
||||||
|
last := map[int]int{}
|
||||||
|
snapshots := map[int]map[int]int{}
|
||||||
|
for lap := 1; lap <= totalLaps; lap++ {
|
||||||
|
for _, p := range byLap[lap] {
|
||||||
|
last[p.DriverNumber] = p.Position
|
||||||
|
}
|
||||||
|
if len(last) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp := make(map[int]int, len(last))
|
||||||
|
for driver, pos := range last {
|
||||||
|
cp[driver] = pos
|
||||||
|
}
|
||||||
|
snapshots[lap] = cp
|
||||||
|
}
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||||
|
for driver, driverPos := range snapshot {
|
||||||
|
if driver != exclude && driverPos == pos {
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||||
|
finishLap := totalLaps
|
||||||
|
finishTime := idx.lapEnd(totalLaps)
|
||||||
|
for _, msg := range rc {
|
||||||
|
text := upperText(string(msg.Flag), msg.Message)
|
||||||
|
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||||
|
if lap := messageLap(msg, idx); lap > 0 {
|
||||||
|
finishLap = lap
|
||||||
|
}
|
||||||
|
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startLap := finishLap - 1
|
||||||
|
if startLap < 1 {
|
||||||
|
startLap = 1
|
||||||
|
}
|
||||||
|
return Chapter{
|
||||||
|
Kind: KindFinish,
|
||||||
|
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||||
|
StartLap: startLap,
|
||||||
|
EndLap: finishLap,
|
||||||
|
StartTime: idx.lapStart(startLap),
|
||||||
|
EndTime: finishTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||||
|
normalized := make([]Chapter, 0, len(chapters))
|
||||||
|
for _, ch := range chapters {
|
||||||
|
if ch.StartLap <= 0 {
|
||||||
|
ch.StartLap = 1
|
||||||
|
}
|
||||||
|
if ch.EndLap <= 0 {
|
||||||
|
ch.EndLap = ch.StartLap
|
||||||
|
}
|
||||||
|
if ch.EndLap < ch.StartLap {
|
||||||
|
ch.EndLap = ch.StartLap
|
||||||
|
}
|
||||||
|
if ch.DriverNumbers == nil {
|
||||||
|
ch.DriverNumbers = []int{}
|
||||||
|
}
|
||||||
|
normalized = append(normalized, ch)
|
||||||
|
}
|
||||||
|
sort.SliceStable(normalized, func(i, j int) bool {
|
||||||
|
if normalized[i].StartLap == normalized[j].StartLap {
|
||||||
|
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||||
|
}
|
||||||
|
return normalized[i].StartLap < normalized[j].StartLap
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make([]Chapter, 0, len(normalized))
|
||||||
|
for _, ch := range normalized {
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last := &out[len(out)-1]
|
||||||
|
if ch.StartLap > last.EndLap {
|
||||||
|
out = append(out, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if priority(ch.Kind) > priority(last.Kind) {
|
||||||
|
if last.StartLap < ch.StartLap {
|
||||||
|
last.EndLap = ch.StartLap - 1
|
||||||
|
out = append(out, ch)
|
||||||
|
} else {
|
||||||
|
*last = ch
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch.EndLap > last.EndLap {
|
||||||
|
ch.StartLap = last.EndLap + 1
|
||||||
|
if ch.StartLap <= ch.EndLap {
|
||||||
|
out = append(out, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFlag(kind string) bool {
|
||||||
|
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(kind string) int {
|
||||||
|
switch kind {
|
||||||
|
case KindStart, KindFinish:
|
||||||
|
return structuralPriority
|
||||||
|
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||||
|
return flagPriority
|
||||||
|
case KindPitPhase:
|
||||||
|
return pitPhasePriority
|
||||||
|
case KindDecisiveSwing:
|
||||||
|
return decisivePriority
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||||
|
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||||
|
return *msg.LapNumber
|
||||||
|
}
|
||||||
|
return idx.lapForTime(msg.Date)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampLap(lap, minLap, maxLap int) int {
|
||||||
|
if lap < minLap {
|
||||||
|
return minLap
|
||||||
|
}
|
||||||
|
if maxLap > 0 && lap > maxLap {
|
||||||
|
return maxLap
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(raw string) (time.Time, bool) {
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
at, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return at, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func upperText(parts ...string) string {
|
||||||
|
return strings.ToUpper(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||||
|
out := make([]int, 0, len(drivers))
|
||||||
|
for driver := range drivers {
|
||||||
|
out = append(out, driver)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sliceToSet(values []int) map[int]bool {
|
||||||
|
out := make(map[int]bool, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
out[value] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
218
internal/chapters/chapters_test.go
Normal file
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package chapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectStructuralChapters(t *testing.T) {
|
||||||
|
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||||
|
|
||||||
|
if len(chapters) < 2 {
|
||||||
|
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||||
|
}
|
||||||
|
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||||
|
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||||
|
}
|
||||||
|
got := chapters[len(chapters)-1]
|
||||||
|
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||||
|
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||||
|
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||||
|
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFlagPeriods(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
start RaceControl
|
||||||
|
end RaceControl
|
||||||
|
wantKind string
|
||||||
|
wantTitle string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "safety car",
|
||||||
|
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
wantKind: KindSafetyCar,
|
||||||
|
wantTitle: "Safety Car (L12-L15)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "virtual safety car",
|
||||||
|
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||||
|
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||||
|
wantKind: KindVirtualSafetyCar,
|
||||||
|
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "red flag",
|
||||||
|
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||||
|
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||||
|
wantKind: KindRedFlag,
|
||||||
|
wantTitle: "Red Flag (L31-L33)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||||
|
got := findKind(chapters, tt.wantKind)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||||
|
}
|
||||||
|
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||||
|
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||||
|
pitOuts := map[int][]int{
|
||||||
|
5: {1},
|
||||||
|
10: {2},
|
||||||
|
20: {3},
|
||||||
|
21: {4},
|
||||||
|
22: {5},
|
||||||
|
30: {6},
|
||||||
|
35: {7},
|
||||||
|
40: {8},
|
||||||
|
45: {9},
|
||||||
|
50: {10},
|
||||||
|
}
|
||||||
|
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||||
|
|
||||||
|
got := findKind(chapters, KindPitPhase)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||||
|
}
|
||||||
|
if got.StartLap != 20 || got.EndLap != 22 {
|
||||||
|
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||||
|
}
|
||||||
|
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||||
|
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||||
|
positions := []PositionSample{
|
||||||
|
pos(1, 1, 1),
|
||||||
|
pos(1, 16, 4),
|
||||||
|
pos(1, 55, 3),
|
||||||
|
pos(6, 16, 3),
|
||||||
|
pos(6, 55, 4),
|
||||||
|
pos(8, 44, 5),
|
||||||
|
pos(8, 63, 6),
|
||||||
|
pos(10, 44, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||||
|
got := findKind(chapters, KindDecisiveSwing)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||||
|
}
|
||||||
|
if got.StartLap != 6 || got.EndLap != 6 {
|
||||||
|
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||||
|
}
|
||||||
|
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||||
|
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||||
|
pitOuts := map[int][]int{
|
||||||
|
12: {1, 2},
|
||||||
|
13: {3, 4},
|
||||||
|
14: {5, 6},
|
||||||
|
}
|
||||||
|
rcs := []RaceControl{
|
||||||
|
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||||
|
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||||
|
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||||
|
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||||
|
}
|
||||||
|
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||||
|
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(chapters); i++ {
|
||||||
|
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||||
|
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rcLap(r RaceControl) int {
|
||||||
|
if r.LapNumber == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *r.LapNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||||
|
for i := range chapters {
|
||||||
|
if chapters[i].Kind == kind {
|
||||||
|
return &chapters[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||||
|
var laps []Lap
|
||||||
|
for lap := 1; lap <= total; lap++ {
|
||||||
|
drivers := []int{1}
|
||||||
|
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||||
|
drivers = pitDrivers
|
||||||
|
}
|
||||||
|
for _, driver := range drivers {
|
||||||
|
laps = append(laps, Lap{
|
||||||
|
DriverNumber: driver,
|
||||||
|
LapNumber: lap,
|
||||||
|
DateStart: lapTime(lap),
|
||||||
|
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return laps
|
||||||
|
}
|
||||||
|
|
||||||
|
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||||
|
return RaceControl{
|
||||||
|
Category: category,
|
||||||
|
Flag: flag,
|
||||||
|
Message: message,
|
||||||
|
LapNumber: &lap,
|
||||||
|
Date: lapTime(lap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pos(lap int, driver int, position int) PositionSample {
|
||||||
|
return PositionSample{
|
||||||
|
DriverNumber: driver,
|
||||||
|
Position: position,
|
||||||
|
Date: lapTime(lap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lapTime(lap int) string {
|
||||||
|
minute := lap - 1
|
||||||
|
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsDriver(drivers []int, driver int) bool {
|
||||||
|
for _, candidate := range drivers {
|
||||||
|
if candidate == driver {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
|||||||
RaceControl []models.RaceControl `json:"race_control"`
|
RaceControl []models.RaceControl `json:"race_control"`
|
||||||
Weather []models.Weather `json:"weather"`
|
Weather []models.Weather `json:"weather"`
|
||||||
Laps []models.Lap `json:"laps"`
|
Laps []models.Lap `json:"laps"`
|
||||||
|
Chapters []chapters.Chapter `json:"chapters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
RaceControl: []models.RaceControl{},
|
RaceControl: []models.RaceControl{},
|
||||||
Weather: []models.Weather{},
|
Weather: []models.Weather{},
|
||||||
Laps: []models.Lap{},
|
Laps: []models.Lap{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := s.store.GetSession(sessionKey)
|
sess, err := s.store.GetSession(sessionKey)
|
||||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
|||||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||||
hub.Source = responseSource(hub.Datasets)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
return hub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||||
|
total := 0
|
||||||
|
for _, result := range results {
|
||||||
|
if result.NumberOfLaps > total {
|
||||||
|
total = result.NumberOfLaps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, lap := range laps {
|
||||||
|
if lap.LapNumber > total {
|
||||||
|
total = lap.LapNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
|||||||
Drivers: []models.Driver{},
|
Drivers: []models.Driver{},
|
||||||
Results: []query.EnrichedResult{},
|
Results: []query.EnrichedResult{},
|
||||||
StartingGrid: []query.EnrichedGrid{},
|
StartingGrid: []query.EnrichedGrid{},
|
||||||
|
Chapters: []chapters.Chapter{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
seedRaceHubStore(t, st)
|
||||||
|
sessionKey := 9472
|
||||||
|
meetingKey := 1229
|
||||||
|
|
||||||
|
for lap := 1; lap <= 12; lap++ {
|
||||||
|
if err := st.UpsertLap(store.Lap{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
DriverNumber: 1,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
LapNumber: lap,
|
||||||
|
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||||
|
LapDuration: 75,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sample := range []store.PositionSample{
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||||
|
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||||
|
} {
|
||||||
|
if err := st.UpsertPositionSample(sample); err != nil {
|
||||||
|
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := testServer(t, st)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.handleRaceHub(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub query.RaceHub
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(hub.Chapters) == 0 {
|
||||||
|
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||||
|
}
|
||||||
|
if hub.Chapters[0].Kind != "start" {
|
||||||
|
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||||
|
}
|
||||||
|
foundSwing := false
|
||||||
|
for _, chapter := range hub.Chapters {
|
||||||
|
if chapter.Kind == "decisive_swing" {
|
||||||
|
foundSwing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundSwing {
|
||||||
|
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
seedRaceHubStore(t, st)
|
seedRaceHubStore(t, st)
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
264
scripts/debug/f1_live_socket_probe.py
Normal file
264
scripts/debug/f1_live_socket_probe.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Probe the official F1 live timing sockets for GPS position topics.
|
||||||
|
|
||||||
|
This intentionally does not call OpenF1 REST. It connects directly to the F1
|
||||||
|
SignalR endpoints used by the app and reports which topics arrive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
RS = b"\x1e"
|
||||||
|
CORE_NEGOTIATE = "https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1"
|
||||||
|
CORE_WS_HOST = "livetiming.formula1.com"
|
||||||
|
LEGACY_HUB = '[{"name":"Streaming"}]'
|
||||||
|
TOPICS = [
|
||||||
|
"Heartbeat",
|
||||||
|
"TimingData",
|
||||||
|
"Position",
|
||||||
|
"Position.z",
|
||||||
|
"CarData",
|
||||||
|
"CarData.z",
|
||||||
|
"DriverList",
|
||||||
|
"LapCount",
|
||||||
|
"ExtrapolatedClock",
|
||||||
|
"TrackStatus",
|
||||||
|
"RaceControlMessages",
|
||||||
|
"WeatherData",
|
||||||
|
"SessionInfo",
|
||||||
|
"TeamRadio",
|
||||||
|
"CurrentTyres",
|
||||||
|
"TimingAppData",
|
||||||
|
"TimingStats",
|
||||||
|
"SessionStatus",
|
||||||
|
"TopThree",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocket:
|
||||||
|
def __init__(self, host: str, path: str, headers: dict[str, str] | None = None):
|
||||||
|
self.host = host
|
||||||
|
self.sock = ssl.create_default_context().wrap_socket(
|
||||||
|
socket.create_connection((host, 443), timeout=10),
|
||||||
|
server_hostname=host,
|
||||||
|
)
|
||||||
|
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
||||||
|
request_headers = {
|
||||||
|
"Host": host,
|
||||||
|
"Upgrade": "websocket",
|
||||||
|
"Connection": "Upgrade",
|
||||||
|
"Sec-WebSocket-Key": key,
|
||||||
|
"Sec-WebSocket-Version": "13",
|
||||||
|
"Origin": "https://www.formula1.com",
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
}
|
||||||
|
request_headers.update(headers or {})
|
||||||
|
lines = [f"GET {path} HTTP/1.1", *[f"{k}: {v}" for k, v in request_headers.items()], "", ""]
|
||||||
|
self.sock.sendall("\r\n".join(lines).encode("utf-8"))
|
||||||
|
response = self._read_http_response()
|
||||||
|
if b" 101 " not in response.split(b"\r\n", 1)[0]:
|
||||||
|
raise RuntimeError(response.decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
def _read_http_response(self) -> bytes:
|
||||||
|
data = b""
|
||||||
|
while b"\r\n\r\n" not in data:
|
||||||
|
data += self.sock.recv(4096)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def send_text(self, text: str) -> None:
|
||||||
|
payload = text.encode("utf-8")
|
||||||
|
header = bytearray([0x81])
|
||||||
|
if len(payload) < 126:
|
||||||
|
header.append(0x80 | len(payload))
|
||||||
|
elif len(payload) < 65536:
|
||||||
|
header.append(0x80 | 126)
|
||||||
|
header.extend(struct.pack("!H", len(payload)))
|
||||||
|
else:
|
||||||
|
header.append(0x80 | 127)
|
||||||
|
header.extend(struct.pack("!Q", len(payload)))
|
||||||
|
mask = random.randbytes(4) if hasattr(random, "randbytes") else os.urandom(4)
|
||||||
|
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||||
|
self.sock.sendall(bytes(header) + mask + masked)
|
||||||
|
|
||||||
|
def recv_text(self, timeout: float = 5.0) -> str | None:
|
||||||
|
self.sock.settimeout(timeout)
|
||||||
|
while True:
|
||||||
|
first = self.sock.recv(2)
|
||||||
|
if not first:
|
||||||
|
return None
|
||||||
|
opcode = first[0] & 0x0F
|
||||||
|
masked = bool(first[1] & 0x80)
|
||||||
|
length = first[1] & 0x7F
|
||||||
|
if length == 126:
|
||||||
|
length = struct.unpack("!H", self._read_exact(2))[0]
|
||||||
|
elif length == 127:
|
||||||
|
length = struct.unpack("!Q", self._read_exact(8))[0]
|
||||||
|
mask = self._read_exact(4) if masked else b""
|
||||||
|
payload = self._read_exact(length)
|
||||||
|
if masked:
|
||||||
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||||
|
if opcode == 0x8:
|
||||||
|
return None
|
||||||
|
if opcode == 0x9:
|
||||||
|
self._send_pong(payload)
|
||||||
|
continue
|
||||||
|
if opcode == 0x1:
|
||||||
|
return payload.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def _send_pong(self, payload: bytes) -> None:
|
||||||
|
self.sock.sendall(bytes([0x8A, len(payload)]) + payload)
|
||||||
|
|
||||||
|
def _read_exact(self, n: int) -> bytes:
|
||||||
|
chunks = []
|
||||||
|
remaining = n
|
||||||
|
while remaining:
|
||||||
|
chunk = self.sock.recv(remaining)
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError("socket closed")
|
||||||
|
chunks.append(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(url: str, method: str = "GET", headers: dict[str, str] | None = None) -> tuple[dict, list[str]]:
|
||||||
|
req = urllib.request.Request(url, method=method, headers=headers or {})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||||
|
return json.loads(resp.read()), cookies
|
||||||
|
|
||||||
|
|
||||||
|
def core_probe(seconds: int) -> None:
|
||||||
|
print("== SignalR Core ==")
|
||||||
|
neg, cookies = http_json(
|
||||||
|
CORE_NEGOTIATE,
|
||||||
|
method="POST",
|
||||||
|
headers={"Origin": "https://www.formula1.com", "User-Agent": "Mozilla/5.0", "Content-Length": "0"},
|
||||||
|
)
|
||||||
|
token = urllib.parse.quote(neg["connectionToken"], safe="")
|
||||||
|
ws = WebSocket(
|
||||||
|
CORE_WS_HOST,
|
||||||
|
f"/signalrcore?id={token}",
|
||||||
|
{"Cookie": "; ".join(cookies)} if cookies else None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ws.send_text(json.dumps({"protocol": "json", "version": 1}, separators=(",", ":")) + RS.decode())
|
||||||
|
print("handshake:", repr(ws.recv_text()))
|
||||||
|
ws.send_text(
|
||||||
|
json.dumps(
|
||||||
|
{"type": 1, "target": "subscribe", "arguments": [TOPICS], "invocationId": "1"},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
+ RS.decode()
|
||||||
|
)
|
||||||
|
collect(ws, seconds)
|
||||||
|
finally:
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_probe(seconds: int, bearer: str | None) -> None:
|
||||||
|
print("\n== Legacy SignalR ==")
|
||||||
|
query = urllib.parse.quote(LEGACY_HUB, safe="")
|
||||||
|
headers = {"User-Agent": "BestHTTP"}
|
||||||
|
if bearer:
|
||||||
|
headers["Authorization"] = f"Bearer {bearer}"
|
||||||
|
neg_url = f"https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData={query}"
|
||||||
|
try:
|
||||||
|
neg, cookies = http_json(neg_url, headers=headers)
|
||||||
|
except Exception as exc:
|
||||||
|
print("negotiate failed:", exc)
|
||||||
|
return
|
||||||
|
token = urllib.parse.quote(neg["ConnectionToken"], safe="")
|
||||||
|
path = f"/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken={token}&connectionData={query}"
|
||||||
|
ws_headers = {"User-Agent": "BestHTTP"}
|
||||||
|
if cookies:
|
||||||
|
ws_headers["Cookie"] = "; ".join(cookies)
|
||||||
|
if bearer:
|
||||||
|
ws_headers["Authorization"] = f"Bearer {bearer}"
|
||||||
|
ws = WebSocket(CORE_WS_HOST, path, ws_headers)
|
||||||
|
try:
|
||||||
|
ws.send_text(json.dumps({"H": "Streaming", "M": "Subscribe", "A": [TOPICS], "I": 1}, separators=(",", ":")))
|
||||||
|
collect(ws, seconds)
|
||||||
|
finally:
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
|
||||||
|
def collect(ws: WebSocket, seconds: int) -> None:
|
||||||
|
deadline = time.time() + seconds
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
message = ws.recv_text(timeout=min(5, max(1, deadline - time.time())))
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
if not message:
|
||||||
|
break
|
||||||
|
for frame in message.split(RS.decode()):
|
||||||
|
if not frame:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(frame)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
record_topics(payload, seen)
|
||||||
|
print("seen topics:")
|
||||||
|
for topic in sorted(seen):
|
||||||
|
print(f" {topic}: {seen[topic]}")
|
||||||
|
if not any(topic.startswith("Position") for topic in seen):
|
||||||
|
print(" (no Position / Position.z topics observed)")
|
||||||
|
|
||||||
|
|
||||||
|
def record_topics(payload: dict, seen: dict[str, int]) -> None:
|
||||||
|
result = payload.get("result") or payload.get("R")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
for topic, data in result.items():
|
||||||
|
seen[topic] = seen.get(topic, 0) + 1
|
||||||
|
maybe_print_position(topic, data)
|
||||||
|
for message in payload.get("M") or []:
|
||||||
|
args = message.get("A") or []
|
||||||
|
if len(args) >= 2:
|
||||||
|
topic = args[0]
|
||||||
|
seen[topic] = seen.get(topic, 0) + 1
|
||||||
|
maybe_print_position(topic, args[1])
|
||||||
|
if payload.get("target") == "feed":
|
||||||
|
args = payload.get("arguments") or []
|
||||||
|
if len(args) >= 2:
|
||||||
|
topic = args[0]
|
||||||
|
seen[topic] = seen.get(topic, 0) + 1
|
||||||
|
maybe_print_position(topic, args[1])
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_print_position(topic: str, data) -> None:
|
||||||
|
if topic in {"Position", "Position.z"}:
|
||||||
|
text = json.dumps(data)[:600] if not isinstance(data, str) else data[:600]
|
||||||
|
print(f"POSITION TOPIC {topic}: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--seconds", type=int, default=20)
|
||||||
|
parser.add_argument("--legacy", action="store_true", help="also probe legacy SignalR")
|
||||||
|
args = parser.parse_args()
|
||||||
|
core_probe(args.seconds)
|
||||||
|
if args.legacy:
|
||||||
|
legacy_probe(args.seconds, os.environ.get("BOXBOX_F1_LIVE_BEARER_TOKEN") or os.environ.get("F1_LIVE_BEARER_TOKEN"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user