mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
39
frontend/src/test/trackmap.test.ts
Normal file
39
frontend/src/test/trackmap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user