Updated Live

This commit is contained in:
2026-03-27 02:54:09 -04:00
parent dc0793bf59
commit 16ea2e8683
18 changed files with 2749 additions and 170 deletions

View File

@@ -17,6 +17,11 @@
- 📅 **Race Calendar**: The full 2025 schedule at your fingertips.
- 🏎️ **Race Details**: Deep dive into session results, starting grids, and lap data.
- 👤 **Driver Profiles**: Detailed stats for every driver on the grid.
- 🔴 **Official Live Timing**: Real-time F1 timing tower via the official SignalR feed — gaps, intervals, tyre age, sector times, DRS, and track status.
- ⚔️ **Battle Tracker**: Auto-detects on-track duels within DRS range with gap sparklines and tyre strategy comparison.
- 🔧 **Pit Window Calculator**: Predicts rejoin position if a driver pits now, using per-circuit pit loss times.
-**Race Replay**: Lap-by-lap scrubber for completed races — relive the whole field's evolution with pit annotations and race control messages.
- 🗺️ **ASCII Track Map**: Live car positions on a terminal-rendered track outline, team-coloured.
- 🔌 **Offline-ish**: Fast, lightweight, and powered by the wonderful [OpenF1 API](https://openf1.org).
## 🚀 Quick Start
@@ -40,14 +45,24 @@ go run cmd/main.go
| Key | Action |
| --- | --- |
| `1` | Switch to **Standings** |
| `2` | Switch to **Calendar** |
| `3` | Switch to **Race Details** |
| `4` | Switch to **Drivers** |
| `1` | Switch to **Home** |
| `2` | Switch to **Standings** |
| `3` | Switch to **Calendar** |
| `4` | Switch to **Race Details** |
| `5` | Switch to **Drivers** |
| `6` | Switch to **Live Timing** |
| `7` | Switch to **Track Map** |
| `tab` / `shift+tab` | Next / Previous tab |
| `j`/`↓` | Navigate down |
| `k`/`↑` | Navigate up |
| `enter` | Select/Inspect item |
| `b` | Go back |
| `b` / `esc` | Go back / collapse |
| `s` | Toggle sector times (Live tab) |
| `b` | Toggle Battle Tracker (Live tab) |
| `p` | Toggle Pit Window Calculator (Live tab) |
| `r` | Enter Race Replay (Race Detail tab, Race sessions) |
| `←`/`h` · `→`/`l` | Scrub laps in Replay |
| `y` | Cycle season year |
| `q` / `ctrl+c` | Exit |
## 🛠️ Tech Stack

BIN
box-box

Binary file not shown.

BIN
box-box-stable Executable file

Binary file not shown.

0
box-box.log Normal file
View File

View File

@@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"os"
"time"
@@ -11,6 +12,12 @@ import (
)
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()
}
var client *api.OpenF1Client
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey)

View File

@@ -21,10 +21,13 @@ const (
tabRaceDetail tabIndex = 3
tabDriver tabIndex = 4
tabLive tabIndex = 5
tabTrackMap tabIndex = 6
)
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live"}
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴"}
const numTabs = 7
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live", "Map"}
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴", "🗺"}
// splashDoneMsg is sent after the splash screen duration has elapsed.
type splashDoneMsg struct{}
@@ -44,6 +47,7 @@ type AppModel struct {
driver DriverModel
dashboard DashboardModel
live OfficialLiveModel
trackMap TrackMapModel
meetings []models.Meeting
@@ -69,6 +73,7 @@ func NewAppModel(client *api.OpenF1Client) AppModel {
driver: NewDriverModel(client),
dashboard: NewDashboardModel(client, year),
live: NewOfficialLiveModel(),
trackMap: NewTrackMapModel(client),
showSplash: true,
splashSpinner: sp,
}
@@ -88,6 +93,7 @@ func (m AppModel) Init() tea.Cmd {
m.calendar.Init(),
m.raceDetail.Init(),
m.driver.Init(),
m.trackMap.Init(),
m.splashSpinner.Tick,
splashTimer(),
)
@@ -103,14 +109,15 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
m.raceDetail.SetSize(m.width-4, contentHeight)
m.live.SetSize(m.width, contentHeight)
var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6 tea.Cmd
var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7 tea.Cmd
m.dashboard, cmd1 = m.dashboard.Update(msg)
m.live, cmd2 = m.live.Update(msg)
m.standings, cmd3 = m.standings.Update(msg)
m.calendar, cmd4 = m.calendar.Update(msg)
m.raceDetail, cmd5 = m.raceDetail.Update(msg)
m.driver, cmd6 = m.driver.Update(msg)
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6)
m.trackMap, cmd7 = m.trackMap.Update(msg)
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7)
case splashDoneMsg:
m.showSplash = false
@@ -150,8 +157,16 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case matchKey(msg, GlobalKeys.Tab6):
m.activeTab = tabLive
return m, nil
case matchKey(msg, GlobalKeys.Tab7):
m.activeTab = tabTrackMap
if !m.trackMap.HasSession() {
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.FetchActiveSession(m.client)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
case matchKey(msg, GlobalKeys.NextTab):
m.activeTab = (m.activeTab + 1) % 6
m.activeTab = (m.activeTab + 1) % numTabs
if m.activeTab == tabDriver {
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
@@ -159,7 +174,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Batch(cmds...)
case matchKey(msg, GlobalKeys.PrevTab):
m.activeTab = (m.activeTab - 1 + 6) % 6
m.activeTab = (m.activeTab - 1 + numTabs) % numTabs
if m.activeTab == tabDriver {
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
@@ -262,6 +277,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case startingGridLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case loadSecondaryDataMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
@@ -308,6 +329,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.live, cmd = m.live.Update(msg)
cmds = append(cmds, cmd)
// Forward driver info to track map for car-marker colouring
m.trackMap.InjectDriverInfo(msg.DriverInfo)
return m, tea.Batch(cmds...)
case trackOutlineLoadedMsg, trackCarsLoadedMsg, trackMapTickMsg:
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case spinner.TickMsg:
@@ -316,12 +345,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
cmds = append(cmds, cmd)
}
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
var cmd1, cmd2, cmd3, cmd4, cmd5 tea.Cmd
m.standings, cmd1 = m.standings.Update(msg)
m.calendar, cmd2 = m.calendar.Update(msg)
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
m.driver, cmd4 = m.driver.Update(msg)
cmds = append(cmds, cmd1, cmd2, cmd3, cmd4)
m.trackMap, cmd5 = m.trackMap.Update(msg)
cmds = append(cmds, cmd1, cmd2, cmd3, cmd4, cmd5)
return m, tea.Batch(cmds...)
}
@@ -351,6 +381,10 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
case tabTrackMap:
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
@@ -387,6 +421,8 @@ func (m AppModel) View() string {
content = m.raceDetail.View()
case tabDriver:
content = m.driver.View()
case tabTrackMap:
content = m.trackMap.View()
}
statusBar := m.renderStatusBar(w)
@@ -497,7 +533,7 @@ func (m AppModel) renderStatusBar(width int) string {
// Right side: cache stats + navigation hints
cacheStats := m.client.CacheStats()
cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses))
right := cacheInfo + " " + styleMuted.Render("1-6 tabs · y year · q quit")
right := cacheInfo + " " + styleMuted.Render("1-7 tabs · y year · q quit")
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)

230
internal/ui/battles.go Normal file
View File

@@ -0,0 +1,230 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
)
// drsRange is the gap threshold (seconds) within which two drivers are
// considered to be in a battle. 1.0s is the DRS activation gap in F1.
const drsRange = 1.0
// Battle represents an active on-track duel between two drivers.
type Battle struct {
LeaderNum string // racing number of the driver ahead
ChaserNum string // racing number of the driver behind
GapSeconds float64
LeaderData LiveDriverData
ChaserData LiveDriverData
LeaderInfo F1DriverListEntry
ChaserInfo F1DriverListEntry
LeaderTyre LiveTyreData
ChaserTyre LiveTyreData
LeaderStints []LiveStintData
ChaserStints []LiveStintData
// GapHistory for the chaser (interval to car ahead)
GapHistory []float64
}
// detectBattles scans the sorted driver list and returns all pairs whose
// interval to the car directly ahead is within drsRange seconds.
func detectBattles(
drivers []LiveDriverData,
driverInfo map[string]F1DriverListEntry,
tyres map[string]LiveTyreData,
stints map[string][]LiveStintData,
gapHistory map[string][]float64,
) []Battle {
var battles []Battle
// Build position-sorted list (positioned drivers only)
var positioned []LiveDriverData
for _, d := range drivers {
if d.Position > 0 && !d.Retired && !d.InPit {
positioned = append(positioned, d)
}
}
sort.Slice(positioned, func(i, j int) bool {
return positioned[i].Position < positioned[j].Position
})
for i := 1; i < len(positioned); i++ {
chaser := positioned[i]
leader := positioned[i-1]
gap := parseGapToFloat(chaser.Interval)
if gap < 0 || gap > drsRange {
continue
}
b := Battle{
LeaderNum: leader.RacingNumber,
ChaserNum: chaser.RacingNumber,
GapSeconds: gap,
LeaderData: leader,
ChaserData: chaser,
LeaderInfo: driverInfo[leader.RacingNumber],
ChaserInfo: driverInfo[chaser.RacingNumber],
LeaderTyre: tyres[leader.RacingNumber],
ChaserTyre: tyres[chaser.RacingNumber],
LeaderStints: stints[leader.RacingNumber],
ChaserStints: stints[chaser.RacingNumber],
GapHistory: gapHistory[chaser.RacingNumber],
}
battles = append(battles, b)
}
return battles
}
// renderBattlePanel renders the full battle tracker panel.
// width is the available character width.
func renderBattlePanel(
battles []Battle,
width int,
isRace bool,
) string {
var sb strings.Builder
title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Render("⚔ BATTLE TRACKER")
subtitle := styleMuted.Render(fmt.Sprintf(" drivers within %.1fs (DRS range)", drsRange))
sb.WriteString(" " + title + " " + subtitle + "\n")
sb.WriteString(" " + divider(min(width-4, 70)) + "\n")
if !isRace {
sb.WriteString("\n" + styleMuted.Render(" Battle tracker is only available during Race sessions.\n"))
return sb.String()
}
if len(battles) == 0 {
sb.WriteString("\n" + styleMuted.Render(" No active battles detected — all gaps > 1.0s.\n"))
return sb.String()
}
for i, b := range battles {
if i > 0 {
sb.WriteString(" " + styleMuted.Render(strings.Repeat("·", min(width-4, 60))) + "\n")
}
sb.WriteString(renderBattleCard(b, width))
}
return sb.String()
}
// renderBattleCard renders a single battle card showing both drivers side by side.
func renderBattleCard(b Battle, width int) string {
var sb strings.Builder
// ── Gap headline ──────────────────────────────────────────────────────────
gapStr := fmt.Sprintf("%.3fs", b.GapSeconds)
var gapColor string
switch {
case b.GapSeconds < 0.3:
gapColor = colorF1Red // very close, danger
case b.GapSeconds < 0.6:
gapColor = colorOrange
default:
gapColor = colorYellow
}
gapStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(gapColor)).Bold(true).Render(gapStr)
trend := ""
if len(b.GapHistory) >= 2 {
trend = " " + gapTrendIndicator(b.GapHistory)
}
sb.WriteString(fmt.Sprintf(" P%d vs P%d gap: %s%s\n",
b.LeaderData.Position, b.ChaserData.Position,
gapStyled, trend))
// ── Driver rows ───────────────────────────────────────────────────────────
// Each row: [TEAM-COLOR-BAR] TLA tyre(age) lastlap best stints
sb.WriteString(renderBattleDriverRow("AHEAD", b.LeaderData, b.LeaderInfo, b.LeaderTyre, b.LeaderStints))
sb.WriteString(renderBattleDriverRow("CHASE", b.ChaserData, b.ChaserInfo, b.ChaserTyre, b.ChaserStints))
return sb.String()
}
func renderBattleDriverRow(
role string,
d LiveDriverData,
info F1DriverListEntry,
tyre LiveTyreData,
stints []LiveStintData,
) string {
tla := d.RacingNumber
teamColor := colorMuted
if info.Tla != "" {
tla = info.Tla
}
if info.TeamColour != "" {
teamColor = "#" + info.TeamColour
} else if info.TeamName != "" {
teamColor = teamColorFromName(info.TeamName)
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor))
roleStyle := styleMuted
tlaRendered := tlaStyle.Render(padRight(tla, 4))
// Tyre indicator
tyreStr := " ?"
if tyre.Compound != "" {
abbrev, ts := compoundAbbrevStyle(strings.ToUpper(tyre.Compound))
newMark := " "
if tyre.New {
newMark = ts.Render("*")
}
tyreStr = ts.Render("●") + " " + abbrev + newMark
if tyre.Age > 0 {
tyreStr += styleMuted.Render(fmt.Sprintf("/%d", tyre.Age))
}
}
// Last lap time
lastLap := styleMuted.Render(" --:--.---")
if d.LastLapTime != "" {
ll := d.LastLapTime
if d.LastLapOB {
lastLap = " " + stylePurple.Render(ll)
} else if d.LastLapPB {
lastLap = " " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(ll)
} else {
lastLap = " " + ll
}
}
// Stint history (compact: S(12)→M(5))
stintStr := ""
if len(stints) > 0 {
var parts []string
for _, st := range stints {
abbrev, ts := compoundAbbrevStyle(strings.ToUpper(st.Compound))
newMark := ""
if st.New {
newMark = "*"
}
parts = append(parts, ts.Render(fmt.Sprintf("%s(%d%s)", abbrev, st.Laps, newMark)))
}
stintStr = " " + strings.Join(parts, styleMuted.Render("→"))
}
row := fmt.Sprintf(" %s %s %s %s%s%s",
colorBar,
roleStyle.Render(padRight(role, 5)),
tlaRendered,
tyreStr,
lastLap,
stintStr,
)
return row + "\n"
}

