Updated Live

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

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

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