Initial UI

This commit is contained in:
2026-03-27 20:12:57 -04:00
parent c4e6c634d1
commit 15faf07691
9 changed files with 3039 additions and 5 deletions

View File

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

786
internal/web/api.go Normal file
View File

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

527
internal/web/assets/app.js Normal file
View File

@@ -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')}`;
}

View File

@@ -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(
`<strong>${driver.name_acronym}</strong> — Stint ${stint.stint_number}<br>` +
`${stint.compound}${isNew}<br>` +
`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}<br>` +
`Stop: ${pit.stop_duration?.toFixed(2)}s &nbsp; 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 `<span style="color:#${d.team_colour||'888888'}">${d.name_acronym}</span>: ${m}:${s.padStart(6,'0')}`;
}).filter(Boolean).join('<br>');
if (tips) showTooltip(`<strong>Lap ${lapNum}</strong><br>${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 `<span style="color:#${d.teamColour||'888888'}">${d.nameAcronym}</span>: ` +
`${pt.Speed}km/h T${pt.Throttle}% B${pt.Brake}% G${pt.NGear}`;
}).filter(Boolean).join('<br>');
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 };
})();

View File

@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>box-box</title>
<link rel="stylesheet" href="/style.css"/>
<style>[x-cloak]{display:none!important}</style>
</head>
<body x-data="appState()" x-cloak>
<!-- Navigation -->
<nav class="nav">
<a class="nav-brand" href="#/">box&#x2011;box</a>
<div class="nav-links">
<a href="#/" :class="{active: page==='home'}">Home</a>
<a href="#/standings" :class="{active: page==='standings'}">Standings</a>
<a href="#/live" :class="{active: page==='live'}">
Live
<span x-show="isLive" class="live-dot"></span>
</a>
</div>
</nav>
<!-- Stale data banner -->
<div x-show="staleWarning" class="stale-banner">
<span>Showing cached data — live session in progress (free tier)</span>
<details>
<summary>Why?</summary>
<p>During live F1 sessions the free-tier OpenF1 API restricts access.
box-box automatically serves the last cached data.</p>
</details>
</div>
<!-- Home page -->
<template x-if="page==='home'">
<div x-data="homePage()" x-init="init()">
<!-- Next race card + championship sidebar -->
<div class="home-hero" x-show="!loading">
<!-- Next race -->
<div class="card next-race" x-show="nextRace">
<div class="card-label">NEXT RACE</div>
<div class="next-race-flag" x-text="flagEmoji(nextRace?.country_code)"></div>
<h2 class="next-race-name" x-text="nextRace?.meeting_name||''"></h2>
<div class="next-race-circuit" x-text="nextRace?.circuit_short_name||''"></div>
<div class="next-race-countdown" x-text="countdown"></div>
</div>
<!-- Championship sidebar -->
<div class="card champ-sidebar">
<div class="card-label">DRIVERS' CHAMPIONSHIP</div>
<template x-for="d in champDrivers.slice(0,5)" :key="d.driver_number">
<div class="champ-row" :style="`--team-color:#${d.team_colour}`">
<span class="champ-pos" x-text="d.position_current"></span>
<span class="team-swatch"></span>
<span class="champ-tla" x-text="d.name_acronym||d.driver_number"></span>
<div class="champ-bar-wrap">
<div class="champ-bar" :style="`width:${barWidth(d.points_current)}%`"></div>
</div>
<span class="champ-pts" x-text="d.points_current"></span>
</div>
</template>
</div>
</div>
<!-- Loading skeleton -->
<div x-show="loading" class="skeleton-wrap">
<div class="skeleton" style="height:120px;margin-bottom:1rem;"></div>
</div>
<!-- Season grid -->
<div class="section-label" x-text="season + ' SEASON'"></div>
<div class="meetings-grid" x-show="!loading">
<template x-for="m in meetings" :key="m.meeting_key">
<a class="meeting-card" :class="{past: isPast(m)}" :href="`#/race/${m.meeting_key}`">
<div class="meeting-flag" x-text="flagEmoji(m.country_code)"></div>
<div class="meeting-name" x-text="m.meeting_name"></div>
<div class="meeting-circuit" x-text="m.circuit_short_name"></div>
<div class="meeting-date" x-text="fmtDate(m.date_start)"></div>
</a>
</template>
</div>
</div>
</template>
<!-- Race page -->
<template x-if="page==='race'">
<div x-data="racePage()" x-init="init()">
<div class="page-header">
<a class="back-btn" href="#/">← Back</a>
<h1 class="page-title" x-text="meetingName"></h1>
</div>
<!-- Session picker -->
<div class="session-pills" x-show="sessions.length">
<template x-for="sess in sessions" :key="sess.session_key">
<button class="pill"
:class="{active: activeSession?.session_key===sess.session_key}"
@click="selectSession(sess)"
x-text="sess.session_name">
</button>
</template>
</div>
<!-- Tabs -->
<div class="tabs" x-show="activeSession">
<button class="tab" :class="{active:tab==='results'}" @click="tab='results'">Results</button>
<button class="tab" :class="{active:tab==='strategy'}" @click="loadStrategy();tab='strategy'">Strategy</button>
<button class="tab" :class="{active:tab==='laps'}" @click="loadLaps();tab='laps'">Lap Times</button>
<button class="tab" :class="{active:tab==='track'}" @click="loadTrack();tab='track'">Track</button>
<button class="tab" :class="{active:tab==='telemetry'}" @click="tab='telemetry'">Telemetry</button>
</div>
<!-- Results tab -->
<div x-show="tab==='results'">
<div x-show="resultsLoading" class="skeleton-wrap">
<template x-for="i in [1,2,3,4,5]" :key="i">
<div class="skeleton" style="height:36px;margin-bottom:4px;"></div>
</template>
</div>
<table class="data-table" x-show="!resultsLoading && results.length">
<thead>
<tr>
<th>POS</th><th>DRIVER</th><th>TEAM</th><th>TIME / GAP</th><th>PTS</th>
</tr>
</thead>
<tbody>
<template x-for="r in results" :key="r.driver_number">
<tr :style="`--team-color:#${r.team_colour}`">
<td class="pos-cell">
<span x-text="r.position||'-'"></span>
<span x-show="r.dnf" class="badge dnf">DNF</span>
<span x-show="r.dns" class="badge dns">DNS</span>
<span x-show="r.dsq" class="badge dsq">DSQ</span>
</td>
<td><span class="team-swatch"></span><span x-text="r.full_name||r.driver_number"></span></td>
<td x-text="r.team_name||'-'"></td>
<td x-text="fmtDuration(r.duration)"></td>
<td x-text="r.points||0"></td>
</tr>
</template>
</tbody>
</table>
<div x-show="!resultsLoading && !results.length" class="empty-msg">No results available.</div>
</div>
<!-- Strategy tab -->
<div x-show="tab==='strategy'" x-effect="tab==='strategy' && strategyData && renderStrategy()">
<div x-show="strategyLoading" class="skeleton-wrap">
<div class="skeleton" style="height:300px;"></div>
</div>
<div x-show="strategyNote" class="empty-msg" x-text="strategyNote"></div>
<div id="strategy-chart" x-show="!strategyLoading && !strategyNote" style="min-height:300px;overflow-x:auto;"></div>
</div>
<!-- Lap Times tab -->
<div x-show="tab==='laps'" x-effect="tab==='laps' && lapsData && renderLapTimes()">
<div x-show="lapsLoading" class="skeleton-wrap">
<div class="skeleton" style="height:300px;"></div>
</div>
<div id="laps-chart" x-show="!lapsLoading" style="min-height:300px;"></div>
</div>
<!-- Track tab -->
<div x-show="tab==='track'" x-effect="tab==='track' && trackData && renderTrack()">
<div x-show="trackLoading" class="skeleton-wrap">
<div class="skeleton" style="height:300px;"></div>
</div>
<div x-show="!trackData && !trackLoading" class="empty-msg">Track outline not available for this circuit.</div>
<div id="track-chart" x-show="!trackLoading && trackData" style="min-height:300px;"></div>
</div>
<!-- Telemetry tab -->
<div x-show="tab==='telemetry'">
<div class="driver-selector">
<label>Select driver:</label>
<template x-for="d in drivers" :key="d.driver_number">
<button class="pill"
:class="{active: telemetryDrivers.includes(d.driver_number)}"
:style="`--team-color:#${d.team_colour}`"
@click="toggleTelemetryDriver(d.driver_number)"
x-text="d.name_acronym">
</button>
</template>
</div>
<div x-show="telemetryLoading" class="skeleton-wrap">
<div class="skeleton" style="height:340px;"></div>
</div>
<div id="telemetry-chart" x-show="!telemetryLoading" style="min-height:340px;"
x-effect="telemetryData.length && tab==='telemetry' && renderTelemetry()"></div>
</div>
</div>
</template>
<!-- Live page -->
<template x-if="page==='live'">
<div x-data="livePage()" x-init="init()" x-destroy="cleanup()">
<div class="page-header">
<h1 class="page-title">
Live Timing
<span x-show="isLive" class="live-dot large"></span>
</h1>
<div class="session-info" x-show="session.meeting_name">
<span x-text="session.meeting_name"></span>
<span x-text="session.session_name"></span>
</div>
</div>
<!-- No session placeholder -->
<div x-show="!isLive" class="empty-msg large">
No live session active. Check back during a race weekend.
</div>
<!-- Timing tower -->
<div x-show="isLive">
<div class="live-meta">
<span>Lap <strong x-text="currentLap"></strong>/<strong x-text="totalLaps"></strong></span>
<span x-show="trackStatus" class="track-status" :class="trackStatusClass()" x-text="trackStatusText()"></span>
<span class="clock" x-text="clockDisplay"></span>
</div>
<table class="timing-tower">
<thead>
<tr><th>POS</th><th>Δ</th><th>DRIVER</th><th>TYRE</th><th>LAST LAP</th><th>GAP</th><th>BEST</th></tr>
</thead>
<tbody>
<template x-for="d in sortedDrivers" :key="d.RacingNumber">
<tr :class="{in-pit: d.InPit, retired: d.Retired, pit-out: d.PitOut}"
:style="`--team-color:#${driverTeamColor(d.RacingNumber)}`">
<td class="pos-cell" x-text="d.Position"></td>
<td class="pos-delta" x-text="posDelta(d)"></td>
<td><span class="team-swatch"></span><span x-text="driverTla(d.RacingNumber)"></span>
<span x-show="d.InPit" class="badge pit">PIT</span>
<span x-show="d.Retired" class="badge out">OUT</span>
</td>
<td>
<span class="tyre-badge" :class="tyreClass(d.RacingNumber)"
x-text="tyreLabel(d.RacingNumber)"></span>
</td>
<td :class="{pb: d.LastLapPB, ob: d.LastLapOB}" x-text="d.LastLapTime||'-'"></td>
<td x-text="d.GapToLeader||'-'"></td>
<td :class="{ob: d.BestLapOB}" x-text="d.BestLapTime||'-'"></td>
</tr>
</template>
</tbody>
</table>
<!-- Race control messages -->
<div class="rc-panel" x-show="rcMessages.length">
<div class="card-label">RACE CONTROL</div>
<template x-for="(msg, i) in rcMessages.slice().reverse().slice(0,10)" :key="i">
<div class="rc-message" :class="`rc-${msg.Category?.toLowerCase()}`">
<span class="rc-time" x-text="msg.Time"></span>
<span class="rc-flag" x-show="msg.Flag" x-text="msg.Flag"></span>
<span x-text="msg.Message"></span>
</div>
</template>
</div>
</div>
</div>
</template>
<!-- Standings page -->
<template x-if="page==='standings'">
<div x-data="standingsPage()" x-init="init()">
<div class="page-header">
<h1 class="page-title">Championship Standings</h1>
<div class="year-selector">
<button class="pill" :class="{active:year===2026}" @click="setYear(2026)">2026</button>
<button class="pill" :class="{active:year===2025}" @click="setYear(2025)">2025</button>
<button class="pill" :class="{active:year===2024}" @click="setYear(2024)">2024</button>
</div>
</div>
<div class="tabs">
<button class="tab" :class="{active:view==='drivers'}" @click="view='drivers'">Drivers</button>
<button class="tab" :class="{active:view==='teams'}" @click="view='teams'">Constructors</button>
</div>
<div x-show="loading" class="skeleton-wrap">
<template x-for="i in [1,2,3,4,5,6,7,8,9,10]" :key="i">
<div class="skeleton" style="height:40px;margin-bottom:4px;"></div>
</template>
</div>
<table class="data-table" x-show="!loading && view==='drivers'">
<thead><tr><th>POS</th><th>DRIVER</th><th>TEAM</th><th>PTS</th></tr></thead>
<tbody>
<template x-for="d in driverStandings" :key="d.driver_number">
<tr :style="`--team-color:#${d.team_colour}`">
<td x-text="d.position_current"></td>
<td><span class="team-swatch"></span><span x-text="d.full_name||d.driver_number"></span></td>
<td x-text="d.team_name||'-'"></td>
<td x-text="d.points_current"></td>
</tr>
</template>
</tbody>
</table>
<table class="data-table" x-show="!loading && view==='teams'">
<thead><tr><th>POS</th><th>TEAM</th><th>PTS</th></tr></thead>
<tbody>
<template x-for="t in teamStandings" :key="t.team_name">
<tr>
<td x-text="t.position_current"></td>
<td x-text="t.team_name"></td>
<td x-text="t.points_current"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="/charts.js"></script>
<script src="/track.js"></script>
<script src="/app.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
</body>
</html>

View File

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

View File

@@ -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 = '<div style="color:#8888aa;text-align:center;padding:2rem;">Track outline not available.</div>';
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 };
})();

220
internal/web/live.go Normal file
View File

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

107
internal/web/server.go Normal file
View File

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