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:
307
internal/ui/pitwindow.go
Normal file
307
internal/ui/pitwindow.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pit loss table — average pit lane + stationary time per circuit.
|
||||
// These are sensible defaults derived from historical F1 data.
|
||||
// All times are in seconds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// pitLossTable maps circuit short names (lowercased) to average pit loss time.
|
||||
// Pit loss = pit lane traversal time delta vs staying on track at race pace.
|
||||
var pitLossTable = map[string]float64{
|
||||
"monza": 17.0,
|
||||
"spa": 21.0,
|
||||
"silverstone": 22.0,
|
||||
"monaco": 25.0,
|
||||
"singapore": 30.0,
|
||||
"baku": 24.0,
|
||||
"montreal": 23.0,
|
||||
"austin": 23.5,
|
||||
"mexico": 21.0,
|
||||
"interlagos": 24.0,
|
||||
"suzuka": 22.5,
|
||||
"bahrain": 22.0,
|
||||
"jeddah": 21.5,
|
||||
"melbourne": 25.0,
|
||||
"shanghai": 25.0,
|
||||
"miami": 24.5,
|
||||
"barcelona": 22.0,
|
||||
"budapest": 25.5,
|
||||
"zandvoort": 24.5,
|
||||
"abu dhabi": 23.0,
|
||||
"las vegas": 26.0,
|
||||
"imola": 25.0,
|
||||
"lusail": 22.0,
|
||||
}
|
||||
|
||||
const defaultPitLoss = 23.0 // fallback when circuit not in table
|
||||
|
||||
// pitWindowLookupLoss returns the pit loss for the current circuit name.
|
||||
// Falls back to defaultPitLoss.
|
||||
func pitWindowLookupLoss(circuitName string) float64 {
|
||||
lower := strings.ToLower(circuitName)
|
||||
for k, v := range pitLossTable {
|
||||
if strings.Contains(lower, k) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defaultPitLoss
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pit window prediction logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PitPrediction describes the predicted outcome if a given driver pits now.
|
||||
type PitPrediction struct {
|
||||
DriverNum string
|
||||
DriverTLA string
|
||||
TeamColor string
|
||||
PredictedP int // predicted position after rejoin
|
||||
RejoinGap float64 // gap to the car they'd rejoin behind (seconds, +ve = behind)
|
||||
TightestCar string // TLA of the nearest rival after rejoin
|
||||
PitLoss float64 // pit stop time cost used in this calculation
|
||||
}
|
||||
|
||||
// computePitWindow calculates the predicted pit window for every positioned
|
||||
// driver that is currently on track (not in pit, not retired).
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. For each driver D, simulate their position after a pit stop of pitLoss seconds.
|
||||
// 2. For each rival R ahead of D: if gap(D→R) + pitLoss > 0, D rejoins behind R.
|
||||
// 3. For each rival R behind D: if gap(D→R) - pitLoss < threshold, R may undercut D.
|
||||
// 4. Return the predicted finishing position after the pit.
|
||||
func computePitWindow(
|
||||
drivers map[string]LiveDriverData,
|
||||
driverInfo map[string]F1DriverListEntry,
|
||||
pitLoss float64,
|
||||
) []PitPrediction {
|
||||
if len(drivers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort all on-track drivers by position
|
||||
var sorted []LiveDriverData
|
||||
for _, d := range drivers {
|
||||
if d.Position > 0 && !d.Retired && !d.InPit {
|
||||
sorted = append(sorted, d)
|
||||
}
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Position < sorted[j].Position
|
||||
})
|
||||
|
||||
// Build gap-to-leader map (seconds, -1 = leader)
|
||||
gapToLeader := make(map[string]float64, len(sorted))
|
||||
for _, d := range sorted {
|
||||
if d.Position == 1 {
|
||||
gapToLeader[d.RacingNumber] = 0
|
||||
} else {
|
||||
g := parseGapToFloat(d.GapToLeader)
|
||||
if g < 0 {
|
||||
g = 9999 // lapped car
|
||||
}
|
||||
gapToLeader[d.RacingNumber] = g
|
||||
}
|
||||
}
|
||||
|
||||
var predictions []PitPrediction
|
||||
|
||||
for _, d := range sorted {
|
||||
dGap := gapToLeader[d.RacingNumber]
|
||||
if dGap == 9999 {
|
||||
continue // don't predict for lapped cars
|
||||
}
|
||||
|
||||
// Gap after pit stop = original gap + pitLoss (D is now pitLoss seconds further back)
|
||||
gapAfterPit := dGap + pitLoss
|
||||
|
||||
// Count rivals ahead that D will now be behind
|
||||
predictedPos := 1
|
||||
var rejoinBehind LiveDriverData
|
||||
tightestGap := 9999.0
|
||||
|
||||
for _, rival := range sorted {
|
||||
if rival.RacingNumber == d.RacingNumber {
|
||||
continue
|
||||
}
|
||||
rGap := gapToLeader[rival.RacingNumber]
|
||||
if rGap == 9999 {
|
||||
continue
|
||||
}
|
||||
// Rival is ahead if their gap < gapAfterPit
|
||||
if rGap < gapAfterPit {
|
||||
predictedPos++
|
||||
// Track the rival we'd rejoin closest behind
|
||||
behind := gapAfterPit - rGap
|
||||
if behind < tightestGap {
|
||||
tightestGap = behind
|
||||
rejoinBehind = rival
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Team color
|
||||
teamColor := colorMuted
|
||||
tla := d.RacingNumber
|
||||
if info, ok := driverInfo[d.RacingNumber]; ok {
|
||||
if info.Tla != "" {
|
||||
tla = info.Tla
|
||||
}
|
||||
if info.TeamColour != "" {
|
||||
teamColor = "#" + info.TeamColour
|
||||
} else if info.TeamName != "" {
|
||||
teamColor = teamColorFromName(info.TeamName)
|
||||
}
|
||||
}
|
||||
|
||||
tightestTLA := ""
|
||||
if rejoinBehind.RacingNumber != "" {
|
||||
if info, ok := driverInfo[rejoinBehind.RacingNumber]; ok && info.Tla != "" {
|
||||
tightestTLA = info.Tla
|
||||
} else {
|
||||
tightestTLA = rejoinBehind.RacingNumber
|
||||
}
|
||||
}
|
||||
|
||||
rejoinGap := 0.0
|
||||
if tightestGap < 9999 {
|
||||
rejoinGap = tightestGap
|
||||
}
|
||||
|
||||
predictions = append(predictions, PitPrediction{
|
||||
DriverNum: d.RacingNumber,
|
||||
DriverTLA: tla,
|
||||
TeamColor: teamColor,
|
||||
PredictedP: predictedPos,
|
||||
RejoinGap: rejoinGap,
|
||||
TightestCar: tightestTLA,
|
||||
PitLoss: pitLoss,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort predictions by current position (same as sorted)
|
||||
sort.Slice(predictions, func(i, j int) bool {
|
||||
di, _ := drivers[predictions[i].DriverNum]
|
||||
dj, _ := drivers[predictions[j].DriverNum]
|
||||
return di.Position < dj.Position
|
||||
})
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pit window view renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderPitWindowPanel renders the full pit window calculator panel.
|
||||
func renderPitWindowPanel(
|
||||
drivers map[string]LiveDriverData,
|
||||
driverInfo map[string]F1DriverListEntry,
|
||||
circuitName string,
|
||||
isRace bool,
|
||||
width int,
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
|
||||
pitLoss := pitWindowLookupLoss(circuitName)
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)).
|
||||
Render("🔧 PIT WINDOW CALCULATOR")
|
||||
meta := styleMuted.Render(fmt.Sprintf("circuit: %s pit loss: %.1fs",
|
||||
circuitNameShort(circuitName), pitLoss))
|
||||
|
||||
sb.WriteString(" " + title + "\n")
|
||||
sb.WriteString(" " + meta + "\n")
|
||||
sb.WriteString(" " + divider(min(width-4, 70)) + "\n")
|
||||
|
||||
if !isRace {
|
||||
sb.WriteString("\n" + styleMuted.Render(" Pit window calculator is only available during Race sessions.\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if len(drivers) == 0 {
|
||||
sb.WriteString("\n" + styleMuted.Render(" Waiting for timing data...\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
predictions := computePitWindow(drivers, driverInfo, pitLoss)
|
||||
if len(predictions) == 0 {
|
||||
sb.WriteString("\n" + styleMuted.Render(" Not enough timing data to compute pit window.\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Table header
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %-4s %-4s %-6s %-6s %s\n",
|
||||
"NOW", "TLA", "→ P", "GAP", "REJOINS BEHIND")))
|
||||
sb.WriteString(" " + divider(min(width-4, 55)) + "\n")
|
||||
|
||||
for _, p := range predictions {
|
||||
d := drivers[p.DriverNum]
|
||||
curPosStr := renderPosition(d.Position)
|
||||
predPosStr := renderPosition(p.PredictedP)
|
||||
|
||||
tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.TeamColor))
|
||||
tlaRendered := tlaStyle.Render(padRight(p.DriverTLA, 4))
|
||||
|
||||
// Position change indicator
|
||||
diff := d.Position - p.PredictedP // negative = losing positions
|
||||
var posChange string
|
||||
switch {
|
||||
case diff > 0:
|
||||
posChange = styleDeltaUp.Render(fmt.Sprintf("▲%d", diff))
|
||||
case diff < 0:
|
||||
posChange = styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff))
|
||||
default:
|
||||
posChange = styleDeltaEqual.Render("─")
|
||||
}
|
||||
|
||||
// Rejoin gap
|
||||
gapStr := styleMuted.Render(" -")
|
||||
if p.RejoinGap > 0 {
|
||||
gapStr = styleGap.Render(fmt.Sprintf("+%.1fs", p.RejoinGap))
|
||||
}
|
||||
|
||||
// Rejoin target
|
||||
rejoinStr := ""
|
||||
if p.TightestCar != "" {
|
||||
rejoinStr = styleMuted.Render("behind ") + styleBold.Render(p.TightestCar)
|
||||
} else if p.PredictedP == 1 {
|
||||
rejoinStr = styleLeader.Render("LEADS")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s\n",
|
||||
padRightVisible(curPosStr, 4),
|
||||
tlaRendered,
|
||||
padRightVisible(predPosStr, 2),
|
||||
padRightVisible(posChange, 3),
|
||||
padRightVisible(gapStr, 7),
|
||||
rejoinStr,
|
||||
))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Assumes %.1fs pit loss. Gaps are approximate.", pitLoss)))
|
||||
sb.WriteString("\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// circuitNameShort returns a display-friendly short name for a circuit.
|
||||
func circuitNameShort(name string) string {
|
||||
if name == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
if len(name) > 20 {
|
||||
return name[:20] + "…"
|
||||
}
|
||||
return name
|
||||
}
|
||||
Reference in New Issue
Block a user