View File

@@ -603,6 +603,12 @@ func (m DriverModel) renderDetailContent() string {
sb.WriteString(m.renderStintBar())
sb.WriteString("\n\n")
// ── TYRE DEGRADATION ────────────────────────────────
if degContent := m.renderTyreDegradation(); degContent != "" {
sb.WriteString(degContent)
sb.WriteString("\n")
}
// ── LAP TIMES ───────────────────────────────────────
sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
sparkWidth := min(m.width-6, 70)
@@ -651,6 +657,12 @@ func (m DriverModel) renderDetailContent() string {
}
sb.WriteString("\n\n")
// ── SECTOR ANALYSIS ─────────────────────────────────
if sectorContent := m.renderSectorAnalysis(); sectorContent != "" {
sb.WriteString(sectorContent)
sb.WriteString("\n")
}
// ── PIT STOPS ───────────────────────────────────────
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
sb.WriteString(m.renderPitStops())
@@ -875,6 +887,288 @@ func (m DriverModel) renderPositionChart() string {
return sb.String()
}
// renderSectorAnalysis renders sector time and speed trap analysis from existing lap data.
func (m DriverModel) renderSectorAnalysis() string {
if len(m.laps) < 2 {
return ""
}
// Collect valid sector times and speeds
type sectorStats struct {
best float64
total float64
count int
bestLap int
}
var s1, s2, s3 sectorStats
s1.best, s2.best, s3.best = 999, 999, 999
var speeds []int // speed trap values
var bestSpeed, bestSpeedLap int
for _, lap := range m.laps {
if lap.IsPitOutLap {
continue
}
if lap.DurationSector1 != nil && *lap.DurationSector1 > 0 {
v := *lap.DurationSector1
s1.total += v
s1.count++
if v < s1.best {
s1.best = v
s1.bestLap = lap.LapNumber
}
}
if lap.DurationSector2 != nil && *lap.DurationSector2 > 0 {
v := *lap.DurationSector2
s2.total += v
s2.count++
if v < s2.best {
s2.best = v
s2.bestLap = lap.LapNumber
}
}
if lap.DurationSector3 != nil && *lap.DurationSector3 > 0 {
v := *lap.DurationSector3
s3.total += v
s3.count++
if v < s3.best {
s3.best = v
s3.bestLap = lap.LapNumber
}
}
if lap.StSpeed > 0 {
speeds = append(speeds, lap.StSpeed)
if lap.StSpeed > bestSpeed {
bestSpeed = lap.StSpeed
bestSpeedLap = lap.LapNumber
}
}
}
// Need at least some sector data
if s1.count == 0 && s2.count == 0 && s3.count == 0 {
return ""
}
var sb strings.Builder
sb.WriteString(" " + styleSectionTitle.Render("SECTOR ANALYSIS") + "\n")
// Sector best/avg table
header := fmt.Sprintf(" %s %s %s",
padRight("", 3),
padLeft("BEST", 10),
padLeft("AVG", 10),
)
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
sectors := []struct {
name string
stats sectorStats
}{
{"S1", s1},
{"S2", s2},
{"S3", s3},
}
// Theoretical best lap
var theoreticalBest float64
for _, sec := range sectors {
if sec.stats.count == 0 {
sb.WriteString(fmt.Sprintf(" %s %s %s\n",
styleBold.Render(padRight(sec.name, 3)),
padLeft("--", 10),
padLeft("--", 10),
))
continue
}
theoreticalBest += sec.stats.best
avg := sec.stats.total / float64(sec.stats.count)
bestStr := lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true).
Render(fmt.Sprintf("%.3f", sec.stats.best))
avgStr := styleBold.Render(fmt.Sprintf("%.3f", avg))
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
styleBold.Render(padRight(sec.name, 3)),
padLeftVisible(bestStr, 10),
padLeftVisible(avgStr, 10),
styleMuted.Render(fmt.Sprintf("L%d", sec.stats.bestLap)),
))
}
// Theoretical best
if theoreticalBest > 0 {
theoryStr := lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true).
Render(formatSeconds(theoreticalBest))
sb.WriteString(fmt.Sprintf(" %s %s\n",
styleMuted.Render("Theoretical best:"),
theoryStr,
))
}
// Speed trap summary
if len(speeds) > 0 && bestSpeed > 0 {
var totalSpeed int
for _, s := range speeds {
totalSpeed += s
}
avgSpeed := totalSpeed / len(speeds)
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s\n",
styleMuted.Render("Top speed:"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Bold(true).
Render(fmt.Sprintf("%dkm/h", bestSpeed)),
styleMuted.Render("Avg:"),
styleBold.Render(fmt.Sprintf("%dkm/h", avgSpeed)),
styleMuted.Render(fmt.Sprintf("(L%d)", bestSpeedLap)),
))
// Speed sparkline
sparkWidth := min(len(speeds), min(m.width-8, 50))
if sparkWidth >= 3 {
sb.WriteString(" " + speedSparkline(speeds, sparkWidth) + "\n")
}
}
return sb.String()
}
// stintLapTimes returns the valid (non-pit, non-nil) lap durations for a stint,
// along with the lap numbers. Used for degradation computation.
func (m DriverModel) stintLapTimes(stint models.Stint) ([]float64, []int) {
var durations []float64
var lapNums []int
for _, lap := range m.laps {
if lap.LapNumber < stint.LapStart || lap.LapNumber > stint.LapEnd {
continue
}
if lap.IsPitOutLap || lap.LapDuration == nil || *lap.LapDuration <= 0 {
continue
}
durations = append(durations, *lap.LapDuration)
lapNums = append(lapNums, lap.LapNumber)
}
return durations, lapNums
}
// renderTyreDegradation computes and renders degradation analysis per stint.
// Returns empty string if insufficient data.
func (m DriverModel) renderTyreDegradation() string {
if len(m.stints) == 0 || len(m.laps) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString(" " + styleSectionTitle.Render("TYRE DEGRADATION") + "\n")
hasData := false
for _, stint := range m.stints {
durations, lapNums := m.stintLapTimes(stint)
if len(durations) < 3 {
continue
}
hasData = true
compoundStyle := tyreStyle(stint.Compound)
label := compoundStyle.Render(fmt.Sprintf("%s L%d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd))
lapsOnTyre := stint.LapEnd - stint.LapStart + 1
if stint.TyreAgeAtStart > 0 {
label += styleMuted.Render(fmt.Sprintf(" (used +%d)", stint.TyreAgeAtStart))
}
sb.WriteString(fmt.Sprintf(" %s %s laps\n", label, styleMuted.Render(fmt.Sprintf("%d", lapsOnTyre))))
// Find best and average lap time in stint
bestTime := durations[0]
var total float64
for _, d := range durations {
total += d
if d < bestTime {
bestTime = d
}
}
avgTime := total / float64(len(durations))
// Degradation rate: compare first 3 laps avg vs last 3 laps avg
firstN := 3
lastN := 3
if len(durations) < 6 {
firstN = len(durations) / 2
lastN = len(durations) / 2
}
if firstN < 1 {
firstN = 1
}
if lastN < 1 {
lastN = 1
}
var earlySum, lateSum float64
for i := 0; i < firstN; i++ {
earlySum += durations[i]
}
for i := len(durations) - lastN; i < len(durations); i++ {
lateSum += durations[i]
}
earlyAvg := earlySum / float64(firstN)
lateAvg := lateSum / float64(lastN)
degTotal := lateAvg - earlyAvg
degPerLap := degTotal / float64(len(durations)-1)
// Stats line
bestStr := lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(formatSeconds(bestTime))
avgStr := styleBold.Render(formatSeconds(avgTime))
sb.WriteString(fmt.Sprintf(" Best %s Avg %s", bestStr, avgStr))
// Deg rate with color coding
if degPerLap > 0 {
var degStyle lipgloss.Style
switch {
case degPerLap < 0.05:
degStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
case degPerLap < 0.15:
degStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
default:
degStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
}
sb.WriteString(fmt.Sprintf(" Deg %s",
degStyle.Render(fmt.Sprintf("+%.3fs/lap", degPerLap))))
} else {
sb.WriteString(fmt.Sprintf(" Deg %s",
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render("improving")))
}
sb.WriteString("\n")
// Mini sparkline for this stint's lap times
sparkWidth := min(len(durations), min(m.width-8, 50))
if sparkWidth >= 3 {
// Build mini laps slice for sparkline
stintLaps := make([]models.Lap, len(durations))
for i, d := range durations {
dur := d
stintLaps[i] = models.Lap{
LapDuration: &dur,
LapNumber: lapNums[i],
}
}
sb.WriteString(" " + sparkline(stintLaps, sparkWidth) + "\n")
sb.WriteString(fmt.Sprintf(" %s%s%s\n",
styleMuted.Render(fmt.Sprintf("L%d", stint.LapStart)),
strings.Repeat(" ", max(sparkWidth-8, 1)),
styleMuted.Render(fmt.Sprintf("L%d", stint.LapEnd)),
))
}
}
if !hasData {
return ""
}
return sb.String()
}
// renderTeamRadio displays team radio messages with timestamps.
func (m DriverModel) renderTeamRadio() string {
if len(m.teamRadios) == 0 {

View File

@@ -10,6 +10,7 @@ type GlobalKeyMap struct {
Tab4 key.Binding
Tab5 key.Binding
Tab6 key.Binding
Tab7 key.Binding
NextTab key.Binding
PrevTab key.Binding
Quit key.Binding
@@ -51,6 +52,10 @@ var GlobalKeys = GlobalKeyMap{
key.WithKeys("6"),
key.WithHelp("6", "live"),
),
Tab7: key.NewBinding(
key.WithKeys("7"),
key.WithHelp("7", "track map"),
),
NextTab: key.NewBinding(
key.WithKeys("tab", "right"),
key.WithHelp("tab/->", "next tab"),
@@ -124,12 +129,14 @@ var StandingsKeys = StandingsKeyMap{
// LiveKeyMap holds keybindings specific to the live timing tab.
type LiveKeyMap struct {
ToggleSectors key.Binding
ToggleRC key.Binding
ScrollRCUp key.Binding
ScrollRCDown key.Binding
ExpandDriver key.Binding
Collapse key.Binding
ToggleSectors key.Binding
ToggleRC key.Binding
ToggleBattles key.Binding
TogglePitWindow key.Binding
ScrollRCUp key.Binding
ScrollRCDown key.Binding
ExpandDriver key.Binding
Collapse key.Binding
}
var LiveKeys = LiveKeyMap{
@@ -141,6 +148,14 @@ var LiveKeys = LiveKeyMap{
key.WithKeys("r"),
key.WithHelp("r", "toggle RC panel"),
),
ToggleBattles: key.NewBinding(
key.WithKeys("b"),
key.WithHelp("b", "toggle battles"),
),
TogglePitWindow: key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "pit window"),
),
ScrollRCUp: key.NewBinding(
key.WithKeys("K"),
key.WithHelp("K", "scroll RC up"),
@@ -154,8 +169,8 @@ var LiveKeys = LiveKeyMap{
key.WithHelp("enter", "driver detail"),
),
Collapse: key.NewBinding(
key.WithKeys("esc", "b"),
key.WithHelp("esc/b", "collapse"),
key.WithKeys("esc"),
key.WithHelp("esc", "collapse"),
),
}
@@ -165,6 +180,7 @@ type RaceDetailKeyMap struct {
ScrollDown key.Binding
PrevSession key.Binding
NextSession key.Binding
Replay key.Binding
}
var RaceDetailKeys = RaceDetailKeyMap{
@@ -184,4 +200,8 @@ var RaceDetailKeys = RaceDetailKeyMap{
key.WithKeys("]"),
key.WithHelp("]", "next session"),
),
Replay: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "replay lap scrubber"),
),
}

View File

@@ -115,6 +115,12 @@ type driverSelectedMsg struct {
sessionKey int
}
// startingGridLoadedMsg carries starting grid data for a session.
type startingGridLoadedMsg struct {
grid []models.StartingGrid
err error
}
// loadSecondaryDataMsg triggers loading of secondary session data (race control, weather, overtakes)
// after the primary data (results, drivers) has arrived.
type loadSecondaryDataMsg struct {

View File

@@ -37,7 +37,7 @@ type F1TimingLine struct {
Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"`
Position interface{} `json:"Position"`
RacingNumber string `json:"RacingNumber"`
RacingNumber string `json:"RacingNumber"`
LastLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
@@ -268,10 +268,6 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
}
if json.Unmarshal(data, &td) == nil {
for num, lineRaw := range td.Lines {
// Debug: dump first driver's raw JSON to see field types
if num == "1" || num == "81" || num == "44" {
log.Printf("[DEBUG TimingData] driver=%s raw=%s", num, string(lineRaw))
}
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(drivers, num, line)
@@ -306,8 +302,8 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
}
case "ExtrapolatedClock":
var ec struct {
Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Extrapolating bool `json:"Extrapolating"`
}
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
@@ -427,10 +423,12 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
New string `json:"New"`
}
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
tyres[num] = LiveTyreData{
Compound: td.Compound,
New: td.New == "true" || td.New == "True",
}
// Preserve the existing Age — CurrentTyres only carries
// compound and newness, not lap count.
t := tyres[num]
t.Compound = td.Compound
t.New = td.New == "true" || td.New == "True"
tyres[num] = t
updated = true
}
}
@@ -462,11 +460,13 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
}
if len(driverStints) > 0 {
stints[num] = driverStints
// Always sync tyre from latest stint
// Sync compound and age from the latest stint.
// Stints are authoritative: they include historical data and
// carry both compound and laps on the current set.
lastStint := driverStints[len(driverStints)-1]
t := tyres[num]
t.Age = lastStint.Laps
if t.Compound == "" && lastStint.Compound != "" {
if lastStint.Compound != "" {
t.Compound = lastStint.Compound
t.New = lastStint.New
}
@@ -727,6 +727,129 @@ func parseGap(val string) string {
return val
}
// parseLapTimeMs parses a lap time string like "1:33.596" or "1:33" into
// milliseconds. Returns -1 if the string cannot be parsed.
func parseLapTimeMs(s string) int64 {
if s == "" {
return -1
}
// Handle M:SS.mmm or M:SS
parts := strings.SplitN(s, ":", 2)
if len(parts) == 2 {
var mins int
if _, err := fmt.Sscanf(parts[0], "%d", &mins); err != nil {
return -1
}
var secs float64
if _, err := fmt.Sscanf(parts[1], "%f", &secs); err != nil {
return -1
}
return int64(float64(mins)*60000 + secs*1000)
}
// Bare seconds: SS.mmm
var secs float64
if _, err := fmt.Sscanf(s, "%f", &secs); err != nil {
return -1
}
return int64(secs * 1000)
}
// computeGapFromBestLap returns a gap string like "+1.234" computed from the
// difference between the driver's best lap time and the leader's best lap time.
// Returns "" if either time is missing.
func computeGapFromBestLap(driverBest, leaderBest string) string {
dMs := parseLapTimeMs(driverBest)
lMs := parseLapTimeMs(leaderBest)
if dMs < 0 || lMs < 0 {
return ""
}
diff := dMs - lMs
if diff <= 0 {
return ""
}
return fmt.Sprintf("+%.3f", float64(diff)/1000.0)
}
// parseGapToFloat extracts a numeric gap value from a gap string like "+1.234" or "1.234".
// Returns -1 if the string cannot be parsed (e.g. "1 LAP", empty, "LAP").
func parseGapToFloat(val string) float64 {
if val == "" {
return -1
}
s := strings.TrimPrefix(val, "+")
var f float64
if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f >= 0 {
return f
}
return -1
}
// gapTrendIndicator renders a compact 8-character sparkline showing gap trend.
// Rising = gap increasing (bad, red), falling = gap decreasing (good, green).
func gapTrendIndicator(history []float64) string {
if len(history) < 2 {
return ""
}
const trendWidth = 8
blocks := []rune("▁▂▃▄▅▆▇█")
// Use the most recent samples that fit
data := history
if len(data) > trendWidth {
data = data[len(data)-trendWidth:]
}
minVal, maxVal := data[0], data[0]
for _, v := range data {
if v < minVal {
minVal = v
}
if v > maxVal {
maxVal = v
}
}
rng := maxVal - minVal
if rng < 0.01 {
// Gap is stable — show flat indicator
return styleMuted.Render(strings.Repeat("─", len(data)))
}
// Determine trend direction: compare first half avg vs second half avg
mid := len(data) / 2
var firstHalf, secondHalf float64
for i := 0; i < mid; i++ {
firstHalf += data[i]
}
for i := mid; i < len(data); i++ {
secondHalf += data[i]
}
firstHalf /= float64(mid)
secondHalf /= float64(len(data) - mid)
closing := secondHalf < firstHalf // gap shrinking = good
var sb strings.Builder
for _, v := range data {
norm := (v - minVal) / rng
idx := int(norm*float64(len(blocks)-1) + 0.5)
if idx < 0 {
idx = 0
}
if idx >= len(blocks) {
idx = len(blocks) - 1
}
var style lipgloss.Style
if closing {
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
} else {
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
}
sb.WriteString(style.Render(string(blocks[idx])))
}
return sb.String()
}
// compoundAbbrevStyle returns the single-letter abbreviation and lipgloss style for a tyre compound string.
func compoundAbbrevStyle(compound string) (string, lipgloss.Style) {
switch {
@@ -759,17 +882,20 @@ func parseHHMMSS(s string) (time.Duration, error) {
return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second, nil
}
// maxGapHistory is the number of gap samples to keep per driver for trend sparklines.
const maxGapHistory = 20
type OfficialLiveModel struct {
width int
height int
dataChan chan LiveStreamData
drivers map[string]LiveDriverData
driverInfo map[string]F1DriverListEntry
tyres map[string]LiveTyreData
rcMessages []LiveRCMessage
weather LiveWeatherData
session LiveSessionMeta
dataChan chan LiveStreamData
drivers map[string]LiveDriverData
driverInfo map[string]F1DriverListEntry
tyres map[string]LiveTyreData
rcMessages []LiveRCMessage
weather LiveWeatherData
session LiveSessionMeta
trackStatus string
currentLap int
totalLaps int
@@ -777,6 +903,7 @@ type OfficialLiveModel struct {
clockRefTime time.Time
clockExtrapolating bool
stints map[string][]LiveStintData
gapHistory map[string][]float64 // racing number -> recent gap-to-leader values
err error
// UI state
@@ -785,6 +912,8 @@ type OfficialLiveModel struct {
showSectors bool
expandedDriver string // racing number, "" if none
showRC bool // compact mode RC overlay toggle
showBattles bool // battle tracker overlay
showPitWindow bool // pit window calculator overlay
rcView viewport.Model
rcReady bool
@@ -797,6 +926,7 @@ func NewOfficialLiveModel() OfficialLiveModel {
driverInfo: make(map[string]F1DriverListEntry),
tyres: make(map[string]LiveTyreData),
stints: make(map[string][]LiveStintData),
gapHistory: make(map[string][]float64),
}
}
@@ -831,9 +961,126 @@ func (m OfficialLiveModel) displayClock() string {
return fmt.Sprintf("%02d:%02d:%02d", h, mnt, sec)
}
// recomputeImpliedPositions assigns positions to drivers based on session type.
//
// In practice and qualifying sessions the feed's Position field is unreliable —
// many drivers never receive an explicit position. Instead, we derive positions
// from best lap times (fastest = P1), which matches the official F1 timing
// screen behaviour. Drivers without a time are placed at the bottom.
//
// In race sessions the feed's Position field is authoritative, so we only
// back-fill drivers that still have Position == 0.
func (m *OfficialLiveModel) recomputeImpliedPositions() {
type entry struct {
num string
bestLapTime string
racingNum int
}
if m.isPracticeOrQuali() {
// -- Practice / Qualifying: rank everyone by best lap time ----------
var all []entry
for num, d := range m.drivers {
var n int
fmt.Sscanf(num, "%d", &n)
all = append(all, entry{num, d.BestLapTime, n})
}
// Also include drivers from driverInfo that have no timing data yet.
for num := range m.driverInfo {
if _, exists := m.drivers[num]; !exists {
var n int
fmt.Sscanf(num, "%d", &n)
all = append(all, entry{num, "", n})
}
}
// Drivers with a time sort first (ascending), then drivers without a
// time are ordered by racing number.
sort.Slice(all, func(i, j int) bool {
ti, tj := all[i].bestLapTime, all[j].bestLapTime
switch {
case ti != "" && tj != "":
return ti < tj
case ti != "":
return true
case tj != "":
return false
default:
return all[i].racingNum < all[j].racingNum
}
})
for i, e := range all {
newPos := i + 1
d, exists := m.drivers[e.num]
if !exists {
d = LiveDriverData{RacingNumber: e.num}
}
if d.Position != newPos {
d.PrevPosition = d.Position
if d.PrevPosition == 0 {
d.PrevPosition = newPos // suppress spurious delta arrow on first assignment
}
d.Position = newPos
}
m.drivers[e.num] = d
}
return
}
// -- Race: only back-fill drivers whose Position is still 0 ---------------
var unpos []entry
for num, d := range m.drivers {
if d.Position == 0 {
var n int
fmt.Sscanf(num, "%d", &n)
unpos = append(unpos, entry{num, d.BestLapTime, n})
}
}
if len(unpos) == 0 {
return
}
highestExplicit := 0
for _, d := range m.drivers {
if d.Position > highestExplicit {
highestExplicit = d.Position
}
}
sort.Slice(unpos, func(i, j int) bool {
ti, tj := unpos[i].bestLapTime, unpos[j].bestLapTime
switch {
case ti != "" && tj != "":
return ti < tj
case ti != "":
return true
case tj != "":
return false
default:
return unpos[i].racingNum < unpos[j].racingNum
}
})
for i, e := range unpos {
impliedPos := highestExplicit + i + 1
d := m.drivers[e.num]
if d.Position != impliedPos {
d.PrevPosition = d.Position
if d.PrevPosition == 0 {
d.PrevPosition = impliedPos
}
d.Position = impliedPos
m.drivers[e.num] = d
}
}
}
func (m OfficialLiveModel) sortedDrivers() []LiveDriverData {
// Merge timing data with driver list so all known drivers appear,
// even those who have not set a lap time yet (Position == 0).
// In practice/qualifying, recomputeImpliedPositions already placed every
// known driver (including driverInfo-only entries) into m.drivers with a
// correct Position. For races we still merge driverInfo to show drivers
// that haven't received any timing data yet.
merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo))
for num, d := range m.drivers {
merged[num] = d
@@ -844,35 +1091,68 @@ func (m OfficialLiveModel) sortedDrivers() []LiveDriverData {
}
}
var positioned, unpositioned []LiveDriverData
var all []LiveDriverData
for _, d := range merged {
if d.Position > 0 {
positioned = append(positioned, d)
} else {
unpositioned = append(unpositioned, d)
}
all = append(all, d)
}
sort.Slice(positioned, func(i, j int) bool {
return positioned[i].Position < positioned[j].Position
})
sort.Slice(unpositioned, func(i, j int) bool {
sort.Slice(all, func(i, j int) bool {
pi, pj := all[i].Position, all[j].Position
if pi > 0 && pj > 0 {
return pi < pj
}
if pi > 0 {
return true
}
if pj > 0 {
return false
}
// Both unpositioned: sort by best lap time, then racing number
ti, tj := all[i].BestLapTime, all[j].BestLapTime
if ti != "" && tj != "" {
return ti < tj
}
if ti != "" {
return true
}
if tj != "" {
return false
}
var ni, nj int
fmt.Sscanf(unpositioned[i].RacingNumber, "%d", &ni)
fmt.Sscanf(unpositioned[j].RacingNumber, "%d", &nj)
fmt.Sscanf(all[i].RacingNumber, "%d", &ni)
fmt.Sscanf(all[j].RacingNumber, "%d", &nj)
return ni < nj
})
return append(positioned, unpositioned...)
// Back-fill any remaining Position==0 entries so the P column is never blank.
for i := range all {
if all[i].Position == 0 {
all[i].Position = i + 1
}
}
return all
}
// isPracticeOrQuali returns true for Free Practice, Qualifying, and Sprint Qualifying.
// In these sessions the timing tower shows BEST lap time as the primary column.
// isPracticeOrQuali returns true for Free Practice, Qualifying, Sprint Qualifying,
// and Sprint Shootout. In these sessions the timing tower shows BEST lap time
// as the primary column.
func (m OfficialLiveModel) isPracticeOrQuali() bool {
t := strings.ToLower(m.session.SessionType)
return m.isTimeBasedSession(t)
}
// isTimeBasedSession returns true for any session type where positions are
// determined by best lap time rather than by on-track race order. This covers
// Free Practice, Qualifying, Sprint Qualifying, and Sprint Shootout.
// "Sprint" on its own is a race and is NOT included.
func (m OfficialLiveModel) isTimeBasedSession(sessionType string) bool {
t := sessionType
if t == "" {
t = strings.ToLower(m.session.SessionType)
}
return strings.Contains(t, "practice") ||
strings.Contains(t, "qualifying") ||
strings.Contains(t, "sprint") ||
strings.Contains(t, "shootout") ||
t == "fp1" || t == "fp2" || t == "fp3" ||
t == "q" || t == "sq"
}
@@ -969,6 +1249,19 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
m.clockRefTime = msg.ClockRefTime
m.clockExtrapolating = msg.ClockExtrapolating
m.stints = msg.Stints
// Record gap history for trend sparklines
for num, d := range msg.Drivers {
if gap := parseGapToFloat(d.GapToLeader); gap >= 0 {
hist := m.gapHistory[num]
hist = append(hist, gap)
if len(hist) > maxGapHistory {
hist = hist[len(hist)-maxGapHistory:]
}
m.gapHistory[num] = hist
}
}
// Back-fill implied positions for drivers the feed hasn't positioned yet.
m.recomputeImpliedPositions()
m.updateRCViewport()
return m, listenForWSData(m.dataChan)
case tea.KeyMsg:
@@ -1011,13 +1304,35 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
case matchKey(msg, LiveKeys.ToggleRC):
if m.width < 100 {
m.showRC = !m.showRC
if m.showRC {
m.showBattles = false
m.showPitWindow = false
}
}
case matchKey(msg, LiveKeys.ToggleBattles):
m.showBattles = !m.showBattles
if m.showBattles {
m.showRC = false
m.showPitWindow = false
m.expandedDriver = ""
}
case matchKey(msg, LiveKeys.TogglePitWindow):
m.showPitWindow = !m.showPitWindow
if m.showPitWindow {
m.showRC = false
m.showBattles = false
m.expandedDriver = ""
}
case matchKey(msg, LiveKeys.ExpandDriver):
if len(drivers) > 0 && m.cursor < len(drivers) {
m.expandedDriver = drivers[m.cursor].RacingNumber
m.showBattles = false
m.showPitWindow = false
}
case matchKey(msg, LiveKeys.Collapse):
m.expandedDriver = ""
m.showBattles = false
m.showPitWindow = false
case matchKey(msg, LiveKeys.ScrollRCUp):
if m.rcReady {
m.rcView.LineUp(3)
@@ -1067,14 +1382,27 @@ func (m OfficialLiveModel) View() string {
leftWidth := int(float64(w) * 0.6)
rightWidth := w - leftWidth - 4
var rightPanel string
if m.showBattles {
rightPanel = m.renderBattlesPanel(rightWidth)
} else if m.showPitWindow {
rightPanel = m.renderPitWindowPanel(rightWidth)
} else {
rightPanel = m.renderRightPanel(rightWidth)
}
panels := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderTimingTower(leftWidth),
" ",
m.renderRightPanel(rightWidth),
rightPanel,
)
sb.WriteString(panels)
} else {
if m.showRC {
if m.showBattles {
sb.WriteString(m.renderBattlesPanel(w - 2))
} else if m.showPitWindow {
sb.WriteString(m.renderPitWindowPanel(w - 2))
} else if m.showRC {
sb.WriteString(m.renderRCContent())
} else {
sb.WriteString(m.renderTimingTower(w - 2))
@@ -1083,9 +1411,9 @@ func (m OfficialLiveModel) View() string {
sb.WriteString("\n")
if wide {
sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "K/J race ctrl", "1-6 tabs", "q quit"))
sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "b battles", "p pit win", "K/J race ctrl", "1-7 tabs", "q quit"))
} else {
sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "r toggle RC", "1-6 tabs", "q quit"))
sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "b battles", "p pit win", "r RC", "1-7 tabs", "q quit"))
}
return sb.String()
@@ -1232,10 +1560,10 @@ func (m OfficialLiveModel) renderTimingTower(w int) string {
padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1),
padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10))
} else {
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s",
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
padRight("TYRE", 5), padRight("AGE", 3), padRight("LAST", 10),
padRight("GAP", 10), padRight("INT", 10))
padRight("GAP", 10), padRight("INT", 10), padRight("TREND", 8))
}
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
sb.WriteString(" " + divider(min(w-4, lipgloss.Width(header))) + "\n")
@@ -1355,13 +1683,18 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row)
}
} else {
// Race mode: LAST + GAP + INT
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s",
// Race mode: LAST + GAP + INT + TREND
trend := ""
if hist, ok := m.gapHistory[d.RacingNumber]; ok && d.Position > 1 {
trend = gapTrendIndicator(hist)
}
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr, tyreStr,
m.renderTyreAge(d.RacingNumber),
lastLap,
m.renderGapStr(d, fpq),
m.renderIntvStr(d))
m.renderIntvStr(d),
padRightVisible(trend, 8))
}
if idx == m.cursor {
@@ -1395,9 +1728,20 @@ func (m OfficialLiveModel) renderGapStr(d LiveDriverData, fpq bool) string {
}
return padRightVisible(styleLeader.Render("LEADER"), 10)
}
// Try the feed-supplied gap first.
if g := parseGap(d.GapToLeader); g != "" {
return padRightVisible(styleGap.Render(g), 10)
}
// In practice/qualifying, compute gap from best lap times when the feed
// doesn't supply one.
if fpq {
if g := computeGapFromBestLap(d.BestLapTime, m.overallBestLapTime()); g != "" {
return padRightVisible(styleGap.Render(g), 10)
}
if d.BestLapTime == "" {
return padRight(styleMuted.Render("no time"), 10)
}
}
return padRight("", 10)
}
@@ -1429,7 +1773,7 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string {
func (m OfficialLiveModel) renderTyreAge(num string) string {
tyre, ok := m.tyres[num]
if !ok {
if !ok || tyre.Age == 0 {
return padRight("", 3)
}
@@ -1525,6 +1869,20 @@ func (m OfficialLiveModel) renderRightPanel(w int) string {
return sb.String()
}
// renderBattlesPanel is a thin receiver wrapper around the free renderBattlePanel function.
func (m OfficialLiveModel) renderBattlesPanel(width int) string {
drivers := m.sortedDrivers()
battles := detectBattles(drivers, m.driverInfo, m.tyres, m.stints, m.gapHistory)
isRace := !m.isPracticeOrQuali()
return renderBattlePanel(battles, width, isRace)
}
// renderPitWindowPanel is a thin receiver wrapper around the free renderPitWindowPanel function.
func (m OfficialLiveModel) renderPitWindowPanel(width int) string {
isRace := !m.isPracticeOrQuali()
return renderPitWindowPanel(m.drivers, m.driverInfo, m.session.CircuitName, isRace, width)
}
func (m OfficialLiveModel) renderDriverDetail(w int) string {
num := m.expandedDriver
d, ok := m.drivers[num]

307
internal/ui/pitwindow.go Normal file
View File

@@ -0,0 +1,307 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Pit loss table — average pit lane + stationary time per circuit.
// These are sensible defaults derived from historical F1 data.
// All times are in seconds.
// ---------------------------------------------------------------------------
// pitLossTable maps circuit short names (lowercased) to average pit loss time.
// Pit loss = pit lane traversal time delta vs staying on track at race pace.
var pitLossTable = map[string]float64{
"monza": 17.0,
"spa": 21.0,
"silverstone": 22.0,
"monaco": 25.0,
"singapore": 30.0,
"baku": 24.0,
"montreal": 23.0,
"austin": 23.5,
"mexico": 21.0,
"interlagos": 24.0,
"suzuka": 22.5,
"bahrain": 22.0,
"jeddah": 21.5,
"melbourne": 25.0,
"shanghai": 25.0,
"miami": 24.5,
"barcelona": 22.0,
"budapest": 25.5,
"zandvoort": 24.5,
"abu dhabi": 23.0,
"las vegas": 26.0,
"imola": 25.0,
"lusail": 22.0,
}
const defaultPitLoss = 23.0 // fallback when circuit not in table
// pitWindowLookupLoss returns the pit loss for the current circuit name.
// Falls back to defaultPitLoss.
func pitWindowLookupLoss(circuitName string) float64 {
lower := strings.ToLower(circuitName)
for k, v := range pitLossTable {
if strings.Contains(lower, k) {
return v
}
}
return defaultPitLoss
}
// ---------------------------------------------------------------------------
// Pit window prediction logic
// ---------------------------------------------------------------------------
// PitPrediction describes the predicted outcome if a given driver pits now.
type PitPrediction struct {
DriverNum string
DriverTLA string
TeamColor string
PredictedP int // predicted position after rejoin
RejoinGap float64 // gap to the car they'd rejoin behind (seconds, +ve = behind)
TightestCar string // TLA of the nearest rival after rejoin
PitLoss float64 // pit stop time cost used in this calculation
}
// computePitWindow calculates the predicted pit window for every positioned
// driver that is currently on track (not in pit, not retired).
//
// Algorithm:
// 1. For each driver D, simulate their position after a pit stop of pitLoss seconds.
// 2. For each rival R ahead of D: if gap(D→R) + pitLoss > 0, D rejoins behind R.
// 3. For each rival R behind D: if gap(D→R) - pitLoss < threshold, R may undercut D.
// 4. Return the predicted finishing position after the pit.
func computePitWindow(
drivers map[string]LiveDriverData,
driverInfo map[string]F1DriverListEntry,
pitLoss float64,
) []PitPrediction {
if len(drivers) == 0 {
return nil
}
// Sort all on-track drivers by position
var sorted []LiveDriverData
for _, d := range drivers {
if d.Position > 0 && !d.Retired && !d.InPit {
sorted = append(sorted, d)
}
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Position < sorted[j].Position
})
// Build gap-to-leader map (seconds, -1 = leader)
gapToLeader := make(map[string]float64, len(sorted))
for _, d := range sorted {
if d.Position == 1 {
gapToLeader[d.RacingNumber] = 0
} else {
g := parseGapToFloat(d.GapToLeader)
if g < 0 {
g = 9999 // lapped car
}
gapToLeader[d.RacingNumber] = g
}
}
var predictions []PitPrediction
for _, d := range sorted {
dGap := gapToLeader[d.RacingNumber]
if dGap == 9999 {
continue // don't predict for lapped cars
}
// Gap after pit stop = original gap + pitLoss (D is now pitLoss seconds further back)
gapAfterPit := dGap + pitLoss
// Count rivals ahead that D will now be behind
predictedPos := 1
var rejoinBehind LiveDriverData
tightestGap := 9999.0
for _, rival := range sorted {
if rival.RacingNumber == d.RacingNumber {
continue
}
rGap := gapToLeader[rival.RacingNumber]
if rGap == 9999 {
continue
}
// Rival is ahead if their gap < gapAfterPit
if rGap < gapAfterPit {
predictedPos++
// Track the rival we'd rejoin closest behind
behind := gapAfterPit - rGap
if behind < tightestGap {
tightestGap = behind
rejoinBehind = rival
}
}
}
// Team color
teamColor := colorMuted
tla := d.RacingNumber
if info, ok := driverInfo[d.RacingNumber]; ok {
if info.Tla != "" {
tla = info.Tla
}
if info.TeamColour != "" {
teamColor = "#" + info.TeamColour
} else if info.TeamName != "" {
teamColor = teamColorFromName(info.TeamName)
}
}
tightestTLA := ""
if rejoinBehind.RacingNumber != "" {
if info, ok := driverInfo[rejoinBehind.RacingNumber]; ok && info.Tla != "" {
tightestTLA = info.Tla
} else {
tightestTLA = rejoinBehind.RacingNumber
}
}
rejoinGap := 0.0
if tightestGap < 9999 {
rejoinGap = tightestGap
}
predictions = append(predictions, PitPrediction{
DriverNum: d.RacingNumber,
DriverTLA: tla,
TeamColor: teamColor,
PredictedP: predictedPos,
RejoinGap: rejoinGap,
TightestCar: tightestTLA,
PitLoss: pitLoss,
})
}
// Sort predictions by current position (same as sorted)
sort.Slice(predictions, func(i, j int) bool {
di, _ := drivers[predictions[i].DriverNum]
dj, _ := drivers[predictions[j].DriverNum]
return di.Position < dj.Position
})
return predictions
}
// ---------------------------------------------------------------------------
// Pit window view renderer
// ---------------------------------------------------------------------------
// renderPitWindowPanel renders the full pit window calculator panel.
func renderPitWindowPanel(
drivers map[string]LiveDriverData,
driverInfo map[string]F1DriverListEntry,
circuitName string,
isRace bool,
width int,
) string {
var sb strings.Builder
pitLoss := pitWindowLookupLoss(circuitName)
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)).
Render("🔧 PIT WINDOW CALCULATOR")
meta := styleMuted.Render(fmt.Sprintf("circuit: %s pit loss: %.1fs",
circuitNameShort(circuitName), pitLoss))
sb.WriteString(" " + title + "\n")
sb.WriteString(" " + meta + "\n")
sb.WriteString(" " + divider(min(width-4, 70)) + "\n")
if !isRace {
sb.WriteString("\n" + styleMuted.Render(" Pit window calculator is only available during Race sessions.\n"))
return sb.String()
}
if len(drivers) == 0 {
sb.WriteString("\n" + styleMuted.Render(" Waiting for timing data...\n"))
return sb.String()
}
predictions := computePitWindow(drivers, driverInfo, pitLoss)
if len(predictions) == 0 {
sb.WriteString("\n" + styleMuted.Render(" Not enough timing data to compute pit window.\n"))
return sb.String()
}
// Table header
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %-4s %-4s %-6s %-6s %s\n",
"NOW", "TLA", "→ P", "GAP", "REJOINS BEHIND")))
sb.WriteString(" " + divider(min(width-4, 55)) + "\n")
for _, p := range predictions {
d := drivers[p.DriverNum]
curPosStr := renderPosition(d.Position)
predPosStr := renderPosition(p.PredictedP)
tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.TeamColor))
tlaRendered := tlaStyle.Render(padRight(p.DriverTLA, 4))
// Position change indicator
diff := d.Position - p.PredictedP // negative = losing positions
var posChange string
switch {
case diff > 0:
posChange = styleDeltaUp.Render(fmt.Sprintf("▲%d", diff))
case diff < 0:
posChange = styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff))
default:
posChange = styleDeltaEqual.Render("─")
}
// Rejoin gap
gapStr := styleMuted.Render(" -")
if p.RejoinGap > 0 {
gapStr = styleGap.Render(fmt.Sprintf("+%.1fs", p.RejoinGap))
}
// Rejoin target
rejoinStr := ""
if p.TightestCar != "" {
rejoinStr = styleMuted.Render("behind ") + styleBold.Render(p.TightestCar)
} else if p.PredictedP == 1 {
rejoinStr = styleLeader.Render("LEADS")
}
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s\n",
padRightVisible(curPosStr, 4),
tlaRendered,
padRightVisible(predPosStr, 2),
padRightVisible(posChange, 3),
padRightVisible(gapStr, 7),
rejoinStr,
))
}
sb.WriteString("\n")
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Assumes %.1fs pit loss. Gaps are approximate.", pitLoss)))
sb.WriteString("\n")
return sb.String()
}
// circuitNameShort returns a display-friendly short name for a circuit.
func circuitNameShort(name string) string {
if name == "" {
return "Unknown"
}
if len(name) > 20 {
return name[:20] + "…"
}
return name
}

