mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
1270 lines
35 KiB
Go
1270 lines
35 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/live"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Live timing types re-exported from internal/live for TUI sub-views.
|
|
type (
|
|
F1DriverListEntry = live.F1DriverListEntry
|
|
LiveTyreData = live.LiveTyreData
|
|
LiveRCMessage = live.LiveRCMessage
|
|
LiveWeatherData = live.LiveWeatherData
|
|
LiveSessionMeta = live.LiveSessionMeta
|
|
LiveSectorData = live.LiveSectorData
|
|
LiveDriverData = live.LiveDriverData
|
|
LiveStintData = live.LiveStintData
|
|
LiveStreamData = live.LiveStreamData
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Model wrapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type wsDataMsg LiveStreamData
|
|
|
|
func listenForWSData(sub chan LiveStreamData) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return wsDataMsg(<-sub)
|
|
}
|
|
}
|
|
|
|
type clockTickMsg time.Time
|
|
|
|
func clockTick() tea.Cmd {
|
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
|
return clockTickMsg(t)
|
|
})
|
|
}
|
|
|
|
func parseGap(val string) string {
|
|
return val
|
|
}
|
|
|
|
// parseLapTimeMs parses a lap time string like "1:33.596" or "1:33" into
|
|
// milliseconds. Returns -1 if the string cannot be parsed.
|
|
func parseLapTimeMs(s string) int64 {
|
|
if s == "" {
|
|
return -1
|
|
}
|
|
// Handle M:SS.mmm or M:SS
|
|
parts := strings.SplitN(s, ":", 2)
|
|
if len(parts) == 2 {
|
|
var mins int
|
|
if _, err := fmt.Sscanf(parts[0], "%d", &mins); err != nil {
|
|
return -1
|
|
}
|
|
var secs float64
|
|
if _, err := fmt.Sscanf(parts[1], "%f", &secs); err != nil {
|
|
return -1
|
|
}
|
|
return int64(float64(mins)*60000 + secs*1000)
|
|
}
|
|
// Bare seconds: SS.mmm
|
|
var secs float64
|
|
if _, err := fmt.Sscanf(s, "%f", &secs); err != nil {
|
|
return -1
|
|
}
|
|
return int64(secs * 1000)
|
|
}
|
|
|
|
// computeGapFromBestLap returns a gap string like "+1.234" computed from the
|
|
// difference between the driver's best lap time and the leader's best lap time.
|
|
// Returns "" if either time is missing.
|
|
func computeGapFromBestLap(driverBest, leaderBest string) string {
|
|
dMs := parseLapTimeMs(driverBest)
|
|
lMs := parseLapTimeMs(leaderBest)
|
|
if dMs < 0 || lMs < 0 {
|
|
return ""
|
|
}
|
|
diff := dMs - lMs
|
|
if diff <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("+%.3f", float64(diff)/1000.0)
|
|
}
|
|
|
|
// parseGapToFloat extracts a numeric gap value from a gap string like "+1.234" or "1.234".
|
|
// Returns -1 if the string cannot be parsed (e.g. "1 LAP", empty, "LAP").
|
|
func parseGapToFloat(val string) float64 {
|
|
if val == "" {
|
|
return -1
|
|
}
|
|
s := strings.TrimPrefix(val, "+")
|
|
var f float64
|
|
if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f >= 0 {
|
|
return f
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// gapTrendIndicator renders a compact 8-character sparkline showing gap trend.
|
|
// Rising = gap increasing (bad, red), falling = gap decreasing (good, green).
|
|
func gapTrendIndicator(history []float64) string {
|
|
if len(history) < 2 {
|
|
return ""
|
|
}
|
|
|
|
const trendWidth = 8
|
|
blocks := []rune("▁▂▃▄▅▆▇█")
|
|
|
|
// Use the most recent samples that fit
|
|
data := history
|
|
if len(data) > trendWidth {
|
|
data = data[len(data)-trendWidth:]
|
|
}
|
|
|
|
minVal, maxVal := data[0], data[0]
|
|
for _, v := range data {
|
|
if v < minVal {
|
|
minVal = v
|
|
}
|
|
if v > maxVal {
|
|
maxVal = v
|
|
}
|
|
}
|
|
|
|
rng := maxVal - minVal
|
|
if rng < 0.01 {
|
|
// Gap is stable — show flat indicator
|
|
return styleMuted.Render(strings.Repeat("─", len(data)))
|
|
}
|
|
|
|
// Determine trend direction: compare first half avg vs second half avg
|
|
mid := len(data) / 2
|
|
var firstHalf, secondHalf float64
|
|
for i := 0; i < mid; i++ {
|
|
firstHalf += data[i]
|
|
}
|
|
for i := mid; i < len(data); i++ {
|
|
secondHalf += data[i]
|
|
}
|
|
firstHalf /= float64(mid)
|
|
secondHalf /= float64(len(data) - mid)
|
|
closing := secondHalf < firstHalf // gap shrinking = good
|
|
|
|
var sb strings.Builder
|
|
for _, v := range data {
|
|
norm := (v - minVal) / rng
|
|
idx := int(norm*float64(len(blocks)-1) + 0.5)
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(blocks) {
|
|
idx = len(blocks) - 1
|
|
}
|
|
var style lipgloss.Style
|
|
if closing {
|
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
|
} else {
|
|
style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
}
|
|
sb.WriteString(style.Render(string(blocks[idx])))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// compoundAbbrevStyle returns the single-letter abbreviation and lipgloss style for a tyre compound string.
|
|
func compoundAbbrevStyle(compound string) (string, lipgloss.Style) {
|
|
switch {
|
|
case strings.Contains(compound, "SOFT") || compound == "C4" || compound == "C5":
|
|
return "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true)
|
|
case strings.Contains(compound, "MEDIUM") || compound == "C3":
|
|
return "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true)
|
|
case strings.Contains(compound, "HARD") || compound == "C1" || compound == "C2":
|
|
return "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true)
|
|
case strings.Contains(compound, "INTER"):
|
|
return "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true)
|
|
case strings.Contains(compound, "WET"):
|
|
return "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true)
|
|
default:
|
|
abbrev := "?"
|
|
if compound != "" {
|
|
abbrev = string([]rune(compound)[0])
|
|
}
|
|
return abbrev, styleMuted
|
|
}
|
|
}
|
|
|
|
// parseHHMMSS parses "H:MM:SS" or "HH:MM:SS" into a time.Duration.
|
|
func parseHHMMSS(s string) (time.Duration, error) {
|
|
var h, m, sec int
|
|
_, err := fmt.Sscanf(s, "%d:%d:%d", &h, &m, &sec)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second, nil
|
|
}
|
|
|
|
// maxGapHistory is the number of gap samples to keep per driver for trend sparklines.
|
|
const maxGapHistory = 20
|
|
|
|
type OfficialLiveModel struct {
|
|
width int
|
|
height int
|
|
|
|
dataChan chan LiveStreamData
|
|
drivers map[string]LiveDriverData
|
|
driverInfo map[string]F1DriverListEntry
|
|
tyres map[string]LiveTyreData
|
|
rcMessages []LiveRCMessage
|
|
weather LiveWeatherData
|
|
session LiveSessionMeta
|
|
trackStatus string
|
|
currentLap int
|
|
totalLaps int
|
|
clock string
|
|
clockRefTime time.Time
|
|
clockExtrapolating bool
|
|
stints map[string][]LiveStintData
|
|
gapHistory map[string][]float64 // racing number -> recent gap-to-leader values
|
|
err error
|
|
|
|
// UI state
|
|
cursor int
|
|
scroll int
|
|
showSectors bool
|
|
expandedDriver string // racing number, "" if none
|
|
showRC bool // compact mode RC overlay toggle
|
|
showBattles bool // battle tracker overlay
|
|
showPitWindow bool // pit window calculator overlay
|
|
|
|
rcView viewport.Model
|
|
rcReady bool
|
|
}
|
|
|
|
func NewOfficialLiveModel() OfficialLiveModel {
|
|
return OfficialLiveModel{
|
|
dataChan: make(chan LiveStreamData, 10),
|
|
drivers: make(map[string]LiveDriverData),
|
|
driverInfo: make(map[string]F1DriverListEntry),
|
|
tyres: make(map[string]LiveTyreData),
|
|
stints: make(map[string][]LiveStintData),
|
|
gapHistory: make(map[string][]float64),
|
|
}
|
|
}
|
|
|
|
func (m OfficialLiveModel) Init() tea.Cmd {
|
|
err := live.ConnectToF1LiveTiming(m.dataChan)
|
|
if err != nil {
|
|
return func() tea.Msg { return err }
|
|
}
|
|
return tea.Batch(listenForWSData(m.dataChan), clockTick())
|
|
}
|
|
|
|
// displayClock returns the session clock, counting down locally between feed updates.
|
|
func (m OfficialLiveModel) displayClock() string {
|
|
if m.clock == "" {
|
|
return ""
|
|
}
|
|
if !m.clockExtrapolating || m.clockRefTime.IsZero() {
|
|
return m.clock
|
|
}
|
|
remaining, err := parseHHMMSS(m.clock)
|
|
if err != nil {
|
|
return m.clock
|
|
}
|
|
elapsed := time.Since(m.clockRefTime)
|
|
actual := remaining - elapsed
|
|
if actual < 0 {
|
|
actual = 0
|
|
}
|
|
h := int(actual.Hours())
|
|
mnt := int(actual.Minutes()) % 60
|
|
sec := int(actual.Seconds()) % 60
|
|
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 {
|
|
// In practice/qualifying, recomputeImpliedPositions already placed every
|
|
// known driver (including driverInfo-only entries) into m.drivers with a
|
|
// correct Position. For races we still merge driverInfo to show drivers
|
|
// that haven't received any timing data yet.
|
|
merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo))
|
|
for num, d := range m.drivers {
|
|
merged[num] = d
|
|
}
|
|
for num := range m.driverInfo {
|
|
if _, exists := merged[num]; !exists {
|
|
merged[num] = LiveDriverData{RacingNumber: num}
|
|
}
|
|
}
|
|
|
|
var all []LiveDriverData
|
|
for _, d := range merged {
|
|
all = append(all, d)
|
|
}
|
|
sort.Slice(all, func(i, j int) bool {
|
|
pi, pj := all[i].Position, all[j].Position
|
|
if pi > 0 && pj > 0 {
|
|
return pi < pj
|
|
}
|
|
if pi > 0 {
|
|
return true
|
|
}
|
|
if pj > 0 {
|
|
return false
|
|
}
|
|
// Both unpositioned: sort by best lap time, then racing number
|
|
ti, tj := all[i].BestLapTime, all[j].BestLapTime
|
|
if ti != "" && tj != "" {
|
|
return ti < tj
|
|
}
|
|
if ti != "" {
|
|
return true
|
|
}
|
|
if tj != "" {
|
|
return false
|
|
}
|
|
var ni, nj int
|
|
fmt.Sscanf(all[i].RacingNumber, "%d", &ni)
|
|
fmt.Sscanf(all[j].RacingNumber, "%d", &nj)
|
|
return ni < nj
|
|
})
|
|
|
|
// 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, Sprint Qualifying,
|
|
// and Sprint Shootout. In these sessions the timing tower shows BEST lap time
|
|
// as the primary column.
|
|
func (m OfficialLiveModel) isPracticeOrQuali() bool {
|
|
t := strings.ToLower(m.session.SessionType)
|
|
return m.isTimeBasedSession(t)
|
|
}
|
|
|
|
// isTimeBasedSession returns true for any session type where positions are
|
|
// determined by best lap time rather than by on-track race order. This covers
|
|
// Free Practice, Qualifying, Sprint Qualifying, and Sprint Shootout.
|
|
// "Sprint" on its own is a race and is NOT included.
|
|
func (m OfficialLiveModel) isTimeBasedSession(sessionType string) bool {
|
|
t := sessionType
|
|
if t == "" {
|
|
t = strings.ToLower(m.session.SessionType)
|
|
}
|
|
return strings.Contains(t, "practice") ||
|
|
strings.Contains(t, "qualifying") ||
|
|
strings.Contains(t, "shootout") ||
|
|
t == "fp1" || t == "fp2" || t == "fp3" ||
|
|
t == "q" || t == "sq"
|
|
}
|
|
|
|
// overallBestLapTime returns the string of the overall fastest BestLapTime across all drivers.
|
|
// Uses lexicographic comparison which is valid for M:SS.mmm formatted times.
|
|
func (m OfficialLiveModel) overallBestLapTime() string {
|
|
best := ""
|
|
for _, d := range m.drivers {
|
|
if d.BestLapTime == "" {
|
|
continue
|
|
}
|
|
if best == "" || d.BestLapTime < best {
|
|
best = d.BestLapTime
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func (m OfficialLiveModel) visibleRows() int {
|
|
rows := m.height - 8
|
|
if m.trackStatus != "" && m.trackStatus != "1" {
|
|
rows--
|
|
}
|
|
if rows < 5 {
|
|
rows = 5
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (m *OfficialLiveModel) ensureVisible() {
|
|
visible := m.visibleRows()
|
|
if m.cursor < m.scroll {
|
|
m.scroll = m.cursor
|
|
}
|
|
if m.cursor >= m.scroll+visible {
|
|
m.scroll = m.cursor - visible + 1
|
|
}
|
|
}
|
|
|
|
func (m *OfficialLiveModel) SetSize(w, h int) {
|
|
m.width = w
|
|
m.height = h
|
|
|
|
if w >= 100 {
|
|
rcWidth := int(float64(w)*0.4) - 4
|
|
rcHeight := h - 12
|
|
if rcHeight < 3 {
|
|
rcHeight = 3
|
|
}
|
|
if !m.rcReady {
|
|
m.rcView = viewport.New(rcWidth, rcHeight)
|
|
m.rcReady = true
|
|
} else {
|
|
m.rcView.Width = rcWidth
|
|
m.rcView.Height = rcHeight
|
|
}
|
|
m.updateRCViewport()
|
|
}
|
|
}
|
|
|
|
func (m *OfficialLiveModel) updateRCViewport() {
|
|
if !m.rcReady {
|
|
return
|
|
}
|
|
m.rcView.SetContent(m.renderRCContent())
|
|
m.rcView.GotoBottom()
|
|
}
|
|
|
|
func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.SetSize(msg.Width, msg.Height)
|
|
return m, nil
|
|
case error:
|
|
m.err = msg
|
|
return m, nil
|
|
case clockTickMsg:
|
|
// Re-render every second so the local countdown stays smooth
|
|
return m, clockTick()
|
|
case wsDataMsg:
|
|
m.drivers = msg.Drivers
|
|
m.driverInfo = msg.DriverInfo
|
|
m.tyres = msg.Tyres
|
|
m.rcMessages = msg.RCMessages
|
|
m.weather = msg.Weather
|
|
m.session = msg.Session
|
|
m.trackStatus = msg.TrackStatus
|
|
m.currentLap = msg.CurrentLap
|
|
m.totalLaps = msg.TotalLaps
|
|
m.clock = msg.Clock
|
|
m.clockRefTime = msg.ClockRefTime
|
|
m.clockExtrapolating = msg.ClockExtrapolating
|
|
m.stints = msg.Stints
|
|
// Record gap history for trend sparklines
|
|
for num, d := range msg.Drivers {
|
|
if gap := parseGapToFloat(d.GapToLeader); gap >= 0 {
|
|
hist := m.gapHistory[num]
|
|
hist = append(hist, gap)
|
|
if len(hist) > maxGapHistory {
|
|
hist = hist[len(hist)-maxGapHistory:]
|
|
}
|
|
m.gapHistory[num] = hist
|
|
}
|
|
}
|
|
// Back-fill implied positions for drivers the feed hasn't positioned yet.
|
|
m.recomputeImpliedPositions()
|
|
m.updateRCViewport()
|
|
return m, listenForWSData(m.dataChan)
|
|
case tea.KeyMsg:
|
|
drivers := m.sortedDrivers()
|
|
switch {
|
|
case matchKey(msg, GlobalKeys.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.ensureVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.Down):
|
|
if m.cursor < len(drivers)-1 {
|
|
m.cursor++
|
|
m.ensureVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.GoTop):
|
|
m.cursor = 0
|
|
m.scroll = 0
|
|
case matchKey(msg, GlobalKeys.GoBottom):
|
|
if len(drivers) > 0 {
|
|
m.cursor = len(drivers) - 1
|
|
m.ensureVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.HalfUp):
|
|
half := m.visibleRows() / 2
|
|
m.cursor -= half
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
m.ensureVisible()
|
|
case matchKey(msg, GlobalKeys.HalfDown):
|
|
half := m.visibleRows() / 2
|
|
m.cursor += half
|
|
if m.cursor >= len(drivers) && len(drivers) > 0 {
|
|
m.cursor = len(drivers) - 1
|
|
}
|
|
m.ensureVisible()
|
|
case matchKey(msg, LiveKeys.ToggleSectors):
|
|
m.showSectors = !m.showSectors
|
|
case matchKey(msg, LiveKeys.ToggleRC):
|
|
if m.width < 100 {
|
|
m.showRC = !m.showRC
|
|
if m.showRC {
|
|
m.showBattles = false
|
|
m.showPitWindow = false
|
|
}
|
|
}
|
|
case matchKey(msg, LiveKeys.ToggleBattles):
|
|
m.showBattles = !m.showBattles
|
|
if m.showBattles {
|
|
m.showRC = false
|
|
m.showPitWindow = false
|
|
m.expandedDriver = ""
|
|
}
|
|
case matchKey(msg, LiveKeys.TogglePitWindow):
|
|
m.showPitWindow = !m.showPitWindow
|
|
if m.showPitWindow {
|
|
m.showRC = false
|
|
m.showBattles = false
|
|
m.expandedDriver = ""
|
|
}
|
|
case matchKey(msg, LiveKeys.ExpandDriver):
|
|
if len(drivers) > 0 && m.cursor < len(drivers) {
|
|
m.expandedDriver = drivers[m.cursor].RacingNumber
|
|
m.showBattles = false
|
|
m.showPitWindow = false
|
|
}
|
|
case matchKey(msg, LiveKeys.Collapse):
|
|
m.expandedDriver = ""
|
|
m.showBattles = false
|
|
m.showPitWindow = false
|
|
case matchKey(msg, LiveKeys.ScrollRCUp):
|
|
if m.rcReady {
|
|
m.rcView.LineUp(3)
|
|
}
|
|
case matchKey(msg, LiveKeys.ScrollRCDown):
|
|
if m.rcReady {
|
|
m.rcView.LineDown(3)
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.rcReady {
|
|
var cmd tea.Cmd
|
|
m.rcView, cmd = m.rcView.Update(msg)
|
|
if cmd != nil {
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// View rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (m OfficialLiveModel) View() string {
|
|
if m.err != nil {
|
|
return fmt.Sprintf("\n Error connecting to F1 live stream: %v\n\n%s",
|
|
m.err, helpBar("1-6 tabs", "q quit"))
|
|
}
|
|
if len(m.drivers) == 0 {
|
|
return "\n Connecting to Official F1 Live Timing Stream...\n"
|
|
}
|
|
|
|
w := m.width
|
|
if w < 40 {
|
|
w = 40
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(m.renderLiveHeader(w))
|
|
sb.WriteString(m.renderTrackStatusBanner(w))
|
|
|
|
wide := w >= 100
|
|
if wide {
|
|
leftWidth := int(float64(w) * 0.6)
|
|
rightWidth := w - leftWidth - 4
|
|
|
|
var rightPanel string
|
|
if m.showBattles {
|
|
rightPanel = m.renderBattlesPanel(rightWidth)
|
|
} else if m.showPitWindow {
|
|
rightPanel = m.renderPitWindowPanel(rightWidth)
|
|
} else {
|
|
rightPanel = m.renderRightPanel(rightWidth)
|
|
}
|
|
|
|
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
|
m.renderTimingTower(leftWidth),
|
|
" ",
|
|
rightPanel,
|
|
)
|
|
sb.WriteString(panels)
|
|
} else {
|
|
if m.showBattles {
|
|
sb.WriteString(m.renderBattlesPanel(w - 2))
|
|
} else if m.showPitWindow {
|
|
sb.WriteString(m.renderPitWindowPanel(w - 2))
|
|
} else if m.showRC {
|
|
sb.WriteString(m.renderRCContent())
|
|
} else {
|
|
sb.WriteString(m.renderTimingTower(w - 2))
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
if wide {
|
|
sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "b battles", "p pit win", "K/J race ctrl", "1-7 tabs", "q quit"))
|
|
} else {
|
|
sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "b battles", "p pit win", "r RC", "1-7 tabs", "q quit"))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderLiveHeader(w int) string {
|
|
var sb strings.Builder
|
|
|
|
// Prefer specific session name (e.g. "FP1", "Q3") over generic type
|
|
sessionType := m.session.SessionName
|
|
if sessionType == "" {
|
|
sessionType = m.session.SessionType
|
|
}
|
|
if sessionType == "" {
|
|
sessionType = "LIVE"
|
|
}
|
|
|
|
var badgeStyle lipgloss.Style
|
|
switch m.trackStatus {
|
|
case "2":
|
|
badgeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Black)).Background(lipgloss.Color(colorYellow))
|
|
case "4", "6":
|
|
badgeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Black)).Background(lipgloss.Color(colorOrange))
|
|
case "5":
|
|
badgeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorWhite)).Background(lipgloss.Color(colorF1Red))
|
|
default:
|
|
badgeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorWhite)).Background(lipgloss.Color(colorGreen))
|
|
}
|
|
badge := badgeStyle.Padding(0, 1).Render(strings.ToUpper(sessionType))
|
|
|
|
parts := []string{badge}
|
|
|
|
if m.session.MeetingName != "" {
|
|
parts = append(parts, styleBold.Render(m.session.MeetingName))
|
|
}
|
|
|
|
if m.totalLaps > 0 && m.currentLap > 0 {
|
|
barWidth := 12
|
|
filled := int(float64(m.currentLap) / float64(m.totalLaps) * float64(barWidth))
|
|
if filled > barWidth {
|
|
filled = barWidth
|
|
}
|
|
bar := stylePointsBarFilled.Render(strings.Repeat("█", filled)) +
|
|
stylePointsBarEmpty.Render(strings.Repeat("░", barWidth-filled))
|
|
parts = append(parts, fmt.Sprintf("Lap %s %s",
|
|
styleBold.Render(fmt.Sprintf("%d/%d", m.currentLap, m.totalLaps)), bar))
|
|
}
|
|
|
|
if label := m.trackStatusLabel(); label != "" {
|
|
parts = append(parts, label)
|
|
}
|
|
|
|
if clk := m.displayClock(); clk != "" {
|
|
parts = append(parts, styleCountdown.Render(clk))
|
|
}
|
|
|
|
sb.WriteString("\n " + strings.Join(parts, " ") + "\n")
|
|
|
|
// Weather mini-line
|
|
if m.weather.AirTemp > 0 || m.weather.TrackTemp > 0 {
|
|
var condStr string
|
|
if m.weather.Rainfall {
|
|
condStr = styleRain.Render("🌧 Rain")
|
|
} else {
|
|
condStr = styleDry.Render("☀ Dry")
|
|
}
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %s Air %s Track %s 💧%s %s%.1fm/s",
|
|
condStr,
|
|
styleWeatherValue.Render(fmt.Sprintf("%.0f°", m.weather.AirTemp)),
|
|
styleWeatherValue.Render(fmt.Sprintf("%.0f°", m.weather.TrackTemp)),
|
|
styleWeatherValue.Render(fmt.Sprintf("%.0f%%", m.weather.Humidity)),
|
|
styleWeatherValue.Render(windArrow(m.weather.WindDir)+" "),
|
|
m.weather.WindSpeed,
|
|
)) + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m OfficialLiveModel) trackStatusLabel() string {
|
|
switch m.trackStatus {
|
|
case "1":
|
|
return styleFlagGreen.Render("🟢 GREEN")
|
|
case "2":
|
|
return styleFlagYellow.Render("🟡 YELLOW")
|
|
case "4":
|
|
return styleSafetyCar.Render("⚠ SAFETY CAR")
|
|
case "5":
|
|
return styleFlagRed.Render("🔴 RED FLAG")
|
|
case "6":
|
|
return styleSafetyCar.Render("⚠ VSC")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderTrackStatusBanner(w int) string {
|
|
if m.trackStatus == "" || m.trackStatus == "1" {
|
|
return ""
|
|
}
|
|
|
|
var bannerStyle lipgloss.Style
|
|
var text string
|
|
switch m.trackStatus {
|
|
case "2":
|
|
bannerStyle = lipgloss.NewStyle().Background(lipgloss.Color(colorYellow)).Foreground(lipgloss.Color(colorF1Black)).Bold(true)
|
|
text = " 🟡 YELLOW FLAG"
|
|
case "4":
|
|
bannerStyle = lipgloss.NewStyle().Background(lipgloss.Color(colorOrange)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
|
text = " ⚠ SAFETY CAR DEPLOYED"
|
|
case "5":
|
|
bannerStyle = lipgloss.NewStyle().Background(lipgloss.Color(colorF1Red)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
|
text = " 🔴 RED FLAG — SESSION STOPPED"
|
|
case "6":
|
|
bannerStyle = lipgloss.NewStyle().Background(lipgloss.Color(colorOrange)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
|
text = " ⚠ VIRTUAL SAFETY CAR"
|
|
default:
|
|
return ""
|
|
}
|
|
|
|
padded := text + strings.Repeat(" ", max(0, w-lipgloss.Width(text)))
|
|
return bannerStyle.Render(padded) + "\n"
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderTimingTower(w int) string {
|
|
var sb strings.Builder
|
|
drivers := m.sortedDrivers()
|
|
|
|
fpq := m.isPracticeOrQuali()
|
|
var header string
|
|
if m.showSectors {
|
|
timeLabel := "LAST"
|
|
if fpq {
|
|
timeLabel = "BEST"
|
|
}
|
|
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
|
|
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
|
|
padRight("TYRE", 5), padRight(timeLabel, 10),
|
|
padRight("S1", 8), padRight("S2", 8), padRight("S3", 8),
|
|
padRight("GAP", 10))
|
|
} else if fpq {
|
|
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
|
|
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
|
|
padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1),
|
|
padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10))
|
|
} else {
|
|
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
|
|
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
|
|
padRight("TYRE", 5), padRight("AGE", 3), padRight("LAST", 10),
|
|
padRight("GAP", 10), padRight("INT", 10), padRight("TREND", 8))
|
|
}
|
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
|
sb.WriteString(" " + divider(min(w-4, lipgloss.Width(header))) + "\n")
|
|
|
|
visible := m.visibleRows()
|
|
endIdx := m.scroll + visible
|
|
if endIdx > len(drivers) {
|
|
endIdx = len(drivers)
|
|
}
|
|
|
|
overallBest := m.overallBestLapTime()
|
|
for i := m.scroll; i < endIdx; i++ {
|
|
sb.WriteString(m.renderDriverRow(drivers[i], i, fpq, overallBest) + "\n")
|
|
}
|
|
|
|
if len(drivers) > visible {
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" [%d-%d of %d]", m.scroll+1, endIdx, len(drivers))) + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool, overallBest string) string {
|
|
info, hasInfo := m.driverInfo[d.RacingNumber]
|
|
tla := d.RacingNumber
|
|
teamColor := colorMuted
|
|
if hasInfo {
|
|
tla = info.Tla
|
|
if info.TeamColour != "" {
|
|
teamColor = "#" + info.TeamColour
|
|
} else {
|
|
teamColor = teamColorFromName(info.TeamName)
|
|
}
|
|
}
|
|
|
|
posStr := padRightVisible(renderPosition(d.Position), 3)
|
|
|
|
deltaStr := padRightVisible(styleDeltaEqual.Render("─"), 2)
|
|
if d.PrevPosition > 0 && d.PrevPosition != d.Position {
|
|
deltaStr = renderDelta(d.Position, d.PrevPosition)
|
|
}
|
|
|
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
|
|
|
// In qualifying, dim knocked-out drivers
|
|
tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor))
|
|
if d.KnockedOut {
|
|
tlaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
|
}
|
|
tlaStr := tlaStyle.Render(padRight(tla, 4))
|
|
tyreStr := m.renderTyreIndicator(d.RacingNumber)
|
|
|
|
// Last lap time coloring
|
|
lastLapRaw := d.LastLapTime
|
|
lastLap := padRight(lastLapRaw, 10)
|
|
if d.Retired {
|
|
lastLap = padRightVisible(styleDNF.Render("RET"), 10)
|
|
} else if d.InPit {
|
|
lastLap = padRightVisible(styleSafetyCar.Render("PIT"), 10)
|
|
} else if lastLapRaw != "" {
|
|
if d.LastLapOB {
|
|
lastLap = padRightVisible(stylePurple.Render(lastLapRaw), 10)
|
|
} else if d.LastLapPB {
|
|
lastLap = padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(lastLapRaw), 10)
|
|
}
|
|
}
|
|
|
|
var row string
|
|
gapStr := m.renderGapStr(d, fpq)
|
|
if d.Retired {
|
|
gapStr = padRightVisible(styleMuted.Render("Retired"), 10)
|
|
}
|
|
|
|
if m.showSectors {
|
|
timeCol := lastLap
|
|
if fpq {
|
|
timeCol = m.renderBestLapTime(d, overallBest)
|
|
}
|
|
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
|
|
posStr, deltaStr, colorBar, tlaStr, tyreStr,
|
|
timeCol,
|
|
m.renderSector(d.Sectors[0]),
|
|
m.renderSector(d.Sectors[1]),
|
|
m.renderSector(d.Sectors[2]),
|
|
gapStr)
|
|
} else if fpq {
|
|
// FP / Qualifying: show BEST lap as primary, LAST as secondary
|
|
bestLap := m.renderBestLapTime(d, overallBest)
|
|
flyingIndicator := " "
|
|
if d.OnFlyingLap {
|
|
flyingIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("◎")
|
|
}
|
|
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
|
|
posStr, deltaStr, colorBar, tlaStr, tyreStr,
|
|
m.renderTyreAge(d.RacingNumber),
|
|
flyingIndicator,
|
|
bestLap,
|
|
lastLap,
|
|
gapStr)
|
|
// Highlight danger zone (cutoff) in qualifying
|
|
if d.Cutoff && idx != m.cursor {
|
|
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row)
|
|
}
|
|
} else {
|
|
// Race mode: LAST + GAP + INT + TREND
|
|
trend := ""
|
|
if hist, ok := m.gapHistory[d.RacingNumber]; ok && d.Position > 1 {
|
|
trend = gapTrendIndicator(hist)
|
|
}
|
|
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
|
|
posStr, deltaStr, colorBar, tlaStr, tyreStr,
|
|
m.renderTyreAge(d.RacingNumber),
|
|
lastLap,
|
|
gapStr,
|
|
m.renderIntvStr(d),
|
|
padRightVisible(trend, 8))
|
|
}
|
|
|
|
// Removed lines are embedded in the previous chunk.
|
|
|
|
if idx == m.cursor {
|
|
return styleSelected.Render(row)
|
|
}
|
|
if d.KnockedOut {
|
|
return styleMuted.Render(row)
|
|
}
|
|
return row
|
|
}
|
|
|
|
// renderBestLapTime renders a driver's session best lap time with appropriate coloring.
|
|
func (m OfficialLiveModel) renderBestLapTime(d LiveDriverData, overallBest string) string {
|
|
if d.BestLapTime == "" {
|
|
return padRightVisible(styleMuted.Render("no time"), 10)
|
|
}
|
|
isOverallBest := overallBest != "" && d.BestLapTime == overallBest
|
|
if isOverallBest || d.BestLapOB {
|
|
return padRightVisible(stylePurple.Render(d.BestLapTime), 10)
|
|
}
|
|
if d.BestLapPB {
|
|
return padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(d.BestLapTime), 10)
|
|
}
|
|
return padRightVisible(styleBold.Render(d.BestLapTime), 10)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderGapStr(d LiveDriverData, fpq bool) string {
|
|
if d.Position == 1 {
|
|
if fpq {
|
|
return padRightVisible(styleLeader.Render("P1"), 10)
|
|
}
|
|
return padRightVisible(styleLeader.Render("LEADER"), 10)
|
|
}
|
|
// Try the feed-supplied gap first.
|
|
if g := parseGap(d.GapToLeader); g != "" {
|
|
return padRightVisible(styleGap.Render(g), 10)
|
|
}
|
|
// In practice/qualifying, compute gap from best lap times when the feed
|
|
// doesn't supply one.
|
|
if fpq {
|
|
if g := computeGapFromBestLap(d.BestLapTime, m.overallBestLapTime()); g != "" {
|
|
return padRightVisible(styleGap.Render(g), 10)
|
|
}
|
|
if d.BestLapTime == "" {
|
|
return padRight(styleMuted.Render("no time"), 10)
|
|
}
|
|
}
|
|
return padRight("", 10)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderIntvStr(d LiveDriverData) string {
|
|
if d.Position == 1 {
|
|
return padRight("", 10)
|
|
}
|
|
if iv := parseGap(d.Interval); iv != "" {
|
|
return padRightVisible(styleGap.Render(iv), 10)
|
|
}
|
|
return padRight("", 10)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderTyreIndicator(num string) string {
|
|
tyre, ok := m.tyres[num]
|
|
if !ok {
|
|
return padRight(" ?", 5)
|
|
}
|
|
|
|
compound := strings.ToUpper(tyre.Compound)
|
|
abbrev, fgStyle := compoundAbbrevStyle(compound)
|
|
|
|
bgStyle := lipgloss.NewStyle().Background(fgStyle.GetForeground()).Foreground(lipgloss.Color("#000")).Bold(true)
|
|
if strings.Contains(compound, "WET") {
|
|
bgStyle = bgStyle.Foreground(lipgloss.Color("#fff"))
|
|
}
|
|
|
|
newMark := " "
|
|
if tyre.New {
|
|
newMark = "*"
|
|
}
|
|
text := fmt.Sprintf(" %s%s ", abbrev, newMark)
|
|
return padRightVisible(bgStyle.Render(text), 5)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderTyreAge(num string) string {
|
|
tyre, ok := m.tyres[num]
|
|
if !ok || tyre.Age == 0 {
|
|
return padRight("", 3)
|
|
}
|
|
|
|
ageStr := fmt.Sprintf("%d", tyre.Age)
|
|
compound := strings.ToUpper(tyre.Compound)
|
|
isOld := (strings.Contains(compound, "SOFT") && tyre.Age > 25) ||
|
|
(strings.Contains(compound, "MEDIUM") && tyre.Age > 35) ||
|
|
(strings.Contains(compound, "HARD") && tyre.Age > 45)
|
|
|
|
if isOld {
|
|
return padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(ageStr), 3)
|
|
}
|
|
return padRightVisible(styleMuted.Render(ageStr), 3)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderSector(s LiveSectorData) string {
|
|
if s.Value == "" {
|
|
return padRight("", 8)
|
|
}
|
|
if s.OverallFastest {
|
|
return padRightVisible(stylePurple.Render(s.Value), 8)
|
|
}
|
|
if s.PersonalFastest {
|
|
return padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(s.Value), 8)
|
|
}
|
|
return padRight(s.Value, 8)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderRCContent() string {
|
|
if len(m.rcMessages) == 0 {
|
|
return styleMuted.Render(" No race control messages.")
|
|
}
|
|
|
|
var lines []string
|
|
for _, rc := range m.rcMessages {
|
|
lapStr := ""
|
|
if rc.Lap > 0 {
|
|
lapStr = styleMuted.Render(fmt.Sprintf("L%d ", rc.Lap))
|
|
}
|
|
|
|
var prefix string
|
|
switch rc.Category {
|
|
case "SafetyCar":
|
|
prefix = styleSafetyCar.Render(fmt.Sprintf(" ⚠ [%s] %s", rc.Time, lapStr))
|
|
case "Drs":
|
|
prefix = styleDRS.Render(fmt.Sprintf(" ▸ [%s] %s", rc.Time, lapStr))
|
|
default:
|
|
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
|
|
}
|
|
prefix = flagStyle.Render(fmt.Sprintf(" %s [%s] %s", icon, rc.Time, lapStr))
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("%s%s", prefix, rc.Message))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderRightPanel(w int) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
|
if m.rcReady {
|
|
sb.WriteString(m.rcView.View() + "\n")
|
|
} else {
|
|
lines := strings.Split(m.renderRCContent(), "\n")
|
|
if len(lines) > 10 {
|
|
lines = lines[len(lines)-10:]
|
|
}
|
|
sb.WriteString(strings.Join(lines, "\n") + "\n")
|
|
}
|
|
|
|
if m.expandedDriver != "" {
|
|
sb.WriteString("\n" + divider(w) + "\n")
|
|
sb.WriteString(m.renderDriverDetail(w))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// renderBattlesPanel is a thin receiver wrapper around the free renderBattlePanel function.
|
|
func (m OfficialLiveModel) renderBattlesPanel(width int) string {
|
|
drivers := m.sortedDrivers()
|
|
battles := detectBattles(drivers, m.driverInfo, m.tyres, m.stints, m.gapHistory)
|
|
isRace := !m.isPracticeOrQuali()
|
|
return renderBattlePanel(battles, width, isRace)
|
|
}
|
|
|
|
// renderPitWindowPanel is a thin receiver wrapper around the free renderPitWindowPanel function.
|
|
func (m OfficialLiveModel) renderPitWindowPanel(width int) string {
|
|
isRace := !m.isPracticeOrQuali()
|
|
return renderPitWindowPanel(m.drivers, m.driverInfo, m.session.CircuitName, isRace, width)
|
|
}
|
|
|
|
func (m OfficialLiveModel) renderDriverDetail(w int) string {
|
|
num := m.expandedDriver
|
|
d, ok := m.drivers[num]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
info, hasInfo := m.driverInfo[num]
|
|
|
|
var sb strings.Builder
|
|
|
|
name := fmt.Sprintf("#%s", num)
|
|
team := ""
|
|
teamColor := colorMuted
|
|
if hasInfo {
|
|
name = fmt.Sprintf("%s %s #%s", info.FirstName, info.LastName, num)
|
|
team = info.TeamName
|
|
if info.TeamColour != "" {
|
|
teamColor = "#" + info.TeamColour
|
|
} else {
|
|
teamColor = teamColorFromName(info.TeamName)
|
|
}
|
|
}
|
|
|
|
nameStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor))
|
|
sb.WriteString(" " + nameStyle.Render(name))
|
|
if team != "" {
|
|
sb.WriteString(" " + styleMuted.Render(team))
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
// Stint timeline
|
|
if driverStints, ok := m.stints[num]; ok && len(driverStints) > 0 {
|
|
sb.WriteString(" ")
|
|
for i, st := range driverStints {
|
|
compound := strings.ToUpper(st.Compound)
|
|
abbrev, style := compoundAbbrevStyle(compound)
|
|
newMark := ""
|
|
if st.New {
|
|
newMark = "*"
|
|
}
|
|
if i > 0 {
|
|
sb.WriteString(styleMuted.Render(" → "))
|
|
}
|
|
sb.WriteString(style.Render(fmt.Sprintf("%s(%d%s)", abbrev, st.Laps, newMark)))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if d.BestLapTime != "" {
|
|
sb.WriteString(styleMuted.Render(" Best: ") + styleBold.Render(d.BestLapTime))
|
|
if d.BestLapOB || d.LastLapOB {
|
|
sb.WriteString(" " + stylePurple.Render("FL"))
|
|
}
|
|
if d.BestLapNum > 0 {
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" (L%d)", d.BestLapNum)))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if d.SpeedTrap != "" {
|
|
sb.WriteString(styleMuted.Render(" Speed: ") + styleWeatherValue.Render(d.SpeedTrap+"km/h") + "\n")
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s P%d %s %d laps\n",
|
|
styleMuted.Render("Pos:"), d.Position,
|
|
styleMuted.Render("Laps:"), d.NumberOfLaps))
|
|
|
|
return sb.String()
|
|
}
|