diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 01ae7e5..d749b5f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,6 +11,7 @@ import type { Meeting, NewsItem, RaceHub, + ReplayFramesResponse, Session, TrackOutline, Weekend, @@ -81,6 +82,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi return data as TrackOutline } +export async function fetchReplayFrames( + sessionKey: number, + intervalMs = 5000, +): Promise { + 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 { const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) if (!res.ok) { diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index 229ff49..4380b19 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -1,5 +1,8 @@ -import { useState, useMemo, useRef, useCallback } from 'react' -import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types' +import { useEffect, useMemo, useRef, useState } from 'react' +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' interface Props { @@ -12,28 +15,97 @@ interface Props { race_control?: any[] pit_stops?: any[] session?: Session + meeting?: Meeting + drivers?: Driver[] } } 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 [scrubTime, setScrubTime] = useState(null) const [hoverDriver, setHoverDriver] = useState(null) + const [mapOpen, setMapOpen] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const [playbackSpeed, setPlaybackSpeed] = useState(10) const svgRef = useRef(null) // Position Evolution Chart Logic const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const hasChartData = hasPositions && allTimes.length > 0 + 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 displayResults = results - if (hasChartData) { - const tMin = new Date(allTimes[0]).getTime() - const tMax = new Date(allTimes[allTimes.length - 1]).getTime() - const tRange = Math.max(tMax - tMin, 1) + if (hasChartData && chartTiming) { + const { tMin, tRange } = chartTiming const byDriver = new Map>() for (const p of positions) { @@ -137,6 +209,7 @@ export function RaceStoryCanvas({ data }: Props) { } const handlePointerMove = (e: React.PointerEvent) => { + setIsPlaying(false) if (!svgRef.current) return const rect = svgRef.current.getBoundingClientRect() const x = e.clientX - rect.left @@ -288,24 +361,78 @@ export function RaceStoryCanvas({ data }: Props) { height={plotH} fill="transparent" onPointerMove={handlePointerMove} - onPointerLeave={() => setScrubTime(null)} + onPointerLeave={() => { + if (!isPlaying) setScrubTime(null) + }} style={{ cursor: 'crosshair', touchAction: 'none' }} /> +
+ +
+ {[1, 10, 30].map((speed) => ( + + ))} +
+ +
) } return (
- {hasChartData ? ( - chartContent - ) : ( -
- Lap-by-lap positions not available. This session does not - have ingested position samples in /api/v1/race-hub. +
+
+ {hasChartData ? ( + chartContent + ) : ( +
+ Lap-by-lap positions not available. This session does not + have ingested position samples in /api/v1/race-hub. +
+ )}
- )} + {mapOpen && ( +
+ {circuitKey > 0 && outlineYear > 0 ? ( + + ) : ( +
track identity unavailable for replay map
+ )} +
+ )} +
{displayResults.length > 0 && (
diff --git a/frontend/src/components/ReplayTrackMap.tsx b/frontend/src/components/ReplayTrackMap.tsx new file mode 100644 index 0000000..d3ec274 --- /dev/null +++ b/frontend/src/components/ReplayTrackMap.tsx @@ -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(null) + const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline]) + const driverInfo = useMemo(() => { + const info = new Map() + 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 ( +
+
loading replay GPS...
+
+ ) + } + + if (error) { + return ( +
+
replay GPS unavailable for this session
+
+ ) + } + + if (!outline || !outlinePath) { + return ( +
+
track outline unavailable for this session
+
+ ) + } + + if (!replay?.frames?.length || cars.length === 0) { + return ( +
+
historical GPS unavailable for this session
+
+ ) + } + + return ( +
+
+ + + + {cars.map((car) => { + const label = car.info?.label ?? car.number + const selected = pinned === car.number + return ( + setPinned(selected ? null : car.number)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setPinned(selected ? null : car.number) + } + }} + > + {label} + + {label} + + ) + })} + +
+
+ ) +} + +function normalizeColor(color: string | undefined): string { + const raw = color?.trim() + if (!raw) return '#777777' + return raw.startsWith('#') ? raw : `#${raw}` +} diff --git a/frontend/src/lib/replay.ts b/frontend/src/lib/replay.ts new file mode 100644 index 0000000..5699016 --- /dev/null +++ b/frontend/src/lib/replay.ts @@ -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, + 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, + tMs: number, +): Record { + 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 = {} + 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)) +} diff --git a/frontend/src/styles/replay-map.css b/frontend/src/styles/replay-map.css new file mode 100644 index 0000000..3915e6b --- /dev/null +++ b/frontend/src/styles/replay-map.css @@ -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; + } +} diff --git a/frontend/src/test/RaceStoryCanvas.test.tsx b/frontend/src/test/RaceStoryCanvas.test.tsx new file mode 100644 index 0000000..161cf56 --- /dev/null +++ b/frontend/src/test/RaceStoryCanvas.test.tsx @@ -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( + + + , + ) +} + +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() + }) +}) diff --git a/frontend/src/test/ReplayTrackMap.test.tsx b/frontend/src/test/ReplayTrackMap.test.tsx new file mode 100644 index 0000000..2aa46bf --- /dev/null +++ b/frontend/src/test/ReplayTrackMap.test.tsx @@ -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() + expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i) + }) + + it('renders car labels from result metadata', () => { + render() + expect(screen.getByLabelText(/VER replay position/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/replay.test.ts b/frontend/src/test/replay.test.ts new file mode 100644 index 0000000..53d9bcc --- /dev/null +++ b/frontend/src/test/replay.test.ts @@ -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 }) + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1d801f0..c14f54c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -360,6 +360,23 @@ export interface TrackOutline { bounds: TrackBounds } +export interface ReplayCarPosition { + x: number + y: number +} + +export interface ReplayFrame { + t: number + cars: Record +} + +export interface ReplayFramesResponse { + session_key: number + interval_ms: number + start_time: string + frames: ReplayFrame[] +} + export interface ChampHubDriver { driver_number: number name_acronym: string diff --git a/internal/web/replay.go b/internal/web/replay.go new file mode 100644 index 0000000..9caa7bc --- /dev/null +++ b/internal/web/replay.go @@ -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) +} diff --git a/internal/web/replay_test.go b/internal/web/replay_test.go new file mode 100644 index 0000000..25e7437 --- /dev/null +++ b/internal/web/replay_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 68245b3..f748c03 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -78,6 +78,7 @@ func (s *Server) routes() (http.Handler, error) { mux.HandleFunc("/api/v1/grid", s.handleGrid) mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison) 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/race-control", s.handleRaceControl) mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)