View File

@@ -17,12 +17,13 @@ type RaceDetailModel struct {
client *api.OpenF1Client
meeting *models.Meeting
sessions []models.Session
results []models.SessionResult
drivers map[int]models.Driver
rcMsgs []models.RaceControl
weather []models.Weather
overtakes []models.Overtake
sessions []models.Session
results []models.SessionResult
drivers map[int]models.Driver
startingGrid []models.StartingGrid
rcMsgs []models.RaceControl
weather []models.Weather
overtakes []models.Overtake
selectedSession *models.Session
sessionCursor int
@@ -40,6 +41,8 @@ type RaceDetailModel struct {
rcView viewport.Model
rcReady bool
replay ReplayModel
width int
height int
}
@@ -53,6 +56,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
client: client,
spinner: s,
drivers: make(map[int]models.Driver),
replay: NewReplayModel(client),
}
}
@@ -82,9 +86,9 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
)
}
// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes).
func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return tea.Batch(
// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes, starting grid).
func fetchSecondaryData(client *api.OpenF1Client, sessionKey int, isRace bool) tea.Cmd {
cmds := []tea.Cmd{
func() tea.Msg {
msgs, err := client.GetRaceControl(sessionKey)
return raceControlLoadedMsg{messages: msgs, err: err}
@@ -97,7 +101,15 @@ func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
overtakes, err := client.GetOvertakesForSession(sessionKey)
return overtakesLoadedMsg{overtakes: overtakes, err: err}
},
)
}
// Starting grid is only relevant for Race sessions — shows qualifying order
if isRace {
cmds = append(cmds, func() tea.Msg {
grid, err := client.GetStartingGrid(sessionKey)
return startingGridLoadedMsg{grid: grid, err: err}
})
}
return tea.Batch(cmds...)
}
func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
@@ -113,11 +125,21 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
}
// Also forward to replay spinner
var cmd tea.Cmd
m.replay, cmd = m.replay.Update(msg)
cmds = append(cmds, cmd)
case replayDataLoadedMsg:
var cmd tea.Cmd
m.replay, cmd = m.replay.Update(msg)
cmds = append(cmds, cmd)
case meetingSelectedMsg:
m.meeting = &msg.meeting
m.sessions = nil
m.results = nil
m.startingGrid = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
@@ -168,6 +190,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.driversLoaded = false
m.secondaryLoading = false
m.results = nil
m.startingGrid = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
@@ -202,7 +225,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
case loadSecondaryDataMsg:
// Only load secondary data if it's still for the currently selected session
if m.selectedSession != nil && m.selectedSession.SessionKey == msg.sessionKey {
cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey))
isRace := m.selectedSession.SessionType == "Race"
cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey, isRace))
}
case raceControlLoadedMsg:
@@ -221,7 +245,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.overtakes = msg.overtakes
}
case startingGridLoadedMsg:
if msg.err == nil {
m.startingGrid = msg.grid
}
case tea.KeyMsg:
// When replay is active, route keys to replay model; only 'b'/'esc' exits.
if m.replay.IsActive() {
if matchKey(msg, GlobalKeys.Back) {
m.replay = m.replay.Exit()
return m, nil
}
var cmd tea.Cmd
m.replay, cmd = m.replay.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
switch {
case matchKey(msg, GlobalKeys.Retry):
if m.errSessions != nil && m.meeting != nil {
@@ -282,6 +323,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.secondaryLoading = false
m.errResults = nil
m.results = nil
m.startingGrid = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
@@ -305,6 +347,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.secondaryLoading = false
m.errResults = nil
m.results = nil
m.startingGrid = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
@@ -325,6 +368,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.secondaryLoading = false
m.errResults = nil
m.results = nil
m.startingGrid = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
@@ -334,6 +378,15 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
}
}
case matchKey(msg, RaceDetailKeys.Replay):
// Only enter replay for Race sessions with data loaded
if m.selectedSession != nil &&
m.selectedSession.SessionType == "Race" &&
!m.loadingResults {
var cmd tea.Cmd
m.replay, cmd = m.replay.Enter(m.selectedSession.SessionKey, m.selectedSession.SessionName)
cmds = append(cmds, cmd)
}
}
}
@@ -399,6 +452,14 @@ func (m RaceDetailModel) View() string {
helpBar("2 calendar", "q quit")
}
// When replay is active, show the replay pane instead of normal race detail.
if m.replay.IsActive() {
m2 := m.replay
m2.width = m.width
m2.height = m.height
return m2.View()
}
w := m.width
if w < 40 {
w = 40
@@ -436,6 +497,10 @@ func (m RaceDetailModel) View() string {
sb.WriteString("\n")
sb.WriteString(m.renderWeatherCard(w - 4))
sb.WriteString("\n")
if len(m.startingGrid) > 0 {
sb.WriteString(m.renderStartingGrid(w - 4))
sb.WriteString("\n")
}
sb.WriteString(m.renderOvertakes(w - 4))
sb.WriteString("\n")
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
@@ -469,7 +534,7 @@ func (m RaceDetailModel) View() string {
sb.WriteString(panels + "\n")
}
sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit"))
sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "r replay", "b back", "q quit"))
return sb.String()
}
@@ -684,6 +749,12 @@ func (m RaceDetailModel) renderRightPanel(width int) string {
sb.WriteString(m.renderWeatherCard(width))
sb.WriteString("\n")
// Starting grid (Race sessions only)
if len(m.startingGrid) > 0 {
sb.WriteString(m.renderStartingGrid(width))
sb.WriteString("\n")
}
// Overtakes summary
sb.WriteString(m.renderOvertakes(width))
sb.WriteString("\n")
@@ -922,3 +993,74 @@ func (m RaceDetailModel) renderOvertakes(width int) string {
return sb.String()
}
// renderStartingGrid renders the qualifying-based starting grid for a Race session.
func (m RaceDetailModel) renderStartingGrid(width int) string {
var sb strings.Builder
sb.WriteString(styleSectionTitle.Render("STARTING GRID") + "\n")
if len(m.startingGrid) == 0 {
sb.WriteString(styleMuted.Render(" No grid data.\n"))
return sb.String()
}
// Find the pole time to compute deltas
var poleTime float64
for _, g := range m.startingGrid {
if g.Position == 1 {
poleTime = g.LapDuration
break
}
}
// Show top entries (limit to keep the panel compact)
maxShow := 10
if len(m.startingGrid) < maxShow {
maxShow = len(m.startingGrid)
}
for i := 0; i < maxShow; i++ {
g := m.startingGrid[i]
d := m.drivers[g.DriverNumber]
acronym := d.NameAcronym
if acronym == "" {
acronym = fmt.Sprintf("#%d", g.DriverNumber)
}
teamColor := colorMuted
if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else if d.TeamName != "" {
teamColor = teamColorFromName(d.TeamName)
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
pos := renderPosition(g.Position)
// Qualifying time or delta to pole
var timeStr string
if g.LapDuration <= 0 {
timeStr = styleMuted.Render("no time")
} else if g.Position == 1 {
timeStr = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true).Render(formatSeconds(g.LapDuration))
} else if poleTime > 0 {
delta := g.LapDuration - poleTime
timeStr = styleGap.Render(fmt.Sprintf("+%.3fs", delta))
} else {
timeStr = formatSeconds(g.LapDuration)
}
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
padRightVisible(pos, 3),
colorBar,
padRight(acronym, 4),
timeStr,
))
}
if len(m.startingGrid) > maxShow {
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more\n", len(m.startingGrid)-maxShow)))
}
return sb.String()
}

