mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Initial UI
This commit is contained in:
24
cmd/main.go
24
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(
|
||||
|
||||
786
internal/web/api.go
Normal file
786
internal/web/api.go
Normal 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
527
internal/web/assets/app.js
Normal 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')}`;
|
||||
}
|
||||
465
internal/web/assets/charts.js
Normal file
465
internal/web/assets/charts.js
Normal 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 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 };
|
||||
})();
|
||||
322
internal/web/assets/index.html
Normal file
322
internal/web/assets/index.html
Normal 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‑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>
|
||||
483
internal/web/assets/style.css
Normal file
483
internal/web/assets/style.css
Normal 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;
|
||||
}
|
||||
110
internal/web/assets/track.js
Normal file
110
internal/web/assets/track.js
Normal 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
220
internal/web/live.go
Normal 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
107
internal/web/server.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user