feat(live): add web track map (#32)

Subscribe to SignalR Position.z and CarData.z, decode DEFLATE payloads, stream throttled positions over SSE, and render live car dots against cached track outline bounds with tap telemetry.

Alignment spike: no checked-in or locally cached real Position.z samples were available in this isolated worktree; verified both streams expose the official raw F1 X/Y/Z coordinate contract and implemented shared-bounds normalization against prefetched OpenF1 outlines. Fallback build-outline-from-stream was not taken.

Playwright remains out of scope for live rendering because BOXBOX_DISABLE_LIVE is used there; coverage is via parser, web handler/SSE, pure transform, and seeded component tests.
This commit is contained in:
Aman Tahiliani
2026-07-03 19:34:09 -04:00
committed by GitHub
parent 5408a45bbd
commit 7263949260
15 changed files with 1076 additions and 18 deletions

View File

@@ -2,10 +2,12 @@ import type {
ArticleContent,
ChampionshipHub,
LiveStateResponse,
LiveSessionMeta,
Meeting,
NewsItem,
RaceHub,
Session,
TrackOutline,
Weekend,
} from './types'
@@ -80,6 +82,20 @@ export async function fetchLiveState(): Promise<LiveStateResponse> {
return res.json()
}
export async function fetchLiveTrackOutline(
session: LiveSessionMeta,
year = new Date().getFullYear(),
): Promise<TrackOutline> {
const params = new URLSearchParams({ year: year.toString() })
if (session.MeetingName) params.set('meeting_name', session.MeetingName)
if (session.CircuitName) params.set('circuit_name', session.CircuitName)
const res = await fetch(`/api/v1/track-outline?${params.toString()}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
const params = new URLSearchParams()
if (limit) params.set('limit', limit.toString())

View File

@@ -0,0 +1,145 @@
import { useMemo, useState } from 'react'
import type {
LiveDriverData,
LiveDriverInfo,
LivePosition,
LiveTelemetry,
TrackOutline,
} from '../../types'
import {
buildOutlinePath,
canvasToSvg,
isOnTrack,
normalizeRawPoint,
} from '../../lib/trackmap'
interface Props {
outline?: TrackOutline | null
positions: Record<string, LivePosition>
telemetry?: Record<string, LiveTelemetry>
drivers?: Record<string, LiveDriverData>
driverInfo?: Record<string, LiveDriverInfo>
loading?: boolean
}
export function TrackMap({
outline,
positions,
telemetry = {},
drivers = {},
driverInfo = {},
loading = false,
}: Props) {
const [selected, setSelected] = useState<string | null>(null)
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const cars = useMemo(() => {
if (!outline?.bounds) return []
return Object.entries(positions)
.map(([number, position]) => {
const canvas = normalizeRawPoint(position, outline.bounds)
return {
number,
position,
svg: canvasToSvg(canvas),
info: driverInfo[number],
driver: drivers[number],
telemetry: telemetry[number],
active: isOnTrack(position.status) && !drivers[number]?.Retired,
}
})
.sort((a, b) => Number(a.number) - Number(b.number))
}, [driverInfo, drivers, outline?.bounds, positions, telemetry])
const selectedCar = selected ? cars.find((car) => car.number === selected) : null
if (loading) {
return (
<section className="live-track-panel" data-testid="track-map">
<div className="sec-header">
<span className="sec-title">Track Map</span>
</div>
<div className="track-map-empty">loading cached circuit outline...</div>
</section>
)
}
if (!outline || !outlinePath) {
return (
<section className="live-track-panel" data-testid="track-map">
<div className="sec-header">
<span className="sec-title">Track Map</span>
</div>
<div className="track-map-empty">track outline unavailable for this live session</div>
</section>
)
}
return (
<section className="live-track-panel" data-testid="track-map">
<div className="sec-header">
<span className="sec-title">Track Map</span>
<span className="sec-meta">{cars.length ? `${cars.length} cars` : 'waiting for GPS'}</span>
</div>
<div className="track-map-stage">
<svg className="track-map-svg" viewBox="0 0 100 100" role="img" aria-label="Live track map">
<path className="track-map-outline-shadow" d={outlinePath} />
<path className="track-map-outline" d={outlinePath} />
{cars.map((car) => {
const label = car.info?.Tla || car.number
return (
<g
key={car.number}
role="button"
tabIndex={0}
aria-label={`${label} telemetry`}
className={`track-car ${car.active ? 'track-car-active' : 'track-car-inactive'} ${selected === car.number ? 'track-car-selected' : ''}`}
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
onClick={() => setSelected(car.number)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelected(car.number)
}
}}
>
<circle r="2.8" fill={teamColor(car.info)} />
<text y="0.85">{label}</text>
</g>
)
})}
</svg>
{cars.length === 0 && <div className="track-map-empty track-map-overlay">waiting for live GPS</div>}
{selectedCar && (
<div className="track-telemetry" data-testid="track-telemetry">
<div className="track-telemetry-head">
<span className="track-driver-code">{selectedCar.info?.Tla || selectedCar.number}</span>
<span>{selectedCar.position.status || 'OnTrack'}</span>
</div>
<dl>
<Metric label="SPD" value={selectedCar.telemetry?.Speed} suffix="km/h" />
<Metric label="THR" value={selectedCar.telemetry?.Throttle} suffix="%" />
<Metric label="BRK" value={selectedCar.telemetry?.Brake} suffix="%" />
<Metric label="DRS" value={selectedCar.telemetry?.DRS} />
<Metric label="GEAR" value={selectedCar.telemetry?.NGear} />
</dl>
</div>
)}
</div>
</section>
)
}
function Metric({ label, value, suffix = '' }: { label: string; value: number | undefined; suffix?: string }) {
return (
<>
<dt>{label}</dt>
<dd>{value === undefined ? '-' : `${value}${suffix}`}</dd>
</>
)
}
function teamColor(info: LiveDriverInfo | undefined): string {
const raw = info?.TeamColour?.trim()
if (!raw) return '#777777'
return raw.startsWith('#') ? raw : `#${raw}`
}

View File

@@ -0,0 +1,66 @@
import type { LivePosition, TrackBounds, TrackPoint } from '../types'
export interface CanvasPoint {
x: number
y: number
}
const VIEWBOX_SIZE = 100
export function normalizeRawPoint(
point: Pick<LivePosition, 'x' | 'y'>,
bounds: TrackBounds,
): CanvasPoint {
return {
x: normalizeAxis(point.x, bounds.minX, bounds.maxX),
y: 1 - normalizeAxis(point.y, bounds.minY, bounds.maxY),
}
}
export function outlinePointToCanvas(point: TrackPoint): CanvasPoint {
return {
x: clamp01(point.x),
y: 1 - clamp01(point.y),
}
}
export function buildOutlinePath(points: ReadonlyArray<TrackPoint>): string {
if (points.length < 2) return ''
return points
.map((point, index) => {
const canvas = outlinePointToCanvas(point)
const command = index === 0 ? 'M' : 'L'
return `${command} ${formatSvgCoord(canvas.x)} ${formatSvgCoord(canvas.y)}`
})
.join(' ')
}
export function canvasToSvg(point: CanvasPoint): CanvasPoint {
return {
x: clamp01(point.x) * VIEWBOX_SIZE,
y: clamp01(point.y) * VIEWBOX_SIZE,
}
}
export function isOnTrack(status: string | null | undefined): boolean {
if (!status) return true
const normalized = status.toLowerCase()
return normalized === 'ontrack' || normalized === 'on-track' || normalized === 'on_track'
}
function normalizeAxis(value: number, min: number, max: number): number {
const range = max - min
if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max)) return 0.5
if (range === 0) return 0.5
return clamp01((value - min) / range)
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0.5
return Math.min(1, Math.max(0, value))
}
function formatSvgCoord(value: number): string {
return (value * VIEWBOX_SIZE).toFixed(2)
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchLiveState } from '../api'
import type { LiveStreamData } from '../types'
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
import type { LivePosition, LiveStreamData } from '../types'
import {
loadPinnedDrivers,
mergeVisibleSectors,
@@ -21,6 +21,7 @@ import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { TrackMap } from '../components/live/TrackMap'
import { Radio } from 'lucide-react'
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
@@ -33,6 +34,7 @@ export function LiveTimingPage() {
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
const { data, isLoading, isError, error } = useQuery({
queryKey: ['live-state'],
@@ -40,6 +42,18 @@ export function LiveTimingPage() {
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(() => {
if (!data) return
setIsLive(data.is_live)
@@ -77,6 +91,17 @@ export function LiveTimingPage() {
if (!cancelled) setStreamStatus('connected')
})
events.addEventListener('positions', (event) => {
if (cancelled) return
try {
const parsed = JSON.parse(event.data) as Record<string, LivePosition>
setPositions(parsed && typeof parsed === 'object' ? parsed : {})
setStreamStatus('connected')
} catch {
// Ignore malformed transient frames; the next 4Hz update will replace it.
}
})
events.onerror = () => {
if (!cancelled) setStreamStatus('disconnected')
}
@@ -160,6 +185,14 @@ export function LiveTimingPage() {
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
<TrackStatusBanner status={snapshot.TrackStatus} />
<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}
/>
<div className="live-columns">
<div className="live-tower-col">
<div className="sec-header">

View File

@@ -649,6 +649,141 @@ a { color: inherit; text-decoration: none; }
gap: var(--s5);
}
.live-track-panel {
margin-bottom: var(--s5);
}
.track-map-stage {
position: relative;
min-height: 360px;
border: 1px solid var(--border);
background: var(--surface);
overflow: hidden;
}
.track-map-svg {
display: block;
width: 100%;
height: 360px;
}
.track-map-outline-shadow,
.track-map-outline {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.track-map-outline-shadow {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 5;
}
.track-map-outline {
stroke: var(--surface-3);
stroke-width: 2.4;
}
.track-car {
cursor: pointer;
outline: none;
}
.track-car circle {
stroke: #050505;
stroke-width: 0.7;
transition: r 0.12s, opacity 0.12s, stroke 0.12s;
}
.track-car text {
fill: #fff;
font-family: var(--f-mono);
font-size: 2.1px;
font-weight: 800;
pointer-events: none;
text-anchor: middle;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.85);
stroke-width: 0.7;
}
.track-car:hover circle,
.track-car:focus-visible circle,
.track-car-selected circle {
r: 3.7;
stroke: #fff;
}
.track-car-inactive {
opacity: 0.35;
filter: grayscale(0.8);
}
.track-map-empty {
display: grid;
min-height: 160px;
place-items: center;
color: var(--text-3);
font-family: var(--f-mono);
font-size: 11px;
text-transform: uppercase;
}
.track-map-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.track-telemetry {
position: absolute;
right: var(--s5);
bottom: var(--s5);
width: min(260px, calc(100% - 32px));
padding: var(--s4);
border: 1px solid var(--border-2);
background: rgba(10, 10, 10, 0.88);
backdrop-filter: blur(10px);
}
.track-telemetry-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
margin-bottom: var(--s3);
color: var(--text-3);
font-family: var(--f-mono);
font-size: 10px;
text-transform: uppercase;
}
.track-driver-code {
color: var(--text);
font-size: 13px;
font-weight: 800;
}
.track-telemetry dl {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s3);
}
.track-telemetry dt {
color: var(--text-3);
font-family: var(--f-mono);
font-size: 9px;
}
.track-telemetry dd {
color: var(--text);
font-family: var(--f-mono);
font-size: 12px;
font-weight: 700;
min-width: 0;
}
@media (min-width: 900px) {
.live-columns {
display: grid;

View File

@@ -7,9 +7,10 @@ import { BattleChips } from '../components/live/BattleChips'
import { GapSparkline } from '../components/live/GapSparkline'
import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { TimingTower } from '../components/live/TimingTower'
import { TrackMap } from '../components/live/TrackMap'
import { detectBattles } from '../lib/battles'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData, LiveWeatherData } from '../types'
import type { LiveDriverData, LiveWeatherData, TrackOutline } from '../types'
function makeRow(
number: string,
@@ -241,3 +242,66 @@ describe('PinnedDrivers', () => {
expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument()
})
})
describe('TrackMap', () => {
const outline: TrackOutline = {
circuit_key: 9,
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
points: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
],
}
it('renders car dots and opens mini telemetry on tap', () => {
render(
<TrackMap
outline={outline}
positions={{
'4': { x: 50, y: 25, z: 0, status: 'OnTrack' },
'81': { x: 25, y: 75, z: 0, status: 'OffTrack' },
}}
telemetry={{
'4': { Speed: 302, Throttle: 88, Brake: 0, DRS: 10, NGear: 8, RPM: 11111 },
}}
driverInfo={{
'4': {
RacingNumber: '4',
BroadcastName: 'L NORRIS',
Tla: 'NOR',
TeamName: 'McLaren',
TeamColour: 'ff8000',
FirstName: 'Lando',
LastName: 'Norris',
},
'81': {
RacingNumber: '81',
BroadcastName: 'O PIASTRI',
Tla: 'PIA',
TeamName: 'McLaren',
TeamColour: 'ff8000',
FirstName: 'Oscar',
LastName: 'Piastri',
},
}}
/>,
)
expect(screen.getByTestId('track-map')).toHaveTextContent('2 cars')
fireEvent.click(screen.getByRole('button', { name: /NOR telemetry/i }))
const readout = screen.getByTestId('track-telemetry')
expect(readout).toHaveTextContent('NOR')
expect(readout).toHaveTextContent('302km/h')
expect(readout).toHaveTextContent('88%')
expect(readout).toHaveTextContent('DRS')
expect(readout).toHaveTextContent('10')
expect(screen.getByRole('button', { name: /PIA telemetry/i })).toHaveClass('track-car-inactive')
})
it('renders an empty state without outline data', () => {
render(<TrackMap outline={null} positions={{}} />)
expect(screen.getByTestId('track-map')).toHaveTextContent(/track outline unavailable/i)
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
buildOutlinePath,
canvasToSvg,
isOnTrack,
normalizeRawPoint,
outlinePointToCanvas,
} from '../lib/trackmap'
describe('track map transforms', () => {
it('normalizes raw F1 coordinates into canvas space', () => {
const point = normalizeRawPoint(
{ x: 50, y: 75 },
{ minX: 0, maxX: 100, minY: 50, maxY: 100 },
)
expect(point).toEqual({ x: 0.5, y: 0.5 })
expect(canvasToSvg(point)).toEqual({ x: 50, y: 50 })
})
it('centers zero-range bounds and clamps out-of-range points', () => {
expect(normalizeRawPoint({ x: 10, y: 20 }, { minX: 10, maxX: 10, minY: 20, maxY: 20 }))
.toEqual({ x: 0.5, y: 0.5 })
expect(normalizeRawPoint({ x: 20, y: 5 }, { minX: 10, maxX: 15, minY: 10, maxY: 15 }))
.toEqual({ x: 1, y: 1 })
})
it('builds an SVG path from normalized outline points', () => {
expect(outlinePointToCanvas({ x: 0.25, y: 0.75 })).toEqual({ x: 0.25, y: 0.25 })
expect(buildOutlinePath([{ x: 0, y: 0 }, { x: 1, y: 1 }]))
.toBe('M 0.00 100.00 L 100.00 0.00')
expect(buildOutlinePath([{ x: 0, y: 0 }])).toBe('')
})
it('classifies on-track status defensively', () => {
expect(isOnTrack('OnTrack')).toBe(true)
expect(isOnTrack('OffTrack')).toBe(false)
expect(isOnTrack('')).toBe(true)
})
})

View File

@@ -177,6 +177,22 @@ export interface LiveStateResponse {
data: LiveStreamData | null
}
export interface LivePosition {
x: number
y: number
z: number
status: string
}
export interface LiveTelemetry {
Speed: number
Throttle: number
Brake: number
DRS: number
NGear: number
RPM: number
}
export interface LiveSectorData {
Value: string
PersonalFastest: boolean
@@ -257,6 +273,7 @@ export interface LiveStreamData {
Drivers: Record<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo>
Tyres: Record<string, LiveTyreData>
Telemetry?: Record<string, LiveTelemetry>
RCMessages: LiveRCMessage[]
Weather: LiveWeatherData
Session: LiveSessionMeta
@@ -269,6 +286,24 @@ export interface LiveStreamData {
Stints: Record<string, LiveStintData[]>
}
export interface TrackPoint {
x: number
y: number
}
export interface TrackBounds {
minX: number
maxX: number
minY: number
maxY: number
}
export interface TrackOutline {
circuit_key: number
points: TrackPoint[]
bounds: TrackBounds
}
export interface ChampHubDriver {
driver_number: number
name_acronym: string