Files
box-box/internal/ui/official_live.go
2026-05-24 20:56:00 -04:00

1948 lines
53 KiB
Go

package ui
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gorilla/websocket"
)
// ---------------------------------------------------------------------------
// SignalR protocol types
// ---------------------------------------------------------------------------
type F1SignalRMessage struct {
M []struct {
A []json.RawMessage `json:"A"`
} `json:"M"`
R json.RawMessage `json:"R"`
}
// ---------------------------------------------------------------------------
// Data types from the WebSocket feed
// ---------------------------------------------------------------------------
type F1TimingLine struct {
GapToLeader interface{} `json:"GapToLeader"`
IntervalToPositionAhead struct {
Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"`
Position interface{} `json:"Position"`
RacingNumber string `json:"RacingNumber"`
LastLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
} `json:"LastLapTime"`
BestLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
Lap int `json:"Lap"`
} `json:"BestLapTime"`
InPit interface{} `json:"InPit"`
PitOut interface{} `json:"PitOut"`
Retired interface{} `json:"Retired"`
KnockedOut interface{} `json:"KnockedOut"`
Cutoff interface{} `json:"Cutoff"`
NumberOfLaps interface{} `json:"NumberOfLaps"`
Sectors map[string]json.RawMessage `json:"Sectors"`
Speeds map[string]json.RawMessage `json:"Speeds"`
}
type F1DriverListEntry struct {
RacingNumber string `json:"RacingNumber"`
BroadcastName string `json:"BroadcastName"`
Tla string `json:"Tla"`
TeamName string `json:"TeamName"`
TeamColour string `json:"TeamColour"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
}
type LiveTyreData struct {
Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET
New bool
Age int // laps on current set
}
type LiveRCMessage struct {
Time string // "15:04" formatted
Category string // Flag, SafetyCar, Drs, Other
Flag string // GREEN, YELLOW, RED, etc.
Message string
Lap int
}
type LiveWeatherData struct {
AirTemp float64
TrackTemp float64
Humidity float64
WindSpeed float64
WindDir int
Rainfall bool
}
type LiveSessionMeta struct {
MeetingName string
CircuitName string
SessionType string
SessionName string
}
type LiveSectorData struct {
Value string
PersonalFastest bool
OverallFastest bool
}
type LiveDriverData struct {
RacingNumber string
Position int
PrevPosition int
GapToLeader string
Interval string
LastLapTime string
LastLapPB bool // personal best
LastLapOB bool // overall best
BestLapTime string
BestLapPB bool // just set a new personal best
BestLapOB bool // overall fastest in session
BestLapNum int // lap number when best was set
InPit bool
PitOut bool
Retired bool
KnockedOut bool // eliminated in qualifying
Cutoff bool // currently in elimination zone (danger zone)
OnFlyingLap bool // currently running a timed lap (derived from sector state)
NumberOfLaps int
SpeedTrap string // fastest recorded speed at speed trap
Sectors [3]LiveSectorData
}
type LiveStintData struct {
Compound string
New bool
Laps int
}
type LiveStreamData struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
Clock string // "HH:MM:SS" remaining at ClockRefTime
ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData
}
// ---------------------------------------------------------------------------
// WebSocket connection & parsing
// ---------------------------------------------------------------------------
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
hubName := `[{"name":"Streaming"}]`
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
req, err := http.NewRequest("GET", negotiateURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
cookies := resp.Cookies()
defer resp.Body.Close()
var neg struct {
ConnectionToken string `json:"ConnectionToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
return err
}
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
url.QueryEscape(neg.ConnectionToken),
url.QueryEscape(hubName),
)
header := http.Header{}
for _, cookie := range cookies {
header.Add("Cookie", cookie.String())
}
header.Add("User-Agent", "BestHTTP")
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
// Subscribe to all desired topics
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
}
go func() {
defer c.Close()
drivers := make(map[string]LiveDriverData)
driverInfo := make(map[string]F1DriverListEntry)
tyres := make(map[string]LiveTyreData)
stints := make(map[string][]LiveStintData)
var rcMessages []LiveRCMessage
var weather LiveWeatherData
var session LiveSessionMeta
var trackStatus string
var currentLap, totalLaps int
var clock string
var clockRefTime time.Time
var clockExtrapolating bool
sendUpdate := func() {
cpyDrivers := make(map[string]LiveDriverData)
for k, v := range drivers {
cpyDrivers[k] = v
}
cpyInfo := make(map[string]F1DriverListEntry)
for k, v := range driverInfo {
cpyInfo[k] = v
}
cpyTyres := make(map[string]LiveTyreData)
for k, v := range tyres {
cpyTyres[k] = v
}
cpyRC := make([]LiveRCMessage, len(rcMessages))
copy(cpyRC, rcMessages)
cpyStints := make(map[string][]LiveStintData)
for k, v := range stints {
s := make([]LiveStintData, len(v))
copy(s, v)
cpyStints[k] = s
}
select {
case dataChan <- LiveStreamData{
Drivers: cpyDrivers,
DriverInfo: cpyInfo,
Tyres: cpyTyres,
RCMessages: cpyRC,
Weather: weather,
Session: session,
TrackStatus: trackStatus,
CurrentLap: currentLap,
TotalLaps: totalLaps,
Clock: clock,
ClockRefTime: clockRefTime,
ClockExtrapolating: clockExtrapolating,
Stints: cpyStints,
}:
default:
}
}
// processTopic handles a single topic's JSON payload (shared by R and M paths)
processTopic := func(topic string, data json.RawMessage) bool {
updated := false
switch topic {
case "TimingData":
var td struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &td) == nil {
for num, lineRaw := range td.Lines {
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(drivers, num, line)
updated = true
}
}
}
case "DriverList":
var dlMap map[string]json.RawMessage
if json.Unmarshal(data, &dlMap) == nil {
for num, entryRaw := range dlMap {
var entry F1DriverListEntry
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
driverInfo[num] = entry
updated = true
}
}
}
case "LapCount":
var lc struct {
CurrentLap json.Number `json:"CurrentLap"`
TotalLaps json.Number `json:"TotalLaps"`
}
if json.Unmarshal(data, &lc) == nil {
if v, err := lc.CurrentLap.Int64(); err == nil {
currentLap = int(v)
}
if v, err := lc.TotalLaps.Int64(); err == nil {
totalLaps = int(v)
}
updated = true
}
case "ExtrapolatedClock":
var ec struct {
Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Extrapolating bool `json:"Extrapolating"`
}
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
clock = ec.Remaining
clockExtrapolating = ec.Extrapolating
if ec.Utc != "" {
// Try RFC3339 first, then with milliseconds
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
clockRefTime = t
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
clockRefTime = t
} else {
clockRefTime = time.Now()
}
} else {
clockRefTime = time.Now()
}
updated = true
}
case "TrackStatus":
var ts struct {
Status string `json:"Status"`
Message string `json:"Message"`
}
if json.Unmarshal(data, &ts) == nil && ts.Status != "" {
trackStatus = ts.Status
updated = true
}
case "RaceControlMessages":
var rcm struct {
Messages map[string]json.RawMessage `json:"Messages"`
}
if json.Unmarshal(data, &rcm) == nil {
for _, msgRaw := range rcm.Messages {
var msg struct {
Utc string `json:"Utc"`
Category string `json:"Category"`
Flag string `json:"Flag"`
Message string `json:"Message"`
Lap int `json:"Lap"`
}
if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" {
t := ""
if len(msg.Utc) >= 19 {
t = msg.Utc[11:16]
}
rcMessages = append(rcMessages, LiveRCMessage{
Time: t,
Category: msg.Category,
Flag: msg.Flag,
Message: msg.Message,
Lap: msg.Lap,
})
updated = true
}
}
}
case "WeatherData":
var wd struct {
AirTemp json.Number `json:"AirTemp"`
TrackTemp json.Number `json:"TrackTemp"`
Humidity json.Number `json:"Humidity"`
WindSpeed json.Number `json:"WindSpeed"`
WindDirection json.Number `json:"WindDirection"`
Rainfall json.Number `json:"Rainfall"`
}
if json.Unmarshal(data, &wd) == nil {
if v, err := wd.AirTemp.Float64(); err == nil {
weather.AirTemp = v
}
if v, err := wd.TrackTemp.Float64(); err == nil {
weather.TrackTemp = v
}
if v, err := wd.Humidity.Float64(); err == nil {
weather.Humidity = v
}
if v, err := wd.WindSpeed.Float64(); err == nil {
weather.WindSpeed = v
}
if v, err := wd.WindDirection.Int64(); err == nil {
weather.WindDir = int(v)
}
if v, err := wd.Rainfall.Float64(); err == nil {
weather.Rainfall = v > 0
}
updated = true
}
case "SessionInfo":
var si struct {
Meeting struct {
Name string `json:"Name"`
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
session.MeetingName = si.Meeting.Name
}
if si.Name != "" {
session.SessionName = si.Name
}
if si.Type != "" {
session.SessionType = si.Type
}
updated = true
}
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
for num, raw := range ct {
if num == "_kf" {
continue
}
var td struct {
Compound string `json:"Compound"`
New string `json:"New"`
}
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
// Preserve the existing Age — CurrentTyres only carries
// compound and newness, not lap count.
t := tyres[num]
t.Compound = td.Compound
t.New = td.New == "true" || td.New == "True"
tyres[num] = t
updated = true
}
}
}
case "TimingAppData":
var tad struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &tad) == nil {
for num, lineRaw := range tad.Lines {
var line struct {
Stints map[string]json.RawMessage `json:"Stints"`
}
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData
for _, sRaw := range line.Stints {
var st struct {
Compound string `json:"Compound"`
New string `json:"New"`
TotalLaps int `json:"TotalLaps"`
}
if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" {
driverStints = append(driverStints, LiveStintData{
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
}
}
if len(driverStints) > 0 {
stints[num] = driverStints
// Sync compound and age from the latest stint.
// Stints are authoritative: they include historical data and
// carry both compound and laps on the current set.
lastStint := driverStints[len(driverStints)-1]
t := tyres[num]
t.Age = lastStint.Laps
if lastStint.Compound != "" {
t.Compound = lastStint.Compound
t.New = lastStint.New
}
tyres[num] = t
updated = true
}
}
}
}
case "TimingStats":
var ts struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &ts) == nil {
for num, lineRaw := range ts.Lines {
var line struct {
PersonalBestLapTime struct {
Value string `json:"Value"`
} `json:"PersonalBestLapTime"`
}
if json.Unmarshal(lineRaw, &line) == nil {
if d, ok := drivers[num]; ok && line.PersonalBestLapTime.Value != "" {
d.BestLapTime = line.PersonalBestLapTime.Value
drivers[num] = d
updated = true
}
}
}
}
}
return updated
}
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("WS Read Error:", err)
return
}
var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil {
continue
}
updated := false
// Full state payload (R)
if len(parsed.R) > 2 {
var rMap map[string]json.RawMessage
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
for topic, data := range rMap {
if processTopic(topic, data) {
updated = true
}
}
}
}
// Incremental feed (M)
for _, m := range parsed.M {
if len(m.A) > 1 {
var topic string
json.Unmarshal(m.A[0], &topic)
if processTopic(topic, m.A[1]) {
updated = true
}
}
}
if updated {
sendUpdate()
}
}
}()
return nil
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num]
if !exists {
d = LiveDriverData{RacingNumber: num}
if line.RacingNumber != "" {
d.RacingNumber = line.RacingNumber
}
}
if line.Position != nil {
var newPos int
switch v := line.Position.(type) {
case string:
fmt.Sscanf(v, "%d", &newPos)
case float64:
newPos = int(v)
}
if newPos > 0 && newPos != d.Position {
d.PrevPosition = d.Position
d.Position = newPos
}
}
if line.GapToLeader != nil {
if s := extractStringVal(line.GapToLeader); s != "" {
d.GapToLeader = s
}
}
if line.IntervalToPositionAhead.Value != nil {
if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" {
d.Interval = s
}
}
if line.LastLapTime.Value != "" {
d.LastLapTime = line.LastLapTime.Value
d.LastLapPB = line.LastLapTime.PersonalFastest
d.LastLapOB = line.LastLapTime.OverallFastest
}
if line.BestLapTime.Value != "" {
d.BestLapTime = line.BestLapTime.Value
d.BestLapPB = line.BestLapTime.PersonalFastest
d.BestLapOB = line.BestLapTime.OverallFastest
if line.BestLapTime.Lap > 0 {
d.BestLapNum = line.BestLapTime.Lap
}
}
if line.InPit != nil {
d.InPit = toBool(line.InPit)
}
if line.PitOut != nil {
d.PitOut = toBool(line.PitOut)
}
if line.Retired != nil {
d.Retired = toBool(line.Retired)
}
if line.KnockedOut != nil {
d.KnockedOut = toBool(line.KnockedOut)
}
if line.Cutoff != nil {
d.Cutoff = toBool(line.Cutoff)
}
if line.NumberOfLaps != nil {
if v, ok := toInt(line.NumberOfLaps); ok {
d.NumberOfLaps = v
}
}
// Parse speed trap (ST = highest speed on track)
if st, ok := line.Speeds["ST"]; ok {
var sp struct {
Value string `json:"Value"`
}
if json.Unmarshal(st, &sp) == nil && sp.Value != "" {
d.SpeedTrap = sp.Value
}
}
// Parse sector times — handle empty Value as a sector clear (new lap starting)
for idx, sRaw := range line.Sectors {
i := 0
fmt.Sscanf(idx, "%d", &i)
if i >= 0 && i < 3 {
var sec struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
}
if json.Unmarshal(sRaw, &sec) == nil {
if sec.Value == "" {
d.Sectors[i] = LiveSectorData{} // clear = new lap starting
} else {
d.Sectors[i] = LiveSectorData{
Value: sec.Value,
PersonalFastest: sec.PersonalFastest,
OverallFastest: sec.OverallFastest,
}
}
}
}
}
// Derive: driver is on a flying lap if S1 or S2 populated but S3 not yet
d.OnFlyingLap = !d.InPit && !d.Retired &&
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
d.Sectors[2].Value == ""
drivers[num] = d
}
// extractStringVal extracts a string from a timing value that may arrive as a
// plain string, a float64, or a {"Value": "..."} object from the SignalR feed.
func extractStringVal(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
if val == 0 {
return ""
}
return fmt.Sprintf("+%.3f", val)
case map[string]interface{}:
if s, ok := val["Value"].(string); ok {
return s
}
}
return ""
}
func toBool(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true" || val == "True"
}
return false
}
func toInt(v interface{}) (int, bool) {
switch val := v.(type) {
case float64:
return int(val), true
case json.Number:
if i, err := val.Int64(); err == nil {
return int(i), true
}
case string:
var i int
if _, err := fmt.Sscanf(val, "%d", &i); err == nil {
return i, true
}
}
return 0, false
}
// ---------------------------------------------------------------------------
// 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 := 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()
}