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

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