mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -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 Calendar**: The full 2025 schedule at your fingertips.
|
||||||
- 🏎️ **Race Details**: Deep dive into session results, starting grids, and lap data.
|
- 🏎️ **Race Details**: Deep dive into session results, starting grids, and lap data.
|
||||||
- 👤 **Driver Profiles**: Detailed stats for every driver on the grid.
|
- 👤 **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).
|
- 🔌 **Offline-ish**: Fast, lightweight, and powered by the wonderful [OpenF1 API](https://openf1.org).
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
@@ -40,14 +45,24 @@ go run cmd/main.go
|
|||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `1` | Switch to **Standings** |
|
| `1` | Switch to **Home** |
|
||||||
| `2` | Switch to **Calendar** |
|
| `2` | Switch to **Standings** |
|
||||||
| `3` | Switch to **Race Details** |
|
| `3` | Switch to **Calendar** |
|
||||||
| `4` | Switch to **Drivers** |
|
| `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 |
|
| `j`/`↓` | Navigate down |
|
||||||
| `k`/`↑` | Navigate up |
|
| `k`/`↑` | Navigate up |
|
||||||
| `enter` | Select/Inspect item |
|
| `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 |
|
| `q` / `ctrl+c` | Exit |
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,6 +12,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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
|
var client *api.OpenF1Client
|
||||||
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
|
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
|
||||||
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey)
|
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey)
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ const (
|
|||||||
tabRaceDetail tabIndex = 3
|
tabRaceDetail tabIndex = 3
|
||||||
tabDriver tabIndex = 4
|
tabDriver tabIndex = 4
|
||||||
tabLive tabIndex = 5
|
tabLive tabIndex = 5
|
||||||
|
tabTrackMap tabIndex = 6
|
||||||
)
|
)
|
||||||
|
|
||||||
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live"}
|
const numTabs = 7
|
||||||
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴"}
|
|
||||||
|
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live", "Map"}
|
||||||
|
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴", "🗺"}
|
||||||
|
|
||||||
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
||||||
type splashDoneMsg struct{}
|
type splashDoneMsg struct{}
|
||||||
@@ -44,6 +47,7 @@ type AppModel struct {
|
|||||||
driver DriverModel
|
driver DriverModel
|
||||||
dashboard DashboardModel
|
dashboard DashboardModel
|
||||||
live OfficialLiveModel
|
live OfficialLiveModel
|
||||||
|
trackMap TrackMapModel
|
||||||
|
|
||||||
meetings []models.Meeting
|
meetings []models.Meeting
|
||||||
|
|
||||||
@@ -69,6 +73,7 @@ func NewAppModel(client *api.OpenF1Client) AppModel {
|
|||||||
driver: NewDriverModel(client),
|
driver: NewDriverModel(client),
|
||||||
dashboard: NewDashboardModel(client, year),
|
dashboard: NewDashboardModel(client, year),
|
||||||
live: NewOfficialLiveModel(),
|
live: NewOfficialLiveModel(),
|
||||||
|
trackMap: NewTrackMapModel(client),
|
||||||
showSplash: true,
|
showSplash: true,
|
||||||
splashSpinner: sp,
|
splashSpinner: sp,
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,7 @@ func (m AppModel) Init() tea.Cmd {
|
|||||||
m.calendar.Init(),
|
m.calendar.Init(),
|
||||||
m.raceDetail.Init(),
|
m.raceDetail.Init(),
|
||||||
m.driver.Init(),
|
m.driver.Init(),
|
||||||
|
m.trackMap.Init(),
|
||||||
m.splashSpinner.Tick,
|
m.splashSpinner.Tick,
|
||||||
splashTimer(),
|
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
|
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
|
||||||
m.raceDetail.SetSize(m.width-4, contentHeight)
|
m.raceDetail.SetSize(m.width-4, contentHeight)
|
||||||
m.live.SetSize(m.width, 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.dashboard, cmd1 = m.dashboard.Update(msg)
|
||||||
m.live, cmd2 = m.live.Update(msg)
|
m.live, cmd2 = m.live.Update(msg)
|
||||||
m.standings, cmd3 = m.standings.Update(msg)
|
m.standings, cmd3 = m.standings.Update(msg)
|
||||||
m.calendar, cmd4 = m.calendar.Update(msg)
|
m.calendar, cmd4 = m.calendar.Update(msg)
|
||||||
m.raceDetail, cmd5 = m.raceDetail.Update(msg)
|
m.raceDetail, cmd5 = m.raceDetail.Update(msg)
|
||||||
m.driver, cmd6 = m.driver.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:
|
case splashDoneMsg:
|
||||||
m.showSplash = false
|
m.showSplash = false
|
||||||
@@ -150,8 +157,16 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case matchKey(msg, GlobalKeys.Tab6):
|
case matchKey(msg, GlobalKeys.Tab6):
|
||||||
m.activeTab = tabLive
|
m.activeTab = tabLive
|
||||||
return m, nil
|
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):
|
case matchKey(msg, GlobalKeys.NextTab):
|
||||||
m.activeTab = (m.activeTab + 1) % 6
|
m.activeTab = (m.activeTab + 1) % numTabs
|
||||||
if m.activeTab == tabDriver {
|
if m.activeTab == tabDriver {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.TriggerLoad()
|
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...)
|
return m, tea.Batch(cmds...)
|
||||||
case matchKey(msg, GlobalKeys.PrevTab):
|
case matchKey(msg, GlobalKeys.PrevTab):
|
||||||
m.activeTab = (m.activeTab - 1 + 6) % 6
|
m.activeTab = (m.activeTab - 1 + numTabs) % numTabs
|
||||||
if m.activeTab == tabDriver {
|
if m.activeTab == tabDriver {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.TriggerLoad()
|
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)
|
cmds = append(cmds, cmd)
|
||||||
return m, tea.Batch(cmds...)
|
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:
|
case loadSecondaryDataMsg:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.raceDetail, cmd = m.raceDetail.Update(msg)
|
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
|
var cmd tea.Cmd
|
||||||
m.live, cmd = m.live.Update(msg)
|
m.live, cmd = m.live.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
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...)
|
return m, tea.Batch(cmds...)
|
||||||
|
|
||||||
case spinner.TickMsg:
|
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)
|
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
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.standings, cmd1 = m.standings.Update(msg)
|
||||||
m.calendar, cmd2 = m.calendar.Update(msg)
|
m.calendar, cmd2 = m.calendar.Update(msg)
|
||||||
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
|
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
|
||||||
m.driver, cmd4 = m.driver.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...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +381,10 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.Update(msg)
|
m.driver, cmd = m.driver.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
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...)
|
return m, tea.Batch(cmds...)
|
||||||
@@ -387,6 +421,8 @@ func (m AppModel) View() string {
|
|||||||
content = m.raceDetail.View()
|
content = m.raceDetail.View()
|
||||||
case tabDriver:
|
case tabDriver:
|
||||||
content = m.driver.View()
|
content = m.driver.View()
|
||||||
|
case tabTrackMap:
|
||||||
|
content = m.trackMap.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
statusBar := m.renderStatusBar(w)
|
statusBar := m.renderStatusBar(w)
|
||||||
@@ -497,7 +533,7 @@ func (m AppModel) renderStatusBar(width int) string {
|
|||||||
// Right side: cache stats + navigation hints
|
// Right side: cache stats + navigation hints
|
||||||
cacheStats := m.client.CacheStats()
|
cacheStats := m.client.CacheStats()
|
||||||
cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses))
|
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)
|
leftW := lipgloss.Width(left)
|
||||||
rightW := lipgloss.Width(right)
|
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(m.renderStintBar())
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// ── TYRE DEGRADATION ────────────────────────────────
|
||||||
|
if degContent := m.renderTyreDegradation(); degContent != "" {
|
||||||
|
sb.WriteString(degContent)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// ── LAP TIMES ───────────────────────────────────────
|
// ── LAP TIMES ───────────────────────────────────────
|
||||||
sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
|
sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
|
||||||
sparkWidth := min(m.width-6, 70)
|
sparkWidth := min(m.width-6, 70)
|
||||||
@@ -651,6 +657,12 @@ func (m DriverModel) renderDetailContent() string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// ── SECTOR ANALYSIS ─────────────────────────────────
|
||||||
|
if sectorContent := m.renderSectorAnalysis(); sectorContent != "" {
|
||||||
|
sb.WriteString(sectorContent)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// ── PIT STOPS ───────────────────────────────────────
|
// ── PIT STOPS ───────────────────────────────────────
|
||||||
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
|
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
|
||||||
sb.WriteString(m.renderPitStops())
|
sb.WriteString(m.renderPitStops())
|
||||||
@@ -875,6 +887,288 @@ func (m DriverModel) renderPositionChart() string {
|
|||||||
return sb.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.
|
// renderTeamRadio displays team radio messages with timestamps.
|
||||||
func (m DriverModel) renderTeamRadio() string {
|
func (m DriverModel) renderTeamRadio() string {
|
||||||
if len(m.teamRadios) == 0 {
|
if len(m.teamRadios) == 0 {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type GlobalKeyMap struct {
|
|||||||
Tab4 key.Binding
|
Tab4 key.Binding
|
||||||
Tab5 key.Binding
|
Tab5 key.Binding
|
||||||
Tab6 key.Binding
|
Tab6 key.Binding
|
||||||
|
Tab7 key.Binding
|
||||||
NextTab key.Binding
|
NextTab key.Binding
|
||||||
PrevTab key.Binding
|
PrevTab key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
@@ -51,6 +52,10 @@ var GlobalKeys = GlobalKeyMap{
|
|||||||
key.WithKeys("6"),
|
key.WithKeys("6"),
|
||||||
key.WithHelp("6", "live"),
|
key.WithHelp("6", "live"),
|
||||||
),
|
),
|
||||||
|
Tab7: key.NewBinding(
|
||||||
|
key.WithKeys("7"),
|
||||||
|
key.WithHelp("7", "track map"),
|
||||||
|
),
|
||||||
NextTab: key.NewBinding(
|
NextTab: key.NewBinding(
|
||||||
key.WithKeys("tab", "right"),
|
key.WithKeys("tab", "right"),
|
||||||
key.WithHelp("tab/->", "next tab"),
|
key.WithHelp("tab/->", "next tab"),
|
||||||
@@ -124,12 +129,14 @@ var StandingsKeys = StandingsKeyMap{
|
|||||||
|
|
||||||
// LiveKeyMap holds keybindings specific to the live timing tab.
|
// LiveKeyMap holds keybindings specific to the live timing tab.
|
||||||
type LiveKeyMap struct {
|
type LiveKeyMap struct {
|
||||||
ToggleSectors key.Binding
|
ToggleSectors key.Binding
|
||||||
ToggleRC key.Binding
|
ToggleRC key.Binding
|
||||||
ScrollRCUp key.Binding
|
ToggleBattles key.Binding
|
||||||
ScrollRCDown key.Binding
|
TogglePitWindow key.Binding
|
||||||
ExpandDriver key.Binding
|
ScrollRCUp key.Binding
|
||||||
Collapse key.Binding
|
ScrollRCDown key.Binding
|
||||||
|
ExpandDriver key.Binding
|
||||||
|
Collapse key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var LiveKeys = LiveKeyMap{
|
var LiveKeys = LiveKeyMap{
|
||||||
@@ -141,6 +148,14 @@ var LiveKeys = LiveKeyMap{
|
|||||||
key.WithKeys("r"),
|
key.WithKeys("r"),
|
||||||
key.WithHelp("r", "toggle RC panel"),
|
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(
|
ScrollRCUp: key.NewBinding(
|
||||||
key.WithKeys("K"),
|
key.WithKeys("K"),
|
||||||
key.WithHelp("K", "scroll RC up"),
|
key.WithHelp("K", "scroll RC up"),
|
||||||
@@ -154,8 +169,8 @@ var LiveKeys = LiveKeyMap{
|
|||||||
key.WithHelp("enter", "driver detail"),
|
key.WithHelp("enter", "driver detail"),
|
||||||
),
|
),
|
||||||
Collapse: key.NewBinding(
|
Collapse: key.NewBinding(
|
||||||
key.WithKeys("esc", "b"),
|
key.WithKeys("esc"),
|
||||||
key.WithHelp("esc/b", "collapse"),
|
key.WithHelp("esc", "collapse"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +180,7 @@ type RaceDetailKeyMap struct {
|
|||||||
ScrollDown key.Binding
|
ScrollDown key.Binding
|
||||||
PrevSession key.Binding
|
PrevSession key.Binding
|
||||||
NextSession key.Binding
|
NextSession key.Binding
|
||||||
|
Replay key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var RaceDetailKeys = RaceDetailKeyMap{
|
var RaceDetailKeys = RaceDetailKeyMap{
|
||||||
@@ -184,4 +200,8 @@ var RaceDetailKeys = RaceDetailKeyMap{
|
|||||||
key.WithKeys("]"),
|
key.WithKeys("]"),
|
||||||
key.WithHelp("]", "next session"),
|
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
|
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)
|
// loadSecondaryDataMsg triggers loading of secondary session data (race control, weather, overtakes)
|
||||||
// after the primary data (results, drivers) has arrived.
|
// after the primary data (results, drivers) has arrived.
|
||||||
type loadSecondaryDataMsg struct {
|
type loadSecondaryDataMsg struct {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type F1TimingLine struct {
|
|||||||
Value interface{} `json:"Value"`
|
Value interface{} `json:"Value"`
|
||||||
} `json:"IntervalToPositionAhead"`
|
} `json:"IntervalToPositionAhead"`
|
||||||
Position interface{} `json:"Position"`
|
Position interface{} `json:"Position"`
|
||||||
RacingNumber string `json:"RacingNumber"`
|
RacingNumber string `json:"RacingNumber"`
|
||||||
LastLapTime struct {
|
LastLapTime struct {
|
||||||
Value string `json:"Value"`
|
Value string `json:"Value"`
|
||||||
PersonalFastest bool `json:"PersonalFastest"`
|
PersonalFastest bool `json:"PersonalFastest"`
|
||||||
@@ -268,10 +268,6 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
|
|||||||
}
|
}
|
||||||
if json.Unmarshal(data, &td) == nil {
|
if json.Unmarshal(data, &td) == nil {
|
||||||
for num, lineRaw := range td.Lines {
|
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
|
var line F1TimingLine
|
||||||
if json.Unmarshal(lineRaw, &line) == nil {
|
if json.Unmarshal(lineRaw, &line) == nil {
|
||||||
updateDriver(drivers, num, line)
|
updateDriver(drivers, num, line)
|
||||||
@@ -306,8 +302,8 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
|
|||||||
}
|
}
|
||||||
case "ExtrapolatedClock":
|
case "ExtrapolatedClock":
|
||||||
var ec struct {
|
var ec struct {
|
||||||
Remaining string `json:"Remaining"`
|
Remaining string `json:"Remaining"`
|
||||||
Utc string `json:"Utc"`
|
Utc string `json:"Utc"`
|
||||||
Extrapolating bool `json:"Extrapolating"`
|
Extrapolating bool `json:"Extrapolating"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
|
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
|
||||||
@@ -427,10 +423,12 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
|
|||||||
New string `json:"New"`
|
New string `json:"New"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
|
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
|
||||||
tyres[num] = LiveTyreData{
|
// Preserve the existing Age — CurrentTyres only carries
|
||||||
Compound: td.Compound,
|
// compound and newness, not lap count.
|
||||||
New: td.New == "true" || td.New == "True",
|
t := tyres[num]
|
||||||
}
|
t.Compound = td.Compound
|
||||||
|
t.New = td.New == "true" || td.New == "True"
|
||||||
|
tyres[num] = t
|
||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,11 +460,13 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
|
|||||||
}
|
}
|
||||||
if len(driverStints) > 0 {
|
if len(driverStints) > 0 {
|
||||||
stints[num] = driverStints
|
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]
|
lastStint := driverStints[len(driverStints)-1]
|
||||||
t := tyres[num]
|
t := tyres[num]
|
||||||
t.Age = lastStint.Laps
|
t.Age = lastStint.Laps
|
||||||
if t.Compound == "" && lastStint.Compound != "" {
|
if lastStint.Compound != "" {
|
||||||
t.Compound = lastStint.Compound
|
t.Compound = lastStint.Compound
|
||||||
t.New = lastStint.New
|
t.New = lastStint.New
|
||||||
}
|
}
|
||||||
@@ -727,6 +727,129 @@ func parseGap(val string) string {
|
|||||||
return val
|
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.
|
// compoundAbbrevStyle returns the single-letter abbreviation and lipgloss style for a tyre compound string.
|
||||||
func compoundAbbrevStyle(compound string) (string, lipgloss.Style) {
|
func compoundAbbrevStyle(compound string) (string, lipgloss.Style) {
|
||||||
switch {
|
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
|
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 {
|
type OfficialLiveModel struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
dataChan chan LiveStreamData
|
dataChan chan LiveStreamData
|
||||||
drivers map[string]LiveDriverData
|
drivers map[string]LiveDriverData
|
||||||
driverInfo map[string]F1DriverListEntry
|
driverInfo map[string]F1DriverListEntry
|
||||||
tyres map[string]LiveTyreData
|
tyres map[string]LiveTyreData
|
||||||
rcMessages []LiveRCMessage
|
rcMessages []LiveRCMessage
|
||||||
weather LiveWeatherData
|
weather LiveWeatherData
|
||||||
session LiveSessionMeta
|
session LiveSessionMeta
|
||||||
trackStatus string
|
trackStatus string
|
||||||
currentLap int
|
currentLap int
|
||||||
totalLaps int
|
totalLaps int
|
||||||
@@ -777,6 +903,7 @@ type OfficialLiveModel struct {
|
|||||||
clockRefTime time.Time
|
clockRefTime time.Time
|
||||||
clockExtrapolating bool
|
clockExtrapolating bool
|
||||||
stints map[string][]LiveStintData
|
stints map[string][]LiveStintData
|
||||||
|
gapHistory map[string][]float64 // racing number -> recent gap-to-leader values
|
||||||
err error
|
err error
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
@@ -785,6 +912,8 @@ type OfficialLiveModel struct {
|
|||||||
showSectors bool
|
showSectors bool
|
||||||
expandedDriver string // racing number, "" if none
|
expandedDriver string // racing number, "" if none
|
||||||
showRC bool // compact mode RC overlay toggle
|
showRC bool // compact mode RC overlay toggle
|
||||||
|
showBattles bool // battle tracker overlay
|
||||||
|
showPitWindow bool // pit window calculator overlay
|
||||||
|
|
||||||
rcView viewport.Model
|
rcView viewport.Model
|
||||||
rcReady bool
|
rcReady bool
|
||||||
@@ -797,6 +926,7 @@ func NewOfficialLiveModel() OfficialLiveModel {
|
|||||||
driverInfo: make(map[string]F1DriverListEntry),
|
driverInfo: make(map[string]F1DriverListEntry),
|
||||||
tyres: make(map[string]LiveTyreData),
|
tyres: make(map[string]LiveTyreData),
|
||||||
stints: make(map[string][]LiveStintData),
|
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)
|
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 {
|
func (m OfficialLiveModel) sortedDrivers() []LiveDriverData {
|
||||||
// Merge timing data with driver list so all known drivers appear,
|
// In practice/qualifying, recomputeImpliedPositions already placed every
|
||||||
// even those who have not set a lap time yet (Position == 0).
|
// 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))
|
merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo))
|
||||||
for num, d := range m.drivers {
|
for num, d := range m.drivers {
|
||||||
merged[num] = d
|
merged[num] = d
|
||||||
@@ -844,35 +1091,68 @@ func (m OfficialLiveModel) sortedDrivers() []LiveDriverData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var positioned, unpositioned []LiveDriverData
|
var all []LiveDriverData
|
||||||
for _, d := range merged {
|
for _, d := range merged {
|
||||||
if d.Position > 0 {
|
all = append(all, d)
|
||||||
positioned = append(positioned, d)
|
|
||||||
} else {
|
|
||||||
unpositioned = append(unpositioned, d)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
sort.Slice(positioned, func(i, j int) bool {
|
pi, pj := all[i].Position, all[j].Position
|
||||||
return positioned[i].Position < positioned[j].Position
|
if pi > 0 && pj > 0 {
|
||||||
})
|
return pi < pj
|
||||||
sort.Slice(unpositioned, func(i, j int) bool {
|
}
|
||||||
|
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
|
var ni, nj int
|
||||||
fmt.Sscanf(unpositioned[i].RacingNumber, "%d", &ni)
|
fmt.Sscanf(all[i].RacingNumber, "%d", &ni)
|
||||||
fmt.Sscanf(unpositioned[j].RacingNumber, "%d", &nj)
|
fmt.Sscanf(all[j].RacingNumber, "%d", &nj)
|
||||||
return ni < 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.
|
// isPracticeOrQuali returns true for Free Practice, Qualifying, Sprint Qualifying,
|
||||||
// In these sessions the timing tower shows BEST lap time as the primary column.
|
// and Sprint Shootout. In these sessions the timing tower shows BEST lap time
|
||||||
|
// as the primary column.
|
||||||
func (m OfficialLiveModel) isPracticeOrQuali() bool {
|
func (m OfficialLiveModel) isPracticeOrQuali() bool {
|
||||||
t := strings.ToLower(m.session.SessionType)
|
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") ||
|
return strings.Contains(t, "practice") ||
|
||||||
strings.Contains(t, "qualifying") ||
|
strings.Contains(t, "qualifying") ||
|
||||||
strings.Contains(t, "sprint") ||
|
strings.Contains(t, "shootout") ||
|
||||||
t == "fp1" || t == "fp2" || t == "fp3" ||
|
t == "fp1" || t == "fp2" || t == "fp3" ||
|
||||||
t == "q" || t == "sq"
|
t == "q" || t == "sq"
|
||||||
}
|
}
|
||||||
@@ -969,6 +1249,19 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
|
|||||||
m.clockRefTime = msg.ClockRefTime
|
m.clockRefTime = msg.ClockRefTime
|
||||||
m.clockExtrapolating = msg.ClockExtrapolating
|
m.clockExtrapolating = msg.ClockExtrapolating
|
||||||
m.stints = msg.Stints
|
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()
|
m.updateRCViewport()
|
||||||
return m, listenForWSData(m.dataChan)
|
return m, listenForWSData(m.dataChan)
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
@@ -1011,13 +1304,35 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
|
|||||||
case matchKey(msg, LiveKeys.ToggleRC):
|
case matchKey(msg, LiveKeys.ToggleRC):
|
||||||
if m.width < 100 {
|
if m.width < 100 {
|
||||||
m.showRC = !m.showRC
|
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):
|
case matchKey(msg, LiveKeys.ExpandDriver):
|
||||||
if len(drivers) > 0 && m.cursor < len(drivers) {
|
if len(drivers) > 0 && m.cursor < len(drivers) {
|
||||||
m.expandedDriver = drivers[m.cursor].RacingNumber
|
m.expandedDriver = drivers[m.cursor].RacingNumber
|
||||||
|
m.showBattles = false
|
||||||
|
m.showPitWindow = false
|
||||||
}
|
}
|
||||||
case matchKey(msg, LiveKeys.Collapse):
|
case matchKey(msg, LiveKeys.Collapse):
|
||||||
m.expandedDriver = ""
|
m.expandedDriver = ""
|
||||||
|
m.showBattles = false
|
||||||
|
m.showPitWindow = false
|
||||||
case matchKey(msg, LiveKeys.ScrollRCUp):
|
case matchKey(msg, LiveKeys.ScrollRCUp):
|
||||||
if m.rcReady {
|
if m.rcReady {
|
||||||
m.rcView.LineUp(3)
|
m.rcView.LineUp(3)
|
||||||
@@ -1067,14 +1382,27 @@ func (m OfficialLiveModel) View() string {
|
|||||||
leftWidth := int(float64(w) * 0.6)
|
leftWidth := int(float64(w) * 0.6)
|
||||||
rightWidth := w - leftWidth - 4
|
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,
|
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
m.renderTimingTower(leftWidth),
|
m.renderTimingTower(leftWidth),
|
||||||
" ",
|
" ",
|
||||||
m.renderRightPanel(rightWidth),
|
rightPanel,
|
||||||
)
|
)
|
||||||
sb.WriteString(panels)
|
sb.WriteString(panels)
|
||||||
} else {
|
} 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())
|
sb.WriteString(m.renderRCContent())
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(m.renderTimingTower(w - 2))
|
sb.WriteString(m.renderTimingTower(w - 2))
|
||||||
@@ -1083,9 +1411,9 @@ func (m OfficialLiveModel) View() string {
|
|||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if wide {
|
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 {
|
} 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()
|
return sb.String()
|
||||||
@@ -1232,10 +1560,10 @@ func (m OfficialLiveModel) renderTimingTower(w int) string {
|
|||||||
padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1),
|
padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1),
|
||||||
padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10))
|
padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10))
|
||||||
} else {
|
} 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("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
|
||||||
padRight("TYRE", 5), padRight("AGE", 3), padRight("LAST", 10),
|
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(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||||
sb.WriteString(" " + divider(min(w-4, lipgloss.Width(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)
|
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Race mode: LAST + GAP + INT
|
// Race mode: LAST + GAP + INT + TREND
|
||||||
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s",
|
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,
|
posStr, deltaStr, colorBar, tlaStr, tyreStr,
|
||||||
m.renderTyreAge(d.RacingNumber),
|
m.renderTyreAge(d.RacingNumber),
|
||||||
lastLap,
|
lastLap,
|
||||||
m.renderGapStr(d, fpq),
|
m.renderGapStr(d, fpq),
|
||||||
m.renderIntvStr(d))
|
m.renderIntvStr(d),
|
||||||
|
padRightVisible(trend, 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx == m.cursor {
|
if idx == m.cursor {
|
||||||
@@ -1395,9 +1728,20 @@ func (m OfficialLiveModel) renderGapStr(d LiveDriverData, fpq bool) string {
|
|||||||
}
|
}
|
||||||
return padRightVisible(styleLeader.Render("LEADER"), 10)
|
return padRightVisible(styleLeader.Render("LEADER"), 10)
|
||||||
}
|
}
|
||||||
|
// Try the feed-supplied gap first.
|
||||||
if g := parseGap(d.GapToLeader); g != "" {
|
if g := parseGap(d.GapToLeader); g != "" {
|
||||||
return padRightVisible(styleGap.Render(g), 10)
|
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)
|
return padRight("", 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,7 +1773,7 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string {
|
|||||||
|
|
||||||
func (m OfficialLiveModel) renderTyreAge(num string) string {
|
func (m OfficialLiveModel) renderTyreAge(num string) string {
|
||||||
tyre, ok := m.tyres[num]
|
tyre, ok := m.tyres[num]
|
||||||
if !ok {
|
if !ok || tyre.Age == 0 {
|
||||||
return padRight("", 3)
|
return padRight("", 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,6 +1869,20 @@ func (m OfficialLiveModel) renderRightPanel(w int) string {
|
|||||||
return sb.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 {
|
func (m OfficialLiveModel) renderDriverDetail(w int) string {
|
||||||
num := m.expandedDriver
|
num := m.expandedDriver
|
||||||
d, ok := m.drivers[num]
|
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
|
client *api.OpenF1Client
|
||||||
meeting *models.Meeting
|
meeting *models.Meeting
|
||||||
|
|
||||||
sessions []models.Session
|
sessions []models.Session
|
||||||
results []models.SessionResult
|
results []models.SessionResult
|
||||||
drivers map[int]models.Driver
|
drivers map[int]models.Driver
|
||||||
rcMsgs []models.RaceControl
|
startingGrid []models.StartingGrid
|
||||||
weather []models.Weather
|
rcMsgs []models.RaceControl
|
||||||
overtakes []models.Overtake
|
weather []models.Weather
|
||||||
|
overtakes []models.Overtake
|
||||||
|
|
||||||
selectedSession *models.Session
|
selectedSession *models.Session
|
||||||
sessionCursor int
|
sessionCursor int
|
||||||
@@ -40,6 +41,8 @@ type RaceDetailModel struct {
|
|||||||
rcView viewport.Model
|
rcView viewport.Model
|
||||||
rcReady bool
|
rcReady bool
|
||||||
|
|
||||||
|
replay ReplayModel
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
|
|||||||
client: client,
|
client: client,
|
||||||
spinner: s,
|
spinner: s,
|
||||||
drivers: make(map[int]models.Driver),
|
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).
|
// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes, starting grid).
|
||||||
func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
func fetchSecondaryData(client *api.OpenF1Client, sessionKey int, isRace bool) tea.Cmd {
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{
|
||||||
func() tea.Msg {
|
func() tea.Msg {
|
||||||
msgs, err := client.GetRaceControl(sessionKey)
|
msgs, err := client.GetRaceControl(sessionKey)
|
||||||
return raceControlLoadedMsg{messages: msgs, err: err}
|
return raceControlLoadedMsg{messages: msgs, err: err}
|
||||||
@@ -97,7 +101,15 @@ func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
|||||||
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
||||||
return overtakesLoadedMsg{overtakes: overtakes, err: err}
|
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) {
|
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)
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
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:
|
case meetingSelectedMsg:
|
||||||
m.meeting = &msg.meeting
|
m.meeting = &msg.meeting
|
||||||
m.sessions = nil
|
m.sessions = nil
|
||||||
m.results = nil
|
m.results = nil
|
||||||
|
m.startingGrid = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
m.overtakes = nil
|
m.overtakes = nil
|
||||||
@@ -168,6 +190,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.driversLoaded = false
|
m.driversLoaded = false
|
||||||
m.secondaryLoading = false
|
m.secondaryLoading = false
|
||||||
m.results = nil
|
m.results = nil
|
||||||
|
m.startingGrid = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
m.overtakes = nil
|
m.overtakes = nil
|
||||||
@@ -202,7 +225,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
case loadSecondaryDataMsg:
|
case loadSecondaryDataMsg:
|
||||||
// Only load secondary data if it's still for the currently selected session
|
// Only load secondary data if it's still for the currently selected session
|
||||||
if m.selectedSession != nil && m.selectedSession.SessionKey == msg.sessionKey {
|
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:
|
case raceControlLoadedMsg:
|
||||||
@@ -221,7 +245,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.overtakes = msg.overtakes
|
m.overtakes = msg.overtakes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case startingGridLoadedMsg:
|
||||||
|
if msg.err == nil {
|
||||||
|
m.startingGrid = msg.grid
|
||||||
|
}
|
||||||
|
|
||||||
case tea.KeyMsg:
|
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 {
|
switch {
|
||||||
case matchKey(msg, GlobalKeys.Retry):
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
if m.errSessions != nil && m.meeting != nil {
|
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.secondaryLoading = false
|
||||||
m.errResults = nil
|
m.errResults = nil
|
||||||
m.results = nil
|
m.results = nil
|
||||||
|
m.startingGrid = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
m.overtakes = nil
|
m.overtakes = nil
|
||||||
@@ -305,6 +347,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.secondaryLoading = false
|
m.secondaryLoading = false
|
||||||
m.errResults = nil
|
m.errResults = nil
|
||||||
m.results = nil
|
m.results = nil
|
||||||
|
m.startingGrid = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
m.overtakes = nil
|
m.overtakes = nil
|
||||||
@@ -325,6 +368,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.secondaryLoading = false
|
m.secondaryLoading = false
|
||||||
m.errResults = nil
|
m.errResults = nil
|
||||||
m.results = nil
|
m.results = nil
|
||||||
|
m.startingGrid = nil
|
||||||
m.rcMsgs = nil
|
m.rcMsgs = nil
|
||||||
m.weather = nil
|
m.weather = nil
|
||||||
m.overtakes = 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))
|
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")
|
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
|
w := m.width
|
||||||
if w < 40 {
|
if w < 40 {
|
||||||
w = 40
|
w = 40
|
||||||
@@ -436,6 +497,10 @@ func (m RaceDetailModel) View() string {
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(m.renderWeatherCard(w - 4))
|
sb.WriteString(m.renderWeatherCard(w - 4))
|
||||||
sb.WriteString("\n")
|
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(m.renderOvertakes(w - 4))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
||||||
@@ -469,7 +534,7 @@ func (m RaceDetailModel) View() string {
|
|||||||
sb.WriteString(panels + "\n")
|
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()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +749,12 @@ func (m RaceDetailModel) renderRightPanel(width int) string {
|
|||||||
sb.WriteString(m.renderWeatherCard(width))
|
sb.WriteString(m.renderWeatherCard(width))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Starting grid (Race sessions only)
|
||||||
|
if len(m.startingGrid) > 0 {
|
||||||
|
sb.WriteString(m.renderStartingGrid(width))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Overtakes summary
|
// Overtakes summary
|
||||||
sb.WriteString(m.renderOvertakes(width))
|
sb.WriteString(m.renderOvertakes(width))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -922,3 +993,74 @@ func (m RaceDetailModel) renderOvertakes(width int) string {
|
|||||||
|
|
||||||
return sb.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
|
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.
|
// windArrow maps a wind direction in degrees to a unicode arrow.
|
||||||
func windArrow(degrees int) string {
|
func windArrow(degrees int) string {
|
||||||
arrows := []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 |
|
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.
|
||||||
|---|---|
|
|
||||||
| **Bubble Tea** | TUI framework — the "engine" (like React for terminals) |
|
- [x] **Starting Grid View** — Show qualifying lap times and grid order in Race Detail tab (secondary data tier, Race sessions only)
|
||||||
| **Lipgloss** | Styling — colors, borders, padding |
|
- [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
|
||||||
| **Bubbles** | Pre-built components — tables, spinners, viewports |
|
- [x] **Sector & Speed Analysis** — Sector time sparklines (S1/S2/S3) and speed trap trends from existing lap data in Driver Detail
|
||||||
| **OpenF1 API** | Data source — free, no key needed |
|
- [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.)
|
- [x] **ASCII Track Map** — Render track outline from Location data with live car positions colored by team
|
||||||
2. **Update** — handles events (keypresses, API responses) and returns a new model
|
- [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
|
||||||
3. **View** — renders the model to a string that gets printed to the terminal
|
- [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
|
||||||
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 { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Phase 3 — "Engineering Room" (Companion Web View)
|
||||||
|
|
||||||
```
|
A lightweight local web UI for visualizations that need a proper canvas.
|
||||||
box-box/
|
|
||||||
├── cmd/
|
- [ ] **`box-box --web` server** — Spawn a localhost SPA from Go embedded assets, sharing the same SQLite cache
|
||||||
│ └── main.go # Entry point
|
- [ ] **SVG Track Map** — Animated car positions on a real circuit layout with team colors
|
||||||
├── internal/
|
- [ ] **Telemetry Overlay** — Interactive throttle/brake/speed graph through a lap (D3.js or Canvas)
|
||||||
│ ├── api/
|
- [ ] **Strategy Timeline** — Visual pit stop and stint timeline for the full field
|
||||||
│ │ └── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Bootstrap It
|
## Phase 4 — "Always On" (Background Intelligence)
|
||||||
|
|
||||||
```bash
|
Passive monitoring and historical analysis features.
|
||||||
mkdir box-box && cd box-box
|
|
||||||
go mod init github.com/yourusername/box-box
|
|
||||||
|
|
||||||
# Install dependencies
|
- [ ] **Daemon / Notification Mode** — `box-box --watch` sends OS notifications for key race events (flags, overtakes, pit stops)
|
||||||
go get github.com/charmbracelet/bubbletea
|
- [ ] **Championship Simulator** — "What if" scenarios: set finishing positions per driver and project championship standings forward
|
||||||
go get github.com/charmbracelet/lipgloss
|
- [ ] **Multi-Year Driver Comparison** — Career arc and season-over-season stats with win/pole rates
|
||||||
go get github.com/charmbracelet/bubbles
|
- [ ] **tmux / Status Bar Integration** — Compact race status output for shell prompts and status bars
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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?
|
|
||||||
|
|||||||
Reference in New Issue
Block a user