mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat: add replay track map scrubber
Spike: replay location fan-out is bounded with a semaphore at 4 concurrent GetLocation calls, covered by TestAssembleReplayFramesBoundsLocationFanOut. Adds /api/v1/replay/frames with 5s interval floor, 3000-frame cap, nearest-sample snapping, and empty-driver omission. Wires RaceStoryCanvas to lazy-load replay frames plus cached track outlines when the map panel opens, sharing the existing scrubber/playback state across chart and map. Tests: HOME=/private/tmp/box-box-home GOCACHE=/private/tmp/box-box-go-cache GOMODCACHE=/Users/aman/go/pkg/mod go test ./internal/web; go build ./... (same env, passed with read-only module stat-cache warning); npm run test; npx tsc --noEmit. Full go test ./... is blocked in this sandbox by existing network/listener-dependent internal/api and internal/news tests.
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
Meeting,
|
||||
NewsItem,
|
||||
RaceHub,
|
||||
ReplayFramesResponse,
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
@@ -80,6 +81,21 @@ export async function fetchTrackOutline(circuitKey: number, year: number): Promi
|
||||
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[]> {
|
||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -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<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)
|
||||
|
||||
// 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<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
@@ -137,6 +209,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
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' }}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="race-story-canvas">
|
||||
{hasChartData ? (
|
||||
chartContent
|
||||
) : (
|
||||
<div className="analysis-notice">
|
||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||
<div className="rs-replay-shell">
|
||||
<div className="rs-replay-main">
|
||||
{hasChartData ? (
|
||||
chartContent
|
||||
) : (
|
||||
<div className="analysis-notice">
|
||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<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}`
|
||||
}
|
||||
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))
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -360,6 +360,23 @@ export interface TrackOutline {
|
||||
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 {
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
|
||||
259
internal/web/replay.go
Normal file
259
internal/web/replay.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultReplayIntervalMS = 5000
|
||||
maxReplayFrames = 3000
|
||||
replayFetchConcurrency = 4
|
||||
)
|
||||
|
||||
type replayDataClient interface {
|
||||
GetDriversForSession(sessionKey int) ([]models.Driver, error)
|
||||
GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
|
||||
}
|
||||
|
||||
type replayFramesResponse struct {
|
||||
SessionKey int `json:"session_key"`
|
||||
Interval int `json:"interval_ms"`
|
||||
StartTime string `json:"start_time"`
|
||||
Frames []replayFrame `json:"frames"`
|
||||
}
|
||||
|
||||
type replayFrame struct {
|
||||
T int64 `json:"t"`
|
||||
Cars map[string]replayCar `json:"cars"`
|
||||
}
|
||||
|
||||
type replayCar struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
||||
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
|
||||
if err != nil || sessionKey == 0 {
|
||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
intervalMS := defaultReplayIntervalMS
|
||||
rawInterval := r.URL.Query().Get("interval_ms")
|
||||
if rawInterval != "" {
|
||||
parsed, err := strconv.Atoi(rawInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid interval_ms", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if parsed > intervalMS {
|
||||
intervalMS = parsed
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
|
||||
if intervalMS < defaultReplayIntervalMS {
|
||||
intervalMS = defaultReplayIntervalMS
|
||||
}
|
||||
|
||||
resp := replayFramesResponse{
|
||||
SessionKey: sessionKey,
|
||||
Interval: intervalMS,
|
||||
Frames: []replayFrame{},
|
||||
}
|
||||
|
||||
drivers, err := client.GetDriversForSession(sessionKey)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
driverNumbers := uniqueDriverNumbers(drivers)
|
||||
if len(driverNumbers) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
||||
if err != nil && len(series) == 0 {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
start, ok := earliestReplayLocationTime(series)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
resp.StartTime = start.Format(time.RFC3339Nano)
|
||||
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
||||
seen := make(map[int]bool, len(drivers))
|
||||
numbers := make([]int, 0, len(drivers))
|
||||
for _, driver := range drivers {
|
||||
if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
|
||||
continue
|
||||
}
|
||||
seen[driver.DriverNumber] = true
|
||||
numbers = append(numbers, driver.DriverNumber)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
return numbers
|
||||
}
|
||||
|
||||
func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
|
||||
type result struct {
|
||||
driver int
|
||||
locs []models.Location
|
||||
err error
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, replayFetchConcurrency)
|
||||
results := make(chan result, len(driverNumbers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, driverNumber := range driverNumbers {
|
||||
driverNumber := driverNumber
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
results <- result{driver: driverNumber, err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
|
||||
locs, err := client.GetLocation(sessionKey, driverNumber)
|
||||
results <- result{driver: driverNumber, locs: locs, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
series := make(map[int][]models.Location, len(driverNumbers))
|
||||
var joined error
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
joined = errors.Join(joined, result.err)
|
||||
continue
|
||||
}
|
||||
if len(result.locs) > 0 {
|
||||
series[result.driver] = result.locs
|
||||
}
|
||||
}
|
||||
return series, joined
|
||||
}
|
||||
|
||||
func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
|
||||
var start time.Time
|
||||
for _, locs := range series {
|
||||
for _, loc := range locs {
|
||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start.IsZero() || t.Before(start) {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return start, true
|
||||
}
|
||||
|
||||
func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
|
||||
type accumulator struct {
|
||||
t int64
|
||||
cars map[string]replayCar
|
||||
nearest map[string]int64
|
||||
}
|
||||
|
||||
interval := int64(intervalMS)
|
||||
framesByIndex := make(map[int]*accumulator)
|
||||
|
||||
for driverNumber, locs := range series {
|
||||
driverKey := strconv.Itoa(driverNumber)
|
||||
for _, loc := range locs {
|
||||
if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
offset := t.Sub(start).Milliseconds()
|
||||
if offset < 0 {
|
||||
continue
|
||||
}
|
||||
index := int((offset + interval/2) / interval)
|
||||
if index < 0 || index >= maxReplayFrames {
|
||||
continue
|
||||
}
|
||||
frameT := int64(index) * interval
|
||||
distance := absInt64(offset - frameT)
|
||||
|
||||
acc, ok := framesByIndex[index]
|
||||
if !ok {
|
||||
acc = &accumulator{
|
||||
t: frameT,
|
||||
cars: make(map[string]replayCar),
|
||||
nearest: make(map[string]int64),
|
||||
}
|
||||
framesByIndex[index] = acc
|
||||
}
|
||||
if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
|
||||
continue
|
||||
}
|
||||
acc.nearest[driverKey] = distance
|
||||
acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
|
||||
}
|
||||
}
|
||||
|
||||
indexes := make([]int, 0, len(framesByIndex))
|
||||
for index, acc := range framesByIndex {
|
||||
if len(acc.cars) > 0 {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
|
||||
frames := make([]replayFrame, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
acc := framesByIndex[index]
|
||||
frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isFiniteFloat(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
||||
}
|
||||
181
internal/web/replay_test.go
Normal file
181
internal/web/replay_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
type fakeReplayClient struct {
|
||||
drivers []models.Driver
|
||||
locs map[int][]models.Location
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
inFlight int
|
||||
maxInFlight int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.drivers, nil
|
||||
}
|
||||
|
||||
func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
|
||||
f.mu.Lock()
|
||||
f.inFlight++
|
||||
if f.inFlight > f.maxInFlight {
|
||||
f.maxInFlight = f.inFlight
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
if f.delay > 0 {
|
||||
time.Sleep(f.delay)
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.inFlight--
|
||||
f.mu.Unlock()
|
||||
|
||||
return f.locs[driverNumber], nil
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
client := &fakeReplayClient{
|
||||
drivers: []models.Driver{
|
||||
{DriverNumber: 1},
|
||||
{DriverNumber: 4},
|
||||
{DriverNumber: 16},
|
||||
},
|
||||
locs: map[int][]models.Location{
|
||||
1: {
|
||||
{Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
|
||||
{Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
|
||||
{Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
|
||||
},
|
||||
4: {
|
||||
{Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
|
||||
},
|
||||
16: {},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
||||
t.Fatalf("response metadata = %+v", resp)
|
||||
}
|
||||
if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
|
||||
t.Fatalf("start_time = %q", resp.StartTime)
|
||||
}
|
||||
if len(resp.Frames) != 2 {
|
||||
t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
|
||||
}
|
||||
if _, ok := resp.Frames[0].Cars["16"]; ok {
|
||||
t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
|
||||
}
|
||||
if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
|
||||
t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
|
||||
}
|
||||
if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
|
||||
t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
|
||||
}
|
||||
if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
|
||||
t.Fatalf("frame 1 car 4 = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
|
||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
locs := make([]models.Location, maxReplayFrames+250)
|
||||
for i := range locs {
|
||||
locs[i] = models.Location{
|
||||
Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
|
||||
X: float64(i),
|
||||
Y: float64(i * 2),
|
||||
}
|
||||
}
|
||||
client := &fakeReplayClient{
|
||||
drivers: []models.Driver{{DriverNumber: 1}},
|
||||
locs: map[int][]models.Location{1: locs},
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if len(resp.Frames) > maxReplayFrames {
|
||||
t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
|
||||
}
|
||||
if len(resp.Frames) != maxReplayFrames {
|
||||
t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
|
||||
drivers := make([]models.Driver, 10)
|
||||
locs := make(map[int][]models.Location, len(drivers))
|
||||
now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
for i := range drivers {
|
||||
number := i + 1
|
||||
drivers[i] = models.Driver{DriverNumber: number}
|
||||
locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
|
||||
}
|
||||
client := &fakeReplayClient{
|
||||
drivers: drivers,
|
||||
locs: locs,
|
||||
delay: 5 * time.Millisecond,
|
||||
}
|
||||
|
||||
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if client.maxInFlight > replayFetchConcurrency {
|
||||
t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
|
||||
srv := testServer(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleReplayFrames(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing session_key status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleReplayFrames(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid interval status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
||||
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
||||
}}
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
body, err := json.Marshal(resp)
|
||||
if err != nil || len(body) == 0 {
|
||||
t.Fatalf("marshal response = %d bytes, %v", len(body), err)
|
||||
}
|
||||
if resp.Interval != defaultReplayIntervalMS {
|
||||
t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||
mux.HandleFunc("/api/v1/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)
|
||||
|
||||
Reference in New Issue
Block a user