mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
UI Updates and Web App
This commit is contained in:
@@ -1616,34 +1616,14 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
|
||||
tlaStr := tlaStyle.Render(padRight(tla, 4))
|
||||
tyreStr := m.renderTyreIndicator(d.RacingNumber)
|
||||
|
||||
if d.Retired {
|
||||
row := fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
posStr, deltaStr, colorBar, tlaStr,
|
||||
styleDNF.Render("RET"), styleMuted.Render("Retired"))
|
||||
if idx == m.cursor {
|
||||
return styleSelected.Render(row)
|
||||
}
|
||||
if d.KnockedOut {
|
||||
return styleMuted.Render(row)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
if d.InPit {
|
||||
row := fmt.Sprintf(" %s %s %s %s %s %s %s",
|
||||
posStr, deltaStr, colorBar, tlaStr, tyreStr,
|
||||
styleSafetyCar.Render(padRight("PIT", 10)),
|
||||
m.renderGapStr(d, fpq))
|
||||
if idx == m.cursor {
|
||||
return styleSelected.Render(row)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// Last lap time coloring
|
||||
lastLapRaw := d.LastLapTime
|
||||
lastLap := padRight(lastLapRaw, 10)
|
||||
if lastLapRaw != "" {
|
||||
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 {
|
||||
@@ -1652,6 +1632,11 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
|
||||
}
|
||||
|
||||
var row string
|
||||
gapStr := m.renderGapStr(d, fpq)
|
||||
if d.Retired {
|
||||
gapStr = padRightVisible(styleMuted.Render("Retired"), 10)
|
||||
}
|
||||
|
||||
if m.showSectors {
|
||||
timeCol := lastLap
|
||||
if fpq {
|
||||
@@ -1663,7 +1648,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
|
||||
m.renderSector(d.Sectors[0]),
|
||||
m.renderSector(d.Sectors[1]),
|
||||
m.renderSector(d.Sectors[2]),
|
||||
m.renderGapStr(d, fpq))
|
||||
gapStr)
|
||||
} else if fpq {
|
||||
// FP / Qualifying: show BEST lap as primary, LAST as secondary
|
||||
bestLap := m.renderBestLapTime(d, overallBest)
|
||||
@@ -1677,7 +1662,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
|
||||
flyingIndicator,
|
||||
bestLap,
|
||||
lastLap,
|
||||
m.renderGapStr(d, fpq))
|
||||
gapStr)
|
||||
// Highlight danger zone (cutoff) in qualifying
|
||||
if d.Cutoff && idx != m.cursor {
|
||||
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row)
|
||||
@@ -1692,11 +1677,13 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool,
|
||||
posStr, deltaStr, colorBar, tlaStr, tyreStr,
|
||||
m.renderTyreAge(d.RacingNumber),
|
||||
lastLap,
|
||||
m.renderGapStr(d, fpq),
|
||||
gapStr,
|
||||
m.renderIntvStr(d),
|
||||
padRightVisible(trend, 8))
|
||||
}
|
||||
|
||||
// Removed lines are embedded in the previous chunk.
|
||||
|
||||
if idx == m.cursor {
|
||||
return styleSelected.Render(row)
|
||||
}
|
||||
@@ -1762,13 +1749,19 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string {
|
||||
}
|
||||
|
||||
compound := strings.ToUpper(tyre.Compound)
|
||||
abbrev, style := compoundAbbrevStyle(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 = style.Render("*")
|
||||
newMark = "*"
|
||||
}
|
||||
return padRightVisible(style.Render("●")+" "+abbrev+newMark, 5)
|
||||
text := fmt.Sprintf(" %s%s ", abbrev, newMark)
|
||||
return padRightVisible(bgStyle.Render(text), 5)
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) renderTyreAge(num string) string {
|
||||
|
||||
@@ -600,9 +600,44 @@ function livePage() {
|
||||
},
|
||||
|
||||
get sortedDrivers() {
|
||||
return Object.values(this.drivers)
|
||||
.filter(d => d.Position > 0)
|
||||
.sort((a, b) => a.Position - b.Position);
|
||||
const merged = {};
|
||||
for (const num of Object.keys(this.drivers)) {
|
||||
merged[num] = { ...this.drivers[num] };
|
||||
}
|
||||
for (const num of Object.keys(this.driverInfo)) {
|
||||
if (!merged[num]) {
|
||||
merged[num] = { RacingNumber: num, Position: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const all = Object.values(merged);
|
||||
|
||||
all.sort((a, b) => {
|
||||
const pi = a.Position || 0;
|
||||
const pj = b.Position || 0;
|
||||
|
||||
if (pi > 0 && pj > 0) return pi - pj;
|
||||
if (pi > 0) return -1;
|
||||
if (pj > 0) return 1;
|
||||
|
||||
const ti = a.BestLapTime || '';
|
||||
const tj = b.BestLapTime || '';
|
||||
if (ti && tj) return ti.localeCompare(tj);
|
||||
if (ti) return -1;
|
||||
if (tj) return 1;
|
||||
|
||||
const ni = parseInt(a.RacingNumber) || 0;
|
||||
const nj = parseInt(b.RacingNumber) || 0;
|
||||
return ni - nj;
|
||||
});
|
||||
|
||||
all.forEach((d, i) => {
|
||||
if (!d.Position) {
|
||||
d.Position = i + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return all;
|
||||
},
|
||||
|
||||
driverTla(num) {
|
||||
|
||||
@@ -142,29 +142,31 @@
|
||||
<div class="skeleton" style="height:36px;margin-bottom:4px;"></div>
|
||||
</template>
|
||||
</div>
|
||||
<table class="data-table" x-show="!resultsLoading && results.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>POS</th><th>DRIVER</th><th>TEAM</th><th>TIME / GAP</th><th>PTS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in results" :key="r.driver_number">
|
||||
<tr :style="`--team-color:#${r.team_colour}`">
|
||||
<td class="pos-cell">
|
||||
<span x-text="r.position||'-'"></span>
|
||||
<span x-show="r.dnf" class="badge dnf">DNF</span>
|
||||
<span x-show="r.dns" class="badge dns">DNS</span>
|
||||
<span x-show="r.dsq" class="badge dsq">DSQ</span>
|
||||
</td>
|
||||
<td><span class="team-swatch"></span><span x-text="r.full_name||r.driver_number"></span></td>
|
||||
<td x-text="r.team_name||'-'"></td>
|
||||
<td x-text="fmtDuration(r.duration)"></td>
|
||||
<td x-text="r.points||0"></td>
|
||||
<div class="table-scroll" x-show="!resultsLoading && results.length">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>POS</th><th>DRIVER</th><th>TEAM</th><th>TIME / GAP</th><th>PTS</th>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in results" :key="r.driver_number">
|
||||
<tr :style="`--team-color:#${r.team_colour}`">
|
||||
<td class="pos-cell">
|
||||
<span x-text="r.position||'-'"></span>
|
||||
<span x-show="r.dnf" class="badge dnf">DNF</span>
|
||||
<span x-show="r.dns" class="badge dns">DNS</span>
|
||||
<span x-show="r.dsq" class="badge dsq">DSQ</span>
|
||||
</td>
|
||||
<td><span class="team-swatch"></span><span x-text="r.full_name||r.driver_number"></span></td>
|
||||
<td x-text="r.team_name||'-'"></td>
|
||||
<td x-text="fmtDuration(r.duration)"></td>
|
||||
<td x-text="r.points||0"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div x-show="!resultsLoading && !results.length" class="empty-msg">No results available.</div>
|
||||
</div>
|
||||
|
||||
@@ -243,31 +245,36 @@
|
||||
<span class="clock" x-text="clockDisplay"></span>
|
||||
</div>
|
||||
|
||||
<table class="timing-tower">
|
||||
<thead>
|
||||
<tr><th>POS</th><th>Δ</th><th>DRIVER</th><th>TYRE</th><th>LAST LAP</th><th>GAP</th><th>BEST</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="d in sortedDrivers" :key="d.RacingNumber">
|
||||
<tr :class="{in-pit: d.InPit, retired: d.Retired, pit-out: d.PitOut}"
|
||||
:style="`--team-color:#${driverTeamColor(d.RacingNumber)}`">
|
||||
<td class="pos-cell" x-text="d.Position"></td>
|
||||
<td class="pos-delta" x-text="posDelta(d)"></td>
|
||||
<td><span class="team-swatch"></span><span x-text="driverTla(d.RacingNumber)"></span>
|
||||
<span x-show="d.InPit" class="badge pit">PIT</span>
|
||||
<span x-show="d.Retired" class="badge out">OUT</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="tyre-badge" :class="tyreClass(d.RacingNumber)"
|
||||
x-text="tyreLabel(d.RacingNumber)"></span>
|
||||
</td>
|
||||
<td :class="{pb: d.LastLapPB, ob: d.LastLapOB}" x-text="d.LastLapTime||'-'"></td>
|
||||
<td x-text="d.GapToLeader||'-'"></td>
|
||||
<td :class="{ob: d.BestLapOB}" x-text="d.BestLapTime||'-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table class="timing-tower">
|
||||
<thead>
|
||||
<tr><th>POS</th><th>Δ</th><th>DRIVER</th><th>TYRE</th><th>LAST LAP</th><th>GAP</th><th>BEST</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="d in sortedDrivers" :key="d.RacingNumber">
|
||||
<tr :class="{in-pit: d.InPit, retired: d.Retired, pit-out: d.PitOut}"
|
||||
:style="`--team-color:#${driverTeamColor(d.RacingNumber)}`">
|
||||
<td class="pos-cell" x-text="d.Position"></td>
|
||||
<td class="pos-delta" x-text="posDelta(d)"></td>
|
||||
<td>
|
||||
<div class="driver-cell">
|
||||
<span class="team-swatch"></span><span x-text="driverTla(d.RacingNumber)"></span>
|
||||
<span x-show="d.InPit" class="badge pit">PIT</span>
|
||||
<span x-show="d.Retired" class="badge out">OUT</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="tyre-badge" :class="tyreClass(d.RacingNumber)"
|
||||
x-text="tyreLabel(d.RacingNumber)"></span>
|
||||
</td>
|
||||
<td :class="{pb: d.LastLapPB, ob: d.LastLapOB}" x-text="d.LastLapTime||'-'"></td>
|
||||
<td x-text="d.GapToLeader||'-'"></td>
|
||||
<td :class="{ob: d.BestLapOB}" x-text="d.BestLapTime||'-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Race control messages -->
|
||||
<div class="rc-panel" x-show="rcMessages.length">
|
||||
|
||||
@@ -566,8 +566,9 @@ a { color: inherit; text-decoration: none; }
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.timing-tower {
|
||||
width: calc(100% - 3rem);
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 1.5rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -585,7 +586,7 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.timing-tower td {
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
padding: 0.45rem 0.75rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.timing-tower .in-pit td { background: rgba(0,51,102,0.25); }
|
||||
@@ -682,6 +683,7 @@ a { color: inherit; text-decoration: none; }
|
||||
#laps-chart,
|
||||
#telemetry-chart,
|
||||
#track-chart {
|
||||
overflow-x: auto;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
#strategy-chart svg,
|
||||
@@ -879,3 +881,79 @@ a { color: inherit; text-decoration: none; }
|
||||
grid-template-columns: 44px 1fr 80px 55px 55px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Scrollable table wrapper ---- */
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
}
|
||||
.table-scroll .data-table,
|
||||
.table-scroll .timing-tower {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile responsive overrides (≤ 640px)
|
||||
============================================================ */
|
||||
@media (max-width: 640px) {
|
||||
/* Reduce global horizontal padding */
|
||||
.home-hero { padding: 0.75rem; }
|
||||
.meetings-grid { padding: 0 0.75rem 1.5rem; }
|
||||
.section-label { padding: 0.75rem 0.75rem 0.5rem; }
|
||||
.page-header { padding: 0.75rem 0.75rem 0.5rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.year-selector { margin-left: 0; }
|
||||
.session-pills { padding: 0.5rem 0.75rem; }
|
||||
.driver-selector { padding: 0.5rem 0.75rem 0.75rem; }
|
||||
.table-scroll { margin: 0 0.75rem 1.5rem; }
|
||||
.rc-panel { margin: 0 0.75rem 1.5rem; }
|
||||
.standings-table { padding: 0 0.75rem 1.5rem; }
|
||||
.skeleton-wrap { padding: 0.75rem; }
|
||||
.empty-msg { padding: 2rem 0.75rem; }
|
||||
|
||||
/* Charts */
|
||||
#strategy-chart,
|
||||
#laps-chart,
|
||||
#telemetry-chart,
|
||||
#track-chart { padding: 0 0.75rem 1.5rem; }
|
||||
|
||||
/* Nav */
|
||||
.nav { gap: 0.75rem; padding: 0.6rem 0.75rem; }
|
||||
|
||||
/* Tabs — allow horizontal scroll so all tabs are reachable */
|
||||
.tabs { overflow-x: auto; padding: 0 0.75rem; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { padding: 0.6rem 0.75rem; font-size: 0.78rem; white-space: nowrap; }
|
||||
|
||||
/* Stale banner */
|
||||
.stale-banner { padding: 0.5rem 0.75rem; flex-wrap: wrap; }
|
||||
|
||||
/* Live timing meta row */
|
||||
.live-meta { gap: 0.5rem; padding: 0.5rem 0.75rem; flex-wrap: wrap; }
|
||||
.clock { margin-left: 0; }
|
||||
|
||||
/* Tighten table cell padding */
|
||||
.data-table th,
|
||||
.data-table td { padding: 0.6rem 0.6rem; }
|
||||
.timing-tower th,
|
||||
.timing-tower td { padding: 0.4rem 0.5rem; }
|
||||
|
||||
/* Hide TEAM column in race results table */
|
||||
.data-table th:nth-child(3),
|
||||
.data-table td:nth-child(3) { display: none; }
|
||||
|
||||
/* Hide BEST LAP column in timing tower */
|
||||
.timing-tower th:nth-child(7),
|
||||
.timing-tower td:nth-child(7) { display: none; }
|
||||
|
||||
/* Scale down hero text */
|
||||
.next-race-name { font-size: 1.35rem; }
|
||||
.next-race-countdown { font-size: 1.6rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Hide position delta column on very small phones to save space */
|
||||
.timing-tower th:nth-child(2),
|
||||
.timing-tower td:nth-child(2) { display: none; }
|
||||
}
|
||||
|
||||
49
suggestions.md
Normal file
49
suggestions.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Evolution Roadmap: box-box for F1 Fanatics
|
||||
|
||||
This document outlines the strategic evolution of **box-box** to transition from a high-quality dashboard to a "Mission Control" and "Analysis Lab" for hardcore F1 fans.
|
||||
|
||||
---
|
||||
|
||||
## 1. Live Session: "Mission Control"
|
||||
*Goal: Enhance the viewing experience with real-time strategist-grade insights.*
|
||||
|
||||
### High-Impact Features
|
||||
* **Command Center View**: A unified, dense dashboard combining the live timing tower, auto-detected battles, track status, weather, and race control into one glanceable screen.
|
||||
* **Quali Hunter Mode**: Specifically for qualifying—tracking "banker" vs "push" laps, purple/green sector watches, traffic density alerts, and live "on the bubble" cutoff lines.
|
||||
* **Strategy Watch**: Real-time undercut/overcut threat detection. Visualizing the "pit window" not just as a static number, but as a dynamic "rejoin position" gap on the timing tower.
|
||||
* **Focus Mode**: Ability to "pin" 2-3 drivers to the top of the tower to monitor their specific gap trends, tyre age offsets, and lap-time deltas.
|
||||
* **Chaos Meter**: A heuristic-driven indicator of "likelihood of a safety car/incident" based on bunching, weather shifts, and recent yellow sector frequency.
|
||||
|
||||
---
|
||||
|
||||
## 2. Between GPs: "The Analysis Lab"
|
||||
*Goal: Provide deep-dive tools for "nerding out" on data during the off-week.*
|
||||
|
||||
### High-Impact Features
|
||||
* **Telemetry Lab**: Side-by-side comparison of two drivers or two laps using `CarData` (Speed, Throttle, Brake, Gear, DRS). Visualized as synchronized traces.
|
||||
* **Stint & Tyre Explorer**: Detailed degradation curves. Visualizing pace drop-off over a stint and comparing "Tyre Whisperer" performance between teammates.
|
||||
* **Season Storylines**: Progression charts for points, qualifying head-to-heads, and "average finish" trends across the season.
|
||||
* **Driver Dossiers**: Historical performance profiles—which tracks a driver excels at, their wet-weather "rating," and comeback statistics (positions gained).
|
||||
* **Track DNA**: Corner-speed profiles and overtaking difficulty maps for every circuit on the calendar.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI/UX & Technical Refinements
|
||||
*Goal: Professionalize the "Mission Control" aesthetic and improve ergonomics.*
|
||||
|
||||
* **Layout Toggles**: Switch between `Broadcast` (clean, high-level), `Dense` (standard dashboard), and `Analyst` (maximum data density).
|
||||
* **Event Rail**: A horizontal timeline of the session showing Pits, Overtakes, Yellow Flags, and Team Radio icons.
|
||||
* **Aggressive Pre-fetching**: Use the time *before* the 30-minute API lockout to cache all necessary historical context, track maps, and driver bios to keep the app rich during live sessions.
|
||||
* **Narrative Heuristics**: Auto-generated status strings like "Verstappen is in the hunting phase" or "Hamilton tyre cliff imminent" based on lap-time trends.
|
||||
|
||||
---
|
||||
|
||||
## 4. Senior Engineer's "Top Pick" (Most Benefit)
|
||||
|
||||
If we were to prioritize only one major evolution, the **Telemetry Lab (Telemetry + Stint Comparison)** brings the most unique value to a TUI.
|
||||
|
||||
**Why?**
|
||||
1. **Unique Value**: Most free apps show results; almost none show interactive terminal-based telemetry traces.
|
||||
2. **Code Readiness**: The `CarData` and `Lap` models are already in the codebase. The API client already knows how to fetch them.
|
||||
3. **Fan Engagement**: It turns the app from a "during the race" tool into a "all week long" tool. Fans love debating *where* a driver lost time (e.g., "He was 5km/h slower through Turn 4"), and a Telemetry Lab proves it.
|
||||
4. **Technical Sophistication**: It demonstrates the power of the Go/Bubble Tea stack to handle dense, high-frequency data visualizations in a terminal.
|
||||
Reference in New Issue
Block a user