diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0b51ae7..a42330a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { return res.json() } +export async function fetchLiveTrackOutline( + session: LiveSessionMeta, + year = new Date().getFullYear(), +): Promise { + 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 { const params = new URLSearchParams() if (limit) params.set('limit', limit.toString()) diff --git a/frontend/src/components/live/TrackMap.tsx b/frontend/src/components/live/TrackMap.tsx new file mode 100644 index 0000000..158c130 --- /dev/null +++ b/frontend/src/components/live/TrackMap.tsx @@ -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 + telemetry?: Record + drivers?: Record + driverInfo?: Record + loading?: boolean +} + +export function TrackMap({ + outline, + positions, + telemetry = {}, + drivers = {}, + driverInfo = {}, + loading = false, +}: Props) { + const [selected, setSelected] = useState(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 ( +
+
+ Track Map +
+
loading cached circuit outline...
+
+ ) + } + + if (!outline || !outlinePath) { + return ( +
+
+ Track Map +
+
track outline unavailable for this live session
+
+ ) + } + + return ( +
+
+ Track Map + {cars.length ? `${cars.length} cars` : 'waiting for GPS'} +
+
+ + + + {cars.map((car) => { + const label = car.info?.Tla || car.number + return ( + setSelected(car.number)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setSelected(car.number) + } + }} + > + + {label} + + ) + })} + + {cars.length === 0 &&
waiting for live GPS
} + {selectedCar && ( +
+
+ {selectedCar.info?.Tla || selectedCar.number} + {selectedCar.position.status || 'OnTrack'} +
+
+ + + + + +
+
+ )} +
+
+ ) +} + +function Metric({ label, value, suffix = '' }: { label: string; value: number | undefined; suffix?: string }) { + return ( + <> +
{label}
+
{value === undefined ? '-' : `${value}${suffix}`}
+ + ) +} + +function teamColor(info: LiveDriverInfo | undefined): string { + const raw = info?.TeamColour?.trim() + if (!raw) return '#777777' + return raw.startsWith('#') ? raw : `#${raw}` +} diff --git a/frontend/src/lib/trackmap.ts b/frontend/src/lib/trackmap.ts new file mode 100644 index 0000000..6ad22c0 --- /dev/null +++ b/frontend/src/lib/trackmap.ts @@ -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, + 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): 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) +} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index 613be01..fd478eb 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -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({}) const [pinned, setPinned] = useState(() => loadPinnedDrivers()) const [visibleSectors, setVisibleSectors] = useState({}) + const [positions, setPositions] = useState>({}) 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 + 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() { +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 40962bc..d0ff358 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx index 16fcd51..c3df461 100644 --- a/frontend/src/test/LiveComponents.test.tsx +++ b/frontend/src/test/LiveComponents.test.tsx @@ -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( + , + ) + + 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() + expect(screen.getByTestId('track-map')).toHaveTextContent(/track outline unavailable/i) + }) +}) diff --git a/frontend/src/test/trackmap.test.ts b/frontend/src/test/trackmap.test.ts new file mode 100644 index 0000000..fbb4376 --- /dev/null +++ b/frontend/src/test/trackmap.test.ts @@ -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) + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index aef8b32..68e4929 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 DriverInfo: Record Tyres: Record + Telemetry?: Record RCMessages: LiveRCMessage[] Weather: LiveWeatherData Session: LiveSessionMeta @@ -269,6 +286,24 @@ export interface LiveStreamData { Stints: Record } +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 diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index 0410908..5667a45 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -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 +} diff --git a/internal/live/signalr.go b/internal/live/signalr.go index d0f89e0..e9b8342 100644 --- a/internal/live/signalr.go +++ b/internal/live/signalr.go @@ -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 diff --git a/internal/live/state.go b/internal/live/state.go index b401c4a..bd7d8d4 100644 --- a/internal/live/state.go +++ b/internal/live/state.go @@ -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 { diff --git a/internal/live/types.go b/internal/live/types.go index 3d17d2a..72e047c 100644 --- a/internal/live/types.go +++ b/internal/live/types.go @@ -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:"-"` } diff --git a/internal/web/api.go b/internal/web/api.go index c6facce..01a61bc 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -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 --- diff --git a/internal/web/live.go b/internal/web/live.go index e8b72dc..36dae78 100644 --- a/internal/web/live.go +++ b/internal/web/live.go @@ -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() diff --git a/internal/web/live_trackmap_test.go b/internal/web/live_trackmap_test.go new file mode 100644 index 0000000..40b6a20 --- /dev/null +++ b/internal/web/live_trackmap_test.go @@ -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) + } +}