mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -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:
@@ -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())
|
||||
|
||||
145
frontend/src/components/live/TrackMap.tsx
Normal file
145
frontend/src/components/live/TrackMap.tsx
Normal 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}`
|
||||
}
|
||||
66
frontend/src/lib/trackmap.ts
Normal file
66
frontend/src/lib/trackmap.ts
Normal 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)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package live_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -158,6 +161,64 @@ func TestProcessTopicTimingData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicCompressedPositionAndCarData(t *testing.T) {
|
||||
state := live.NewState()
|
||||
positionPayload := `{
|
||||
"Position": [
|
||||
{"Timestamp": "2026-07-03T14:00:00Z", "Entries": {
|
||||
"1": {"Status": "OnTrack", "X": 1000, "Y": -200, "Z": 3},
|
||||
"44": {"Status": "OffTrack", "X": 1200, "Y": -250, "Z": 2}
|
||||
}}
|
||||
]
|
||||
}`
|
||||
if !state.ProcessTopic("Position.z", encodedDeflatePayload(t, positionPayload)) {
|
||||
t.Fatal("Position.z should update state")
|
||||
}
|
||||
|
||||
snap := state.Snapshot()
|
||||
if !snap.PositionUpdated || snap.SnapshotUpdated {
|
||||
t.Fatalf("position flags = position:%v snapshot:%v", snap.PositionUpdated, snap.SnapshotUpdated)
|
||||
}
|
||||
if got := snap.Positions["1"]; got.X != 1000 || got.Y != -200 || got.Z != 3 || got.Status != "OnTrack" {
|
||||
t.Fatalf("position 1 = %+v", got)
|
||||
}
|
||||
if got := snap.Positions["44"]; got.Status != "OffTrack" {
|
||||
t.Fatalf("position 44 = %+v", got)
|
||||
}
|
||||
|
||||
carPayload := `{
|
||||
"Entries": [
|
||||
{"Utc": "2026-07-03T14:00:00Z", "Cars": {
|
||||
"1": {"Channels": {"0": 11234, "2": 318, "3": 8, "4": 92, "5": 0, "45": 10}}
|
||||
}}
|
||||
]
|
||||
}`
|
||||
if !state.ProcessTopic("CarData.z", encodedDeflatePayload(t, carPayload)) {
|
||||
t.Fatal("CarData.z should update state")
|
||||
}
|
||||
snap = state.Snapshot()
|
||||
if !snap.SnapshotUpdated {
|
||||
t.Fatal("CarData should mark snapshot updated")
|
||||
}
|
||||
tel := snap.Telemetry["1"]
|
||||
if tel.RPM != 11234 || tel.Speed != 318 || tel.NGear != 8 || tel.Throttle != 92 || tel.Brake != 0 || tel.DRS != 10 {
|
||||
t.Fatalf("telemetry = %+v", tel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.ProcessTopic("SessionInfo", json.RawMessage(`{
|
||||
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
|
||||
"Name": "Race",
|
||||
"Type": "Race"
|
||||
}`))
|
||||
s := state.Snapshot().Session
|
||||
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
|
||||
t.Fatalf("session = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicDriverList(t *testing.T) {
|
||||
state := live.NewState()
|
||||
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
|
||||
@@ -356,3 +417,23 @@ func TestProcessMessageEmptyPayload(t *testing.T) {
|
||||
t.Error("empty envelope should not update state")
|
||||
}
|
||||
}
|
||||
|
||||
func encodedDeflatePayload(t *testing.T, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w, err := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
if err != nil {
|
||||
t.Fatalf("flate.NewWriter() error = %v", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(payload)); err != nil {
|
||||
t.Fatalf("flate write error = %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("flate close error = %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload error = %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
|
||||
topics := []string{
|
||||
"Heartbeat",
|
||||
"TimingData",
|
||||
"Position.z",
|
||||
"CarData.z",
|
||||
"DriverList",
|
||||
"LapCount",
|
||||
"ExtrapolatedClock",
|
||||
@@ -194,7 +196,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
|
||||
return err
|
||||
}
|
||||
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
|
||||
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/zlib"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -11,6 +17,8 @@ type State struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
Tyres map[string]LiveTyreData
|
||||
Telemetry map[string]LiveTelemetryData
|
||||
Positions map[string]LivePositionData
|
||||
Stints map[string][]LiveStintData
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
@@ -21,6 +29,8 @@ type State struct {
|
||||
Clock string
|
||||
ClockRefTime time.Time
|
||||
ClockExtrapolating bool
|
||||
positionUpdated bool
|
||||
snapshotUpdated bool
|
||||
}
|
||||
|
||||
const signalRRecordSeparator = byte(0x1e)
|
||||
@@ -31,6 +41,8 @@ func NewState() *State {
|
||||
Drivers: make(map[string]LiveDriverData),
|
||||
DriverInfo: make(map[string]F1DriverListEntry),
|
||||
Tyres: make(map[string]LiveTyreData),
|
||||
Telemetry: make(map[string]LiveTelemetryData),
|
||||
Positions: make(map[string]LivePositionData),
|
||||
Stints: make(map[string][]LiveStintData),
|
||||
}
|
||||
}
|
||||
@@ -49,6 +61,14 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
for k, v := range s.Tyres {
|
||||
cpyTyres[k] = v
|
||||
}
|
||||
cpyTelemetry := make(map[string]LiveTelemetryData, len(s.Telemetry))
|
||||
for k, v := range s.Telemetry {
|
||||
cpyTelemetry[k] = v
|
||||
}
|
||||
cpyPositions := make(map[string]LivePositionData, len(s.Positions))
|
||||
for k, v := range s.Positions {
|
||||
cpyPositions[k] = v
|
||||
}
|
||||
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
|
||||
copy(cpyRC, s.RCMessages)
|
||||
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
|
||||
@@ -62,6 +82,7 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
Drivers: cpyDrivers,
|
||||
DriverInfo: cpyInfo,
|
||||
Tyres: cpyTyres,
|
||||
Telemetry: cpyTelemetry,
|
||||
RCMessages: cpyRC,
|
||||
Weather: s.Weather,
|
||||
Session: s.Session,
|
||||
@@ -72,11 +93,16 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
ClockRefTime: s.ClockRefTime,
|
||||
ClockExtrapolating: s.ClockExtrapolating,
|
||||
Stints: cpyStints,
|
||||
Positions: cpyPositions,
|
||||
PositionUpdated: s.positionUpdated,
|
||||
SnapshotUpdated: s.snapshotUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
|
||||
func (s *State) ProcessMessage(message []byte) bool {
|
||||
s.clearTransientFlags()
|
||||
|
||||
var parsed F1SignalRMessage
|
||||
if err := json.Unmarshal(message, &parsed); err != nil {
|
||||
return false
|
||||
@@ -111,6 +137,8 @@ func (s *State) ProcessMessage(message []byte) bool {
|
||||
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
|
||||
// completion snapshots and feed deltas from the current official F1 live timing hub.
|
||||
func (s *State) ProcessCoreMessage(message []byte) bool {
|
||||
s.clearTransientFlags()
|
||||
|
||||
updated := false
|
||||
for _, frame := range splitSignalRFrames(message) {
|
||||
var envelope struct {
|
||||
@@ -156,7 +184,15 @@ func (s *State) ProcessCoreMessage(message []byte) bool {
|
||||
// ProcessTopic applies a single topic payload to the accumulator.
|
||||
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
updated := false
|
||||
switch topic {
|
||||
baseTopic := strings.TrimSuffix(topic, ".z")
|
||||
if topic != baseTopic {
|
||||
var ok bool
|
||||
data, ok = inflateTopicPayload(data)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch baseTopic {
|
||||
case "TimingData":
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
@@ -170,6 +206,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
case "Position":
|
||||
updated = s.updatePositions(data)
|
||||
case "CarData":
|
||||
updated = s.updateTelemetry(data)
|
||||
case "DriverList":
|
||||
var dlMap map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &dlMap) == nil {
|
||||
@@ -288,7 +328,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
case "SessionInfo":
|
||||
var si struct {
|
||||
Meeting struct {
|
||||
Name string `json:"Name"`
|
||||
Name string `json:"Name"`
|
||||
Circuit struct {
|
||||
ShortName string `json:"ShortName"`
|
||||
} `json:"Circuit"`
|
||||
} `json:"Meeting"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
@@ -297,6 +340,9 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
if si.Meeting.Name != "" {
|
||||
s.Session.MeetingName = si.Meeting.Name
|
||||
}
|
||||
if si.Meeting.Circuit.ShortName != "" {
|
||||
s.Session.CircuitName = si.Meeting.Circuit.ShortName
|
||||
}
|
||||
if si.Name != "" {
|
||||
s.Session.SessionName = si.Name
|
||||
}
|
||||
@@ -386,9 +432,177 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
if updated {
|
||||
if baseTopic == "Position" {
|
||||
s.positionUpdated = true
|
||||
} else {
|
||||
s.snapshotUpdated = true
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func (s *State) clearTransientFlags() {
|
||||
s.positionUpdated = false
|
||||
s.snapshotUpdated = false
|
||||
}
|
||||
|
||||
func inflateTopicPayload(data json.RawMessage) (json.RawMessage, bool) {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(data, &encoded); err != nil {
|
||||
var wrapper struct {
|
||||
Z string `json:"z"`
|
||||
}
|
||||
if json.Unmarshal(data, &wrapper) != nil || wrapper.Z == "" {
|
||||
return nil, false
|
||||
}
|
||||
encoded = wrapper.Z
|
||||
}
|
||||
|
||||
compressed, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if inflated, ok := readCompressed(zlib.NewReader(bytes.NewReader(compressed))); ok {
|
||||
return json.RawMessage(inflated), true
|
||||
}
|
||||
if inflated, ok := readCompressed(func() (io.ReadCloser, error) {
|
||||
return flate.NewReader(bytes.NewReader(compressed)), nil
|
||||
}()); ok {
|
||||
return json.RawMessage(inflated), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer r.Close()
|
||||
out, err := io.ReadAll(r)
|
||||
return out, err == nil
|
||||
}
|
||||
|
||||
func (s *State) updatePositions(data json.RawMessage) bool {
|
||||
var payload struct {
|
||||
Position json.RawMessage `json:"Position"`
|
||||
}
|
||||
if json.Unmarshal(data, &payload) != nil || len(payload.Position) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := false
|
||||
for _, sampleRaw := range indexedRawValues(payload.Position) {
|
||||
var sample struct {
|
||||
Entries map[string]struct {
|
||||
Status string `json:"Status"`
|
||||
X json.Number `json:"X"`
|
||||
Y json.Number `json:"Y"`
|
||||
Z json.Number `json:"Z"`
|
||||
} `json:"Entries"`
|
||||
}
|
||||
if json.Unmarshal(sampleRaw.Raw, &sample) != nil {
|
||||
continue
|
||||
}
|
||||
for num, entry := range sample.Entries {
|
||||
x, okX := numberToFloat(entry.X)
|
||||
y, okY := numberToFloat(entry.Y)
|
||||
z, okZ := numberToFloat(entry.Z)
|
||||
if !okX || !okY {
|
||||
continue
|
||||
}
|
||||
if !okZ {
|
||||
z = 0
|
||||
}
|
||||
s.Positions[num] = LivePositionData{
|
||||
X: x,
|
||||
Y: y,
|
||||
Z: z,
|
||||
Status: entry.Status,
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func (s *State) updateTelemetry(data json.RawMessage) bool {
|
||||
var payload struct {
|
||||
Entries json.RawMessage `json:"Entries"`
|
||||
}
|
||||
if json.Unmarshal(data, &payload) != nil || len(payload.Entries) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := false
|
||||
for _, entryRaw := range indexedRawValues(payload.Entries) {
|
||||
var entry struct {
|
||||
Cars map[string]struct {
|
||||
Channels map[string]json.RawMessage `json:"Channels"`
|
||||
} `json:"Cars"`
|
||||
}
|
||||
if json.Unmarshal(entryRaw.Raw, &entry) != nil {
|
||||
continue
|
||||
}
|
||||
for num, car := range entry.Cars {
|
||||
t := s.Telemetry[num]
|
||||
if v, ok := channelInt(car.Channels, "0"); ok {
|
||||
t.RPM = v
|
||||
}
|
||||
if v, ok := channelInt(car.Channels, "2"); ok {
|
||||
t.Speed = v
|
||||
}
|
||||
if v, ok := channelInt(car.Channels, "3"); ok {
|
||||
t.NGear = v
|
||||
}
|
||||
if v, ok := channelInt(car.Channels, "4"); ok {
|
||||
t.Throttle = v
|
||||
}
|
||||
if v, ok := channelInt(car.Channels, "5"); ok {
|
||||
t.Brake = v
|
||||
}
|
||||
if v, ok := channelInt(car.Channels, "45"); ok {
|
||||
t.DRS = v
|
||||
}
|
||||
s.Telemetry[num] = t
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func channelInt(channels map[string]json.RawMessage, key string) (int, bool) {
|
||||
raw, ok := channels[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
if f, err := n.Float64(); err == nil {
|
||||
return int(f), true
|
||||
}
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
var i int
|
||||
if _, err := fmt.Sscanf(s, "%d", &i); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func numberToFloat(n json.Number) (float64, bool) {
|
||||
if n == "" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := n.Float64()
|
||||
return v, err == nil
|
||||
}
|
||||
|
||||
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
|
||||
d, exists := drivers[num]
|
||||
if !exists {
|
||||
|
||||
@@ -87,6 +87,24 @@ type LiveSessionMeta struct {
|
||||
SessionName string
|
||||
}
|
||||
|
||||
// LivePositionData is the latest raw F1 GPS position for one driver.
|
||||
type LivePositionData struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// LiveTelemetryData is the latest car telemetry for one driver.
|
||||
type LiveTelemetryData struct {
|
||||
Speed int `json:"Speed"`
|
||||
Throttle int `json:"Throttle"`
|
||||
Brake int `json:"Brake"`
|
||||
DRS int `json:"DRS"`
|
||||
NGear int `json:"NGear"`
|
||||
RPM int `json:"RPM"`
|
||||
}
|
||||
|
||||
// LiveSectorData holds a single sector time and flags.
|
||||
type LiveSectorData struct {
|
||||
Value string
|
||||
@@ -131,6 +149,7 @@ type LiveStreamData struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
Tyres map[string]LiveTyreData
|
||||
Telemetry map[string]LiveTelemetryData
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
@@ -141,4 +160,7 @@ type LiveStreamData struct {
|
||||
ClockRefTime time.Time // UTC when Clock was accurate
|
||||
ClockExtrapolating bool // true = actively counting down
|
||||
Stints map[string][]LiveStintData
|
||||
Positions map[string]LivePositionData `json:"-"`
|
||||
PositionUpdated bool `json:"-"`
|
||||
SnapshotUpdated bool `json:"-"`
|
||||
}
|
||||
|
||||
@@ -1046,21 +1046,35 @@ type trackPoint struct {
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type trackBounds struct {
|
||||
MinX float64 `json:"minX"`
|
||||
MaxX float64 `json:"maxX"`
|
||||
MinY float64 `json:"minY"`
|
||||
MaxY float64 `json:"maxY"`
|
||||
}
|
||||
|
||||
type trackOutlineResponse struct {
|
||||
CircuitKey int `json:"circuit_key"`
|
||||
Points []trackPoint `json:"points"`
|
||||
Bounds trackBounds `json:"bounds"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
||||
circuitKey, err := strconv.Atoi(r.URL.Query().Get("circuit_key"))
|
||||
if err != nil || circuitKey == 0 {
|
||||
http.Error(w, "circuit_key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||
if year == 0 {
|
||||
year = time.Now().Year()
|
||||
}
|
||||
circuitKey, err := strconv.Atoi(r.URL.Query().Get("circuit_key"))
|
||||
if err != nil {
|
||||
circuitKey = 0
|
||||
}
|
||||
if circuitKey == 0 {
|
||||
circuitKey = s.resolveCircuitKey(year, r.URL.Query().Get("meeting_name"), r.URL.Query().Get("circuit_name"))
|
||||
}
|
||||
if circuitKey == 0 {
|
||||
http.Error(w, "circuit_key or live meeting identity required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
locs, ok := s.client.Cache().GetTrackOutline(circuitKey, year)
|
||||
if !ok || len(locs) == 0 {
|
||||
@@ -1110,7 +1124,85 @@ func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, trackOutlineResponse{CircuitKey: circuitKey, Points: points})
|
||||
writeJSON(w, trackOutlineResponse{
|
||||
CircuitKey: circuitKey,
|
||||
Points: points,
|
||||
Bounds: trackBounds{MinX: minX, MaxX: maxX, MinY: minY, MaxY: maxY},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveCircuitKey(year int, meetingName, circuitName string) int {
|
||||
if !s.hasLocalQuery() {
|
||||
return 0
|
||||
}
|
||||
meetings, err := s.query.ListMeetingsByYear(year)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
wantMeeting := normalizeTrackIdentity(meetingName)
|
||||
wantCircuit := normalizeTrackIdentity(circuitName)
|
||||
bestScore := 0
|
||||
bestCircuitKey := 0
|
||||
for _, m := range meetings {
|
||||
if m.CircuitKey == 0 {
|
||||
continue
|
||||
}
|
||||
score := identityScore(wantMeeting, m.MeetingName, m.MeetingOfficialName, m.Location)
|
||||
score += identityScore(wantCircuit, m.CircuitShortName, m.Location, m.MeetingName)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestCircuitKey = m.CircuitKey
|
||||
}
|
||||
}
|
||||
if bestScore == 0 {
|
||||
return 0
|
||||
}
|
||||
return bestCircuitKey
|
||||
}
|
||||
|
||||
func identityScore(want string, candidates ...string) int {
|
||||
if want == "" {
|
||||
return 0
|
||||
}
|
||||
best := 0
|
||||
for _, candidate := range candidates {
|
||||
got := normalizeTrackIdentity(candidate)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case got == want:
|
||||
if best < 4 {
|
||||
best = 4
|
||||
}
|
||||
case strings.Contains(got, want) || strings.Contains(want, got):
|
||||
if best < 2 {
|
||||
best = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func normalizeTrackIdentity(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
replacer := strings.NewReplacer(
|
||||
"grand prix", "",
|
||||
" gp", "",
|
||||
"circuit", "",
|
||||
"autodromo", "",
|
||||
"autódromo", "",
|
||||
"international", "",
|
||||
"street", "",
|
||||
" ", "",
|
||||
"-", "",
|
||||
"_", "",
|
||||
".", "",
|
||||
",", "",
|
||||
"'", "",
|
||||
"’", "",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(s))
|
||||
}
|
||||
|
||||
// --- /api/v1/strategy ---
|
||||
|
||||
@@ -29,9 +29,10 @@ type SSEHub struct {
|
||||
deregister chan *sseClient
|
||||
broadcast chan sseEvent
|
||||
|
||||
mu sync.RWMutex
|
||||
lastSnapshot *live.LiveStreamData
|
||||
isLive bool
|
||||
mu sync.RWMutex
|
||||
lastSnapshot *live.LiveStreamData
|
||||
lastPositions map[string]live.LivePositionData
|
||||
isLive bool
|
||||
}
|
||||
|
||||
func newSSEHub() *SSEHub {
|
||||
@@ -52,6 +53,7 @@ func (h *SSEHub) run() {
|
||||
// Send catch-up snapshot so new clients see current state immediately.
|
||||
h.mu.RLock()
|
||||
snap := h.lastSnapshot
|
||||
positions := cloneLivePositions(h.lastPositions)
|
||||
live := h.isLive
|
||||
h.mu.RUnlock()
|
||||
if snap != nil {
|
||||
@@ -62,6 +64,14 @@ func (h *SSEHub) run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(positions) > 0 {
|
||||
if data, err := json.Marshal(positions); err == nil {
|
||||
select {
|
||||
case c.ch <- formatSSEFrame("positions", data):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case c := <-h.deregister:
|
||||
if clients[c] {
|
||||
@@ -122,6 +132,7 @@ func (s *Server) signalRLoop() {
|
||||
s.hub.mu.Lock()
|
||||
s.hub.isLive = false
|
||||
s.hub.lastSnapshot = nil
|
||||
s.hub.lastPositions = nil
|
||||
s.hub.mu.Unlock()
|
||||
|
||||
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
|
||||
@@ -150,17 +161,33 @@ func (s *Server) connectAndDrain() error {
|
||||
idleTimeout := 60 * time.Second
|
||||
timer := time.NewTimer(idleTimeout)
|
||||
defer timer.Stop()
|
||||
lastPositionBroadcast := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case data := <-dataChan:
|
||||
now := time.Now()
|
||||
s.hub.mu.Lock()
|
||||
s.hub.lastSnapshot = &data
|
||||
if data.SnapshotUpdated {
|
||||
s.hub.lastSnapshot = &data
|
||||
}
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
s.hub.lastPositions = cloneLivePositions(data.Positions)
|
||||
}
|
||||
s.hub.isLive = true
|
||||
s.hub.mu.Unlock()
|
||||
|
||||
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
||||
if data.SnapshotUpdated {
|
||||
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
||||
}
|
||||
}
|
||||
|
||||
if data.PositionUpdated && len(data.Positions) > 0 && now.Sub(lastPositionBroadcast) >= 250*time.Millisecond {
|
||||
if payload, err := json.Marshal(data.Positions); err == nil {
|
||||
s.hub.broadcast <- sseEvent{name: "positions", data: payload}
|
||||
lastPositionBroadcast = now
|
||||
}
|
||||
}
|
||||
|
||||
if !timer.Stop() {
|
||||
@@ -177,6 +204,17 @@ func (s *Server) connectAndDrain() error {
|
||||
}
|
||||
}
|
||||
|
||||
func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.LivePositionData {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]live.LivePositionData, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleLiveState returns the current live data snapshot as JSON.
|
||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||
snap, isLive := s.hub.Snapshot()
|
||||
|
||||
76
internal/web/live_trackmap_test.go
Normal file
76
internal/web/live_trackmap_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/live"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func TestHandleTrackOutlineReturnsBoundsAndResolvesLiveIdentity(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.UpsertMeeting(store.Meeting{
|
||||
MeetingKey: 1234,
|
||||
MeetingName: "British Grand Prix",
|
||||
Location: "Silverstone",
|
||||
CircuitKey: 9,
|
||||
CircuitShortName: "Silverstone",
|
||||
Year: 2026,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMeeting() error = %v", err)
|
||||
}
|
||||
srv := testServer(t, st)
|
||||
locs := []models.Location{
|
||||
{X: -100, Y: 50, Z: 0},
|
||||
{X: 0, Y: 100, Z: 0},
|
||||
{X: 100, Y: 50, Z: 0},
|
||||
}
|
||||
if err := srv.client.Cache().SetTrackOutline(9, 2026, locs); err != nil {
|
||||
t.Fatalf("SetTrackOutline() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track-outline?meeting_name=British+Grand+Prix&circuit_name=Silverstone&year=2026", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleTrackOutline(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp trackOutlineResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.CircuitKey != 9 {
|
||||
t.Fatalf("circuit_key = %d, want 9", resp.CircuitKey)
|
||||
}
|
||||
if resp.Bounds.MinX != -100 || resp.Bounds.MaxX != 100 || resp.Bounds.MinY != 50 || resp.Bounds.MaxY != 100 {
|
||||
t.Fatalf("bounds = %+v", resp.Bounds)
|
||||
}
|
||||
if len(resp.Points) != 3 {
|
||||
t.Fatalf("points len = %d, want 3", len(resp.Points))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionsSSEFrameShape(t *testing.T) {
|
||||
payload, err := json.Marshal(map[string]live.LivePositionData{
|
||||
"1": {X: 100, Y: -50, Z: 2, Status: "OnTrack"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal positions: %v", err)
|
||||
}
|
||||
frame := string(formatSSEFrame("positions", payload))
|
||||
if !strings.HasPrefix(frame, "event: positions\ndata: ") {
|
||||
t.Fatalf("frame prefix = %q", frame)
|
||||
}
|
||||
if !strings.Contains(frame, `"1":{"x":100,"y":-50,"z":2,"status":"OnTrack"}`) {
|
||||
t.Fatalf("frame data = %q", frame)
|
||||
}
|
||||
if !strings.HasSuffix(frame, "\n\n") {
|
||||
t.Fatalf("frame should end with blank line: %q", frame)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user