568
internal/ui/replay.go Normal file
View File

@@ -0,0 +1,568 @@
package ui
import (
"fmt"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Replay data structures
// ---------------------------------------------------------------------------
// ReplayLapSnapshot holds a full field snapshot at the end of a given lap.
type ReplayLapSnapshot struct {
LapNumber int
// Positions: driver number → position at end of this lap
Positions map[int]int
// GapsToLeader: driver number → gap to leader in seconds (-1 = leader/lap down)
GapsToLeader map[int]float64
// PitsThisLap: driver numbers who pitted on this lap
PitsThisLap []int
// PitDurations: driver number → stop duration for this lap (0 if no pit)
PitDurations map[int]float64
// RCMessages this lap
RCMessages []models.RaceControl
// Weather snapshot (last reading before/at this lap)
Weather *models.Weather
// LapTimes: driver number → lap duration this lap (0 if unknown)
LapTimes map[int]float64
}
// ReplayData is the full pre-processed replay dataset for a race session.
type ReplayData struct {
SessionKey int
SessionName string
TotalLaps int
Drivers map[int]models.Driver // driver number → Driver
Snapshots []ReplayLapSnapshot // index 0 = lap 1
}
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
// replayLoadMsg is sent when the user presses `r` on a race session to trigger
// a lazy data load.
type replayLoadMsg struct {
sessionKey int
sessionName string
}
// replayDataLoadedMsg is the async response with the fully processed ReplayData.
type replayDataLoadedMsg struct {
data *ReplayData
err error
}
// ---------------------------------------------------------------------------
// Async fetch command
// ---------------------------------------------------------------------------
func fetchReplayData(client *api.OpenF1Client, sessionKey int, sessionName string) tea.Cmd {
return func() tea.Msg {
data, err := buildReplayData(client, sessionKey, sessionName)
return replayDataLoadedMsg{data: data, err: err}
}
}
func buildReplayData(client *api.OpenF1Client, sessionKey int, sessionName string) (*ReplayData, error) {
// Fetch all required data concurrently via goroutines with a simple fan-in.
type result struct {
tag string
val interface{}
err error
}
ch := make(chan result, 5)
go func() {
v, err := client.GetDriversForSession(sessionKey)
ch <- result{"drivers", v, err}
}()
go func() {
v, err := client.GetPositions(sessionKey, 0)
ch <- result{"positions", v, err}
}()
go func() {
v, err := client.GetLapsForSession(sessionKey)
ch <- result{"laps", v, err}
}()
go func() {
v, err := client.GetPitStopsForSession(sessionKey)
ch <- result{"pits", v, err}
}()
go func() {
v, err := client.GetRaceControl(sessionKey)
ch <- result{"rc", v, err}
}()
var (
driverList []models.Driver
positions []models.Position
laps []models.Lap
pits []models.Pit
rcMsgs []models.RaceControl
)
for i := 0; i < 5; i++ {
r := <-ch
if r.err != nil {
return nil, fmt.Errorf("replay fetch %s: %w", r.tag, r.err)
}
switch r.tag {
case "drivers":
driverList = r.val.([]models.Driver)
case "positions":
positions = r.val.([]models.Position)
case "laps":
laps = r.val.([]models.Lap)
case "pits":
pits = r.val.([]models.Pit)
case "rc":
rcMsgs = r.val.([]models.RaceControl)
}
}
// Build driver map
drivers := make(map[int]models.Driver, len(driverList))
for _, d := range driverList {
drivers[d.DriverNumber] = d
}
// Determine total laps from lap data
totalLaps := 0
for _, l := range laps {
if l.LapNumber > totalLaps {
totalLaps = l.LapNumber
}
}
if totalLaps == 0 {
totalLaps = 1
}
// Build per-lap position snapshots from the position stream.
// The position stream provides the position of each driver at timestamps.
// We bucket positions by lap number: for each driver, their position at
// the end of each lap is the last recorded position entry whose timestamp
// falls before or at the next lap's start timestamp.
//
// Strategy: parse lap start times per driver, then for each lap find the
// latest position reading before that driver's next lap start.
// lap start times: driverNum → lapNum → DateStart
lapStartByDriver := make(map[int]map[int]time.Time)
for _, l := range laps {
if _, ok := lapStartByDriver[l.DriverNumber]; !ok {
lapStartByDriver[l.DriverNumber] = make(map[int]time.Time)
}
t, err := time.Parse(time.RFC3339, l.DateStart)
if err == nil {
lapStartByDriver[l.DriverNumber][l.LapNumber] = t.UTC()
}
}
// Sort position stream per driver by time
type posEntry struct {
t time.Time
pos int
}
posByDriver := make(map[int][]posEntry)
for _, p := range positions {
t, err := time.Parse(time.RFC3339, p.Date)
if err != nil {
continue
}
posByDriver[p.DriverNumber] = append(posByDriver[p.DriverNumber], posEntry{t.UTC(), p.Position})
}
for dn := range posByDriver {
sort.Slice(posByDriver[dn], func(i, j int) bool {
return posByDriver[dn][i].t.Before(posByDriver[dn][j].t)
})
}
// Lap time per driver per lap
lapTimeMap := make(map[int]map[int]float64) // driverNum → lapNum → seconds
for _, l := range laps {
if l.LapDuration == nil || *l.LapDuration <= 0 || l.IsPitOutLap {
continue
}
if _, ok := lapTimeMap[l.DriverNumber]; !ok {
lapTimeMap[l.DriverNumber] = make(map[int]float64)
}
lapTimeMap[l.DriverNumber][l.LapNumber] = *l.LapDuration
}
// Pit stop map: driverNum → lapNum → stop duration
pitMap := make(map[int]map[int]float64)
for _, p := range pits {
if _, ok := pitMap[p.DriverNumber]; !ok {
pitMap[p.DriverNumber] = make(map[int]float64)
}
dur := p.StopDuration
if dur == 0 {
dur = p.PitDuration
}
pitMap[p.DriverNumber][p.LapNumber] = dur
}
// RC messages per lap
rcByLap := make(map[int][]models.RaceControl)
for _, rc := range rcMsgs {
lap := 0
if rc.LapNumber != nil {
lap = *rc.LapNumber
}
rcByLap[lap] = append(rcByLap[lap], rc)
}
// Build snapshots for each lap
snapshots := make([]ReplayLapSnapshot, totalLaps)
for lapIdx := 0; lapIdx < totalLaps; lapIdx++ {
lapNum := lapIdx + 1
snap := ReplayLapSnapshot{
LapNumber: lapNum,
Positions: make(map[int]int),
GapsToLeader: make(map[int]float64),
PitDurations: make(map[int]float64),
LapTimes: make(map[int]float64),
}
// Get position of each driver at end of this lap.
// Use the last position reading before (lapNum+1)'s start time for each driver.
for dn, entries := range posByDriver {
// Find the upper bound time: start of next lap for this driver
var upperBound time.Time
if nextStart, ok := lapStartByDriver[dn][lapNum+1]; ok {
upperBound = nextStart
} else if lapStart, ok := lapStartByDriver[dn][lapNum]; ok {
// No next lap start — use current lap start + 2 min as safety bound
upperBound = lapStart.Add(2 * time.Minute)
} else {
// No timing at all, use last entry
upperBound = time.Now()
}
// Binary search for last entry before upperBound
lastPos := 0
for _, e := range entries {
if e.t.Before(upperBound) {
lastPos = e.pos
}
}
if lastPos > 0 {
snap.Positions[dn] = lastPos
}
}
// Compute approximate gaps to leader from positions.
// We derive gap from the accumulated lap-time differences — a rough but
// useful reconstruction. Set leader gap = 0, others accumulate based on
// position ordering relative to leader average lap pace.
// For simplicity we store 0 = leader.
for dn := range snap.Positions {
snap.GapsToLeader[dn] = -1 // will be filled per position order
}
// Pits this lap
for dn, lapPits := range pitMap {
if dur, ok := lapPits[lapNum]; ok {
snap.PitsThisLap = append(snap.PitsThisLap, dn)
snap.PitDurations[dn] = dur
}
}
sort.Ints(snap.PitsThisLap)
// Lap times this lap
for dn, lapTimes := range lapTimeMap {
if lt, ok := lapTimes[lapNum]; ok {
snap.LapTimes[dn] = lt
}
}
// RC messages this lap
snap.RCMessages = rcByLap[lapNum]
snapshots[lapIdx] = snap
}
return &ReplayData{
SessionKey: sessionKey,
SessionName: sessionName,
TotalLaps: totalLaps,
Drivers: drivers,
Snapshots: snapshots,
}, nil
}
// ---------------------------------------------------------------------------
// ReplayModel — Bubble Tea model for the replay UI
// ---------------------------------------------------------------------------
// ReplayState tracks what mode the race-detail tab's replay sub-component is in.
type ReplayState int
const (
ReplayStateInactive ReplayState = iota // not in replay mode
ReplayStateLoading // data fetch in flight
ReplayStateActive // showing replay
)
type ReplayModel struct {
client *api.OpenF1Client
state ReplayState
data *ReplayData
err error
spinner spinner.Model
// Current scrub position (0-indexed into data.Snapshots)
cursor int
// Number of visible rows in content area
height int
width int
}
func NewReplayModel(client *api.OpenF1Client) ReplayModel {
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return ReplayModel{
client: client,
state: ReplayStateInactive,
spinner: sp,
}
}
// IsActive returns true when the replay pane is active (loading or showing).
func (m ReplayModel) IsActive() bool {
return m.state != ReplayStateInactive
}
// Enter triggers a data load for the given race session.
func (m ReplayModel) Enter(sessionKey int, sessionName string) (ReplayModel, tea.Cmd) {
m.state = ReplayStateLoading
m.data = nil
m.err = nil
m.cursor = 0
return m, tea.Batch(
fetchReplayData(m.client, sessionKey, sessionName),
m.spinner.Tick,
)
}
// Exit resets replay to inactive.
func (m ReplayModel) Exit() ReplayModel {
m.state = ReplayStateInactive
m.data = nil
m.err = nil
return m
}
func (m ReplayModel) Update(msg tea.Msg) (ReplayModel, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
if m.state == ReplayStateLoading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case replayDataLoadedMsg:
if msg.err != nil {
m.err = msg.err
m.state = ReplayStateActive // show error in active state
return m, nil
}
m.data = msg.data
m.state = ReplayStateActive
// Start at lap 1
m.cursor = 0
return m, nil
case tea.KeyMsg:
if m.state != ReplayStateActive || m.data == nil {
return m, nil
}
switch {
case matchKey(msg, replayKeyLeft):
if m.cursor > 0 {
m.cursor--
}
case matchKey(msg, replayKeyRight):
if m.cursor < m.data.TotalLaps-1 {
m.cursor++
}
case matchKey(msg, replayKeyStart):
m.cursor = 0
case matchKey(msg, replayKeyEnd):
if m.data.TotalLaps > 0 {
m.cursor = m.data.TotalLaps - 1
}
}
}
return m, nil
}
// replayKey helpers — local bindings for the replay scrubber.
var (
replayKeyLeft = mustNewBinding("left", "h")
replayKeyRight = mustNewBinding("right", "l")
replayKeyStart = mustNewBinding("g", "home")
replayKeyEnd = mustNewBinding("G", "end")
)
// simpleBinding is a minimal implementation of the Keys() interface.
type simpleBinding struct{ keys []string }
func (s simpleBinding) Keys() []string { return s.keys }
func mustNewBinding(keys ...string) simpleBinding {
return simpleBinding{keys: keys}
}
// View renders the replay pane for embedding into the RaceDetail view.
func (m ReplayModel) View() string {
switch m.state {
case ReplayStateInactive:
return ""
case ReplayStateLoading:
return fmt.Sprintf("\n %s Loading replay data...\n", m.spinner.View())
case ReplayStateActive:
if m.err != nil {
return renderErrorView(m.err)
}
if m.data == nil {
return styleMuted.Render("\n No replay data.\n")
}
return m.renderReplay()
}
return ""
}
func (m ReplayModel) renderReplay() string {
var sb strings.Builder
data := m.data
if m.cursor >= len(data.Snapshots) {
return styleMuted.Render("\n No snapshot data for this lap.\n")
}
snap := data.Snapshots[m.cursor]
// ── Header ────────────────────────────────────────────────────────────────
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)).
Render("⏪ RACE REPLAY")
lapBadge := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorYellow)).
Render(fmt.Sprintf("LAP %d / %d", snap.LapNumber, data.TotalLaps))
// Progress bar
barWidth := 24
filled := 0
if data.TotalLaps > 0 {
filled = int(float64(snap.LapNumber) / float64(data.TotalLaps) * float64(barWidth))
}
if filled > barWidth {
filled = barWidth
}
bar := stylePointsBarFilled.Render(strings.Repeat("█", filled)) +
stylePointsBarEmpty.Render(strings.Repeat("░", barWidth-filled))
sb.WriteString(fmt.Sprintf("\n %s %s %s\n", title, lapBadge, bar))
sb.WriteString(" " + divider(min(m.width-4, 72)) + "\n")
// ── Field snapshot ────────────────────────────────────────────────────────
// Sort drivers by position at this lap
type driverPos struct {
driverNum int
pos int
}
var sorted []driverPos
for dn, pos := range snap.Positions {
sorted = append(sorted, driverPos{dn, pos})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].pos < sorted[j].pos
})
// Table header
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %-4s %-4s %-18s %-12s %s\n",
"POS", "NO", "DRIVER", "TIME", "PIT")))
sb.WriteString(" " + divider(min(m.width-4, 60)) + "\n")
for _, dp := range sorted {
d, ok := data.Drivers[dp.driverNum]
name := fmt.Sprintf("#%d", dp.driverNum)
teamColor := colorMuted
if ok {
name = d.NameAcronym
if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else {
teamColor = teamColorFromName(d.TeamName)
}
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
nameStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).Render(padRight(name, 4))
posStyled := renderPosition(dp.pos)
// Lap time
ltStr := styleMuted.Render(" --")
if lt, ok := snap.LapTimes[dp.driverNum]; ok && lt > 0 {
ltStr = padRight(formatSeconds(lt), 12)
}
// Pit this lap
pitStr := ""
if dur, ok := snap.PitDurations[dp.driverNum]; ok && dur > 0 {
pitStr = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Bold(true).
Render(fmt.Sprintf("PIT %.1fs", dur))
}
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s\n",
padRightVisible(posStyled, 4),
colorBar,
nameStyled,
ltStr,
pitStr,
))
}
// ── RC messages this lap ─────────────────────────────────────────────────
if len(snap.RCMessages) > 0 {
sb.WriteString("\n " + styleSectionTitle.Render("RACE CONTROL") + "\n")
for _, rc := range snap.RCMessages {
icon := " "
var flagStyle lipgloss.Style
switch rc.Flag {
case "GREEN":
icon, flagStyle = "🟢", styleFlagGreen
case "YELLOW", "DOUBLE YELLOW":
icon, flagStyle = "🟡", styleFlagYellow
case "RED":
icon, flagStyle = "🔴", styleFlagRed
case "BLUE":
icon, flagStyle = "🔵", styleFlagBlue
case "CHEQUERED":
icon = "🏁"
flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true)
default:
flagStyle = styleMuted
}
sb.WriteString(fmt.Sprintf(" %s%s\n",
flagStyle.Render(icon+" "),
rc.Message))
}
}
// ── Help bar ──────────────────────────────────────────────────────────────
sb.WriteString("\n")
sb.WriteString(helpBar("←/h prev lap", "→/l next lap", "g first", "G last", "b back to race"))
return sb.String()
}

