From 15faf07691ef73b18c2ccc0a34850af09d3ae253 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 27 Mar 2026 20:12:57 -0400 Subject: [PATCH] Initial UI --- cmd/main.go | 24 +- internal/web/api.go | 786 +++++++++++++++++++++++++++++++++ internal/web/assets/app.js | 527 ++++++++++++++++++++++ internal/web/assets/charts.js | 465 +++++++++++++++++++ internal/web/assets/index.html | 322 ++++++++++++++ internal/web/assets/style.css | 483 ++++++++++++++++++++ internal/web/assets/track.js | 110 +++++ internal/web/live.go | 220 +++++++++ internal/web/server.go | 107 +++++ 9 files changed, 3039 insertions(+), 5 deletions(-) create mode 100644 internal/web/api.go create mode 100644 internal/web/assets/app.js create mode 100644 internal/web/assets/charts.js create mode 100644 internal/web/assets/index.html create mode 100644 internal/web/assets/style.css create mode 100644 internal/web/assets/track.js create mode 100644 internal/web/live.go create mode 100644 internal/web/server.go diff --git a/cmd/main.go b/cmd/main.go index 71d5bf6..38a119f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "log" "os" @@ -8,15 +9,14 @@ import ( "github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/ui" + "github.com/AmanTahiliani/box-box/internal/web" tea "github.com/charmbracelet/bubbletea" ) func main() { - // Redirect all log output to a file so it never pollutes the TUI. - if f, err := os.OpenFile("box-box.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { - log.SetOutput(f) - defer f.Close() - } + webMode := flag.Bool("web", false, "Start web companion server instead of TUI") + port := flag.Int("port", 8080, "Port for web server (used with --web)") + flag.Parse() var client *api.OpenF1Client if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" { @@ -29,6 +29,20 @@ func main() { // Clean up old file-based cache (one-time migration). go api.CleanupOldFileCache() + if *webMode { + log.SetOutput(os.Stderr) // web mode logs to stderr, not file + fmt.Printf("box-box web → http://localhost:%d\n", *port) + srv := web.NewServer(client, *port) + log.Fatal(srv.Start()) + return + } + + // TUI mode: redirect logs to file so they don't pollute the terminal. + if f, err := os.OpenFile("box-box.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { + log.SetOutput(f) + defer f.Close() + } + model := ui.NewAppModel(client) p := tea.NewProgram( diff --git a/internal/web/api.go b/internal/web/api.go new file mode 100644 index 0000000..f6a879c --- /dev/null +++ b/internal/web/api.go @@ -0,0 +1,786 @@ +package web + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +// writeJSON writes v as JSON with status 200. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, err error, status int, stale bool) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stale": stale}) +} + +// --- /api/v1/meetings --- + +func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) { + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year == 0 { + year = time.Now().Year() + } + meetings, err := s.client.GetMeetingsForYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, meetings) +} + +// --- /api/v1/sessions --- + +func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { + meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key")) + if err != nil || meetingKey == 0 { + http.Error(w, "meeting_key required", http.StatusBadRequest) + return + } + sessions, err := s.client.GetSessionsForMeeting(meetingKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, sessions) +} + +// --- /api/v1/drivers --- + +func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + drivers, err := s.client.GetDriversForSession(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, drivers) +} + +// --- /api/v1/results --- + +type resultWithDriver struct { + models.SessionResult + NameAcronym string `json:"name_acronym"` + FullName string `json:"full_name"` + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` +} + +func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + + var ( + results []models.SessionResult + drivers []models.Driver + resultsErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }() + go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() + wg.Wait() + + if resultsErr != nil { + writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + + driverMap := buildDriverMap(drivers) + enriched := make([]resultWithDriver, 0, len(results)) + for _, res := range results { + e := resultWithDriver{SessionResult: res} + if d, ok := driverMap[res.DriverNumber]; ok { + e.NameAcronym = d.NameAcronym + e.FullName = d.FullName + e.TeamName = d.TeamName + e.TeamColour = d.TeamColour + } + enriched = append(enriched, e) + } + writeJSON(w, enriched) +} + +// --- /api/v1/grid --- + +type gridWithDriver struct { + models.StartingGrid + NameAcronym string `json:"name_acronym"` + FullName string `json:"full_name"` + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` +} + +func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + + var ( + grid []models.StartingGrid + drivers []models.Driver + gridErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }() + go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() + wg.Wait() + + if gridErr != nil { + writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + + driverMap := buildDriverMap(drivers) + enriched := make([]gridWithDriver, 0, len(grid)) + for _, g := range grid { + e := gridWithDriver{StartingGrid: g} + if d, ok := driverMap[g.DriverNumber]; ok { + e.NameAcronym = d.NameAcronym + e.FullName = d.FullName + e.TeamName = d.TeamName + e.TeamColour = d.TeamColour + } + enriched = append(enriched, e) + } + writeJSON(w, enriched) +} + +// --- /api/v1/laps --- + +func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + + if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" { + driverNumber, err := strconv.Atoi(dnStr) + if err != nil || driverNumber == 0 { + http.Error(w, "invalid driver_number", http.StatusBadRequest) + return + } + laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, laps) + return + } + + laps, err := s.client.GetLapsForSession(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, laps) +} + +// --- /api/v1/weather --- + +func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + weather, err := s.client.GetWeather(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, weather) +} + +// --- /api/v1/race-control --- + +func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + rc, err := s.client.GetRaceControl(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, rc) +} + +// --- /api/v1/telemetry --- + +func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number")) + if err != nil || driverNumber == 0 { + http.Error(w, "driver_number required", http.StatusBadRequest) + return + } + carData, err := s.client.GetCarData(sessionKey, driverNumber) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, carData) +} + +// --- /api/v1/overtakes --- + +func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + overtakes, err := s.client.GetOvertakesForSession(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, overtakes) +} + +// --- /api/v1/team-radio --- + +func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number")) + if err != nil || driverNumber == 0 { + http.Error(w, "driver_number required", http.StatusBadRequest) + return + } + radios, err := s.client.GetTeamRadio(sessionKey, driverNumber) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, radios) +} + +// --- /api/v1/championship/drivers --- + +type champDriverWithInfo struct { + models.ChampionshipDriver + NameAcronym string `json:"name_acronym"` + FullName string `json:"full_name"` + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` +} + +func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Request) { + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year == 0 { + year = time.Now().Year() + } + champ, err := s.client.GetDriverChampionshipForYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + if len(champ) == 0 { + writeJSON(w, []any{}) + return + } + + drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey) + driverMap := buildDriverMap(drivers) + + enriched := make([]champDriverWithInfo, 0, len(champ)) + for _, c := range champ { + e := champDriverWithInfo{ChampionshipDriver: c} + if d, ok := driverMap[c.DriverNumber]; ok { + e.NameAcronym = d.NameAcronym + e.FullName = d.FullName + e.TeamName = d.TeamName + e.TeamColour = d.TeamColour + } + enriched = append(enriched, e) + } + writeJSON(w, enriched) +} + +// --- /api/v1/championship/teams --- + +func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request) { + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year == 0 { + year = time.Now().Year() + } + teams, err := s.client.GetTeamChampionshipForYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + writeJSON(w, teams) +} + +// --- /api/v1/track-outline --- +// Accepts circuit_key and year (the frontend has both from meeting+session data). + +type trackPoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type trackOutlineResponse struct { + CircuitKey int `json:"circuit_key"` + Points []trackPoint `json:"points"` +} + +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() + } + + locs, ok := s.client.Cache().GetTrackOutline(circuitKey, year) + if !ok || len(locs) == 0 { + writeJSON(w, map[string]any{"error": "track outline not available", "circuit_key": circuitKey}) + return + } + + // Normalize X/Y to [0, 1]. + minX, maxX := locs[0].X, locs[0].X + minY, maxY := locs[0].Y, locs[0].Y + for _, l := range locs { + if l.X < minX { + minX = l.X + } + if l.X > maxX { + maxX = l.X + } + if l.Y < minY { + minY = l.Y + } + if l.Y > maxY { + maxY = l.Y + } + } + rangeX := maxX - minX + rangeY := maxY - minY + if rangeX == 0 { + rangeX = 1 + } + if rangeY == 0 { + rangeY = 1 + } + + // Deduplicate points. + type key2 struct{ x, y float64 } + seen := make(map[key2]bool, len(locs)) + points := make([]trackPoint, 0, len(locs)) + for _, l := range locs { + p := trackPoint{ + X: (l.X - minX) / rangeX, + Y: (l.Y - minY) / rangeY, + } + k := key2{p.X, p.Y} + if !seen[k] { + seen[k] = true + points = append(points, p) + } + } + + writeJSON(w, trackOutlineResponse{CircuitKey: circuitKey, Points: points}) +} + +// --- /api/v1/strategy --- + +type scPeriod struct { + LapStart int `json:"lap_start"` + LapEnd int `json:"lap_end"` + Type string `json:"type"` // "SC" or "VSC" +} + +type stintInfo struct { + StintNumber int `json:"stint_number"` + Compound string `json:"compound"` + LapStart int `json:"lap_start"` + LapEnd int `json:"lap_end"` + LapCount int `json:"lap_count"` + TyreAgeAtStart int `json:"tyre_age_at_start"` + IsNew bool `json:"is_new"` +} + +type pitStopInfo struct { + LapNumber int `json:"lap_number"` + StopDuration float64 `json:"stop_duration"` + LaneDuration float64 `json:"lane_duration"` +} + +type strategyDriver struct { + DriverNumber int `json:"driver_number"` + NameAcronym string `json:"name_acronym"` + TeamColour string `json:"team_colour"` + FinishPosition int `json:"finish_position"` + DNF bool `json:"dnf"` + DNS bool `json:"dns"` + DSQ bool `json:"dsq"` + Stints []stintInfo `json:"stints"` + PitStops []pitStopInfo `json:"pit_stops"` +} + +type strategyResponse struct { + SessionKey int `json:"session_key"` + TotalLaps int `json:"total_laps"` + SCPeriods []scPeriod `json:"sc_periods"` + Drivers []strategyDriver `json:"drivers"` +} + +func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + + var ( + stints []models.Stint + pits []models.Pit + results []models.SessionResult + drivers []models.Driver + rc []models.RaceControl + stintsErr error + pitsErr error + resErr error + wg sync.WaitGroup + ) + wg.Add(5) + go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }() + go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }() + go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }() + go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }() + go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() + wg.Wait() + + if stintsErr != nil || pitsErr != nil || resErr != nil { + e := stintsErr + if e == nil { + e = pitsErr + } + if e == nil { + e = resErr + } + writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + + // Non-race sessions have no stints. + if len(stints) == 0 { + writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}}) + return + } + + driverMap := buildDriverMap(drivers) + + resultMap := make(map[int]models.SessionResult, len(results)) + totalLaps := 0 + for _, res := range results { + resultMap[res.DriverNumber] = res + if res.NumberOfLaps > totalLaps { + totalLaps = res.NumberOfLaps + } + } + + stintMap := make(map[int][]models.Stint) + for _, st := range stints { + stintMap[st.DriverNumber] = append(stintMap[st.DriverNumber], st) + } + + pitMap := make(map[int][]models.Pit) + for _, p := range pits { + pitMap[p.DriverNumber] = append(pitMap[p.DriverNumber], p) + } + + // Collect all driver numbers. + seenDrivers := make(map[int]bool) + for _, st := range stints { + seenDrivers[st.DriverNumber] = true + } + for _, res := range results { + seenDrivers[res.DriverNumber] = true + } + + stratDrivers := make([]strategyDriver, 0, len(seenDrivers)) + for dn := range seenDrivers { + d := driverMap[dn] + res := resultMap[dn] + + sd := strategyDriver{ + DriverNumber: dn, + NameAcronym: d.NameAcronym, + TeamColour: d.TeamColour, + FinishPosition: res.Position, + DNF: res.DNF, + DNS: res.DNS, + DSQ: res.DSQ, + } + + if res.DNS { + sd.Stints = []stintInfo{} + } else { + for _, st := range stintMap[dn] { + lapEnd := st.LapEnd + if res.DNF && lapEnd > res.NumberOfLaps && res.NumberOfLaps > 0 { + lapEnd = res.NumberOfLaps + } + sd.Stints = append(sd.Stints, stintInfo{ + StintNumber: st.StintNumber, + Compound: string(st.Compound), + LapStart: st.LapStart, + LapEnd: lapEnd, + LapCount: lapEnd - st.LapStart + 1, + TyreAgeAtStart: st.TyreAgeAtStart, + IsNew: st.TyreAgeAtStart == 0, + }) + } + } + + for _, p := range pitMap[dn] { + sd.PitStops = append(sd.PitStops, pitStopInfo{ + LapNumber: p.LapNumber, + StopDuration: p.StopDuration, + LaneDuration: p.LaneDuration, + }) + } + stratDrivers = append(stratDrivers, sd) + } + + // Sort by finish position (DNF/DNS/no-result last). + sort.Slice(stratDrivers, func(i, j int) bool { + pi, pj := stratDrivers[i].FinishPosition, stratDrivers[j].FinishPosition + if pi == 0 { + pi = 999 + } + if pj == 0 { + pj = 999 + } + return pi < pj + }) + + writeJSON(w, strategyResponse{ + SessionKey: sessionKey, + TotalLaps: totalLaps, + SCPeriods: extractSCPeriods(rc), + Drivers: stratDrivers, + }) +} + +// extractSCPeriods parses race control messages to find SC/VSC deployment periods. +func extractSCPeriods(rc []models.RaceControl) []scPeriod { + type pending struct { + lapStart int + scType string + } + var periods []scPeriod + var active *pending + + for _, msg := range rc { + if msg.Category != models.CategorySafetyCar { + continue + } + text := strings.ToUpper(msg.Message) + lap := 0 + if msg.LapNumber != nil { + lap = *msg.LapNumber + } + + if strings.Contains(text, "DEPLOYED") { + scType := "SC" + if strings.Contains(text, "VIRTUAL") { + scType = "VSC" + } + active = &pending{lapStart: lap, scType: scType} + } else if active != nil && (strings.Contains(text, "IN THIS LAP") || + strings.Contains(text, "ENDING") || + strings.Contains(text, "WITHDRAWN") || + strings.Contains(text, "RESUME")) { + periods = append(periods, scPeriod{ + LapStart: active.lapStart, + LapEnd: lap, + Type: active.scType, + }) + active = nil + } + } + // If SC was still active at end of data, close it with an estimate. + if active != nil && active.lapStart > 0 { + periods = append(periods, scPeriod{ + LapStart: active.lapStart, + LapEnd: active.lapStart + 5, + Type: active.scType, + }) + } + return periods +} + +// --- /api/v1/laps/comparison --- + +type lapWithCompound struct { + models.Lap + Compound string `json:"compound"` +} + +type comparisonDriver struct { + DriverNumber int `json:"driver_number"` + NameAcronym string `json:"name_acronym"` + TeamColour string `json:"team_colour"` + Laps []lapWithCompound `json:"laps"` +} + +type lapsComparisonResponse struct { + SessionKey int `json:"session_key"` + SCPeriods []scPeriod `json:"sc_periods"` + PitLaps map[string][]int `json:"pit_laps"` + Drivers []comparisonDriver `json:"drivers"` +} + +func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + + // Parse requested driver numbers (comma-separated). + var requestedDrivers []int + if drvParam := r.URL.Query().Get("drivers"); drvParam != "" { + for _, part := range strings.Split(drvParam, ",") { + if n, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && n > 0 { + requestedDrivers = append(requestedDrivers, n) + } + } + } + + var ( + allLaps []models.Lap + stints []models.Stint + pits []models.Pit + rc []models.RaceControl + wg sync.WaitGroup + ) + wg.Add(4) + go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }() + go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }() + go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }() + go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }() + wg.Wait() + + allDrivers, _ := s.client.GetDriversForSession(sessionKey) + driverMap := buildDriverMap(allDrivers) + + // If no filter, default to first 3 unique driver numbers from lap data. + if len(requestedDrivers) == 0 { + seen := make(map[int]bool) + for _, l := range allLaps { + if !seen[l.DriverNumber] { + seen[l.DriverNumber] = true + requestedDrivers = append(requestedDrivers, l.DriverNumber) + } + if len(requestedDrivers) >= 3 { + break + } + } + } + + // Build per-driver lap map. + lapMap := make(map[int][]models.Lap) + for _, l := range allLaps { + lapMap[l.DriverNumber] = append(lapMap[l.DriverNumber], l) + } + + // Build stint map for compound lookup. + stintMap := make(map[int][]models.Stint) + for _, st := range stints { + stintMap[st.DriverNumber] = append(stintMap[st.DriverNumber], st) + } + + // Build pit laps map. + pitLaps := make(map[string][]int) + for _, p := range pits { + key := strconv.Itoa(p.DriverNumber) + pitLaps[key] = append(pitLaps[key], p.LapNumber) + } + + compDrivers := make([]comparisonDriver, 0, len(requestedDrivers)) + for _, dn := range requestedDrivers { + d := driverMap[dn] + cd := comparisonDriver{ + DriverNumber: dn, + NameAcronym: d.NameAcronym, + TeamColour: d.TeamColour, + Laps: make([]lapWithCompound, 0, len(lapMap[dn])), + } + for _, lap := range lapMap[dn] { + cd.Laps = append(cd.Laps, lapWithCompound{ + Lap: lap, + Compound: compoundForLap(lap.LapNumber, stintMap[dn]), + }) + } + compDrivers = append(compDrivers, cd) + } + + writeJSON(w, lapsComparisonResponse{ + SessionKey: sessionKey, + SCPeriods: extractSCPeriods(rc), + PitLaps: pitLaps, + Drivers: compDrivers, + }) +} + +// compoundForLap returns the tyre compound active on a given lap number. +func compoundForLap(lapNum int, stints []models.Stint) string { + for _, st := range stints { + if lapNum >= st.LapStart && lapNum <= st.LapEnd { + return string(st.Compound) + } + } + return "UNKNOWN" +} + +// buildDriverMap returns a map of driver_number → Driver. +func buildDriverMap(drivers []models.Driver) map[int]models.Driver { + m := make(map[int]models.Driver, len(drivers)) + for _, d := range drivers { + m[d.DriverNumber] = d + } + return m +} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js new file mode 100644 index 0000000..543bedb --- /dev/null +++ b/internal/web/assets/app.js @@ -0,0 +1,527 @@ +/* ============================================================ + box-box — Alpine.js page components + ============================================================ */ + +// ---- Shared helpers ---- + +// Convert ISO 3166-1 alpha-2 country code → emoji flag (mirrors TUI countryFlag). +function flagEmoji(code) { + if (!code || code.length !== 2) return '🏁'; + const offset = 0x1F1E6 - 65; // Regional Indicator A offset + return String.fromCodePoint( + code.toUpperCase().charCodeAt(0) + offset, + code.toUpperCase().charCodeAt(1) + offset + ); +} + +// Current F1 season year derived from today's date. +function currentSeason() { + return new Date().getFullYear(); +} + +// ---- Root state: routing + live check ---- + +function appState() { + return { + page: 'home', + isLive: false, + staleWarning: false, + + init() { + this.parseRoute(); + window.addEventListener('hashchange', () => this.parseRoute()); + this.checkLive(); + setInterval(() => this.checkLive(), 30000); + }, + + parseRoute() { + const hash = window.location.hash || '#/'; + const path = hash.slice(1) || '/'; + if (path === '/' || path === '') { + this.page = 'home'; + } else if (path.startsWith('/race/')) { + this.page = 'race'; + } else if (path === '/live') { + this.page = 'live'; + } else if (path === '/standings') { + this.page = 'standings'; + } else { + this.page = 'home'; + } + }, + + async checkLive() { + try { + const r = await fetch('/api/v1/live/state'); + if (!r.ok) return; + const data = await r.json(); + this.isLive = !!data.is_live; + } catch (_) {} + }, + }; +} + +// ---- Home page ---- + +function homePage() { + return { + loading: true, + meetings: [], + champDrivers: [], + nextRace: null, + countdown: '', + season: currentSeason(), + _countdownTimer: null, + + async init() { + this.loading = true; + await Promise.all([ + this.loadMeetings(), + this.loadChampionship(), + ]); + this.loading = false; + this.startCountdown(); + }, + + async loadMeetings() { + try { + const r = await fetch(`/api/v1/meetings?year=${this.season}`); + const data = await r.json(); + this.meetings = Array.isArray(data) ? data : []; + // Find next race + const now = Date.now(); + this.nextRace = this.meetings.find(m => new Date(m.date_start).getTime() > now) || null; + } catch (_) {} + }, + + async loadChampionship() { + try { + const r = await fetch(`/api/v1/championship/drivers?year=${this.season}`); + const data = await r.json(); + this.champDrivers = Array.isArray(data) + ? [...data].sort((a, b) => a.position_current - b.position_current) + : []; + } catch (_) {} + }, + + startCountdown() { + if (!this.nextRace) return; + const target = new Date(this.nextRace.date_start).getTime(); + const update = () => { + const diff = target - Date.now(); + if (diff <= 0) { this.countdown = 'Race day!'; return; } + const d = Math.floor(diff / 86400000); + const h = Math.floor((diff % 86400000) / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + const s = Math.floor((diff % 60000) / 1000); + this.countdown = d > 0 + ? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}` + : `${pad(h)}:${pad(m)}:${pad(s)}`; + }; + update(); + this._countdownTimer = setInterval(update, 1000); + }, + + barWidth(pts) { + if (!this.champDrivers.length) return 0; + const max = this.champDrivers[0].points_current; + return max > 0 ? Math.round((pts / max) * 100) : 0; + }, + + flagEmoji, + isPast(m) { return new Date(m.date_start).getTime() < Date.now(); }, + fmtDate(d) { return d ? new Date(d).toLocaleDateString('en-GB', {day:'numeric',month:'short'}) : ''; }, + + destroy() { clearInterval(this._countdownTimer); }, + }; +} + +// ---- Race page ---- + +function racePage() { + return { + meetingKey: 0, + meetingName: '', + year: 2025, + sessions: [], + activeSession: null, + tab: 'results', + drivers: [], + + // Results + results: [], + resultsLoading: false, + + // Strategy + strategyData: null, + strategyLoading: false, + strategyNote: '', + + // Laps comparison + lapsData: null, + lapsLoading: false, + + // Track + trackData: null, + trackLoading: false, + + // Telemetry + telemetryDrivers: [], + telemetryData: [], + telemetryLoading: false, + + // Resize observers + _observers: [], + + async init() { + const hash = window.location.hash || ''; + const m = hash.match(/#\/race\/(\d+)/); + if (!m) return; + this.meetingKey = parseInt(m[1]); + + // Load sessions + try { + const r = await fetch(`/api/v1/sessions?meeting_key=${this.meetingKey}`); + this.sessions = await r.json(); + if (this.sessions.length) { + // Prefer Race session, else last session + const race = this.sessions.find(s => s.session_type === 'Race') + || this.sessions[this.sessions.length - 1]; + await this.selectSession(race); + } + } catch (_) {} + }, + + async selectSession(sess) { + this.activeSession = sess; + // Extract year from date_start + this.year = sess.date_start ? parseInt(sess.date_start.slice(0, 4)) : 2025; + // Clear all lazy-loaded data + this.strategyData = null; + this.strategyNote = ''; + this.lapsData = null; + this.trackData = null; + this.telemetryData = []; + this.telemetryDrivers = []; + + await Promise.all([ + this.loadResults(sess.session_key), + this.loadDrivers(sess.session_key), + ]); + }, + + async loadResults(sk) { + this.resultsLoading = true; + try { + const r = await fetch(`/api/v1/results?session_key=${sk}`); + const data = await r.json(); + this.results = Array.isArray(data) + ? [...data].sort((a, b) => (a.position || 99) - (b.position || 99)) + : []; + this.meetingName = ''; + if (this.sessions.length) { + const sess = this.sessions.find(s => s.session_key === sk); + this.meetingName = sess?.meeting_key ? `Round — Meeting ${this.meetingKey}` : ''; + } + } catch (_) { this.results = []; } + this.resultsLoading = false; + }, + + async loadDrivers(sk) { + try { + const r = await fetch(`/api/v1/drivers?session_key=${sk}`); + this.drivers = await r.json(); + // Set default meeting name from sessions list + const sess = this.sessions.find(s => s.session_key === sk); + if (!this.meetingName && this.sessions.length) { + this.meetingName = `Meeting ${this.meetingKey}`; + } + } catch (_) { this.drivers = []; } + }, + + async loadStrategy() { + if (this.strategyData || this.strategyLoading || !this.activeSession) return; + this.strategyLoading = true; + try { + const r = await fetch(`/api/v1/strategy?session_key=${this.activeSession.session_key}`); + const data = await r.json(); + if (data.note) { + this.strategyNote = data.note; + } else { + this.strategyData = data; + } + } catch (_) { this.strategyNote = 'Failed to load strategy data.'; } + this.strategyLoading = false; + }, + + renderStrategy() { + if (!this.strategyData) return; + this.$nextTick(() => Charts.renderStrategy('strategy-chart', this.strategyData)); + }, + + async loadLaps() { + if (this.lapsData || this.lapsLoading || !this.activeSession) return; + this.lapsLoading = true; + try { + const r = await fetch(`/api/v1/laps/comparison?session_key=${this.activeSession.session_key}`); + this.lapsData = await r.json(); + } catch (_) {} + this.lapsLoading = false; + }, + + renderLapTimes() { + if (!this.lapsData) return; + this.$nextTick(() => Charts.renderLapTimes('laps-chart', this.lapsData)); + }, + + async loadTrack() { + if (this.trackData || this.trackLoading || !this.activeSession) return; + this.trackLoading = true; + try { + const ck = this.activeSession.circuit_key; + const yr = this.year; + const r = await fetch(`/api/v1/track-outline?circuit_key=${ck}&year=${yr}`); + const data = await r.json(); + if (data.points && data.points.length > 0) { + this.trackData = data; + } + } catch (_) {} + this.trackLoading = false; + }, + + renderTrack() { + if (!this.trackData) return; + this.$nextTick(() => Track.render('track-chart', this.trackData.points, [], {})); + }, + + async toggleTelemetryDriver(driverNumber) { + if (this.telemetryDrivers.includes(driverNumber)) { + this.telemetryDrivers = this.telemetryDrivers.filter(n => n !== driverNumber); + } else { + if (this.telemetryDrivers.length >= 3) return; // max 3 + this.telemetryDrivers.push(driverNumber); + } + await this.loadTelemetry(); + }, + + async loadTelemetry() { + if (!this.activeSession || this.telemetryDrivers.length === 0) { + this.telemetryData = []; + return; + } + this.telemetryLoading = true; + const sk = this.activeSession.session_key; + const results = await Promise.all( + this.telemetryDrivers.map(dn => + fetch(`/api/v1/telemetry?session_key=${sk}&driver_number=${dn}`) + .then(r => r.json()) + .then(data => ({ driverNumber: dn, data: Array.isArray(data) ? data : [] })) + .catch(() => ({ driverNumber: dn, data: [] })) + ) + ); + this.telemetryData = results.map(r => { + const driver = this.drivers.find(d => d.driver_number === r.driverNumber); + return { + driverNumber: r.driverNumber, + nameAcronym: driver?.name_acronym || String(r.driverNumber), + teamColour: driver?.team_colour || '888888', + data: r.data, + }; + }); + this.telemetryLoading = false; + }, + + renderTelemetry() { + if (!this.telemetryData.length) return; + this.$nextTick(() => Charts.renderTelemetry('telemetry-chart', this.telemetryData)); + }, + + fmtDuration(v) { + if (v === null || v === undefined) return '-'; + if (Array.isArray(v)) return v.map(t => fmtSecs(t)).join(' / '); + return fmtSecs(v); + }, + }; +} + +// ---- Live page ---- + +function livePage() { + return { + isLive: false, + drivers: {}, + driverInfo: {}, + tyres: {}, + stints: {}, + rcMessages: [], + trackStatus: '', + currentLap: 0, + totalLaps: 0, + clock: '', + clockRefTime: null, + clockExtrapolating: false, + session: {}, + _es: null, + _clockTimer: null, + clockDisplay: '', + + init() { + this.connectSSE(); + this._clockTimer = setInterval(() => this.updateClock(), 1000); + }, + + cleanup() { + if (this._es) this._es.close(); + clearInterval(this._clockTimer); + }, + + connectSSE() { + const es = new EventSource('/api/v1/live/stream'); + this._es = es; + + es.addEventListener('snapshot', e => { + try { + const msg = JSON.parse(e.data); + this.isLive = !!msg.is_live; + if (msg.data) this.applySnapshot(msg.data); + } catch (_) {} + }); + + es.addEventListener('heartbeat', () => {}); + + es.onerror = () => { + this.isLive = false; + setTimeout(() => this.connectSSE(), 5000); + es.close(); + }; + }, + + applySnapshot(d) { + if (d.Drivers) this.drivers = d.Drivers; + if (d.DriverInfo) this.driverInfo = d.DriverInfo; + if (d.Tyres) this.tyres = d.Tyres; + if (d.Stints) this.stints = d.Stints; + if (d.RCMessages) this.rcMessages = d.RCMessages; + if (d.TrackStatus) this.trackStatus = d.TrackStatus; + if (d.CurrentLap) this.currentLap = d.CurrentLap; + if (d.TotalLaps) this.totalLaps = d.TotalLaps; + if (d.Clock) this.clock = d.Clock; + if (d.ClockRefTime) this.clockRefTime = new Date(d.ClockRefTime); + if (d.ClockExtrapolating !== undefined) this.clockExtrapolating = d.ClockExtrapolating; + if (d.Session) this.session = d.Session; + }, + + get sortedDrivers() { + return Object.values(this.drivers) + .filter(d => d.Position > 0) + .sort((a, b) => a.Position - b.Position); + }, + + driverTla(num) { + return this.driverInfo[num]?.Tla || num; + }, + + driverTeamColor(num) { + return this.driverInfo[num]?.TeamColour || '666666'; + }, + + tyreLabel(num) { + const t = this.tyres[num]; + if (!t) return '?'; + return `${t.Compound?.charAt(0) || '?'} +${t.Age || 0}`; + }, + + tyreClass(num) { + const t = this.tyres[num]; + if (!t) return 'tyre-unknown'; + return 'tyre-' + (t.Compound || 'unknown').toLowerCase(); + }, + + posDelta(d) { + if (!d.PrevPosition || d.PrevPosition === d.Position) return ''; + return d.PrevPosition > d.Position ? '▲' : '▼'; + }, + + trackStatusText() { + const map = {'1':'GREEN','2':'YELLOW','4':'SC','5':'RED','6':'VSC'}; + return map[this.trackStatus] || this.trackStatus; + }, + + trackStatusClass() { + const map = {'1':'track-green','2':'track-yellow','4':'track-sc','5':'track-red','6':'track-vsc'}; + return map[this.trackStatus] || ''; + }, + + updateClock() { + if (!this.clock || !this.clockExtrapolating || !this.clockRefTime) { + this.clockDisplay = this.clock || ''; + return; + } + // Extrapolate: remaining = clock - elapsed since clockRefTime + const [h, m, s] = this.clock.split(':').map(Number); + const totalSecs = h * 3600 + m * 60 + s; + const elapsed = (Date.now() - this.clockRefTime.getTime()) / 1000; + const remaining = Math.max(0, totalSecs - elapsed); + const rh = Math.floor(remaining / 3600); + const rm = Math.floor((remaining % 3600) / 60); + const rs = Math.floor(remaining % 60); + this.clockDisplay = `${pad(rh)}:${pad(rm)}:${pad(rs)}`; + }, + }; +} + +// ---- Standings page ---- + +function standingsPage() { + return { + year: currentSeason(), + view: 'drivers', + loading: false, + driverStandings: [], + teamStandings: [], + + async init() { + await this.load(); + }, + + async setYear(y) { + this.year = y; + await this.load(); + }, + + async load() { + this.loading = true; + await Promise.all([this.loadDrivers(), this.loadTeams()]); + this.loading = false; + }, + + async loadDrivers() { + try { + const r = await fetch(`/api/v1/championship/drivers?year=${this.year}`); + const data = await r.json(); + this.driverStandings = Array.isArray(data) + ? [...data].sort((a, b) => a.position_current - b.position_current) + : []; + } catch (_) { this.driverStandings = []; } + }, + + async loadTeams() { + try { + const r = await fetch(`/api/v1/championship/teams?year=${this.year}`); + const data = await r.json(); + this.teamStandings = Array.isArray(data) + ? [...data].sort((a, b) => a.position_current - b.position_current) + : []; + } catch (_) { this.teamStandings = []; } + }, + }; +} + +function pad(n) { return String(n).padStart(2, '0'); } + +function fmtSecs(s) { + if (!s) return '-'; + const m = Math.floor(s / 60); + const rem = (s - m * 60).toFixed(3); + return `${m}:${rem.padStart(6, '0')}`; +} diff --git a/internal/web/assets/charts.js b/internal/web/assets/charts.js new file mode 100644 index 0000000..18c4e62 --- /dev/null +++ b/internal/web/assets/charts.js @@ -0,0 +1,465 @@ +/* ============================================================ + box-box — D3 visualization library + All functions: Charts.renderX(containerId, data) + Charts re-render on ResizeObserver via stored callbacks. + ============================================================ */ + +const Charts = (() => { + // Compound → fill colour + const COMPOUND_COLOR = { + SOFT: '#FF3333', + MEDIUM: '#FFD700', + HARD: '#CCCCCC', + INTERMEDIATE: '#39B54A', + WET: '#0080FF', + UNKNOWN: '#666688', + }; + + const MARGIN = { top: 32, right: 24, bottom: 32, left: 60 }; + const LABEL_W = 48; // width reserved for driver labels + + // Tooltip element (shared) + let tooltip = null; + function getTooltip() { + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.className = 'chart-tooltip'; + tooltip.style.display = 'none'; + document.body.appendChild(tooltip); + } + return tooltip; + } + function showTooltip(html, e) { + const t = getTooltip(); + t.innerHTML = html; + t.style.display = 'block'; + t.style.left = (e.clientX + 12) + 'px'; + t.style.top = (e.clientY - 8) + 'px'; + } + function hideTooltip() { + getTooltip().style.display = 'none'; + } + + // ResizeObserver registry: containerId → callback + const observers = {}; + function observeResize(id, fn) { + const el = document.getElementById(id); + if (!el) return; + if (observers[id]) observers[id].disconnect(); + const obs = new ResizeObserver(() => fn()); + obs.observe(el); + observers[id] = obs; + } + + // ---- Strategy Gantt chart ---- + + function renderStrategy(containerId, data) { + const el = document.getElementById(containerId); + if (!el || !data || !data.drivers) return; + el.innerHTML = ''; + + const drivers = (data.drivers || []).filter(d => d.stints && d.stints.length > 0 || d.dns); + if (drivers.length === 0) { el.textContent = 'No strategy data.'; return; } + + const totalLaps = data.total_laps || 60; + const rowH = 28; + const padding = 4; + const height = MARGIN.top + drivers.length * (rowH + padding) + MARGIN.bottom; + const width = Math.max(el.clientWidth || 800, 500); + + const svg = d3.select(el).append('svg') + .attr('width', width).attr('height', height); + + const xScale = d3.scaleLinear() + .domain([1, totalLaps]) + .range([MARGIN.left + LABEL_W, width - MARGIN.right]); + + const yScale = d3.scaleBand() + .domain(drivers.map(d => d.driver_number)) + .range([MARGIN.top, height - MARGIN.bottom]) + .padding(0.15); + + // SC/VSC zones + (data.sc_periods || []).forEach(p => { + svg.append('rect') + .attr('x', xScale(p.lap_start)) + .attr('y', MARGIN.top) + .attr('width', xScale(p.lap_end) - xScale(p.lap_start)) + .attr('height', height - MARGIN.top - MARGIN.bottom) + .attr('fill', p.type === 'VSC' ? 'rgba(80,130,255,0.12)' : 'rgba(255,200,0,0.12)') + .attr('pointer-events', 'none'); + }); + + // Grid lines + for (let lap = 5; lap <= totalLaps; lap += 5) { + svg.append('line') + .attr('x1', xScale(lap)).attr('x2', xScale(lap)) + .attr('y1', MARGIN.top).attr('y2', height - MARGIN.bottom) + .attr('stroke', '#2D2D44').attr('stroke-width', 1); + } + + // X-axis + const xAxis = d3.axisBottom(xScale).ticks(Math.min(20, Math.floor(totalLaps / 5))).tickFormat(d3.format('d')); + svg.append('g').attr('transform', `translate(0,${height - MARGIN.bottom})`).call(xAxis); + + // Driver rows + drivers.forEach(driver => { + const y = yScale(driver.driver_number); + const rh = yScale.bandwidth(); + + // Label + svg.append('text') + .attr('x', MARGIN.left + LABEL_W - 6) + .attr('y', y + rh / 2 + 4) + .attr('text-anchor', 'end') + .attr('fill', `#${driver.team_colour || '888888'}`) + .attr('font-size', 11) + .attr('font-weight', '700') + .text(driver.name_acronym || driver.driver_number); + + // DNS: single gray bar + if (driver.dns) { + svg.append('rect') + .attr('x', xScale(1)).attr('y', y + 2) + .attr('width', xScale(totalLaps) - xScale(1)) + .attr('height', rh - 4) + .attr('fill', '#333355').attr('rx', 3); + svg.append('text') + .attr('x', (xScale(1) + xScale(totalLaps)) / 2).attr('y', y + rh / 2 + 4) + .attr('text-anchor', 'middle').attr('fill', '#8888aa').attr('font-size', 10) + .text('DNS'); + return; + } + + // Stints + (driver.stints || []).forEach(stint => { + const x1 = xScale(stint.lap_start); + const x2 = xScale(stint.lap_end); + const color = COMPOUND_COLOR[stint.compound] || COMPOUND_COLOR.UNKNOWN; + + const bar = svg.append('rect') + .attr('x', x1).attr('y', y + 2) + .attr('width', Math.max(2, x2 - x1)) + .attr('height', rh - 4) + .attr('fill', color).attr('rx', 3) + .attr('cursor', 'pointer'); + + // DNF: diagonal stripe overlay + if (driver.dnf && stint === driver.stints[driver.stints.length - 1]) { + bar.attr('opacity', 0.7); + // Add simple DNF label + svg.append('text') + .attr('x', x2 - 14).attr('y', y + rh / 2 + 4) + .attr('text-anchor', 'end').attr('fill', '#cc4422') + .attr('font-size', 9).attr('font-weight', '700').text('DNF'); + } + + bar.on('mousemove', e => { + const isNew = stint.is_new ? ' (new)' : ` (+${stint.tyre_age_at_start})`; + showTooltip( + `${driver.name_acronym} — Stint ${stint.stint_number}
` + + `${stint.compound}${isNew}
` + + `Laps ${stint.lap_start}–${stint.lap_end} (${stint.lap_count} laps)`, e + ); + }).on('mouseleave', hideTooltip); + }); + + // Pit stops + (driver.pit_stops || []).forEach(pit => { + svg.append('circle') + .attr('cx', xScale(pit.lap_number)) + .attr('cy', y + rh / 2) + .attr('r', 4) + .attr('fill', '#ffffff').attr('stroke', '#000').attr('stroke-width', 1) + .attr('cursor', 'pointer') + .on('mousemove', e => { + showTooltip( + `Pit lap ${pit.lap_number}
` + + `Stop: ${pit.stop_duration?.toFixed(2)}s   Lane: ${pit.lane_duration?.toFixed(2)}s`, e + ); + }) + .on('mouseleave', hideTooltip); + }); + }); + + observeResize(containerId, () => renderStrategy(containerId, data)); + } + + // ---- Lap time progression chart ---- + + function renderLapTimes(containerId, data) { + const el = document.getElementById(containerId); + if (!el || !data || !data.drivers) return; + el.innerHTML = ''; + + const drivers = data.drivers || []; + if (!drivers.length) { el.textContent = 'No lap time data.'; return; } + + const ML = MARGIN.left + 10; + const MT = MARGIN.top; + const MB = MARGIN.bottom + 20; + const MR = MARGIN.right; + const width = Math.max(el.clientWidth || 800, 400); + const height = 300; + + const svg = d3.select(el).append('svg') + .attr('width', width).attr('height', height); + + // Collect all valid laps for domain calculation + const allLaps = drivers.flatMap(d => + (d.laps || []).filter(l => l.lap_duration && !l.is_pit_out_lap) + .map(l => ({ lapNum: l.lap_number, t: l.lap_duration })) + ); + if (!allLaps.length) { el.textContent = 'No lap data.'; return; } + + const maxLap = d3.max(allLaps, l => l.lapNum) || 1; + const yMin = d3.min(allLaps, l => l.t); + const yMax = d3.max(allLaps, l => l.t); + + const xScale = d3.scaleLinear().domain([1, maxLap]).range([ML, width - MR]); + const yScale = d3.scaleLinear() + .domain([yMin * 0.995, yMax * 1.005]) + .range([height - MB, MT]); + + // SC zones + (data.sc_periods || []).forEach(p => { + svg.append('rect') + .attr('x', xScale(p.lap_start)).attr('y', MT) + .attr('width', xScale(p.lap_end) - xScale(p.lap_start)) + .attr('height', height - MT - MB) + .attr('fill', 'rgba(255,200,0,0.10)').attr('pointer-events', 'none'); + }); + + // Axes + svg.append('g').attr('transform', `translate(0,${height - MB})`).call( + d3.axisBottom(xScale).ticks(Math.min(20, maxLap)).tickFormat(d3.format('d')) + ); + svg.append('g').attr('transform', `translate(${ML},0)`).call( + d3.axisLeft(yScale).ticks(6).tickFormat(t => { + const m = Math.floor(t / 60); + const s = (t - m * 60).toFixed(1); + return `${m}:${s.padStart(4, '0')}`; + }) + ); + + // Per-driver lines + const driverVisible = {}; + drivers.forEach(d => { driverVisible[d.driver_number] = true; }); + + const lineGen = d3.line() + .defined(l => l.lap_duration != null && !l.is_pit_out_lap) + .x(l => xScale(l.lap_number)) + .y(l => yScale(l.lap_duration)); + + const paths = {}; + drivers.forEach(driver => { + const color = '#' + (driver.team_colour || '888888'); + const path = svg.append('path') + .datum(driver.laps || []) + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', 2) + .attr('d', lineGen); + paths[driver.driver_number] = path; + + // Pit lap circles + const pitNums = new Set((data.pit_laps || {})[String(driver.driver_number)] || []); + (driver.laps || []).filter(l => pitNums.has(l.lap_number) && l.lap_duration).forEach(l => { + svg.append('circle') + .attr('cx', xScale(l.lap_number)).attr('cy', yScale(l.lap_duration)) + .attr('r', 4).attr('fill', 'none').attr('stroke', color).attr('stroke-width', 2); + }); + }); + + // Crosshair tooltip + const crosshair = svg.append('line') + .attr('y1', MT).attr('y2', height - MB) + .attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none'); + + svg.append('rect') + .attr('x', ML).attr('y', MT) + .attr('width', width - ML - MR).attr('height', height - MT - MB) + .attr('fill', 'none').attr('pointer-events', 'all') + .on('mousemove', e => { + const [mx] = d3.pointer(e); + const lapNum = Math.round(xScale.invert(mx)); + crosshair.attr('x1', xScale(lapNum)).attr('x2', xScale(lapNum)).attr('display', null); + const tips = drivers.map(d => { + const lap = (d.laps || []).find(l => l.lap_number === lapNum); + if (!lap || !lap.lap_duration) return ''; + const m = Math.floor(lap.lap_duration / 60); + const s = (lap.lap_duration - m * 60).toFixed(3); + return `${d.name_acronym}: ${m}:${s.padStart(6,'0')}`; + }).filter(Boolean).join('
'); + if (tips) showTooltip(`Lap ${lapNum}
${tips}`, e); + }) + .on('mouseleave', () => { crosshair.attr('display', 'none'); hideTooltip(); }); + + // Legend with toggle + const legendG = svg.append('g').attr('transform', `translate(${ML},${MT - 20})`); + let lx = 0; + drivers.forEach(driver => { + const color = '#' + (driver.team_colour || '888888'); + const g = legendG.append('g').attr('transform', `translate(${lx},0)`).attr('cursor', 'pointer'); + g.append('rect').attr('width', 12).attr('height', 12).attr('fill', color).attr('rx', 2); + g.append('text').attr('x', 15).attr('y', 10).attr('fill', color).attr('font-size', 11) + .text(driver.name_acronym || driver.driver_number); + lx += 60; + + g.on('click', () => { + const visible = !driverVisible[driver.driver_number]; + driverVisible[driver.driver_number] = visible; + paths[driver.driver_number].attr('opacity', visible ? 1 : 0.15); + g.attr('opacity', visible ? 1 : 0.4); + }); + }); + + observeResize(containerId, () => renderLapTimes(containerId, data)); + } + + // ---- Telemetry 4-panel chart ---- + + function renderTelemetry(containerId, driversData) { + const el = document.getElementById(containerId); + if (!el || !driversData || !driversData.length) return; + el.innerHTML = ''; + + const panelConfigs = [ + { key: 'speed', label: 'Speed (km/h)', height: 120, yKey: 'Speed' }, + { key: 'throttle', label: 'Throttle (%)', height: 80, yKey: 'Throttle' }, + { key: 'brake', label: 'Brake (%)', height: 80, yKey: 'Brake' }, + { key: 'gear', label: 'Gear', height: 60, yKey: 'NGear' }, + ]; + + const GAP = 10; + const totalHeight = panelConfigs.reduce((s, p) => s + p.height, 0) + GAP * (panelConfigs.length - 1) + MARGIN.top + MARGIN.bottom; + const width = Math.max(el.clientWidth || 800, 400); + const ML = MARGIN.left + 10; + const MR = MARGIN.right; + + const svg = d3.select(el).append('svg').attr('width', width).attr('height', totalHeight); + + // Compute distances from speed + time for all drivers, pick max dist + const driverSeries = driversData.map(d => { + const pts = computeDistanceSeries(d.data || []); + return { ...d, pts }; + }); + const maxDist = d3.max(driverSeries, d => d.pts.length > 0 ? d.pts[d.pts.length - 1].dist : 0) || 1; + + const xScale = d3.scaleLinear().domain([0, maxDist]).range([ML, width - MR]); + + let yOffset = MARGIN.top; + panelConfigs.forEach((panel, pi) => { + const ph = panel.height; + const g = svg.append('g').attr('transform', `translate(0,${yOffset})`); + + // Y domain + const allVals = driverSeries.flatMap(d => d.pts.map(p => p[panel.yKey] ?? 0)); + let yMin = d3.min(allVals) ?? 0; + let yMax = d3.max(allVals) ?? 1; + if (panel.key === 'throttle' || panel.key === 'brake') { yMin = 0; yMax = 100; } + if (panel.key === 'gear') { yMin = 0; yMax = 8; } + const yScale = d3.scaleLinear().domain([yMin, yMax]).range([ph, 0]); + + // Panel background + g.append('rect').attr('x', ML).attr('y', 0) + .attr('width', width - ML - MR).attr('height', ph) + .attr('fill', '#1B1B2F').attr('rx', 4); + + // Label + g.append('text').attr('x', ML - 8).attr('y', ph / 2 + 4) + .attr('text-anchor', 'end').attr('fill', '#8888aa').attr('font-size', 10) + .text(panel.label); + + // Y axis (right side of first panel) + if (pi === 0) { + g.append('g').attr('transform', `translate(${width - MR},0)`) + .call(d3.axisRight(yScale).ticks(4)); + } + + // Lines + const lineGen = d3.line() + .x(p => xScale(p.dist)) + .y(p => yScale(p[panel.yKey] ?? 0)) + .defined(p => p[panel.yKey] != null); + + driverSeries.forEach(driver => { + if (!driver.pts.length) return; + g.append('path') + .datum(driver.pts) + .attr('fill', 'none') + .attr('stroke', '#' + (driver.teamColour || '888888')) + .attr('stroke-width', 1.5) + .attr('d', lineGen); + }); + + // X axis only on last panel + if (pi === panelConfigs.length - 1) { + g.append('g').attr('transform', `translate(0,${ph})`).call( + d3.axisBottom(xScale).ticks(8).tickFormat(d => `${Math.round(d / 1000)}k`) + ); + } + + yOffset += ph + GAP; + }); + + // Shared crosshair + const crosslines = panelConfigs.map((panel, pi) => { + const g = svg.select(`g:nth-of-type(${pi + 1})`); + return svg.append('line') + .attr('y1', MARGIN.top + panelConfigs.slice(0, pi).reduce((s, p) => s + p.height + GAP, 0)) + .attr('y2', MARGIN.top + panelConfigs.slice(0, pi + 1).reduce((s, p) => s + p.height + GAP, 0) - GAP) + .attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none'); + }); + + svg.append('rect') + .attr('x', ML).attr('y', MARGIN.top) + .attr('width', width - ML - MR).attr('height', totalHeight - MARGIN.top - MARGIN.bottom) + .attr('fill', 'none').attr('pointer-events', 'all') + .on('mousemove', e => { + const [mx] = d3.pointer(e); + crosslines.forEach(l => l.attr('x1', mx).attr('x2', mx).attr('display', null)); + const dist = xScale.invert(mx); + const tips = driverSeries.map(d => { + const pt = d.pts.find(p => p.dist >= dist); + if (!pt) return ''; + return `${d.nameAcronym}: ` + + `${pt.Speed}km/h T${pt.Throttle}% B${pt.Brake}% G${pt.NGear}`; + }).filter(Boolean).join('
'); + if (tips) showTooltip(tips, e); + }) + .on('mouseleave', () => { + crosslines.forEach(l => l.attr('display', 'none')); + hideTooltip(); + }); + + observeResize(containerId, () => renderTelemetry(containerId, driversData)); + } + + // Compute cumulative distance from car data samples. + function computeDistanceSeries(samples) { + if (!samples.length) return []; + const pts = []; + let dist = 0; + for (let i = 0; i < samples.length; i++) { + const s = samples[i]; + if (i > 0) { + const prev = samples[i - 1]; + const dt = (new Date(s.date) - new Date(prev.date)) / 1000; // seconds + if (dt > 0 && dt < 5) { // ignore large gaps + dist += (s.speed / 3.6) * dt; // speed in km/h → m/s * dt → metres + } + } + pts.push({ + dist, + Speed: s.speed ?? 0, + Throttle: s.throttle ?? 0, + Brake: s.brake ?? 0, + NGear: s.n_gear ?? 0, + }); + } + return pts; + } + + return { renderStrategy, renderLapTimes, renderTelemetry }; +})(); diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html new file mode 100644 index 0000000..e5c6866 --- /dev/null +++ b/internal/web/assets/index.html @@ -0,0 +1,322 @@ + + + + + + box-box + + + + + + + + + +
+ Showing cached data — live session in progress (free tier) +
+ Why? +

During live F1 sessions the free-tier OpenF1 API restricts access. + box-box automatically serves the last cached data.

+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css new file mode 100644 index 0000000..a44c84c --- /dev/null +++ b/internal/web/assets/style.css @@ -0,0 +1,483 @@ +/* ============================================================ + box-box web companion — dark F1 theme + Mirrors styles.go palette as CSS custom properties. + ============================================================ */ + +:root { + --f1-red: #E10600; + --surface-0: #0d0d1a; /* page background */ + --surface-1: #1B1B2F; /* card background */ + --surface-2: #222236; /* elevated card */ + --surface-3: #2D2D44; /* borders, dividers */ + --text-1: #ffffff; + --text-2: #8888aa; + --text-3: #555577; + + --soft: #FF3333; + --medium:#FFD700; + --hard: #CCCCCC; + --inter: #39B54A; + --wet: #0080FF; + + font-family: system-ui, -apple-system, sans-serif; + font-size: 14px; + color: var(--text-1); + background: var(--surface-0); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +a { color: inherit; text-decoration: none; } + +/* ---- Nav ---- */ +.nav { + display: flex; + align-items: center; + gap: 2rem; + padding: 0.75rem 1.5rem; + background: var(--surface-1); + border-bottom: 1px solid var(--surface-3); + position: sticky; + top: 0; + z-index: 100; +} +.nav-brand { + font-size: 1.1rem; + font-weight: 700; + color: var(--f1-red); + letter-spacing: 0.04em; +} +.nav-links { + display: flex; + gap: 1.5rem; +} +.nav-links a { + color: var(--text-2); + font-size: 0.85rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 0.4rem; + transition: color 0.15s; +} +.nav-links a:hover, +.nav-links a.active { color: var(--text-1); } +.nav-links a.active { border-bottom: 2px solid var(--f1-red); padding-bottom: 2px; } + +/* ---- Live dot ---- */ +.live-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + background: #E10600; + animation: pulse 1.2s ease-in-out infinite; +} +.live-dot.large { width: 12px; height: 12px; } +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ---- Stale banner ---- */ +.stale-banner { + background: #2a1800; + border-bottom: 1px solid #aa6600; + color: #ffcc66; + padding: 0.5rem 1.5rem; + font-size: 0.82rem; + display: flex; + align-items: center; + gap: 1rem; +} +.stale-banner details summary { cursor: pointer; color: #aa8844; } +.stale-banner details p { padding: 0.4rem 0; color: var(--text-2); } + +/* ---- Page layout ---- */ +.page-header { + padding: 1.25rem 1.5rem 0.75rem; + display: flex; + align-items: baseline; + gap: 1rem; + flex-wrap: wrap; +} +.page-title { + font-size: 1.25rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.5rem; +} +.back-btn { + color: var(--text-2); + font-size: 0.85rem; + white-space: nowrap; + transition: color 0.15s; +} +.back-btn:hover { color: var(--text-1); } +.session-info { color: var(--text-2); font-size: 0.85rem; } + +/* ---- Cards ---- */ +.card { + background: var(--surface-1); + border: 1px solid var(--surface-3); + border-radius: 8px; + padding: 1rem 1.25rem; +} +.card-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: 0.6rem; +} + +/* ---- Home hero ---- */ +.home-hero { + display: grid; + grid-template-columns: 1fr 320px; + gap: 1rem; + padding: 1rem 1.5rem; +} +@media (max-width: 900px) { + .home-hero { grid-template-columns: 1fr; } +} + +/* Next race card */ +.next-race { text-align: center; } +.next-race-flag { font-size: 3rem; margin: 0.25rem 0; } +.next-race-name { font-size: 1.4rem; font-weight: 700; margin: 0.25rem 0; } +.next-race-circuit { color: var(--text-2); font-size: 0.85rem; margin-bottom: 0.5rem; } +.next-race-countdown { + font-size: 1.6rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: var(--f1-red); + letter-spacing: 0.04em; +} + +/* Championship sidebar */ +.champ-sidebar { display: flex; flex-direction: column; gap: 0.45rem; } +.champ-row { + display: grid; + grid-template-columns: 20px 12px 36px 1fr 40px; + align-items: center; + gap: 0.4rem; +} +.champ-pos { color: var(--text-2); font-size: 0.8rem; text-align: right; } +.champ-tla { font-weight: 600; font-size: 0.85rem; } +.champ-bar-wrap { + background: var(--surface-3); + border-radius: 2px; + height: 6px; + overflow: hidden; +} +.champ-bar { + height: 100%; + background: var(--team-color, var(--f1-red)); + border-radius: 2px; + transition: width 0.4s; +} +.champ-pts { font-size: 0.8rem; color: var(--text-2); text-align: right; } + +/* Team color swatch */ +.team-swatch { + display: inline-block; + width: 3px; + height: 14px; + background: var(--team-color, #666); + border-radius: 2px; + vertical-align: middle; + margin-right: 6px; +} + +/* ---- Season meetings grid ---- */ +.section-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); + padding: 0 1.5rem; + margin-bottom: 0.5rem; +} +.meetings-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem; + padding: 0 1.5rem 2rem; +} +.meeting-card { + display: block; + background: var(--surface-1); + border: 1px solid var(--surface-3); + border-radius: 8px; + padding: 0.75rem 1rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.meeting-card:hover { background: var(--surface-2); border-color: var(--text-3); } +.meeting-card.past { opacity: 0.55; } +.meeting-flag { font-size: 1.5rem; margin-bottom: 0.3rem; } +.meeting-name { font-weight: 600; font-size: 0.9rem; line-height: 1.3; } +.meeting-circuit { color: var(--text-2); font-size: 0.78rem; margin-top: 0.15rem; } +.meeting-date { color: var(--text-3); font-size: 0.75rem; margin-top: 0.3rem; } + +/* ---- Session pills ---- */ +.session-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.75rem 1.5rem; +} +.pill { + background: var(--surface-2); + border: 1px solid var(--surface-3); + border-radius: 20px; + color: var(--text-2); + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; + padding: 0.3rem 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.pill:hover { background: var(--surface-3); color: var(--text-1); } +.pill.active { + background: var(--team-color, var(--f1-red)); + border-color: transparent; + color: #fff; +} + +/* ---- Tabs ---- */ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--surface-3); + padding: 0 1.5rem; + margin-bottom: 1rem; +} +.tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-2); + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.05em; + padding: 0.6rem 1rem; + text-transform: uppercase; + transition: color 0.15s, border-color 0.15s; +} +.tab:hover { color: var(--text-1); } +.tab.active { color: var(--text-1); border-bottom-color: var(--f1-red); } + +/* ---- Data table ---- */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin: 0 1.5rem 1.5rem; + width: calc(100% - 3rem); +} +.data-table th { + background: var(--surface-1); + border-bottom: 1px solid var(--surface-3); + color: var(--text-3); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + padding: 0.5rem 0.75rem; + text-align: left; + text-transform: uppercase; +} +.data-table td { + border-bottom: 1px solid var(--surface-3); + padding: 0.5rem 0.75rem; + vertical-align: middle; +} +.data-table tr:hover td { background: var(--surface-1); } +.pos-cell { font-weight: 700; display: flex; align-items: center; gap: 0.4rem; } + +/* ---- Badges ---- */ +.badge { + border-radius: 3px; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.06em; + padding: 1px 5px; + text-transform: uppercase; +} +.badge.dnf { background: #442200; color: #ff8844; } +.badge.dns { background: #333355; color: #8888cc; } +.badge.dsq { background: #220000; color: #ff4444; } +.badge.pit { background: #003366; color: #66aaff; } +.badge.out { background: #330000; color: #ff6666; } + +/* ---- Tyre badges ---- */ +.tyre-badge { + border-radius: 3px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 1px 6px; +} +.tyre-soft { background: var(--soft); color: #fff; } +.tyre-medium { background: var(--medium); color: #111; } +.tyre-hard { background: var(--hard); color: #111; } +.tyre-inter { background: var(--inter); color: #fff; } +.tyre-wet { background: var(--wet); color: #fff; } +.tyre-unknown { background: var(--surface-3); color: var(--text-2); } + +/* ---- Timing tower ---- */ +.live-meta { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.5rem 1.5rem 0.75rem; + font-size: 0.85rem; +} +.timing-tower { + width: calc(100% - 3rem); + margin: 0 1.5rem 1.5rem; + border-collapse: collapse; + font-size: 0.85rem; + font-variant-numeric: tabular-nums; +} +.timing-tower th { + background: var(--surface-1); + border-bottom: 1px solid var(--surface-3); + color: var(--text-3); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + padding: 0.5rem 0.75rem; + text-align: left; + text-transform: uppercase; +} +.timing-tower td { + border-bottom: 1px solid var(--surface-3); + padding: 0.45rem 0.75rem; + vertical-align: middle; +} +.timing-tower .in-pit td { background: rgba(0,51,102,0.25); } +.timing-tower .retired td { background: rgba(60,0,0,0.3); opacity: 0.7; } +.timing-tower .pit-out td { background: rgba(0,80,0,0.2); } +.timing-tower .pb { color: #aaffaa; } +.timing-tower .ob { color: #cc44ff; } +.pos-delta { color: var(--text-3); font-size: 0.75rem; } +.clock { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; } + +/* Track status */ +.track-status { + border-radius: 4px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + padding: 2px 8px; + text-transform: uppercase; +} +.track-green { background: #1a3a1a; color: #44cc44; } +.track-yellow { background: #3a2a00; color: #ffcc00; } +.track-red { background: #3a0000; color: #ff4444; } +.track-sc { background: #3a2a00; color: #ffaa00; } +.track-vsc { background: #1a2a3a; color: #6699ff; } + +/* ---- Race control panel ---- */ +.rc-panel { + background: var(--surface-1); + border: 1px solid var(--surface-3); + border-radius: 8px; + margin: 0 1.5rem 1.5rem; + padding: 0.75rem 1rem; +} +.rc-message { + border-bottom: 1px solid var(--surface-3); + display: flex; + gap: 0.75rem; + font-size: 0.82rem; + padding: 0.4rem 0; +} +.rc-message:last-child { border-bottom: none; } +.rc-time { color: var(--text-3); flex-shrink: 0; font-size: 0.75rem; } +.rc-flag { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; } +.rc-flag { background: var(--surface-3); border-radius: 2px; padding: 1px 5px; } +.rc-safetycarpanel .rc-flag, +.rc-safetycarpanel { color: #ffcc00; } + +/* ---- Driver selector ---- */ +.driver-selector { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1.5rem 0.75rem; +} +.driver-selector label { color: var(--text-2); font-size: 0.8rem; margin-right: 0.25rem; } + +/* ---- Year selector ---- */ +.year-selector { + display: flex; + gap: 0.4rem; + margin-left: auto; +} + +/* ---- Skeleton loading ---- */ +.skeleton-wrap { padding: 0.75rem 1.5rem; } +.skeleton { + background: linear-gradient( + 90deg, + var(--surface-1) 25%, + var(--surface-2) 50%, + var(--surface-1) 75% + ); + background-size: 200% 100%; + border-radius: 6px; + animation: shimmer 1.4s infinite; +} +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ---- Empty state ---- */ +.empty-msg { + color: var(--text-2); + font-size: 0.9rem; + padding: 2rem 1.5rem; + text-align: center; +} +.empty-msg.large { font-size: 1.1rem; padding: 4rem 1.5rem; } + +/* ---- D3 charts ---- */ +#strategy-chart, +#laps-chart, +#telemetry-chart, +#track-chart { + padding: 0 1.5rem 1.5rem; +} +#strategy-chart svg, +#laps-chart svg, +#telemetry-chart svg, +#track-chart svg { display: block; } + +/* D3 axis text */ +.tick text { fill: var(--text-2); font-size: 11px; } +.domain, .tick line { stroke: var(--surface-3); } + +/* Tooltip */ +.chart-tooltip { + background: var(--surface-2); + border: 1px solid var(--surface-3); + border-radius: 6px; + color: var(--text-1); + font-size: 0.8rem; + padding: 6px 10px; + pointer-events: none; + position: fixed; + z-index: 200; +} diff --git a/internal/web/assets/track.js b/internal/web/assets/track.js new file mode 100644 index 0000000..aafd632 --- /dev/null +++ b/internal/web/assets/track.js @@ -0,0 +1,110 @@ +/* ============================================================ + box-box — SVG track map renderer + Track.render(containerId, outlinePoints, carPositions, driverInfo) + Track.updatePositions(containerId, carPositions) + ============================================================ */ + +const Track = (() => { + // Store rendered state per container + const state = {}; + + function render(containerId, outlinePoints, carPositions, driverInfo) { + const el = document.getElementById(containerId); + if (!el) return; + el.innerHTML = ''; + + if (!outlinePoints || outlinePoints.length < 3) { + el.innerHTML = '
Track outline not available.
'; + return; + } + + const width = el.clientWidth || 500; + const height = el.clientHeight || 340; + const PAD = 32; + + const xScale = d3.scaleLinear().domain([0, 1]).range([PAD, width - PAD]); + const yScale = d3.scaleLinear().domain([0, 1]).range([PAD, height - PAD]); + + const svg = d3.select(el).append('svg') + .attr('width', width).attr('height', height); + + // Track outline path + const lineGen = d3.line() + .x(p => xScale(p.x)) + .y(p => yScale(p.y)) + .curve(d3.curveCatmullRomClosed); + + svg.append('path') + .datum(outlinePoints) + .attr('d', lineGen) + .attr('fill', 'none') + .attr('stroke', '#444466') + .attr('stroke-width', 8) + .attr('stroke-linecap', 'round'); + + svg.append('path') + .datum(outlinePoints) + .attr('d', lineGen) + .attr('fill', 'none') + .attr('stroke', '#2D2D44') + .attr('stroke-width', 4) + .attr('stroke-linecap', 'round'); + + // Car group (updated separately) + svg.append('g').attr('id', `${containerId}-cars`); + + state[containerId] = { svg, xScale, yScale, driverInfo: driverInfo || {} }; + + // Initial positions + if (carPositions && Object.keys(carPositions).length) { + updatePositions(containerId, carPositions); + } + } + + function updatePositions(containerId, carPositions) { + const s = state[containerId]; + if (!s) return; + + const { svg, xScale, yScale, driverInfo } = s; + const carsG = svg.select(`#${containerId}-cars`); + + const cars = Object.entries(carPositions || {}).map(([num, pos]) => ({ + num, + x: pos.x, + y: pos.y, + info: driverInfo[num] || {}, + })); + + // Bind data + const sel = carsG.selectAll('.car-marker').data(cars, d => d.num); + + // Enter + const enter = sel.enter().append('g').attr('class', 'car-marker'); + enter.append('circle').attr('r', 7); + enter.append('text') + .attr('text-anchor', 'middle') + .attr('dy', '0.35em') + .attr('font-size', 7) + .attr('font-weight', '700') + .attr('fill', '#fff') + .attr('pointer-events', 'none'); + + // Update (enter + update) + const merged = enter.merge(sel); + merged.transition().duration(500).ease(d3.easeLinear) + .attr('transform', d => `translate(${xScale(d.x)},${yScale(d.y)})`); + + merged.select('circle') + .attr('fill', d => '#' + (d.info.TeamColour || '666666')) + .attr('stroke', '#111') + .attr('stroke-width', 1); + + merged.select('text') + .text(d => d.info.Tla || d.num.slice(0, 3)); + + // Exit + sel.exit().remove(); + } + + return { render, updatePositions }; +})(); diff --git a/internal/web/live.go b/internal/web/live.go new file mode 100644 index 0000000..14d5564 --- /dev/null +++ b/internal/web/live.go @@ -0,0 +1,220 @@ +package web + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/AmanTahiliani/box-box/internal/ui" +) + +// sseClient is a connected SSE subscriber. +type sseClient struct { + ch chan []byte // buffered; non-blocking sends + done chan struct{} +} + +// sseEvent is an outbound SSE frame. +type sseEvent struct { + name string + data []byte +} + +// SSEHub manages SSE clients and broadcasts live events. +type SSEHub struct { + register chan *sseClient + deregister chan *sseClient + broadcast chan sseEvent + + mu sync.RWMutex + lastSnapshot *ui.LiveStreamData + isLive bool +} + +func newSSEHub() *SSEHub { + return &SSEHub{ + register: make(chan *sseClient, 16), + deregister: make(chan *sseClient, 16), + broadcast: make(chan sseEvent, 64), + } +} + +// run is the hub's event loop. Must be called in a goroutine. +func (h *SSEHub) run() { + clients := make(map[*sseClient]bool) + for { + select { + case c := <-h.register: + clients[c] = true + // Send catch-up snapshot so new clients see current state immediately. + h.mu.RLock() + snap := h.lastSnapshot + live := h.isLive + h.mu.RUnlock() + if snap != nil { + if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil { + select { + case c.ch <- formatSSEFrame("snapshot", data): + default: + } + } + } + + case c := <-h.deregister: + if clients[c] { + delete(clients, c) + close(c.ch) + } + + case ev := <-h.broadcast: + frame := formatSSEFrame(ev.name, ev.data) + for c := range clients { + select { + case c.ch <- frame: + default: + // Slow client — drop frame rather than block. + } + } + } + } +} + +func formatSSEFrame(event string, data []byte) []byte { + return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)) +} + +// Snapshot returns the latest live data snapshot and whether a session is active. +func (h *SSEHub) Snapshot() (*ui.LiveStreamData, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + return h.lastSnapshot, h.isLive +} + +// runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive. +func (s *Server) runLiveFeeds() { + // Goroutine A: F1 SignalR bridge. + go s.signalRLoop() + + // Goroutine B: SSE keepalive every 20s to prevent proxy timeouts. + go func() { + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.hub.broadcast <- sseEvent{name: "heartbeat", data: []byte(`"ping"`)} + } + }() +} + +// signalRLoop connects to the F1 live timing feed, processes updates, and +// reconnects with exponential backoff on failure. +func (s *Server) signalRLoop() { + backoff := 5 * time.Second + const maxBackoff = 2 * time.Minute + + for { + if err := s.connectAndDrain(); err != nil { + log.Printf("web: live feed ended: %v", err) + } + + s.hub.mu.Lock() + s.hub.isLive = false + s.hub.mu.Unlock() + + log.Printf("web: live feed reconnecting in %v", backoff) + time.Sleep(backoff) + if backoff < maxBackoff { + backoff = min(backoff*2, maxBackoff) + } + } +} + +// connectAndDrain establishes a SignalR connection and drains the data channel +// until the feed goes silent for 60 seconds. +func (s *Server) connectAndDrain() error { + dataChan := make(chan ui.LiveStreamData, 16) + + if err := ui.ConnectToF1LiveTiming(dataChan); err != nil { + return err + } + log.Printf("web: live feed connected") + + // Reset backoff on successful connect. + idleTimeout := 60 * time.Second + timer := time.NewTimer(idleTimeout) + defer timer.Stop() + + for { + select { + case data := <-dataChan: + s.hub.mu.Lock() + s.hub.lastSnapshot = &data + 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 !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(idleTimeout) + + case <-timer.C: + return fmt.Errorf("idle timeout (%v)", idleTimeout) + } + } +} + +// handleLiveState returns the current live data snapshot as JSON. +func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) { + snap, isLive := s.hub.Snapshot() + writeJSON(w, map[string]any{ + "is_live": isLive, + "data": snap, + }) +} + +// handleSSEStream is the persistent SSE endpoint for live data. +func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + flusher.Flush() + + client := &sseClient{ + ch: make(chan []byte, 8), + done: make(chan struct{}), + } + s.hub.register <- client + defer func() { s.hub.deregister <- client }() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-client.ch: + if !ok { + return + } + if _, err := w.Write(msg); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..d569b72 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,107 @@ +package web + +import ( + "embed" + "fmt" + "io/fs" + "log" + "net/http" + "strings" + + "github.com/AmanTahiliani/box-box/internal/api" +) + +//go:embed assets +var assetsFS embed.FS + +// Server is the box-box web companion HTTP server. +type Server struct { + client *api.OpenF1Client + hub *SSEHub + addr string +} + +// NewServer creates a new Server. Call Start() to begin serving. +func NewServer(client *api.OpenF1Client, port int) *Server { + return &Server{ + client: client, + hub: newSSEHub(), + addr: fmt.Sprintf(":%d", port), + } +} + +// Start registers routes, launches background goroutines, and begins serving. +func (s *Server) Start() error { + mux := http.NewServeMux() + + // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps + // because Go's ServeMux uses longest-prefix matching. + mux.HandleFunc("/api/v1/meetings", s.handleMeetings) + mux.HandleFunc("/api/v1/sessions", s.handleSessions) + mux.HandleFunc("/api/v1/drivers", s.handleDrivers) + mux.HandleFunc("/api/v1/results", s.handleResults) + mux.HandleFunc("/api/v1/grid", s.handleGrid) + mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison) + mux.HandleFunc("/api/v1/laps", s.handleLaps) + mux.HandleFunc("/api/v1/weather", s.handleWeather) + mux.HandleFunc("/api/v1/race-control", s.handleRaceControl) + mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry) + mux.HandleFunc("/api/v1/overtakes", s.handleOvertakes) + mux.HandleFunc("/api/v1/team-radio", s.handleTeamRadio) + mux.HandleFunc("/api/v1/championship/drivers", s.handleChampionshipDrivers) + mux.HandleFunc("/api/v1/championship/teams", s.handleChampionshipTeams) + mux.HandleFunc("/api/v1/track-outline", s.handleTrackOutline) + mux.HandleFunc("/api/v1/strategy", s.handleStrategy) + mux.HandleFunc("/api/v1/live/state", s.handleLiveState) + mux.HandleFunc("/api/v1/live/stream", s.handleSSEStream) + + // Static files + SPA catchall + subFS, err := fs.Sub(assetsFS, "assets") + if err != nil { + return err + } + fileServer := http.FileServer(http.FS(subFS)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // If the path maps to a real asset file, serve it directly. + if r.URL.Path != "/" { + p := strings.TrimPrefix(r.URL.Path, "/") + if f, err := subFS.Open(p); err == nil { + f.Close() + fileServer.ServeHTTP(w, r) + return + } + } + // SPA catchall: all unknown paths serve index.html. + r2 := *r + r2.URL.Path = "/" + fileServer.ServeHTTP(w, &r2) + }) + + // Start background goroutines. + go s.hub.run() + go s.runLiveFeeds() + + return http.ListenAndServe(s.addr, withCORS(withLogging(mux))) +} + +// withCORS adds permissive CORS headers (localhost use only). +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +// withLogging logs each request to stderr. +func withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("web: %s %s", r.Method, r.URL.RequestURI()) + next.ServeHTTP(w, r) + }) +}