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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user