mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
308 lines
8.4 KiB
Go
308 lines
8.4 KiB
Go
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
|
|
}
|