mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Updated Live
This commit is contained in:
25
README.md
25
README.md
@@ -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-stable
Executable file
BIN
box-box-stable
Executable file
Binary file not shown.
0
box-box.log
Normal file
0
box-box.log
Normal 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)
|
||||
|
||||
@@ -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
230
internal/ui/battles.go
Normal 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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
307
internal/ui/pitwindow.go
Normal 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
|
||||
}
|
||||
@@ -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
568
internal/ui/replay.go
Normal 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
599
internal/ui/trackmap.go
Normal 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)
|
||||
}
|
||||
@@ -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{"↑", "↗", "→", "↘", "↓", "↙", "←", "↖"}
|
||||
|
||||
115
plan.md
115
plan.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user