mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56: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:
@@ -2,10 +2,12 @@ import type {
|
|||||||
ArticleContent,
|
ArticleContent,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
|
LiveSessionMeta,
|
||||||
Meeting,
|
Meeting,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
RaceHub,
|
RaceHub,
|
||||||
Session,
|
Session,
|
||||||
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -80,6 +82,20 @@ export async function fetchLiveState(): Promise<LiveStateResponse> {
|
|||||||
return res.json()
|
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[]> {
|
export async function fetchNews(limit?: number, source?: string): Promise<NewsItem[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (limit) params.set('limit', limit.toString())
|
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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLiveState } from '../api'
|
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||||
import type { LiveStreamData } from '../types'
|
import type { LivePosition, LiveStreamData } from '../types'
|
||||||
import {
|
import {
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
mergeVisibleSectors,
|
mergeVisibleSectors,
|
||||||
@@ -21,6 +21,7 @@ import { TimingTower } from '../components/live/TimingTower'
|
|||||||
import { BattleChips } from '../components/live/BattleChips'
|
import { BattleChips } from '../components/live/BattleChips'
|
||||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||||
|
import { TrackMap } from '../components/live/TrackMap'
|
||||||
import { Radio } from 'lucide-react'
|
import { Radio } from 'lucide-react'
|
||||||
|
|
||||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
@@ -33,6 +34,7 @@ export function LiveTimingPage() {
|
|||||||
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
||||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||||
|
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ['live-state'],
|
queryKey: ['live-state'],
|
||||||
@@ -40,6 +42,18 @@ export function LiveTimingPage() {
|
|||||||
staleTime: 5_000,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setIsLive(data.is_live)
|
setIsLive(data.is_live)
|
||||||
@@ -77,6 +91,17 @@ export function LiveTimingPage() {
|
|||||||
if (!cancelled) setStreamStatus('connected')
|
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 = () => {
|
events.onerror = () => {
|
||||||
if (!cancelled) setStreamStatus('disconnected')
|
if (!cancelled) setStreamStatus('disconnected')
|
||||||
}
|
}
|
||||||
@@ -160,6 +185,14 @@ export function LiveTimingPage() {
|
|||||||
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
|
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
|
||||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
<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-columns">
|
||||||
<div className="live-tower-col">
|
<div className="live-tower-col">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
|
|||||||
@@ -649,6 +649,141 @@ a { color: inherit; text-decoration: none; }
|
|||||||
gap: var(--s5);
|
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) {
|
@media (min-width: 900px) {
|
||||||
.live-columns {
|
.live-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { BattleChips } from '../components/live/BattleChips'
|
|||||||
import { GapSparkline } from '../components/live/GapSparkline'
|
import { GapSparkline } from '../components/live/GapSparkline'
|
||||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||||
import { TimingTower } from '../components/live/TimingTower'
|
import { TimingTower } from '../components/live/TimingTower'
|
||||||
|
import { TrackMap } from '../components/live/TrackMap'
|
||||||
import { detectBattles } from '../lib/battles'
|
import { detectBattles } from '../lib/battles'
|
||||||
import type { LiveTimingRow } from '../lib/live'
|
import type { LiveTimingRow } from '../lib/live'
|
||||||
import type { LiveDriverData, LiveWeatherData } from '../types'
|
import type { LiveDriverData, LiveWeatherData, TrackOutline } from '../types'
|
||||||
|
|
||||||
function makeRow(
|
function makeRow(
|
||||||
number: string,
|
number: string,
|
||||||
@@ -241,3 +242,66 @@ describe('PinnedDrivers', () => {
|
|||||||
expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument()
|
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
|
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 {
|
export interface LiveSectorData {
|
||||||
Value: string
|
Value: string
|
||||||
PersonalFastest: boolean
|
PersonalFastest: boolean
|
||||||
@@ -257,6 +273,7 @@ export interface LiveStreamData {
|
|||||||
Drivers: Record<string, LiveDriverData>
|
Drivers: Record<string, LiveDriverData>
|
||||||
DriverInfo: Record<string, LiveDriverInfo>
|
DriverInfo: Record<string, LiveDriverInfo>
|
||||||
Tyres: Record<string, LiveTyreData>
|
Tyres: Record<string, LiveTyreData>
|
||||||
|
Telemetry?: Record<string, LiveTelemetry>
|
||||||
RCMessages: LiveRCMessage[]
|
RCMessages: LiveRCMessage[]
|
||||||
Weather: LiveWeatherData
|
Weather: LiveWeatherData
|
||||||
Session: LiveSessionMeta
|
Session: LiveSessionMeta
|
||||||
@@ -269,6 +286,24 @@ export interface LiveStreamData {
|
|||||||
Stints: Record<string, LiveStintData[]>
|
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 {
|
export interface ChampHubDriver {
|
||||||
driver_number: number
|
driver_number: number
|
||||||
name_acronym: string
|
name_acronym: string
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package live_test
|
package live_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestProcessTopicDriverList(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
|
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")
|
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{
|
topics := []string{
|
||||||
"Heartbeat",
|
"Heartbeat",
|
||||||
"TimingData",
|
"TimingData",
|
||||||
|
"Position.z",
|
||||||
|
"CarData.z",
|
||||||
"DriverList",
|
"DriverList",
|
||||||
"LapCount",
|
"LapCount",
|
||||||
"ExtrapolatedClock",
|
"ExtrapolatedClock",
|
||||||
@@ -194,7 +196,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
|
|||||||
return err
|
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)
|
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package live
|
package live
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"compress/zlib"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +17,8 @@ type State struct {
|
|||||||
Drivers map[string]LiveDriverData
|
Drivers map[string]LiveDriverData
|
||||||
DriverInfo map[string]F1DriverListEntry
|
DriverInfo map[string]F1DriverListEntry
|
||||||
Tyres map[string]LiveTyreData
|
Tyres map[string]LiveTyreData
|
||||||
|
Telemetry map[string]LiveTelemetryData
|
||||||
|
Positions map[string]LivePositionData
|
||||||
Stints map[string][]LiveStintData
|
Stints map[string][]LiveStintData
|
||||||
RCMessages []LiveRCMessage
|
RCMessages []LiveRCMessage
|
||||||
Weather LiveWeatherData
|
Weather LiveWeatherData
|
||||||
@@ -21,6 +29,8 @@ type State struct {
|
|||||||
Clock string
|
Clock string
|
||||||
ClockRefTime time.Time
|
ClockRefTime time.Time
|
||||||
ClockExtrapolating bool
|
ClockExtrapolating bool
|
||||||
|
positionUpdated bool
|
||||||
|
snapshotUpdated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const signalRRecordSeparator = byte(0x1e)
|
const signalRRecordSeparator = byte(0x1e)
|
||||||
@@ -31,6 +41,8 @@ func NewState() *State {
|
|||||||
Drivers: make(map[string]LiveDriverData),
|
Drivers: make(map[string]LiveDriverData),
|
||||||
DriverInfo: make(map[string]F1DriverListEntry),
|
DriverInfo: make(map[string]F1DriverListEntry),
|
||||||
Tyres: make(map[string]LiveTyreData),
|
Tyres: make(map[string]LiveTyreData),
|
||||||
|
Telemetry: make(map[string]LiveTelemetryData),
|
||||||
|
Positions: make(map[string]LivePositionData),
|
||||||
Stints: make(map[string][]LiveStintData),
|
Stints: make(map[string][]LiveStintData),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +61,14 @@ func (s *State) Snapshot() LiveStreamData {
|
|||||||
for k, v := range s.Tyres {
|
for k, v := range s.Tyres {
|
||||||
cpyTyres[k] = v
|
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))
|
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
|
||||||
copy(cpyRC, s.RCMessages)
|
copy(cpyRC, s.RCMessages)
|
||||||
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
|
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
|
||||||
@@ -62,6 +82,7 @@ func (s *State) Snapshot() LiveStreamData {
|
|||||||
Drivers: cpyDrivers,
|
Drivers: cpyDrivers,
|
||||||
DriverInfo: cpyInfo,
|
DriverInfo: cpyInfo,
|
||||||
Tyres: cpyTyres,
|
Tyres: cpyTyres,
|
||||||
|
Telemetry: cpyTelemetry,
|
||||||
RCMessages: cpyRC,
|
RCMessages: cpyRC,
|
||||||
Weather: s.Weather,
|
Weather: s.Weather,
|
||||||
Session: s.Session,
|
Session: s.Session,
|
||||||
@@ -72,11 +93,16 @@ func (s *State) Snapshot() LiveStreamData {
|
|||||||
ClockRefTime: s.ClockRefTime,
|
ClockRefTime: s.ClockRefTime,
|
||||||
ClockExtrapolating: s.ClockExtrapolating,
|
ClockExtrapolating: s.ClockExtrapolating,
|
||||||
Stints: cpyStints,
|
Stints: cpyStints,
|
||||||
|
Positions: cpyPositions,
|
||||||
|
PositionUpdated: s.positionUpdated,
|
||||||
|
SnapshotUpdated: s.snapshotUpdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
|
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
|
||||||
func (s *State) ProcessMessage(message []byte) bool {
|
func (s *State) ProcessMessage(message []byte) bool {
|
||||||
|
s.clearTransientFlags()
|
||||||
|
|
||||||
var parsed F1SignalRMessage
|
var parsed F1SignalRMessage
|
||||||
if err := json.Unmarshal(message, &parsed); err != nil {
|
if err := json.Unmarshal(message, &parsed); err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -111,6 +137,8 @@ func (s *State) ProcessMessage(message []byte) bool {
|
|||||||
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
|
// ProcessCoreMessage parses one or more SignalR Core JSON frames and applies
|
||||||
// completion snapshots and feed deltas from the current official F1 live timing hub.
|
// completion snapshots and feed deltas from the current official F1 live timing hub.
|
||||||
func (s *State) ProcessCoreMessage(message []byte) bool {
|
func (s *State) ProcessCoreMessage(message []byte) bool {
|
||||||
|
s.clearTransientFlags()
|
||||||
|
|
||||||
updated := false
|
updated := false
|
||||||
for _, frame := range splitSignalRFrames(message) {
|
for _, frame := range splitSignalRFrames(message) {
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
@@ -156,7 +184,15 @@ func (s *State) ProcessCoreMessage(message []byte) bool {
|
|||||||
// ProcessTopic applies a single topic payload to the accumulator.
|
// ProcessTopic applies a single topic payload to the accumulator.
|
||||||
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||||
updated := false
|
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":
|
case "TimingData":
|
||||||
var td struct {
|
var td struct {
|
||||||
Lines map[string]json.RawMessage `json:"Lines"`
|
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":
|
case "DriverList":
|
||||||
var dlMap map[string]json.RawMessage
|
var dlMap map[string]json.RawMessage
|
||||||
if json.Unmarshal(data, &dlMap) == nil {
|
if json.Unmarshal(data, &dlMap) == nil {
|
||||||
@@ -288,7 +328,10 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
|||||||
case "SessionInfo":
|
case "SessionInfo":
|
||||||
var si struct {
|
var si struct {
|
||||||
Meeting struct {
|
Meeting struct {
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
|
Circuit struct {
|
||||||
|
ShortName string `json:"ShortName"`
|
||||||
|
} `json:"Circuit"`
|
||||||
} `json:"Meeting"`
|
} `json:"Meeting"`
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
Type string `json:"Type"`
|
Type string `json:"Type"`
|
||||||
@@ -297,6 +340,9 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
|||||||
if si.Meeting.Name != "" {
|
if si.Meeting.Name != "" {
|
||||||
s.Session.MeetingName = si.Meeting.Name
|
s.Session.MeetingName = si.Meeting.Name
|
||||||
}
|
}
|
||||||
|
if si.Meeting.Circuit.ShortName != "" {
|
||||||
|
s.Session.CircuitName = si.Meeting.Circuit.ShortName
|
||||||
|
}
|
||||||
if si.Name != "" {
|
if si.Name != "" {
|
||||||
s.Session.SessionName = 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
|
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) {
|
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
|
||||||
d, exists := drivers[num]
|
d, exists := drivers[num]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|||||||
@@ -87,6 +87,24 @@ type LiveSessionMeta struct {
|
|||||||
SessionName string
|
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.
|
// LiveSectorData holds a single sector time and flags.
|
||||||
type LiveSectorData struct {
|
type LiveSectorData struct {
|
||||||
Value string
|
Value string
|
||||||
@@ -131,6 +149,7 @@ type LiveStreamData struct {
|
|||||||
Drivers map[string]LiveDriverData
|
Drivers map[string]LiveDriverData
|
||||||
DriverInfo map[string]F1DriverListEntry
|
DriverInfo map[string]F1DriverListEntry
|
||||||
Tyres map[string]LiveTyreData
|
Tyres map[string]LiveTyreData
|
||||||
|
Telemetry map[string]LiveTelemetryData
|
||||||
RCMessages []LiveRCMessage
|
RCMessages []LiveRCMessage
|
||||||
Weather LiveWeatherData
|
Weather LiveWeatherData
|
||||||
Session LiveSessionMeta
|
Session LiveSessionMeta
|
||||||
@@ -141,4 +160,7 @@ type LiveStreamData struct {
|
|||||||
ClockRefTime time.Time // UTC when Clock was accurate
|
ClockRefTime time.Time // UTC when Clock was accurate
|
||||||
ClockExtrapolating bool // true = actively counting down
|
ClockExtrapolating bool // true = actively counting down
|
||||||
Stints map[string][]LiveStintData
|
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"`
|
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 {
|
type trackOutlineResponse struct {
|
||||||
CircuitKey int `json:"circuit_key"`
|
CircuitKey int `json:"circuit_key"`
|
||||||
Points []trackPoint `json:"points"`
|
Points []trackPoint `json:"points"`
|
||||||
|
Bounds trackBounds `json:"bounds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
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"))
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
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)
|
locs, ok := s.client.Cache().GetTrackOutline(circuitKey, year)
|
||||||
if !ok || len(locs) == 0 {
|
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 ---
|
// --- /api/v1/strategy ---
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ type SSEHub struct {
|
|||||||
deregister chan *sseClient
|
deregister chan *sseClient
|
||||||
broadcast chan sseEvent
|
broadcast chan sseEvent
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
lastSnapshot *live.LiveStreamData
|
lastSnapshot *live.LiveStreamData
|
||||||
isLive bool
|
lastPositions map[string]live.LivePositionData
|
||||||
|
isLive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSSEHub() *SSEHub {
|
func newSSEHub() *SSEHub {
|
||||||
@@ -52,6 +53,7 @@ func (h *SSEHub) run() {
|
|||||||
// Send catch-up snapshot so new clients see current state immediately.
|
// Send catch-up snapshot so new clients see current state immediately.
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
snap := h.lastSnapshot
|
snap := h.lastSnapshot
|
||||||
|
positions := cloneLivePositions(h.lastPositions)
|
||||||
live := h.isLive
|
live := h.isLive
|
||||||
h.mu.RUnlock()
|
h.mu.RUnlock()
|
||||||
if snap != nil {
|
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:
|
case c := <-h.deregister:
|
||||||
if clients[c] {
|
if clients[c] {
|
||||||
@@ -122,6 +132,7 @@ func (s *Server) signalRLoop() {
|
|||||||
s.hub.mu.Lock()
|
s.hub.mu.Lock()
|
||||||
s.hub.isLive = false
|
s.hub.isLive = false
|
||||||
s.hub.lastSnapshot = nil
|
s.hub.lastSnapshot = nil
|
||||||
|
s.hub.lastPositions = nil
|
||||||
s.hub.mu.Unlock()
|
s.hub.mu.Unlock()
|
||||||
|
|
||||||
if payload, err := json.Marshal(map[string]any{"data": nil, "is_live": false}); err == nil {
|
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
|
idleTimeout := 60 * time.Second
|
||||||
timer := time.NewTimer(idleTimeout)
|
timer := time.NewTimer(idleTimeout)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
lastPositionBroadcast := time.Time{}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case data := <-dataChan:
|
case data := <-dataChan:
|
||||||
|
now := time.Now()
|
||||||
s.hub.mu.Lock()
|
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.isLive = true
|
||||||
s.hub.mu.Unlock()
|
s.hub.mu.Unlock()
|
||||||
|
|
||||||
if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
|
if data.SnapshotUpdated {
|
||||||
s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
|
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() {
|
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.
|
// handleLiveState returns the current live data snapshot as JSON.
|
||||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||||
snap, isLive := s.hub.Snapshot()
|
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