599
internal/ui/trackmap.go Normal file
View File

@@ -0,0 +1,599 @@
package ui
import (
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Track Map model
// ---------------------------------------------------------------------------
// trackPoint is a normalized (col, row) point in the terminal canvas.
type trackPoint struct {
col, row int
}
// TrackMapModel renders an ASCII track outline with live car positions.
type TrackMapModel struct {
client *api.OpenF1Client
width int
height int
// Resolved session key used to fetch location data
sessionKey int
// Track outline (normalized points from driver 1's path)
outline []trackPoint
// Bounds of the raw coordinate space (filled during normalization)
rawMinX, rawMaxX float64
rawMinY, rawMaxY float64
// Car positions: driver number → latest location
carPositions map[int]models.Location
// Driver info from OfficialLiveModel (injected on each render)
driverInfo map[string]F1DriverListEntry
// State machine
loadingSession bool // true while resolving the active session key
loadingOutline bool
loadingCars bool
outlineReady bool
err error
spinner spinner.Model
}
func NewTrackMapModel(client *api.OpenF1Client) TrackMapModel {
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return TrackMapModel{
client: client,
spinner: sp,
carPositions: make(map[int]models.Location),
driverInfo: make(map[string]F1DriverListEntry),
}
}
// HasSession returns true if a session key is already set.
func (m TrackMapModel) HasSession() bool {
return m.sessionKey != 0
}
// FetchActiveSession fetches the currently active session from OpenF1 and sets it.
func (m *TrackMapModel) FetchActiveSession(client *api.OpenF1Client) (TrackMapModel, tea.Cmd) {
year := time.Now().Year()
m.loadingSession = true
m.err = nil
cmd := tea.Batch(m.fetchActiveSession(client, year), m.spinner.Tick)
return *m, cmd
}
func (m *TrackMapModel) fetchActiveSession(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg {
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return trackOutlineLoadedMsg{err: err}
}
now := time.Now()
var currentMtg *models.Meeting
for i := range meetings {
end, _ := time.Parse(time.RFC3339, meetings[i].DateEnd)
if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour {
currentMtg = &meetings[i]
break
}
}
if currentMtg == nil {
return trackOutlineLoadedMsg{err: fmt.Errorf("no active weekend found")}
}
sessions, err := client.GetSessionsForMeeting(int(currentMtg.MeetingKey))
if err != nil {
return trackOutlineLoadedMsg{err: err}
}
var activeSess *models.Session
for i := range sessions {
st, _ := time.Parse(time.RFC3339, sessions[i].DateStart)
en, _ := time.Parse(time.RFC3339, sessions[i].DateEnd)
if now.After(st.Local()) && now.Before(en.Local().Add(2*time.Hour)) {
activeSess = &sessions[i]
}
}
if activeSess == nil && len(sessions) > 0 {
activeSess = &sessions[len(sessions)-1]
}
if activeSess == nil {
return trackOutlineLoadedMsg{err: fmt.Errorf("no active session found")}
}
return sessionKeyMsg{sessionKey: activeSess.SessionKey}
}
}
type sessionKeyMsg struct {
sessionKey int
}
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
type trackOutlineLoadedMsg struct {
locations []models.Location
err error
}
type trackCarsLoadedMsg struct {
locations []models.Location
err error
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
// fetchTrackOutline downloads location data for a single reference driver
// (driver 1 by convention, then any driver if 1 is absent) to build the
// track outline for the given session.
func fetchTrackOutline(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return func() tea.Msg {
// Try a set of likely driver numbers to find one with location data.
candidates := []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
for _, dn := range candidates {
locs, err := client.GetLocation(sessionKey, dn)
if err == nil && len(locs) > 50 {
return trackOutlineLoadedMsg{locations: locs}
}
}
return trackOutlineLoadedMsg{err: fmt.Errorf("no location data available for session %d", sessionKey)}
}
}
// fetchAllCarPositions downloads the most recent location for every driver
// (using the live session key stored in the model). We fetch all 20 drivers
// concurrently and keep only the last location per driver.
func fetchAllCarPositions(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return func() tea.Msg {
locs, err := client.GetLocation(sessionKey, 0)
if err != nil {
return trackCarsLoadedMsg{err: err}
}
return trackCarsLoadedMsg{locations: locs}
}
}
// tickTrackMap schedules a periodic car-position refresh.
func tickTrackMap() tea.Cmd {
return tea.Tick(5*time.Second, func(_ time.Time) tea.Msg {
return trackMapTickMsg{}
})
}
type trackMapTickMsg struct{}
// ---------------------------------------------------------------------------
// Init / Update / View
// ---------------------------------------------------------------------------
func (m TrackMapModel) Init() tea.Cmd {
return m.spinner.Tick
}
// SetSessionKey wires the track map to a specific session. If the session
// differs from the one already loaded, it triggers a fresh outline fetch.
func (m TrackMapModel) SetSessionKey(sessionKey int) (TrackMapModel, tea.Cmd) {
if sessionKey == m.sessionKey && m.outlineReady {
return m, nil
}
m.sessionKey = sessionKey
m.loadingOutline = true
m.outlineReady = false
m.outline = nil
m.carPositions = make(map[int]models.Location)
m.err = nil
return m, tea.Batch(fetchTrackOutline(m.client, sessionKey), m.spinner.Tick)
}
// InjectDriverInfo forwards the latest DriverInfo map from OfficialLiveModel
// so car markers can be team-coloured.
func (m *TrackMapModel) InjectDriverInfo(info map[string]F1DriverListEntry) {
m.driverInfo = info
}
func (m TrackMapModel) Update(msg tea.Msg) (TrackMapModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case spinner.TickMsg:
if m.loadingSession || m.loadingOutline || m.loadingCars {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case trackOutlineLoadedMsg:
m.loadingSession = false
m.loadingOutline = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.buildOutline(msg.locations)
m.outlineReady = true
// Start fetching car positions
m.loadingCars = true
return m, tea.Batch(fetchAllCarPositions(m.client, m.sessionKey), tickTrackMap())
case trackCarsLoadedMsg:
m.loadingCars = false
if msg.err != nil {
// Soft error — keep the outline, show empty cars
return m, nil
}
// Keep only the latest location per driver
latest := make(map[int]models.Location)
for _, loc := range msg.locations {
existing, ok := latest[loc.DriverNumber]
if !ok || loc.Date > existing.Date {
latest[loc.DriverNumber] = loc
}
}
m.carPositions = latest
return m, nil
case sessionKeyMsg:
m.loadingSession = false
if msg.sessionKey != m.sessionKey {
m.sessionKey = msg.sessionKey
m.loadingOutline = true
m.outlineReady = false
m.outline = nil
m.carPositions = make(map[int]models.Location)
m.err = nil
return m, tea.Batch(fetchTrackOutline(m.client, msg.sessionKey), m.spinner.Tick)
}
return m, nil
case trackMapTickMsg:
if m.outlineReady && m.sessionKey != 0 {
m.loadingCars = true
return m, fetchAllCarPositions(m.client, m.sessionKey)
}
return m, tickTrackMap()
case wsDataMsg:
// When live WS data arrives, update driver info if we can.
// (TrackMapModel.InjectDriverInfo is called from app.go on each wsDataMsg)
return m, nil
}
return m, nil
}
func (m TrackMapModel) View() string {
if m.loadingSession {
return fmt.Sprintf("\n %s Resolving active session...\n", m.spinner.View())
}
if m.loadingOutline {
return fmt.Sprintf("\n %s Building track outline...\n", m.spinner.View())
}
if m.err != nil {
return renderErrorView(m.err)
}
if !m.outlineReady {
return "\n " + styleMuted.Render("No active session found. Press 7 again once a session is underway.") + "\n"
}
return m.renderMap()
}
// ---------------------------------------------------------------------------
// Track outline builder
// ---------------------------------------------------------------------------
// buildOutline computes a set of normalized terminal-space (col, row) points
// from raw X/Y location data.
func (m *TrackMapModel) buildOutline(locs []models.Location) {
if len(locs) == 0 {
return
}
// Find bounding box of raw coordinates
m.rawMinX, m.rawMaxX = locs[0].X, locs[0].X
m.rawMinY, m.rawMaxY = locs[0].Y, locs[0].Y
for _, l := range locs {
if l.X < m.rawMinX {
m.rawMinX = l.X
}
if l.X > m.rawMaxX {
m.rawMaxX = l.X
}
if l.Y < m.rawMinY {
m.rawMinY = l.Y
}
if l.Y > m.rawMaxY {
m.rawMaxY = l.Y
}
}
// Use a step-down approach to avoid over-sampling: only add a new point
// if it's sufficiently different from the previous one (in normalised space).
// We normalize to a 60×24 grid first, then de-duplicate.
const gridW, gridH = 60, 22
seen := make(map[trackPoint]struct{})
var pts []trackPoint
for _, l := range locs {
tp := m.rawToGrid(l.X, l.Y, gridW, gridH)
if _, dup := seen[tp]; dup {
continue
}
seen[tp] = struct{}{}
pts = append(pts, tp)
}
m.outline = pts
}
// rawToGrid converts raw X/Y to (col, row) in a canvas of gridW×gridH.
// Terminal characters are roughly 2× taller than wide, so we compress the
// X dimension by a factor of 0.5 to preserve the circuit's visual aspect ratio.
func (m *TrackMapModel) rawToGrid(x, y float64, gridW, gridH int) trackPoint {
rangeX := m.rawMaxX - m.rawMinX
rangeY := m.rawMaxY - m.rawMinY
if rangeX == 0 {
rangeX = 1
}
if rangeY == 0 {
rangeY = 1
}
// Apply 0.5× X compression for terminal aspect ratio
normX := (x - m.rawMinX) / rangeX
normY := (y - m.rawMinY) / rangeY
col := int(normX * float64(gridW-1) * 0.5) // compress horizontal
row := gridH - 1 - int(normY*float64(gridH-1)) // flip Y (screen rows go down)
return trackPoint{col: clampInt(col, 0, gridW-1), row: clampInt(row, 0, gridH-1)}
}
// rawToCanvas converts raw X/Y to (col, row) for the actual render canvas size.
func (m *TrackMapModel) rawToCanvas(x, y float64, canvasW, canvasH int) trackPoint {
rangeX := m.rawMaxX - m.rawMinX
rangeY := m.rawMaxY - m.rawMinY
if rangeX == 0 {
rangeX = 1
}
if rangeY == 0 {
rangeY = 1
}
normX := (x - m.rawMinX) / rangeX
normY := (y - m.rawMinY) / rangeY
// Margins
marginH := 2
marginV := 1
drawW := canvasW - marginH*2
drawH := canvasH - marginV*2
col := marginH + int(normX*float64(drawW-1)*0.5)
row := marginV + (drawH - 1 - int(normY*float64(drawH-1)))
return trackPoint{
col: clampInt(col, marginH, marginH+drawW-1),
row: clampInt(row, marginV, marginV+drawH-1),
}
}
// ---------------------------------------------------------------------------
// Map renderer
// ---------------------------------------------------------------------------
func (m TrackMapModel) renderMap() string {
w := m.width
if w < 40 {
w = 40
}
h := m.height
if h < 20 {
h = 20
}
// Reserve space for header (3 lines) + help bar (1 line)
const headerLines = 4
canvasW := min(w-2, 80) // cap width for readability
canvasH := h - headerLines - 2
if canvasH < 10 {
canvasH = 10
}
// Allocate canvas grid
grid := make([][]rune, canvasH)
colorGrid := make([][]string, canvasH)
for r := range grid {
grid[r] = make([]rune, canvasW)
colorGrid[r] = make([]string, canvasW)
for c := range grid[r] {
grid[r][c] = ' '
}
}
// Draw track outline using '·' dots
for _, loc := range m.outline {
// Re-normalize outline points from the 60×22 grid to the canvas
// by converting back through raw fraction space.
normCol := float64(loc.col) / (30.0) // gridW/2 for the 0.5 compression
normRow := float64(22-1-loc.row) / float64(22-1)
rawX := m.rawMinX + normCol*(m.rawMaxX-m.rawMinX)
rawY := m.rawMinY + normRow*(m.rawMaxY-m.rawMinY)
tp := m.rawToCanvas(rawX, rawY, canvasW, canvasH)
if tp.row >= 0 && tp.row < canvasH && tp.col >= 0 && tp.col < canvasW {
if grid[tp.row][tp.col] == ' ' {
grid[tp.row][tp.col] = '·'
colorGrid[tp.row][tp.col] = colorSurface2
}
}
}
// Place car markers
type carPlacement struct {
tp trackPoint
dn int
color string
tla string
}
var placements []carPlacement
// Sort driver numbers for deterministic overdraw
var dnums []int
for dn := range m.carPositions {
dnums = append(dnums, dn)
}
sort.Ints(dnums)
for _, dn := range dnums {
loc := m.carPositions[dn]
tp := m.rawToCanvas(loc.X, loc.Y, canvasW, canvasH)
numStr := fmt.Sprintf("%d", dn)
teamColor := colorMuted
tla := numStr
if info, ok := m.driverInfo[numStr]; ok {
if info.Tla != "" {
tla = info.Tla
}
if info.TeamColour != "" {
teamColor = "#" + info.TeamColour
} else if info.TeamName != "" {
teamColor = teamColorFromName(info.TeamName)
}
}
placements = append(placements, carPlacement{tp, dn, teamColor, tla})
}
// Draw car glyphs — use '●' at the car's point, then try to fit TLA inline
for _, cp := range placements {
r, c := cp.tp.row, cp.tp.col
if r < 0 || r >= canvasH || c < 0 || c >= canvasW {
continue
}
grid[r][c] = '●'
colorGrid[r][c] = cp.color
// Write TLA to the right of the marker if space allows
for i, ch := range cp.tla {
nc := c + 1 + i
if nc >= canvasW {
break
}
grid[r][nc] = ch
colorGrid[r][nc] = cp.color
}
}
// Render grid to string
var sb strings.Builder
// Header
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)).Render("🗺 TRACK MAP")
driverCount := fmt.Sprintf("%d cars tracked", len(m.carPositions))
if len(m.carPositions) == 0 {
driverCount = "waiting for car positions..."
}
sb.WriteString("\n " + title + " " + styleMuted.Render(driverCount) + "\n")
sb.WriteString(" " + divider(min(w-4, canvasW)) + "\n")
// Canvas border top
borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorBorder))
sb.WriteString(" " + borderStyle.Render("╭"+strings.Repeat("─", canvasW)+"╮") + "\n")
for r := 0; r < canvasH; r++ {
sb.WriteString(" " + borderStyle.Render("│"))
for c := 0; c < canvasW; c++ {
ch := grid[r][c]
color := colorGrid[r][c]
if color != "" {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(string(ch)))
} else {
sb.WriteRune(ch)
}
}
sb.WriteString(borderStyle.Render("│") + "\n")
}
sb.WriteString(" " + borderStyle.Render("╰"+strings.Repeat("─", canvasW)+"╯") + "\n")
sb.WriteString("\n")
sb.WriteString(helpBar("1-7 tabs", "q quit"))
return sb.String()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// distanceSq returns the squared Euclidean distance between two points.
func distanceSq(x1, y1, x2, y2 float64) float64 {
dx, dy := x2-x1, y2-y1
return dx*dx + dy*dy
}
// nearestOutlinePoint finds the outline point closest to (x, y) in raw space.
// Returns math.MaxFloat64 if outline is empty.
func (m *TrackMapModel) nearestOutlineDistance(x, y float64) float64 {
if len(m.outline) == 0 {
return math.MaxFloat64
}
minDist := math.MaxFloat64
rangeX := m.rawMaxX - m.rawMinX
rangeY := m.rawMaxY - m.rawMinY
if rangeX == 0 {
rangeX = 1
}
if rangeY == 0 {
rangeY = 1
}
// Convert outline back to raw for distance calculation
for _, tp := range m.outline {
nx := float64(tp.col) / 30.0
ny := float64(22-1-tp.row) / float64(22-1)
rx := m.rawMinX + nx*rangeX
ry := m.rawMinY + ny*rangeY
d := distanceSq(x, y, rx, ry)
if d < minDist {
minDist = d
}
}
return math.Sqrt(minDist)
}

