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

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

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

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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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:"-"`
}