Files
box-box/internal/ui/trackmap.go

627 lines
17 KiB
Go
Raw Normal View History

2026-03-27 02:54:09 -04:00
package ui
import (
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Track Map model
// ---------------------------------------------------------------------------
// trackPoint is a normalized (col, row) point in the terminal canvas.
type trackPoint struct {
col, row int
}
// TrackMapModel renders an ASCII track outline with live car positions.
type TrackMapModel struct {
client *api.OpenF1Client
width int
height int
2026-03-27 19:27:38 -04:00
// Resolved session key used to fetch live location data.
2026-03-27 02:54:09 -04:00
sessionKey int
2026-03-27 19:27:38 -04:00
// circuitKey identifies the physical circuit for cached track outline lookups.
// It is stable across sessions and years for the same track.
circuitKey int
2026-03-27 02:54:09 -04:00
// Track outline (normalized points from driver 1's path)
outline []trackPoint
// Bounds of the raw coordinate space (filled during normalization)
rawMinX, rawMaxX float64
rawMinY, rawMaxY float64
// Car positions: driver number → latest location
carPositions map[int]models.Location
// Driver info from OfficialLiveModel (injected on each render)
driverInfo map[string]F1DriverListEntry
// State machine
loadingSession bool // true while resolving the active session key
loadingOutline bool
loadingCars bool
outlineReady bool
err error
spinner spinner.Model
}
func NewTrackMapModel(client *api.OpenF1Client) TrackMapModel {
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return TrackMapModel{
client: client,
spinner: sp,
carPositions: make(map[int]models.Location),
driverInfo: make(map[string]F1DriverListEntry),
}
}
// HasSession returns true if a session key is already set.
func (m TrackMapModel) HasSession() bool {
return m.sessionKey != 0
}
// FetchActiveSession fetches the currently active session from OpenF1 and sets it.
func (m *TrackMapModel) FetchActiveSession(client *api.OpenF1Client) (TrackMapModel, tea.Cmd) {
year := time.Now().Year()
m.loadingSession = true
m.err = nil
cmd := tea.Batch(m.fetchActiveSession(client, year), m.spinner.Tick)
return *m, cmd
}
func (m *TrackMapModel) fetchActiveSession(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg {
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return trackOutlineLoadedMsg{err: err}
}
now := time.Now()
var currentMtg *models.Meeting
for i := range meetings {
end, _ := time.Parse(time.RFC3339, meetings[i].DateEnd)
if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour {
currentMtg = &meetings[i]
break
}
}
if currentMtg == nil {
return trackOutlineLoadedMsg{err: fmt.Errorf("no active weekend found")}
}
sessions, err := client.GetSessionsForMeeting(int(currentMtg.MeetingKey))
if err != nil {
return trackOutlineLoadedMsg{err: err}
}
var activeSess *models.Session
for i := range sessions {
st, _ := time.Parse(time.RFC3339, sessions[i].DateStart)
en, _ := time.Parse(time.RFC3339, sessions[i].DateEnd)
if now.After(st.Local()) && now.Before(en.Local().Add(2*time.Hour)) {
activeSess = &sessions[i]
}
}
if activeSess == nil && len(sessions) > 0 {
activeSess = &sessions[len(sessions)-1]
}
if activeSess == nil {
return trackOutlineLoadedMsg{err: fmt.Errorf("no active session found")}
}
2026-03-27 19:27:38 -04:00
return sessionKeyMsg{
sessionKey: activeSess.SessionKey,
circuitKey: currentMtg.CircuitKey,
}
2026-03-27 02:54:09 -04:00
}
}
type sessionKeyMsg struct {
sessionKey int
2026-03-27 19:27:38 -04:00
circuitKey int
2026-03-27 02:54:09 -04:00
}
// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------
type trackOutlineLoadedMsg struct {
locations []models.Location
err error
}
type trackCarsLoadedMsg struct {
locations []models.Location
err error
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
// fetchTrackOutline downloads location data for a single reference driver
// (driver 1 by convention, then any driver if 1 is absent) to build the
// track outline for the given session.
2026-03-27 19:27:38 -04:00
//
// It first checks the persistent track outline cache keyed by circuitKey.
// If a stored outline exists for this season it is used directly, which means
// the track map works even during a live-session API lockout on the free tier.
func fetchTrackOutline(client *api.OpenF1Client, sessionKey, circuitKey int) tea.Cmd {
2026-03-27 02:54:09 -04:00
return func() tea.Msg {
2026-03-27 19:27:38 -04:00
year := time.Now().Year()
// Check the pre-fetched outline cache before hitting the API.
if circuitKey != 0 {
if locs, ok := client.Cache().GetTrackOutline(circuitKey, year); ok && len(locs) >= 50 {
return trackOutlineLoadedMsg{locations: locs}
}
}
// Fall back to a live API fetch.
2026-03-27 02:54:09 -04:00
candidates := []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
for _, dn := range candidates {
locs, err := client.GetLocation(sessionKey, dn)
if err == nil && len(locs) > 50 {
2026-03-27 19:27:38 -04:00
// Opportunistically save to the track outline cache for next time.
if circuitKey != 0 {
_ = client.Cache().SetTrackOutline(circuitKey, year, locs)
}
2026-03-27 02:54:09 -04:00
return trackOutlineLoadedMsg{locations: locs}
}
}
return trackOutlineLoadedMsg{err: fmt.Errorf("no location data available for session %d", sessionKey)}
}
}
// fetchAllCarPositions downloads the most recent location for every driver
// (using the live session key stored in the model). We fetch all 20 drivers
// concurrently and keep only the last location per driver.
func fetchAllCarPositions(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return func() tea.Msg {
locs, err := client.GetLocation(sessionKey, 0)
if err != nil {
return trackCarsLoadedMsg{err: err}
}
return trackCarsLoadedMsg{locations: locs}
}
}
// tickTrackMap schedules a periodic car-position refresh.
func tickTrackMap() tea.Cmd {
return tea.Tick(5*time.Second, func(_ time.Time) tea.Msg {
return trackMapTickMsg{}
})
}
type trackMapTickMsg struct{}
// ---------------------------------------------------------------------------
// Init / Update / View
// ---------------------------------------------------------------------------
func (m TrackMapModel) Init() tea.Cmd {
return m.spinner.Tick
}
// SetSessionKey wires the track map to a specific session. If the session
// differs from the one already loaded, it triggers a fresh outline fetch.
2026-03-27 19:27:38 -04:00
// circuitKey is used to look up the pre-cached track outline for this circuit.
func (m TrackMapModel) SetSessionKey(sessionKey, circuitKey int) (TrackMapModel, tea.Cmd) {
2026-03-27 02:54:09 -04:00
if sessionKey == m.sessionKey && m.outlineReady {
return m, nil
}
m.sessionKey = sessionKey
2026-03-27 19:27:38 -04:00
m.circuitKey = circuitKey
2026-03-27 02:54:09 -04:00
m.loadingOutline = true
m.outlineReady = false
m.outline = nil
m.carPositions = make(map[int]models.Location)
m.err = nil
2026-03-27 19:27:38 -04:00
return m, tea.Batch(fetchTrackOutline(m.client, sessionKey, circuitKey), m.spinner.Tick)
2026-03-27 02:54:09 -04:00
}
// InjectDriverInfo forwards the latest DriverInfo map from OfficialLiveModel
// so car markers can be team-coloured.
func (m *TrackMapModel) InjectDriverInfo(info map[string]F1DriverListEntry) {
m.driverInfo = info
}
func (m TrackMapModel) Update(msg tea.Msg) (TrackMapModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case spinner.TickMsg:
if m.loadingSession || m.loadingOutline || m.loadingCars {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case trackOutlineLoadedMsg:
m.loadingSession = false
m.loadingOutline = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.buildOutline(msg.locations)
m.outlineReady = true
// Start fetching car positions
m.loadingCars = true
return m, tea.Batch(fetchAllCarPositions(m.client, m.sessionKey), tickTrackMap())
case trackCarsLoadedMsg:
m.loadingCars = false
if msg.err != nil {
// Soft error — keep the outline, show empty cars
return m, nil
}
// Keep only the latest location per driver
latest := make(map[int]models.Location)
for _, loc := range msg.locations {
existing, ok := latest[loc.DriverNumber]
if !ok || loc.Date > existing.Date {
latest[loc.DriverNumber] = loc
}
}
m.carPositions = latest
return m, nil
case sessionKeyMsg:
m.loadingSession = false
2026-03-27 19:27:38 -04:00
if msg.sessionKey != m.sessionKey || msg.circuitKey != m.circuitKey {
2026-03-27 02:54:09 -04:00
m.sessionKey = msg.sessionKey
2026-03-27 19:27:38 -04:00
m.circuitKey = msg.circuitKey
2026-03-27 02:54:09 -04:00
m.loadingOutline = true
m.outlineReady = false
m.outline = nil
m.carPositions = make(map[int]models.Location)
m.err = nil
2026-03-27 19:27:38 -04:00
return m, tea.Batch(fetchTrackOutline(m.client, msg.sessionKey, msg.circuitKey), m.spinner.Tick)
2026-03-27 02:54:09 -04:00
}
return m, nil
case trackMapTickMsg:
if m.outlineReady && m.sessionKey != 0 {
m.loadingCars = true
return m, fetchAllCarPositions(m.client, m.sessionKey)
}
return m, tickTrackMap()
case wsDataMsg:
// When live WS data arrives, update driver info if we can.
// (TrackMapModel.InjectDriverInfo is called from app.go on each wsDataMsg)
return m, nil
}
return m, nil
}
func (m TrackMapModel) View() string {
if m.loadingSession {
return fmt.Sprintf("\n %s Resolving active session...\n", m.spinner.View())
}
if m.loadingOutline {
return fmt.Sprintf("\n %s Building track outline...\n", m.spinner.View())
}
if m.err != nil {
return renderErrorView(m.err)
}
if !m.outlineReady {
return "\n " + styleMuted.Render("No active session found. Press 7 again once a session is underway.") + "\n"
}
return m.renderMap()
}
// ---------------------------------------------------------------------------
// Track outline builder
// ---------------------------------------------------------------------------
// buildOutline computes a set of normalized terminal-space (col, row) points
// from raw X/Y location data.
func (m *TrackMapModel) buildOutline(locs []models.Location) {
if len(locs) == 0 {
return
}
// Find bounding box of raw coordinates
m.rawMinX, m.rawMaxX = locs[0].X, locs[0].X
m.rawMinY, m.rawMaxY = locs[0].Y, locs[0].Y
for _, l := range locs {
if l.X < m.rawMinX {
m.rawMinX = l.X
}
if l.X > m.rawMaxX {
m.rawMaxX = l.X
}
if l.Y < m.rawMinY {
m.rawMinY = l.Y
}
if l.Y > m.rawMaxY {
m.rawMaxY = l.Y
}
}
// Use a step-down approach to avoid over-sampling: only add a new point
// if it's sufficiently different from the previous one (in normalised space).
// We normalize to a 60×24 grid first, then de-duplicate.
const gridW, gridH = 60, 22
seen := make(map[trackPoint]struct{})
var pts []trackPoint
for _, l := range locs {
tp := m.rawToGrid(l.X, l.Y, gridW, gridH)
if _, dup := seen[tp]; dup {
continue
}
seen[tp] = struct{}{}
pts = append(pts, tp)
}
m.outline = pts
}
// rawToGrid converts raw X/Y to (col, row) in a canvas of gridW×gridH.
// Terminal characters are roughly 2× taller than wide, so we compress the
// X dimension by a factor of 0.5 to preserve the circuit's visual aspect ratio.
func (m *TrackMapModel) rawToGrid(x, y float64, gridW, gridH int) trackPoint {
rangeX := m.rawMaxX - m.rawMinX
rangeY := m.rawMaxY - m.rawMinY
if rangeX == 0 {
rangeX = 1
}
if rangeY == 0 {
rangeY = 1
}
// Apply 0.5× X compression for terminal aspect ratio
normX := (x - m.rawMinX) / rangeX
normY := (y - m.rawMinY) / rangeY
col := int(normX * float64(gridW-1) * 0.5) // compress horizontal
row := gridH - 1 - int(normY*float64(gridH-1)) // flip Y (screen rows go down)
return trackPoint{col: clampInt(col, 0, gridW-1), row: clampInt(row, 0, gridH-1)}
}
// rawToCanvas converts raw X/Y to (col, row) for the actual render canvas size.
func (m *TrackMapModel) rawToCanvas(x, y float64, canvasW, canvasH int) trackPoint {
rangeX := m.rawMaxX - m.rawMinX
rangeY := m.rawMaxY - m.rawMinY
if rangeX == 0 {
rangeX = 1
}
if rangeY == 0 {
rangeY = 1
}
normX := (x - m.rawMinX) / rangeX
normY := (y - m.rawMinY) / rangeY
// Margins
marginH := 2
marginV := 1
drawW := canvasW - marginH*2
drawH := canvasH - marginV*2
col := marginH + int(normX*float64(drawW-1)*0.5)
row := marginV + (drawH - 1 - int(normY*float64(drawH-1)))
return trackPoint{
col: clampInt(col, marginH, marginH+drawW-1),
row: clampInt(row, marginV, marginV+drawH-1),
}
}
// ---------------------------------------------------------------------------
// Map renderer
// ---------------------------------------------------------------------------
func (m TrackMapModel) renderMap() string {
w := m.width
if w < 40 {
w = 40
}
h := m.height
if h < 20 {
h = 20
}
// Reserve space for header (3 lines) + help bar (1 line)
const headerLines = 4
canvasW := min(w-2, 80) // cap width for readability
canvasH := h - headerLines - 2
if canvasH < 10 {
canvasH = 10
}
// Allocate canvas grid
grid := make([][]rune, canvasH)
colorGrid := make([][]string, canvasH)
for r := range grid {
grid[r] = make([]rune, canvasW)
colorGrid[r] = make([]string, canvasW)
for c := range grid[r] {
grid[r][c] = ' '
}
}
// Draw track outline using '·' dots
for _, loc := range m.outline {
// Re-normalize outline points from the 60×22 grid to the canvas
// by converting back through raw fraction space.
normCol := float64(loc.col) / (30.0) // gridW/2 for the 0.5 compression
normRow := float64(22-1-loc.row) / float64(22-1)
rawX := m.rawMinX + normCol*(m.rawMaxX-m.rawMinX)
rawY := m.rawMinY + normRow*(m.rawMaxY-m.rawMinY)
tp := m.rawToCanvas(rawX, rawY, canvasW, canvasH)
if tp.row >= 0 && tp.row < canvasH && tp.col >= 0 && tp.col < canvasW {
if grid[tp.row][tp.col] == ' ' {
grid[tp.row][tp.col] = '·'
colorGrid[tp.row][tp.col] = colorSurface2
}
}
}
// Place car markers
type carPlacement struct {
tp trackPoint
dn int
color string
tla string
}
var placements []carPlacement
// Sort driver numbers for deterministic overdraw
var dnums []int
for dn := range m.carPositions {
dnums = append(dnums, dn)
}
sort.Ints(dnums)
for _, dn := range dnums {
loc := m.carPositions[dn]
tp := m.rawToCanvas(loc.X, loc.Y, canvasW, canvasH)
numStr := fmt.Sprintf("%d", dn)
teamColor := colorMuted
tla := numStr
if info, ok := m.driverInfo[numStr]; ok {
if info.Tla != "" {
tla = info.Tla
}
if info.TeamColour != "" {
teamColor = "#" + info.TeamColour
} else if info.TeamName != "" {
teamColor = teamColorFromName(info.TeamName)
}
}
placements = append(placements, carPlacement{tp, dn, teamColor, tla})
}
// Draw car glyphs — use '●' at the car's point, then try to fit TLA inline
for _, cp := range placements {
r, c := cp.tp.row, cp.tp.col
if r < 0 || r >= canvasH || c < 0 || c >= canvasW {
continue
}
grid[r][c] = '●'
colorGrid[r][c] = cp.color
// Write TLA to the right of the marker if space allows
for i, ch := range cp.tla {
nc := c + 1 + i
if nc >= canvasW {
break
}
grid[r][nc] = ch
colorGrid[r][nc] = cp.color
}
}
// Render grid to string
var sb strings.Builder
// Header
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)).Render("🗺 TRACK MAP")
driverCount := fmt.Sprintf("%d cars tracked", len(m.carPositions))
if len(m.carPositions) == 0 {
driverCount = "waiting for car positions..."
}
sb.WriteString("\n " + title + " " + styleMuted.Render(driverCount) + "\n")
sb.WriteString(" " + divider(min(w-4, canvasW)) + "\n")
// Canvas border top
borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorBorder))
sb.WriteString(" " + borderStyle.Render("╭"+strings.Repeat("─", canvasW)+"╮") + "\n")
for r := 0; r < canvasH; r++ {
sb.WriteString(" " + borderStyle.Render("│"))
for c := 0; c < canvasW; c++ {
ch := grid[r][c]
color := colorGrid[r][c]
if color != "" {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(string(ch)))
} else {
sb.WriteRune(ch)
}
}
sb.WriteString(borderStyle.Render("│") + "\n")
}
sb.WriteString(" " + borderStyle.Render("╰"+strings.Repeat("─", canvasW)+"╯") + "\n")
sb.WriteString("\n")
sb.WriteString(helpBar("1-7 tabs", "q quit"))
return sb.String()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// distanceSq returns the squared Euclidean distance between two points.
func distanceSq(x1, y1, x2, y2 float64) float64 {
dx, dy := x2-x1, y2-y1
return dx*dx + dy*dy
}
// nearestOutlinePoint finds the outline point closest to (x, y) in raw space.
// Returns math.MaxFloat64 if outline is empty.
func (m *TrackMapModel) nearestOutlineDistance(x, y float64) float64 {
if len(m.outline) == 0 {
return math.MaxFloat64
}
minDist := math.MaxFloat64
rangeX := m.rawMaxX - m.rawMinX
rangeY := m.rawMaxY - m.rawMinY
if rangeX == 0 {
rangeX = 1
}
if rangeY == 0 {
rangeY = 1
}
// Convert outline back to raw for distance calculation
for _, tp := range m.outline {
nx := float64(tp.col) / 30.0
ny := float64(22-1-tp.row) / float64(22-1)
rx := m.rawMinX + nx*rangeX
ry := m.rawMinY + ny*rangeY
d := distanceSq(x, y, rx, ry)
if d < minDist {
minDist = d
}
}
return math.Sqrt(minDist)
}