View File

@@ -164,6 +164,62 @@ func sparkline(laps []models.Lap, width int) string {
return result
}
// speedSparkline generates a unicode block chart for speed trap values.
// Higher speed = taller bar (green). Lower speed = shorter bar (red).
func speedSparkline(speeds []int, width int) string {
const blocks = "▁▂▃▄▅▆▇█"
blockRunes := []rune(blocks)
if len(speeds) == 0 {
return styleMuted.Render(strings.Repeat("·", width))
}
minSpd, maxSpd := speeds[0], speeds[0]
for _, s := range speeds {
if s < minSpd {
minSpd = s
}
if s > maxSpd {
maxSpd = s
}
}
rng := maxSpd - minSpd
if rng == 0 {
rng = 1
}
var sb strings.Builder
count := 0
for _, s := range speeds {
if count >= width {
break
}
norm := float64(s-minSpd) / float64(rng)
idx := int(norm*float64(len(blockRunes)-1) + 0.5)
if idx < 0 {
idx = 0
}
if idx >= len(blockRunes) {
idx = len(blockRunes) - 1
}
var style lipgloss.Style
switch {
case norm > 0.75:
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan))
case norm > 0.5:
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
case norm > 0.25:
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
default:
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
}
sb.WriteString(style.Render(string(blockRunes[idx])))
count++
}
return sb.String()
}
// windArrow maps a wind direction in degrees to a unicode arrow.
func windArrow(degrees int) string {
arrows := []string{"↑", "↗", "→", "↘", "↓", "↙", "←", "↖"}

BIN
main

Binary file not shown.

115
plan.md
View File

@@ -1,106 +1,47 @@
Great name! **box-box** it is 🏎️
# box-box Roadmap
Here's your getting started guide:
A living document tracking the evolution of box-box from a solid F1 terminal dashboard into the ultimate pit wall companion.
---
## The Stack
## Phase 1 — "Pit Wall Mode" (Enhance What Exists)
| Tool | Purpose |
|---|---|
| **Bubble Tea** | TUI framework — the "engine" (like React for terminals) |
| **Lipgloss** | Styling — colors, borders, padding |
| **Bubbles** | Pre-built components — tables, spinners, viewports |
| **OpenF1 API** | Data source — free, no key needed |
Enrich existing tabs with data that's already available from the API but not yet rendered. Zero or minimal new API calls — prioritizes computed insights from data we already fetch.
- [x] **Starting Grid View** — Show qualifying lap times and grid order in Race Detail tab (secondary data tier, Race sessions only)
- [x] **Tyre Degradation Analysis** — Compute deg rate per stint from existing laps + stints data in Driver Detail. Show pace drop-off, stint consistency, and degradation sparklines
- [x] **Sector & Speed Analysis** — Sector time sparklines (S1/S2/S3) and speed trap trends from existing lap data in Driver Detail
- [x] **Gap Trend Sparklines in Live Feed** — Track gap-to-leader history per driver over WebSocket updates. Show mini trend indicator in the timing tower during races
---
## Core Bubble Tea Concepts to Know
## Phase 2 — "Race Director" (Killer Features)
Bubble Tea follows the **Elm architecture** — just 3 things:
New views that reconstruct the race narrative and make box-box indispensable during a live session.
1. **Model** — your app's state (what data you're holding, which tab is active, etc.)
2. **Update** — handles events (keypresses, API responses) and returns a new model
3. **View** — renders the model to a string that gets printed to the terminal
Everything flows in one direction: `event → update → view`. That's it.
```
type model struct {
activeTab int
standings []Driver
loading bool
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ... }
func (m model) View() string { ... }
```
- [x] **ASCII Track Map** — Render track outline from Location data with live car positions colored by team
- [x] **Race Replay / Lap Scrubber** — Step through a completed race lap-by-lap with full context (positions, pits, RC messages, weather). Arrow keys scrub, showing the field evolve over time
- [x] **Battle Tracker** — Auto-detect duels (drivers within DRS range) and show head-to-head gap analysis, pace comparison, and pit strategy divergence
- [x] **Pit Window Calculator** — Predict rejoin position if a driver pits now, based on current gaps + avg pit loss + estimated tyre deg rate
---
## Project Structure
## Phase 3 — "Engineering Room" (Companion Web View)
```
box-box/
├── cmd/
│ └── main.go # Entry point
├── internal/
│ ├── api/
│ │ └── openf1.go # All API calls
│ ├── ui/
│ │ ├── app.go # Root model, tab switching
│ │ ├── standings.go # Standings tab
│ │ ├── calendar.go # Calendar tab
│ │ ├── results.go # Results tab
│ │ └── driver.go # Driver lookup tab
│ └── models/
│ └── types.go # Structs (Driver, Race, Result, etc.)
├── go.mod
└── README.md
```
A lightweight local web UI for visualizations that need a proper canvas.
- [ ] **`box-box --web` server** — Spawn a localhost SPA from Go embedded assets, sharing the same SQLite cache
- [ ] **SVG Track Map** — Animated car positions on a real circuit layout with team colors
- [ ] **Telemetry Overlay** — Interactive throttle/brake/speed graph through a lap (D3.js or Canvas)
- [ ] **Strategy Timeline** — Visual pit stop and stint timeline for the full field
---
## How to Bootstrap It
## Phase 4 — "Always On" (Background Intelligence)
```bash
mkdir box-box && cd box-box
go mod init github.com/yourusername/box-box
Passive monitoring and historical analysis features.
# Install dependencies
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss
go get github.com/charmbracelet/bubbles
```
---
## Key Concepts for a Beginner
**1. Commands (Cmd) are how you do async work**
API calls happen outside the Update loop — you return a `tea.Cmd` which runs in the background and sends a message back when done. This keeps the UI non-blocking.
**2. Messages (Msg) are how things communicate**
When your API call finishes, it sends a message like `standingsFetchedMsg` back into Update. You pattern match on it and update your model.
**3. Tabs = multiple models composed together**
Each tab (standings, calendar, etc.) can be its own mini Bubble Tea model. The root `app.go` model holds them all and delegates keypresses to whichever tab is active.
**4. Lipgloss is just styling strings**
Since everything in Bubble Tea is strings, Lipgloss lets you wrap them with colors, borders, and layout — think of it like CSS for your terminal output.
---
## Suggested Learning Order
1. Follow the [Bubble Tea tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials) — takes ~30 mins
2. Build a single tab first (just standings) — get data showing in a table
3. Add tab navigation
4. Add the remaining views one by one
5. Polish with Lipgloss last
---
The OpenF1 API is straightforward REST — for example `https://api.openf1.org/v1/drivers?session_key=latest` gives you current session drivers. No auth, no rate limits to worry about for personal use.
Want me to write out the skeleton code to get you started — just the structure with empty stubs and the Bubble Tea boilerplate wired up?
- [ ] **Daemon / Notification Mode**`box-box --watch` sends OS notifications for key race events (flags, overtakes, pit stops)
- [ ] **Championship Simulator** — "What if" scenarios: set finishing positions per driver and project championship standings forward
- [ ] **Multi-Year Driver Comparison** — Career arc and season-over-season stats with win/pole rates
- [ ] **tmux / Status Bar Integration** — Compact race status output for shell prompts and status bars