mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Initial UI
This commit is contained in:
527
internal/web/assets/app.js
Normal file
527
internal/web/assets/app.js
Normal file
@@ -0,0 +1,527 @@
|
||||
/* ============================================================
|
||||
box-box — Alpine.js page components
|
||||
============================================================ */
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 country code → emoji flag (mirrors TUI countryFlag).
|
||||
function flagEmoji(code) {
|
||||
if (!code || code.length !== 2) return '🏁';
|
||||
const offset = 0x1F1E6 - 65; // Regional Indicator A offset
|
||||
return String.fromCodePoint(
|
||||
code.toUpperCase().charCodeAt(0) + offset,
|
||||
code.toUpperCase().charCodeAt(1) + offset
|
||||
);
|
||||
}
|
||||
|
||||
// Current F1 season year derived from today's date.
|
||||
function currentSeason() {
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
|
||||
// ---- Root state: routing + live check ----
|
||||
|
||||
function appState() {
|
||||
return {
|
||||
page: 'home',
|
||||
isLive: false,
|
||||
staleWarning: false,
|
||||
|
||||
init() {
|
||||
this.parseRoute();
|
||||
window.addEventListener('hashchange', () => this.parseRoute());
|
||||
this.checkLive();
|
||||
setInterval(() => this.checkLive(), 30000);
|
||||
},
|
||||
|
||||
parseRoute() {
|
||||
const hash = window.location.hash || '#/';
|
||||
const path = hash.slice(1) || '/';
|
||||
if (path === '/' || path === '') {
|
||||
this.page = 'home';
|
||||
} else if (path.startsWith('/race/')) {
|
||||
this.page = 'race';
|
||||
} else if (path === '/live') {
|
||||
this.page = 'live';
|
||||
} else if (path === '/standings') {
|
||||
this.page = 'standings';
|
||||
} else {
|
||||
this.page = 'home';
|
||||
}
|
||||
},
|
||||
|
||||
async checkLive() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/live/state');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
this.isLive = !!data.is_live;
|
||||
} catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Home page ----
|
||||
|
||||
function homePage() {
|
||||
return {
|
||||
loading: true,
|
||||
meetings: [],
|
||||
champDrivers: [],
|
||||
nextRace: null,
|
||||
countdown: '',
|
||||
season: currentSeason(),
|
||||
_countdownTimer: null,
|
||||
|
||||
async init() {
|
||||
this.loading = true;
|
||||
await Promise.all([
|
||||
this.loadMeetings(),
|
||||
this.loadChampionship(),
|
||||
]);
|
||||
this.loading = false;
|
||||
this.startCountdown();
|
||||
},
|
||||
|
||||
async loadMeetings() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/meetings?year=${this.season}`);
|
||||
const data = await r.json();
|
||||
this.meetings = Array.isArray(data) ? data : [];
|
||||
// Find next race
|
||||
const now = Date.now();
|
||||
this.nextRace = this.meetings.find(m => new Date(m.date_start).getTime() > now) || null;
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async loadChampionship() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/championship/drivers?year=${this.season}`);
|
||||
const data = await r.json();
|
||||
this.champDrivers = Array.isArray(data)
|
||||
? [...data].sort((a, b) => a.position_current - b.position_current)
|
||||
: [];
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
startCountdown() {
|
||||
if (!this.nextRace) return;
|
||||
const target = new Date(this.nextRace.date_start).getTime();
|
||||
const update = () => {
|
||||
const diff = target - Date.now();
|
||||
if (diff <= 0) { this.countdown = 'Race day!'; return; }
|
||||
const d = Math.floor(diff / 86400000);
|
||||
const h = Math.floor((diff % 86400000) / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
const s = Math.floor((diff % 60000) / 1000);
|
||||
this.countdown = d > 0
|
||||
? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`
|
||||
: `${pad(h)}:${pad(m)}:${pad(s)}`;
|
||||
};
|
||||
update();
|
||||
this._countdownTimer = setInterval(update, 1000);
|
||||
},
|
||||
|
||||
barWidth(pts) {
|
||||
if (!this.champDrivers.length) return 0;
|
||||
const max = this.champDrivers[0].points_current;
|
||||
return max > 0 ? Math.round((pts / max) * 100) : 0;
|
||||
},
|
||||
|
||||
flagEmoji,
|
||||
isPast(m) { return new Date(m.date_start).getTime() < Date.now(); },
|
||||
fmtDate(d) { return d ? new Date(d).toLocaleDateString('en-GB', {day:'numeric',month:'short'}) : ''; },
|
||||
|
||||
destroy() { clearInterval(this._countdownTimer); },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Race page ----
|
||||
|
||||
function racePage() {
|
||||
return {
|
||||
meetingKey: 0,
|
||||
meetingName: '',
|
||||
year: 2025,
|
||||
sessions: [],
|
||||
activeSession: null,
|
||||
tab: 'results',
|
||||
drivers: [],
|
||||
|
||||
// Results
|
||||
results: [],
|
||||
resultsLoading: false,
|
||||
|
||||
// Strategy
|
||||
strategyData: null,
|
||||
strategyLoading: false,
|
||||
strategyNote: '',
|
||||
|
||||
// Laps comparison
|
||||
lapsData: null,
|
||||
lapsLoading: false,
|
||||
|
||||
// Track
|
||||
trackData: null,
|
||||
trackLoading: false,
|
||||
|
||||
// Telemetry
|
||||
telemetryDrivers: [],
|
||||
telemetryData: [],
|
||||
telemetryLoading: false,
|
||||
|
||||
// Resize observers
|
||||
_observers: [],
|
||||
|
||||
async init() {
|
||||
const hash = window.location.hash || '';
|
||||
const m = hash.match(/#\/race\/(\d+)/);
|
||||
if (!m) return;
|
||||
this.meetingKey = parseInt(m[1]);
|
||||
|
||||
// Load sessions
|
||||
try {
|
||||
const r = await fetch(`/api/v1/sessions?meeting_key=${this.meetingKey}`);
|
||||
this.sessions = await r.json();
|
||||
if (this.sessions.length) {
|
||||
// Prefer Race session, else last session
|
||||
const race = this.sessions.find(s => s.session_type === 'Race')
|
||||
|| this.sessions[this.sessions.length - 1];
|
||||
await this.selectSession(race);
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async selectSession(sess) {
|
||||
this.activeSession = sess;
|
||||
// Extract year from date_start
|
||||
this.year = sess.date_start ? parseInt(sess.date_start.slice(0, 4)) : 2025;
|
||||
// Clear all lazy-loaded data
|
||||
this.strategyData = null;
|
||||
this.strategyNote = '';
|
||||
this.lapsData = null;
|
||||
this.trackData = null;
|
||||
this.telemetryData = [];
|
||||
this.telemetryDrivers = [];
|
||||
|
||||
await Promise.all([
|
||||
this.loadResults(sess.session_key),
|
||||
this.loadDrivers(sess.session_key),
|
||||
]);
|
||||
},
|
||||
|
||||
async loadResults(sk) {
|
||||
this.resultsLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/results?session_key=${sk}`);
|
||||
const data = await r.json();
|
||||
this.results = Array.isArray(data)
|
||||
? [...data].sort((a, b) => (a.position || 99) - (b.position || 99))
|
||||
: [];
|
||||
this.meetingName = '';
|
||||
if (this.sessions.length) {
|
||||
const sess = this.sessions.find(s => s.session_key === sk);
|
||||
this.meetingName = sess?.meeting_key ? `Round — Meeting ${this.meetingKey}` : '';
|
||||
}
|
||||
} catch (_) { this.results = []; }
|
||||
this.resultsLoading = false;
|
||||
},
|
||||
|
||||
async loadDrivers(sk) {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/drivers?session_key=${sk}`);
|
||||
this.drivers = await r.json();
|
||||
// Set default meeting name from sessions list
|
||||
const sess = this.sessions.find(s => s.session_key === sk);
|
||||
if (!this.meetingName && this.sessions.length) {
|
||||
this.meetingName = `Meeting ${this.meetingKey}`;
|
||||
}
|
||||
} catch (_) { this.drivers = []; }
|
||||
},
|
||||
|
||||
async loadStrategy() {
|
||||
if (this.strategyData || this.strategyLoading || !this.activeSession) return;
|
||||
this.strategyLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/strategy?session_key=${this.activeSession.session_key}`);
|
||||
const data = await r.json();
|
||||
if (data.note) {
|
||||
this.strategyNote = data.note;
|
||||
} else {
|
||||
this.strategyData = data;
|
||||
}
|
||||
} catch (_) { this.strategyNote = 'Failed to load strategy data.'; }
|
||||
this.strategyLoading = false;
|
||||
},
|
||||
|
||||
renderStrategy() {
|
||||
if (!this.strategyData) return;
|
||||
this.$nextTick(() => Charts.renderStrategy('strategy-chart', this.strategyData));
|
||||
},
|
||||
|
||||
async loadLaps() {
|
||||
if (this.lapsData || this.lapsLoading || !this.activeSession) return;
|
||||
this.lapsLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/laps/comparison?session_key=${this.activeSession.session_key}`);
|
||||
this.lapsData = await r.json();
|
||||
} catch (_) {}
|
||||
this.lapsLoading = false;
|
||||
},
|
||||
|
||||
renderLapTimes() {
|
||||
if (!this.lapsData) return;
|
||||
this.$nextTick(() => Charts.renderLapTimes('laps-chart', this.lapsData));
|
||||
},
|
||||
|
||||
async loadTrack() {
|
||||
if (this.trackData || this.trackLoading || !this.activeSession) return;
|
||||
this.trackLoading = true;
|
||||
try {
|
||||
const ck = this.activeSession.circuit_key;
|
||||
const yr = this.year;
|
||||
const r = await fetch(`/api/v1/track-outline?circuit_key=${ck}&year=${yr}`);
|
||||
const data = await r.json();
|
||||
if (data.points && data.points.length > 0) {
|
||||
this.trackData = data;
|
||||
}
|
||||
} catch (_) {}
|
||||
this.trackLoading = false;
|
||||
},
|
||||
|
||||
renderTrack() {
|
||||
if (!this.trackData) return;
|
||||
this.$nextTick(() => Track.render('track-chart', this.trackData.points, [], {}));
|
||||
},
|
||||
|
||||
async toggleTelemetryDriver(driverNumber) {
|
||||
if (this.telemetryDrivers.includes(driverNumber)) {
|
||||
this.telemetryDrivers = this.telemetryDrivers.filter(n => n !== driverNumber);
|
||||
} else {
|
||||
if (this.telemetryDrivers.length >= 3) return; // max 3
|
||||
this.telemetryDrivers.push(driverNumber);
|
||||
}
|
||||
await this.loadTelemetry();
|
||||
},
|
||||
|
||||
async loadTelemetry() {
|
||||
if (!this.activeSession || this.telemetryDrivers.length === 0) {
|
||||
this.telemetryData = [];
|
||||
return;
|
||||
}
|
||||
this.telemetryLoading = true;
|
||||
const sk = this.activeSession.session_key;
|
||||
const results = await Promise.all(
|
||||
this.telemetryDrivers.map(dn =>
|
||||
fetch(`/api/v1/telemetry?session_key=${sk}&driver_number=${dn}`)
|
||||
.then(r => r.json())
|
||||
.then(data => ({ driverNumber: dn, data: Array.isArray(data) ? data : [] }))
|
||||
.catch(() => ({ driverNumber: dn, data: [] }))
|
||||
)
|
||||
);
|
||||
this.telemetryData = results.map(r => {
|
||||
const driver = this.drivers.find(d => d.driver_number === r.driverNumber);
|
||||
return {
|
||||
driverNumber: r.driverNumber,
|
||||
nameAcronym: driver?.name_acronym || String(r.driverNumber),
|
||||
teamColour: driver?.team_colour || '888888',
|
||||
data: r.data,
|
||||
};
|
||||
});
|
||||
this.telemetryLoading = false;
|
||||
},
|
||||
|
||||
renderTelemetry() {
|
||||
if (!this.telemetryData.length) return;
|
||||
this.$nextTick(() => Charts.renderTelemetry('telemetry-chart', this.telemetryData));
|
||||
},
|
||||
|
||||
fmtDuration(v) {
|
||||
if (v === null || v === undefined) return '-';
|
||||
if (Array.isArray(v)) return v.map(t => fmtSecs(t)).join(' / ');
|
||||
return fmtSecs(v);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Live page ----
|
||||
|
||||
function livePage() {
|
||||
return {
|
||||
isLive: false,
|
||||
drivers: {},
|
||||
driverInfo: {},
|
||||
tyres: {},
|
||||
stints: {},
|
||||
rcMessages: [],
|
||||
trackStatus: '',
|
||||
currentLap: 0,
|
||||
totalLaps: 0,
|
||||
clock: '',
|
||||
clockRefTime: null,
|
||||
clockExtrapolating: false,
|
||||
session: {},
|
||||
_es: null,
|
||||
_clockTimer: null,
|
||||
clockDisplay: '',
|
||||
|
||||
init() {
|
||||
this.connectSSE();
|
||||
this._clockTimer = setInterval(() => this.updateClock(), 1000);
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
if (this._es) this._es.close();
|
||||
clearInterval(this._clockTimer);
|
||||
},
|
||||
|
||||
connectSSE() {
|
||||
const es = new EventSource('/api/v1/live/stream');
|
||||
this._es = es;
|
||||
|
||||
es.addEventListener('snapshot', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
this.isLive = !!msg.is_live;
|
||||
if (msg.data) this.applySnapshot(msg.data);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
es.addEventListener('heartbeat', () => {});
|
||||
|
||||
es.onerror = () => {
|
||||
this.isLive = false;
|
||||
setTimeout(() => this.connectSSE(), 5000);
|
||||
es.close();
|
||||
};
|
||||
},
|
||||
|
||||
applySnapshot(d) {
|
||||
if (d.Drivers) this.drivers = d.Drivers;
|
||||
if (d.DriverInfo) this.driverInfo = d.DriverInfo;
|
||||
if (d.Tyres) this.tyres = d.Tyres;
|
||||
if (d.Stints) this.stints = d.Stints;
|
||||
if (d.RCMessages) this.rcMessages = d.RCMessages;
|
||||
if (d.TrackStatus) this.trackStatus = d.TrackStatus;
|
||||
if (d.CurrentLap) this.currentLap = d.CurrentLap;
|
||||
if (d.TotalLaps) this.totalLaps = d.TotalLaps;
|
||||
if (d.Clock) this.clock = d.Clock;
|
||||
if (d.ClockRefTime) this.clockRefTime = new Date(d.ClockRefTime);
|
||||
if (d.ClockExtrapolating !== undefined) this.clockExtrapolating = d.ClockExtrapolating;
|
||||
if (d.Session) this.session = d.Session;
|
||||
},
|
||||
|
||||
get sortedDrivers() {
|
||||
return Object.values(this.drivers)
|
||||
.filter(d => d.Position > 0)
|
||||
.sort((a, b) => a.Position - b.Position);
|
||||
},
|
||||
|
||||
driverTla(num) {
|
||||
return this.driverInfo[num]?.Tla || num;
|
||||
},
|
||||
|
||||
driverTeamColor(num) {
|
||||
return this.driverInfo[num]?.TeamColour || '666666';
|
||||
},
|
||||
|
||||
tyreLabel(num) {
|
||||
const t = this.tyres[num];
|
||||
if (!t) return '?';
|
||||
return `${t.Compound?.charAt(0) || '?'} +${t.Age || 0}`;
|
||||
},
|
||||
|
||||
tyreClass(num) {
|
||||
const t = this.tyres[num];
|
||||
if (!t) return 'tyre-unknown';
|
||||
return 'tyre-' + (t.Compound || 'unknown').toLowerCase();
|
||||
},
|
||||
|
||||
posDelta(d) {
|
||||
if (!d.PrevPosition || d.PrevPosition === d.Position) return '';
|
||||
return d.PrevPosition > d.Position ? '▲' : '▼';
|
||||
},
|
||||
|
||||
trackStatusText() {
|
||||
const map = {'1':'GREEN','2':'YELLOW','4':'SC','5':'RED','6':'VSC'};
|
||||
return map[this.trackStatus] || this.trackStatus;
|
||||
},
|
||||
|
||||
trackStatusClass() {
|
||||
const map = {'1':'track-green','2':'track-yellow','4':'track-sc','5':'track-red','6':'track-vsc'};
|
||||
return map[this.trackStatus] || '';
|
||||
},
|
||||
|
||||
updateClock() {
|
||||
if (!this.clock || !this.clockExtrapolating || !this.clockRefTime) {
|
||||
this.clockDisplay = this.clock || '';
|
||||
return;
|
||||
}
|
||||
// Extrapolate: remaining = clock - elapsed since clockRefTime
|
||||
const [h, m, s] = this.clock.split(':').map(Number);
|
||||
const totalSecs = h * 3600 + m * 60 + s;
|
||||
const elapsed = (Date.now() - this.clockRefTime.getTime()) / 1000;
|
||||
const remaining = Math.max(0, totalSecs - elapsed);
|
||||
const rh = Math.floor(remaining / 3600);
|
||||
const rm = Math.floor((remaining % 3600) / 60);
|
||||
const rs = Math.floor(remaining % 60);
|
||||
this.clockDisplay = `${pad(rh)}:${pad(rm)}:${pad(rs)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Standings page ----
|
||||
|
||||
function standingsPage() {
|
||||
return {
|
||||
year: currentSeason(),
|
||||
view: 'drivers',
|
||||
loading: false,
|
||||
driverStandings: [],
|
||||
teamStandings: [],
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async setYear(y) {
|
||||
this.year = y;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
await Promise.all([this.loadDrivers(), this.loadTeams()]);
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDrivers() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/championship/drivers?year=${this.year}`);
|
||||
const data = await r.json();
|
||||
this.driverStandings = Array.isArray(data)
|
||||
? [...data].sort((a, b) => a.position_current - b.position_current)
|
||||
: [];
|
||||
} catch (_) { this.driverStandings = []; }
|
||||
},
|
||||
|
||||
async loadTeams() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/championship/teams?year=${this.year}`);
|
||||
const data = await r.json();
|
||||
this.teamStandings = Array.isArray(data)
|
||||
? [...data].sort((a, b) => a.position_current - b.position_current)
|
||||
: [];
|
||||
} catch (_) { this.teamStandings = []; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function fmtSecs(s) {
|
||||
if (!s) return '-';
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = (s - m * 60).toFixed(3);
|
||||
return `${m}:${rem.padStart(6, '0')}`;
|
||||
}
|
||||
465
internal/web/assets/charts.js
Normal file
465
internal/web/assets/charts.js
Normal file
@@ -0,0 +1,465 @@
|
||||
/* ============================================================
|
||||
box-box — D3 visualization library
|
||||
All functions: Charts.renderX(containerId, data)
|
||||
Charts re-render on ResizeObserver via stored callbacks.
|
||||
============================================================ */
|
||||
|
||||
const Charts = (() => {
|
||||
// Compound → fill colour
|
||||
const COMPOUND_COLOR = {
|
||||
SOFT: '#FF3333',
|
||||
MEDIUM: '#FFD700',
|
||||
HARD: '#CCCCCC',
|
||||
INTERMEDIATE: '#39B54A',
|
||||
WET: '#0080FF',
|
||||
UNKNOWN: '#666688',
|
||||
};
|
||||
|
||||
const MARGIN = { top: 32, right: 24, bottom: 32, left: 60 };
|
||||
const LABEL_W = 48; // width reserved for driver labels
|
||||
|
||||
// Tooltip element (shared)
|
||||
let tooltip = null;
|
||||
function getTooltip() {
|
||||
if (!tooltip) {
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.className = 'chart-tooltip';
|
||||
tooltip.style.display = 'none';
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
return tooltip;
|
||||
}
|
||||
function showTooltip(html, e) {
|
||||
const t = getTooltip();
|
||||
t.innerHTML = html;
|
||||
t.style.display = 'block';
|
||||
t.style.left = (e.clientX + 12) + 'px';
|
||||
t.style.top = (e.clientY - 8) + 'px';
|
||||
}
|
||||
function hideTooltip() {
|
||||
getTooltip().style.display = 'none';
|
||||
}
|
||||
|
||||
// ResizeObserver registry: containerId → callback
|
||||
const observers = {};
|
||||
function observeResize(id, fn) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (observers[id]) observers[id].disconnect();
|
||||
const obs = new ResizeObserver(() => fn());
|
||||
obs.observe(el);
|
||||
observers[id] = obs;
|
||||
}
|
||||
|
||||
// ---- Strategy Gantt chart ----
|
||||
|
||||
function renderStrategy(containerId, data) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el || !data || !data.drivers) return;
|
||||
el.innerHTML = '';
|
||||
|
||||
const drivers = (data.drivers || []).filter(d => d.stints && d.stints.length > 0 || d.dns);
|
||||
if (drivers.length === 0) { el.textContent = 'No strategy data.'; return; }
|
||||
|
||||
const totalLaps = data.total_laps || 60;
|
||||
const rowH = 28;
|
||||
const padding = 4;
|
||||
const height = MARGIN.top + drivers.length * (rowH + padding) + MARGIN.bottom;
|
||||
const width = Math.max(el.clientWidth || 800, 500);
|
||||
|
||||
const svg = d3.select(el).append('svg')
|
||||
.attr('width', width).attr('height', height);
|
||||
|
||||
const xScale = d3.scaleLinear()
|
||||
.domain([1, totalLaps])
|
||||
.range([MARGIN.left + LABEL_W, width - MARGIN.right]);
|
||||
|
||||
const yScale = d3.scaleBand()
|
||||
.domain(drivers.map(d => d.driver_number))
|
||||
.range([MARGIN.top, height - MARGIN.bottom])
|
||||
.padding(0.15);
|
||||
|
||||
// SC/VSC zones
|
||||
(data.sc_periods || []).forEach(p => {
|
||||
svg.append('rect')
|
||||
.attr('x', xScale(p.lap_start))
|
||||
.attr('y', MARGIN.top)
|
||||
.attr('width', xScale(p.lap_end) - xScale(p.lap_start))
|
||||
.attr('height', height - MARGIN.top - MARGIN.bottom)
|
||||
.attr('fill', p.type === 'VSC' ? 'rgba(80,130,255,0.12)' : 'rgba(255,200,0,0.12)')
|
||||
.attr('pointer-events', 'none');
|
||||
});
|
||||
|
||||
// Grid lines
|
||||
for (let lap = 5; lap <= totalLaps; lap += 5) {
|
||||
svg.append('line')
|
||||
.attr('x1', xScale(lap)).attr('x2', xScale(lap))
|
||||
.attr('y1', MARGIN.top).attr('y2', height - MARGIN.bottom)
|
||||
.attr('stroke', '#2D2D44').attr('stroke-width', 1);
|
||||
}
|
||||
|
||||
// X-axis
|
||||
const xAxis = d3.axisBottom(xScale).ticks(Math.min(20, Math.floor(totalLaps / 5))).tickFormat(d3.format('d'));
|
||||
svg.append('g').attr('transform', `translate(0,${height - MARGIN.bottom})`).call(xAxis);
|
||||
|
||||
// Driver rows
|
||||
drivers.forEach(driver => {
|
||||
const y = yScale(driver.driver_number);
|
||||
const rh = yScale.bandwidth();
|
||||
|
||||
// Label
|
||||
svg.append('text')
|
||||
.attr('x', MARGIN.left + LABEL_W - 6)
|
||||
.attr('y', y + rh / 2 + 4)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('fill', `#${driver.team_colour || '888888'}`)
|
||||
.attr('font-size', 11)
|
||||
.attr('font-weight', '700')
|
||||
.text(driver.name_acronym || driver.driver_number);
|
||||
|
||||
// DNS: single gray bar
|
||||
if (driver.dns) {
|
||||
svg.append('rect')
|
||||
.attr('x', xScale(1)).attr('y', y + 2)
|
||||
.attr('width', xScale(totalLaps) - xScale(1))
|
||||
.attr('height', rh - 4)
|
||||
.attr('fill', '#333355').attr('rx', 3);
|
||||
svg.append('text')
|
||||
.attr('x', (xScale(1) + xScale(totalLaps)) / 2).attr('y', y + rh / 2 + 4)
|
||||
.attr('text-anchor', 'middle').attr('fill', '#8888aa').attr('font-size', 10)
|
||||
.text('DNS');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stints
|
||||
(driver.stints || []).forEach(stint => {
|
||||
const x1 = xScale(stint.lap_start);
|
||||
const x2 = xScale(stint.lap_end);
|
||||
const color = COMPOUND_COLOR[stint.compound] || COMPOUND_COLOR.UNKNOWN;
|
||||
|
||||
const bar = svg.append('rect')
|
||||
.attr('x', x1).attr('y', y + 2)
|
||||
.attr('width', Math.max(2, x2 - x1))
|
||||
.attr('height', rh - 4)
|
||||
.attr('fill', color).attr('rx', 3)
|
||||
.attr('cursor', 'pointer');
|
||||
|
||||
// DNF: diagonal stripe overlay
|
||||
if (driver.dnf && stint === driver.stints[driver.stints.length - 1]) {
|
||||
bar.attr('opacity', 0.7);
|
||||
// Add simple DNF label
|
||||
svg.append('text')
|
||||
.attr('x', x2 - 14).attr('y', y + rh / 2 + 4)
|
||||
.attr('text-anchor', 'end').attr('fill', '#cc4422')
|
||||
.attr('font-size', 9).attr('font-weight', '700').text('DNF');
|
||||
}
|
||||
|
||||
bar.on('mousemove', e => {
|
||||
const isNew = stint.is_new ? ' (new)' : ` (+${stint.tyre_age_at_start})`;
|
||||
showTooltip(
|
||||
`<strong>${driver.name_acronym}</strong> — Stint ${stint.stint_number}<br>` +
|
||||
`${stint.compound}${isNew}<br>` +
|
||||
`Laps ${stint.lap_start}–${stint.lap_end} (${stint.lap_count} laps)`, e
|
||||
);
|
||||
}).on('mouseleave', hideTooltip);
|
||||
});
|
||||
|
||||
// Pit stops
|
||||
(driver.pit_stops || []).forEach(pit => {
|
||||
svg.append('circle')
|
||||
.attr('cx', xScale(pit.lap_number))
|
||||
.attr('cy', y + rh / 2)
|
||||
.attr('r', 4)
|
||||
.attr('fill', '#ffffff').attr('stroke', '#000').attr('stroke-width', 1)
|
||||
.attr('cursor', 'pointer')
|
||||
.on('mousemove', e => {
|
||||
showTooltip(
|
||||
`Pit lap ${pit.lap_number}<br>` +
|
||||
`Stop: ${pit.stop_duration?.toFixed(2)}s Lane: ${pit.lane_duration?.toFixed(2)}s`, e
|
||||
);
|
||||
})
|
||||
.on('mouseleave', hideTooltip);
|
||||
});
|
||||
});
|
||||
|
||||
observeResize(containerId, () => renderStrategy(containerId, data));
|
||||
}
|
||||
|
||||
// ---- Lap time progression chart ----
|
||||
|
||||
function renderLapTimes(containerId, data) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el || !data || !data.drivers) return;
|
||||
el.innerHTML = '';
|
||||
|
||||
const drivers = data.drivers || [];
|
||||
if (!drivers.length) { el.textContent = 'No lap time data.'; return; }
|
||||
|
||||
const ML = MARGIN.left + 10;
|
||||
const MT = MARGIN.top;
|
||||
const MB = MARGIN.bottom + 20;
|
||||
const MR = MARGIN.right;
|
||||
const width = Math.max(el.clientWidth || 800, 400);
|
||||
const height = 300;
|
||||
|
||||
const svg = d3.select(el).append('svg')
|
||||
.attr('width', width).attr('height', height);
|
||||
|
||||
// Collect all valid laps for domain calculation
|
||||
const allLaps = drivers.flatMap(d =>
|
||||
(d.laps || []).filter(l => l.lap_duration && !l.is_pit_out_lap)
|
||||
.map(l => ({ lapNum: l.lap_number, t: l.lap_duration }))
|
||||
);
|
||||
if (!allLaps.length) { el.textContent = 'No lap data.'; return; }
|
||||
|
||||
const maxLap = d3.max(allLaps, l => l.lapNum) || 1;
|
||||
const yMin = d3.min(allLaps, l => l.t);
|
||||
const yMax = d3.max(allLaps, l => l.t);
|
||||
|
||||
const xScale = d3.scaleLinear().domain([1, maxLap]).range([ML, width - MR]);
|
||||
const yScale = d3.scaleLinear()
|
||||
.domain([yMin * 0.995, yMax * 1.005])
|
||||
.range([height - MB, MT]);
|
||||
|
||||
// SC zones
|
||||
(data.sc_periods || []).forEach(p => {
|
||||
svg.append('rect')
|
||||
.attr('x', xScale(p.lap_start)).attr('y', MT)
|
||||
.attr('width', xScale(p.lap_end) - xScale(p.lap_start))
|
||||
.attr('height', height - MT - MB)
|
||||
.attr('fill', 'rgba(255,200,0,0.10)').attr('pointer-events', 'none');
|
||||
});
|
||||
|
||||
// Axes
|
||||
svg.append('g').attr('transform', `translate(0,${height - MB})`).call(
|
||||
d3.axisBottom(xScale).ticks(Math.min(20, maxLap)).tickFormat(d3.format('d'))
|
||||
);
|
||||
svg.append('g').attr('transform', `translate(${ML},0)`).call(
|
||||
d3.axisLeft(yScale).ticks(6).tickFormat(t => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = (t - m * 60).toFixed(1);
|
||||
return `${m}:${s.padStart(4, '0')}`;
|
||||
})
|
||||
);
|
||||
|
||||
// Per-driver lines
|
||||
const driverVisible = {};
|
||||
drivers.forEach(d => { driverVisible[d.driver_number] = true; });
|
||||
|
||||
const lineGen = d3.line()
|
||||
.defined(l => l.lap_duration != null && !l.is_pit_out_lap)
|
||||
.x(l => xScale(l.lap_number))
|
||||
.y(l => yScale(l.lap_duration));
|
||||
|
||||
const paths = {};
|
||||
drivers.forEach(driver => {
|
||||
const color = '#' + (driver.team_colour || '888888');
|
||||
const path = svg.append('path')
|
||||
.datum(driver.laps || [])
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', color)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', lineGen);
|
||||
paths[driver.driver_number] = path;
|
||||
|
||||
// Pit lap circles
|
||||
const pitNums = new Set((data.pit_laps || {})[String(driver.driver_number)] || []);
|
||||
(driver.laps || []).filter(l => pitNums.has(l.lap_number) && l.lap_duration).forEach(l => {
|
||||
svg.append('circle')
|
||||
.attr('cx', xScale(l.lap_number)).attr('cy', yScale(l.lap_duration))
|
||||
.attr('r', 4).attr('fill', 'none').attr('stroke', color).attr('stroke-width', 2);
|
||||
});
|
||||
});
|
||||
|
||||
// Crosshair tooltip
|
||||
const crosshair = svg.append('line')
|
||||
.attr('y1', MT).attr('y2', height - MB)
|
||||
.attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none');
|
||||
|
||||
svg.append('rect')
|
||||
.attr('x', ML).attr('y', MT)
|
||||
.attr('width', width - ML - MR).attr('height', height - MT - MB)
|
||||
.attr('fill', 'none').attr('pointer-events', 'all')
|
||||
.on('mousemove', e => {
|
||||
const [mx] = d3.pointer(e);
|
||||
const lapNum = Math.round(xScale.invert(mx));
|
||||
crosshair.attr('x1', xScale(lapNum)).attr('x2', xScale(lapNum)).attr('display', null);
|
||||
const tips = drivers.map(d => {
|
||||
const lap = (d.laps || []).find(l => l.lap_number === lapNum);
|
||||
if (!lap || !lap.lap_duration) return '';
|
||||
const m = Math.floor(lap.lap_duration / 60);
|
||||
const s = (lap.lap_duration - m * 60).toFixed(3);
|
||||
return `<span style="color:#${d.team_colour||'888888'}">${d.name_acronym}</span>: ${m}:${s.padStart(6,'0')}`;
|
||||
}).filter(Boolean).join('<br>');
|
||||
if (tips) showTooltip(`<strong>Lap ${lapNum}</strong><br>${tips}`, e);
|
||||
})
|
||||
.on('mouseleave', () => { crosshair.attr('display', 'none'); hideTooltip(); });
|
||||
|
||||
// Legend with toggle
|
||||
const legendG = svg.append('g').attr('transform', `translate(${ML},${MT - 20})`);
|
||||
let lx = 0;
|
||||
drivers.forEach(driver => {
|
||||
const color = '#' + (driver.team_colour || '888888');
|
||||
const g = legendG.append('g').attr('transform', `translate(${lx},0)`).attr('cursor', 'pointer');
|
||||
g.append('rect').attr('width', 12).attr('height', 12).attr('fill', color).attr('rx', 2);
|
||||
g.append('text').attr('x', 15).attr('y', 10).attr('fill', color).attr('font-size', 11)
|
||||
.text(driver.name_acronym || driver.driver_number);
|
||||
lx += 60;
|
||||
|
||||
g.on('click', () => {
|
||||
const visible = !driverVisible[driver.driver_number];
|
||||
driverVisible[driver.driver_number] = visible;
|
||||
paths[driver.driver_number].attr('opacity', visible ? 1 : 0.15);
|
||||
g.attr('opacity', visible ? 1 : 0.4);
|
||||
});
|
||||
});
|
||||
|
||||
observeResize(containerId, () => renderLapTimes(containerId, data));
|
||||
}
|
||||
|
||||
// ---- Telemetry 4-panel chart ----
|
||||
|
||||
function renderTelemetry(containerId, driversData) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el || !driversData || !driversData.length) return;
|
||||
el.innerHTML = '';
|
||||
|
||||
const panelConfigs = [
|
||||
{ key: 'speed', label: 'Speed (km/h)', height: 120, yKey: 'Speed' },
|
||||
{ key: 'throttle', label: 'Throttle (%)', height: 80, yKey: 'Throttle' },
|
||||
{ key: 'brake', label: 'Brake (%)', height: 80, yKey: 'Brake' },
|
||||
{ key: 'gear', label: 'Gear', height: 60, yKey: 'NGear' },
|
||||
];
|
||||
|
||||
const GAP = 10;
|
||||
const totalHeight = panelConfigs.reduce((s, p) => s + p.height, 0) + GAP * (panelConfigs.length - 1) + MARGIN.top + MARGIN.bottom;
|
||||
const width = Math.max(el.clientWidth || 800, 400);
|
||||
const ML = MARGIN.left + 10;
|
||||
const MR = MARGIN.right;
|
||||
|
||||
const svg = d3.select(el).append('svg').attr('width', width).attr('height', totalHeight);
|
||||
|
||||
// Compute distances from speed + time for all drivers, pick max dist
|
||||
const driverSeries = driversData.map(d => {
|
||||
const pts = computeDistanceSeries(d.data || []);
|
||||
return { ...d, pts };
|
||||
});
|
||||
const maxDist = d3.max(driverSeries, d => d.pts.length > 0 ? d.pts[d.pts.length - 1].dist : 0) || 1;
|
||||
|
||||
const xScale = d3.scaleLinear().domain([0, maxDist]).range([ML, width - MR]);
|
||||
|
||||
let yOffset = MARGIN.top;
|
||||
panelConfigs.forEach((panel, pi) => {
|
||||
const ph = panel.height;
|
||||
const g = svg.append('g').attr('transform', `translate(0,${yOffset})`);
|
||||
|
||||
// Y domain
|
||||
const allVals = driverSeries.flatMap(d => d.pts.map(p => p[panel.yKey] ?? 0));
|
||||
let yMin = d3.min(allVals) ?? 0;
|
||||
let yMax = d3.max(allVals) ?? 1;
|
||||
if (panel.key === 'throttle' || panel.key === 'brake') { yMin = 0; yMax = 100; }
|
||||
if (panel.key === 'gear') { yMin = 0; yMax = 8; }
|
||||
const yScale = d3.scaleLinear().domain([yMin, yMax]).range([ph, 0]);
|
||||
|
||||
// Panel background
|
||||
g.append('rect').attr('x', ML).attr('y', 0)
|
||||
.attr('width', width - ML - MR).attr('height', ph)
|
||||
.attr('fill', '#1B1B2F').attr('rx', 4);
|
||||
|
||||
// Label
|
||||
g.append('text').attr('x', ML - 8).attr('y', ph / 2 + 4)
|
||||
.attr('text-anchor', 'end').attr('fill', '#8888aa').attr('font-size', 10)
|
||||
.text(panel.label);
|
||||
|
||||
// Y axis (right side of first panel)
|
||||
if (pi === 0) {
|
||||
g.append('g').attr('transform', `translate(${width - MR},0)`)
|
||||
.call(d3.axisRight(yScale).ticks(4));
|
||||
}
|
||||
|
||||
// Lines
|
||||
const lineGen = d3.line()
|
||||
.x(p => xScale(p.dist))
|
||||
.y(p => yScale(p[panel.yKey] ?? 0))
|
||||
.defined(p => p[panel.yKey] != null);
|
||||
|
||||
driverSeries.forEach(driver => {
|
||||
if (!driver.pts.length) return;
|
||||
g.append('path')
|
||||
.datum(driver.pts)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', '#' + (driver.teamColour || '888888'))
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('d', lineGen);
|
||||
});
|
||||
|
||||
// X axis only on last panel
|
||||
if (pi === panelConfigs.length - 1) {
|
||||
g.append('g').attr('transform', `translate(0,${ph})`).call(
|
||||
d3.axisBottom(xScale).ticks(8).tickFormat(d => `${Math.round(d / 1000)}k`)
|
||||
);
|
||||
}
|
||||
|
||||
yOffset += ph + GAP;
|
||||
});
|
||||
|
||||
// Shared crosshair
|
||||
const crosslines = panelConfigs.map((panel, pi) => {
|
||||
const g = svg.select(`g:nth-of-type(${pi + 1})`);
|
||||
return svg.append('line')
|
||||
.attr('y1', MARGIN.top + panelConfigs.slice(0, pi).reduce((s, p) => s + p.height + GAP, 0))
|
||||
.attr('y2', MARGIN.top + panelConfigs.slice(0, pi + 1).reduce((s, p) => s + p.height + GAP, 0) - GAP)
|
||||
.attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none');
|
||||
});
|
||||
|
||||
svg.append('rect')
|
||||
.attr('x', ML).attr('y', MARGIN.top)
|
||||
.attr('width', width - ML - MR).attr('height', totalHeight - MARGIN.top - MARGIN.bottom)
|
||||
.attr('fill', 'none').attr('pointer-events', 'all')
|
||||
.on('mousemove', e => {
|
||||
const [mx] = d3.pointer(e);
|
||||
crosslines.forEach(l => l.attr('x1', mx).attr('x2', mx).attr('display', null));
|
||||
const dist = xScale.invert(mx);
|
||||
const tips = driverSeries.map(d => {
|
||||
const pt = d.pts.find(p => p.dist >= dist);
|
||||
if (!pt) return '';
|
||||
return `<span style="color:#${d.teamColour||'888888'}">${d.nameAcronym}</span>: ` +
|
||||
`${pt.Speed}km/h T${pt.Throttle}% B${pt.Brake}% G${pt.NGear}`;
|
||||
}).filter(Boolean).join('<br>');
|
||||
if (tips) showTooltip(tips, e);
|
||||
})
|
||||
.on('mouseleave', () => {
|
||||
crosslines.forEach(l => l.attr('display', 'none'));
|
||||
hideTooltip();
|
||||
});
|
||||
|
||||
observeResize(containerId, () => renderTelemetry(containerId, driversData));
|
||||
}
|
||||
|
||||
// Compute cumulative distance from car data samples.
|
||||
function computeDistanceSeries(samples) {
|
||||
if (!samples.length) return [];
|
||||
const pts = [];
|
||||
let dist = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = samples[i];
|
||||
if (i > 0) {
|
||||
const prev = samples[i - 1];
|
||||
const dt = (new Date(s.date) - new Date(prev.date)) / 1000; // seconds
|
||||
if (dt > 0 && dt < 5) { // ignore large gaps
|
||||
dist += (s.speed / 3.6) * dt; // speed in km/h → m/s * dt → metres
|
||||
}
|
||||
}
|
||||
pts.push({
|
||||
dist,
|
||||
Speed: s.speed ?? 0,
|
||||
Throttle: s.throttle ?? 0,
|
||||
Brake: s.brake ?? 0,
|
||||
NGear: s.n_gear ?? 0,
|
||||
});
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
return { renderStrategy, renderLapTimes, renderTelemetry };
|
||||
})();
|
||||
322
internal/web/assets/index.html
Normal file
322
internal/web/assets/index.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>box-box</title>
|
||||
<link rel="stylesheet" href="/style.css"/>
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</head>
|
||||
<body x-data="appState()" x-cloak>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="#/">box‑box</a>
|
||||
<div class="nav-links">
|
||||
<a href="#/" :class="{active: page==='home'}">Home</a>
|
||||
<a href="#/standings" :class="{active: page==='standings'}">Standings</a>
|
||||
<a href="#/live" :class="{active: page==='live'}">
|
||||
Live
|
||||
<span x-show="isLive" class="live-dot"></span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Stale data banner -->
|
||||
<div x-show="staleWarning" class="stale-banner">
|
||||
<span>Showing cached data — live session in progress (free tier)</span>
|
||||
<details>
|
||||
<summary>Why?</summary>
|
||||
<p>During live F1 sessions the free-tier OpenF1 API restricts access.
|
||||
box-box automatically serves the last cached data.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Home page -->
|
||||
<template x-if="page==='home'">
|
||||
<div x-data="homePage()" x-init="init()">
|
||||
|
||||
<!-- Next race card + championship sidebar -->
|
||||
<div class="home-hero" x-show="!loading">
|
||||
<!-- Next race -->
|
||||
<div class="card next-race" x-show="nextRace">
|
||||
<div class="card-label">NEXT RACE</div>
|
||||
<div class="next-race-flag" x-text="flagEmoji(nextRace?.country_code)"></div>
|
||||
<h2 class="next-race-name" x-text="nextRace?.meeting_name||''"></h2>
|
||||
<div class="next-race-circuit" x-text="nextRace?.circuit_short_name||''"></div>
|
||||
<div class="next-race-countdown" x-text="countdown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Championship sidebar -->
|
||||
<div class="card champ-sidebar">
|
||||
<div class="card-label">DRIVERS' CHAMPIONSHIP</div>
|
||||
<template x-for="d in champDrivers.slice(0,5)" :key="d.driver_number">
|
||||
<div class="champ-row" :style="`--team-color:#${d.team_colour}`">
|
||||
<span class="champ-pos" x-text="d.position_current"></span>
|
||||
<span class="team-swatch"></span>
|
||||
<span class="champ-tla" x-text="d.name_acronym||d.driver_number"></span>
|
||||
<div class="champ-bar-wrap">
|
||||
<div class="champ-bar" :style="`width:${barWidth(d.points_current)}%`"></div>
|
||||
</div>
|
||||
<span class="champ-pts" x-text="d.points_current"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div x-show="loading" class="skeleton-wrap">
|
||||
<div class="skeleton" style="height:120px;margin-bottom:1rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Season grid -->
|
||||
<div class="section-label" x-text="season + ' SEASON'"></div>
|
||||
<div class="meetings-grid" x-show="!loading">
|
||||
<template x-for="m in meetings" :key="m.meeting_key">
|
||||
<a class="meeting-card" :class="{past: isPast(m)}" :href="`#/race/${m.meeting_key}`">
|
||||
<div class="meeting-flag" x-text="flagEmoji(m.country_code)"></div>
|
||||
<div class="meeting-name" x-text="m.meeting_name"></div>
|
||||
<div class="meeting-circuit" x-text="m.circuit_short_name"></div>
|
||||
<div class="meeting-date" x-text="fmtDate(m.date_start)"></div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Race page -->
|
||||
<template x-if="page==='race'">
|
||||
<div x-data="racePage()" x-init="init()">
|
||||
<div class="page-header">
|
||||
<a class="back-btn" href="#/">← Back</a>
|
||||
<h1 class="page-title" x-text="meetingName"></h1>
|
||||
</div>
|
||||
|
||||
<!-- Session picker -->
|
||||
<div class="session-pills" x-show="sessions.length">
|
||||
<template x-for="sess in sessions" :key="sess.session_key">
|
||||
<button class="pill"
|
||||
:class="{active: activeSession?.session_key===sess.session_key}"
|
||||
@click="selectSession(sess)"
|
||||
x-text="sess.session_name">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs" x-show="activeSession">
|
||||
<button class="tab" :class="{active:tab==='results'}" @click="tab='results'">Results</button>
|
||||
<button class="tab" :class="{active:tab==='strategy'}" @click="loadStrategy();tab='strategy'">Strategy</button>
|
||||
<button class="tab" :class="{active:tab==='laps'}" @click="loadLaps();tab='laps'">Lap Times</button>
|
||||
<button class="tab" :class="{active:tab==='track'}" @click="loadTrack();tab='track'">Track</button>
|
||||
<button class="tab" :class="{active:tab==='telemetry'}" @click="tab='telemetry'">Telemetry</button>
|
||||
</div>
|
||||
|
||||
<!-- Results tab -->
|
||||
<div x-show="tab==='results'">
|
||||
<div x-show="resultsLoading" class="skeleton-wrap">
|
||||
<template x-for="i in [1,2,3,4,5]" :key="i">
|
||||
<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>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div x-show="!resultsLoading && !results.length" class="empty-msg">No results available.</div>
|
||||
</div>
|
||||
|
||||
<!-- Strategy tab -->
|
||||
<div x-show="tab==='strategy'" x-effect="tab==='strategy' && strategyData && renderStrategy()">
|
||||
<div x-show="strategyLoading" class="skeleton-wrap">
|
||||
<div class="skeleton" style="height:300px;"></div>
|
||||
</div>
|
||||
<div x-show="strategyNote" class="empty-msg" x-text="strategyNote"></div>
|
||||
<div id="strategy-chart" x-show="!strategyLoading && !strategyNote" style="min-height:300px;overflow-x:auto;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Lap Times tab -->
|
||||
<div x-show="tab==='laps'" x-effect="tab==='laps' && lapsData && renderLapTimes()">
|
||||
<div x-show="lapsLoading" class="skeleton-wrap">
|
||||
<div class="skeleton" style="height:300px;"></div>
|
||||
</div>
|
||||
<div id="laps-chart" x-show="!lapsLoading" style="min-height:300px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Track tab -->
|
||||
<div x-show="tab==='track'" x-effect="tab==='track' && trackData && renderTrack()">
|
||||
<div x-show="trackLoading" class="skeleton-wrap">
|
||||
<div class="skeleton" style="height:300px;"></div>
|
||||
</div>
|
||||
<div x-show="!trackData && !trackLoading" class="empty-msg">Track outline not available for this circuit.</div>
|
||||
<div id="track-chart" x-show="!trackLoading && trackData" style="min-height:300px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry tab -->
|
||||
<div x-show="tab==='telemetry'">
|
||||
<div class="driver-selector">
|
||||
<label>Select driver:</label>
|
||||
<template x-for="d in drivers" :key="d.driver_number">
|
||||
<button class="pill"
|
||||
:class="{active: telemetryDrivers.includes(d.driver_number)}"
|
||||
:style="`--team-color:#${d.team_colour}`"
|
||||
@click="toggleTelemetryDriver(d.driver_number)"
|
||||
x-text="d.name_acronym">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="telemetryLoading" class="skeleton-wrap">
|
||||
<div class="skeleton" style="height:340px;"></div>
|
||||
</div>
|
||||
<div id="telemetry-chart" x-show="!telemetryLoading" style="min-height:340px;"
|
||||
x-effect="telemetryData.length && tab==='telemetry' && renderTelemetry()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Live page -->
|
||||
<template x-if="page==='live'">
|
||||
<div x-data="livePage()" x-init="init()" x-destroy="cleanup()">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">
|
||||
Live Timing
|
||||
<span x-show="isLive" class="live-dot large"></span>
|
||||
</h1>
|
||||
<div class="session-info" x-show="session.meeting_name">
|
||||
<span x-text="session.meeting_name"></span> —
|
||||
<span x-text="session.session_name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No session placeholder -->
|
||||
<div x-show="!isLive" class="empty-msg large">
|
||||
No live session active. Check back during a race weekend.
|
||||
</div>
|
||||
|
||||
<!-- Timing tower -->
|
||||
<div x-show="isLive">
|
||||
<div class="live-meta">
|
||||
<span>Lap <strong x-text="currentLap"></strong>/<strong x-text="totalLaps"></strong></span>
|
||||
<span x-show="trackStatus" class="track-status" :class="trackStatusClass()" x-text="trackStatusText()"></span>
|
||||
<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>
|
||||
|
||||
<!-- Race control messages -->
|
||||
<div class="rc-panel" x-show="rcMessages.length">
|
||||
<div class="card-label">RACE CONTROL</div>
|
||||
<template x-for="(msg, i) in rcMessages.slice().reverse().slice(0,10)" :key="i">
|
||||
<div class="rc-message" :class="`rc-${msg.Category?.toLowerCase()}`">
|
||||
<span class="rc-time" x-text="msg.Time"></span>
|
||||
<span class="rc-flag" x-show="msg.Flag" x-text="msg.Flag"></span>
|
||||
<span x-text="msg.Message"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Standings page -->
|
||||
<template x-if="page==='standings'">
|
||||
<div x-data="standingsPage()" x-init="init()">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Championship Standings</h1>
|
||||
<div class="year-selector">
|
||||
<button class="pill" :class="{active:year===2026}" @click="setYear(2026)">2026</button>
|
||||
<button class="pill" :class="{active:year===2025}" @click="setYear(2025)">2025</button>
|
||||
<button class="pill" :class="{active:year===2024}" @click="setYear(2024)">2024</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{active:view==='drivers'}" @click="view='drivers'">Drivers</button>
|
||||
<button class="tab" :class="{active:view==='teams'}" @click="view='teams'">Constructors</button>
|
||||
</div>
|
||||
|
||||
<div x-show="loading" class="skeleton-wrap">
|
||||
<template x-for="i in [1,2,3,4,5,6,7,8,9,10]" :key="i">
|
||||
<div class="skeleton" style="height:40px;margin-bottom:4px;"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<table class="data-table" x-show="!loading && view==='drivers'">
|
||||
<thead><tr><th>POS</th><th>DRIVER</th><th>TEAM</th><th>PTS</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="d in driverStandings" :key="d.driver_number">
|
||||
<tr :style="`--team-color:#${d.team_colour}`">
|
||||
<td x-text="d.position_current"></td>
|
||||
<td><span class="team-swatch"></span><span x-text="d.full_name||d.driver_number"></span></td>
|
||||
<td x-text="d.team_name||'-'"></td>
|
||||
<td x-text="d.points_current"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="data-table" x-show="!loading && view==='teams'">
|
||||
<thead><tr><th>POS</th><th>TEAM</th><th>PTS</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="t in teamStandings" :key="t.team_name">
|
||||
<tr>
|
||||
<td x-text="t.position_current"></td>
|
||||
<td x-text="t.team_name"></td>
|
||||
<td x-text="t.points_current"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script src="/charts.js"></script>
|
||||
<script src="/track.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
483
internal/web/assets/style.css
Normal file
483
internal/web/assets/style.css
Normal file
@@ -0,0 +1,483 @@
|
||||
/* ============================================================
|
||||
box-box web companion — dark F1 theme
|
||||
Mirrors styles.go palette as CSS custom properties.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--f1-red: #E10600;
|
||||
--surface-0: #0d0d1a; /* page background */
|
||||
--surface-1: #1B1B2F; /* card background */
|
||||
--surface-2: #222236; /* elevated card */
|
||||
--surface-3: #2D2D44; /* borders, dividers */
|
||||
--text-1: #ffffff;
|
||||
--text-2: #8888aa;
|
||||
--text-3: #555577;
|
||||
|
||||
--soft: #FF3333;
|
||||
--medium:#FFD700;
|
||||
--hard: #CCCCCC;
|
||||
--inter: #39B54A;
|
||||
--wet: #0080FF;
|
||||
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-1);
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ---- Nav ---- */
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--surface-1);
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.nav-brand {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--f1-red);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.nav-links a {
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active { color: var(--text-1); }
|
||||
.nav-links a.active { border-bottom: 2px solid var(--f1-red); padding-bottom: 2px; }
|
||||
|
||||
/* ---- Live dot ---- */
|
||||
.live-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #E10600;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.live-dot.large { width: 12px; height: 12px; }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ---- Stale banner ---- */
|
||||
.stale-banner {
|
||||
background: #2a1800;
|
||||
border-bottom: 1px solid #aa6600;
|
||||
color: #ffcc66;
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stale-banner details summary { cursor: pointer; color: #aa8844; }
|
||||
.stale-banner details p { padding: 0.4rem 0; color: var(--text-2); }
|
||||
|
||||
/* ---- Page layout ---- */
|
||||
.page-header {
|
||||
padding: 1.25rem 1.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.back-btn {
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.back-btn:hover { color: var(--text-1); }
|
||||
.session-info { color: var(--text-2); font-size: 0.85rem; }
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.card-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
/* ---- Home hero ---- */
|
||||
.home-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.home-hero { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Next race card */
|
||||
.next-race { text-align: center; }
|
||||
.next-race-flag { font-size: 3rem; margin: 0.25rem 0; }
|
||||
.next-race-name { font-size: 1.4rem; font-weight: 700; margin: 0.25rem 0; }
|
||||
.next-race-circuit { color: var(--text-2); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.next-race-countdown {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--f1-red);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Championship sidebar */
|
||||
.champ-sidebar { display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
.champ-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 12px 36px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.champ-pos { color: var(--text-2); font-size: 0.8rem; text-align: right; }
|
||||
.champ-tla { font-weight: 600; font-size: 0.85rem; }
|
||||
.champ-bar-wrap {
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.champ-bar {
|
||||
height: 100%;
|
||||
background: var(--team-color, var(--f1-red));
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s;
|
||||
}
|
||||
.champ-pts { font-size: 0.8rem; color: var(--text-2); text-align: right; }
|
||||
|
||||
/* Team color swatch */
|
||||
.team-swatch {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: var(--team-color, #666);
|
||||
border-radius: 2px;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ---- Season meetings grid ---- */
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding: 0 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.meetings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0 1.5rem 2rem;
|
||||
}
|
||||
.meeting-card {
|
||||
display: block;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.meeting-card:hover { background: var(--surface-2); border-color: var(--text-3); }
|
||||
.meeting-card.past { opacity: 0.55; }
|
||||
.meeting-flag { font-size: 1.5rem; margin-bottom: 0.3rem; }
|
||||
.meeting-name { font-weight: 600; font-size: 0.9rem; line-height: 1.3; }
|
||||
.meeting-circuit { color: var(--text-2); font-size: 0.78rem; margin-top: 0.15rem; }
|
||||
.meeting-date { color: var(--text-3); font-size: 0.75rem; margin-top: 0.3rem; }
|
||||
|
||||
/* ---- Session pills ---- */
|
||||
.session-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
.pill {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 20px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.pill:hover { background: var(--surface-3); color: var(--text-1); }
|
||||
.pill.active {
|
||||
background: var(--team-color, var(--f1-red));
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
padding: 0 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.6rem 1rem;
|
||||
text-transform: uppercase;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--text-1); }
|
||||
.tab.active { color: var(--text-1); border-bottom-color: var(--f1-red); }
|
||||
|
||||
/* ---- Data table ---- */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface-1);
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
color: var(--text-3);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.data-table td {
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table tr:hover td { background: var(--surface-1); }
|
||||
.pos-cell { font-weight: 700; display: flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
/* ---- Badges ---- */
|
||||
.badge {
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1px 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge.dnf { background: #442200; color: #ff8844; }
|
||||
.badge.dns { background: #333355; color: #8888cc; }
|
||||
.badge.dsq { background: #220000; color: #ff4444; }
|
||||
.badge.pit { background: #003366; color: #66aaff; }
|
||||
.badge.out { background: #330000; color: #ff6666; }
|
||||
|
||||
/* ---- Tyre badges ---- */
|
||||
.tyre-badge {
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.tyre-soft { background: var(--soft); color: #fff; }
|
||||
.tyre-medium { background: var(--medium); color: #111; }
|
||||
.tyre-hard { background: var(--hard); color: #111; }
|
||||
.tyre-inter { background: var(--inter); color: #fff; }
|
||||
.tyre-wet { background: var(--wet); color: #fff; }
|
||||
.tyre-unknown { background: var(--surface-3); color: var(--text-2); }
|
||||
|
||||
/* ---- Timing tower ---- */
|
||||
.live-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.5rem 1.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.timing-tower {
|
||||
width: calc(100% - 3rem);
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.timing-tower th {
|
||||
background: var(--surface-1);
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
color: var(--text-3);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.timing-tower td {
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
padding: 0.45rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.timing-tower .in-pit td { background: rgba(0,51,102,0.25); }
|
||||
.timing-tower .retired td { background: rgba(60,0,0,0.3); opacity: 0.7; }
|
||||
.timing-tower .pit-out td { background: rgba(0,80,0,0.2); }
|
||||
.timing-tower .pb { color: #aaffaa; }
|
||||
.timing-tower .ob { color: #cc44ff; }
|
||||
.pos-delta { color: var(--text-3); font-size: 0.75rem; }
|
||||
.clock { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; }
|
||||
|
||||
/* Track status */
|
||||
.track-status {
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.track-green { background: #1a3a1a; color: #44cc44; }
|
||||
.track-yellow { background: #3a2a00; color: #ffcc00; }
|
||||
.track-red { background: #3a0000; color: #ff4444; }
|
||||
.track-sc { background: #3a2a00; color: #ffaa00; }
|
||||
.track-vsc { background: #1a2a3a; color: #6699ff; }
|
||||
|
||||
/* ---- Race control panel ---- */
|
||||
.rc-panel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 8px;
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.rc-message {
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.rc-message:last-child { border-bottom: none; }
|
||||
.rc-time { color: var(--text-3); flex-shrink: 0; font-size: 0.75rem; }
|
||||
.rc-flag { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.rc-flag { background: var(--surface-3); border-radius: 2px; padding: 1px 5px; }
|
||||
.rc-safetycarpanel .rc-flag,
|
||||
.rc-safetycarpanel { color: #ffcc00; }
|
||||
|
||||
/* ---- Driver selector ---- */
|
||||
.driver-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.5rem 0.75rem;
|
||||
}
|
||||
.driver-selector label { color: var(--text-2); font-size: 0.8rem; margin-right: 0.25rem; }
|
||||
|
||||
/* ---- Year selector ---- */
|
||||
.year-selector {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Skeleton loading ---- */
|
||||
.skeleton-wrap { padding: 0.75rem 1.5rem; }
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--surface-1) 25%,
|
||||
var(--surface-2) 50%,
|
||||
var(--surface-1) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 6px;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ---- Empty state ---- */
|
||||
.empty-msg {
|
||||
color: var(--text-2);
|
||||
font-size: 0.9rem;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-msg.large { font-size: 1.1rem; padding: 4rem 1.5rem; }
|
||||
|
||||
/* ---- D3 charts ---- */
|
||||
#strategy-chart,
|
||||
#laps-chart,
|
||||
#telemetry-chart,
|
||||
#track-chart {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
#strategy-chart svg,
|
||||
#laps-chart svg,
|
||||
#telemetry-chart svg,
|
||||
#track-chart svg { display: block; }
|
||||
|
||||
/* D3 axis text */
|
||||
.tick text { fill: var(--text-2); font-size: 11px; }
|
||||
.domain, .tick line { stroke: var(--surface-3); }
|
||||
|
||||
/* Tooltip */
|
||||
.chart-tooltip {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 6px;
|
||||
color: var(--text-1);
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 10px;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
}
|
||||
110
internal/web/assets/track.js
Normal file
110
internal/web/assets/track.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/* ============================================================
|
||||
box-box — SVG track map renderer
|
||||
Track.render(containerId, outlinePoints, carPositions, driverInfo)
|
||||
Track.updatePositions(containerId, carPositions)
|
||||
============================================================ */
|
||||
|
||||
const Track = (() => {
|
||||
// Store rendered state per container
|
||||
const state = {};
|
||||
|
||||
function render(containerId, outlinePoints, carPositions, driverInfo) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
|
||||
if (!outlinePoints || outlinePoints.length < 3) {
|
||||
el.innerHTML = '<div style="color:#8888aa;text-align:center;padding:2rem;">Track outline not available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const width = el.clientWidth || 500;
|
||||
const height = el.clientHeight || 340;
|
||||
const PAD = 32;
|
||||
|
||||
const xScale = d3.scaleLinear().domain([0, 1]).range([PAD, width - PAD]);
|
||||
const yScale = d3.scaleLinear().domain([0, 1]).range([PAD, height - PAD]);
|
||||
|
||||
const svg = d3.select(el).append('svg')
|
||||
.attr('width', width).attr('height', height);
|
||||
|
||||
// Track outline path
|
||||
const lineGen = d3.line()
|
||||
.x(p => xScale(p.x))
|
||||
.y(p => yScale(p.y))
|
||||
.curve(d3.curveCatmullRomClosed);
|
||||
|
||||
svg.append('path')
|
||||
.datum(outlinePoints)
|
||||
.attr('d', lineGen)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', '#444466')
|
||||
.attr('stroke-width', 8)
|
||||
.attr('stroke-linecap', 'round');
|
||||
|
||||
svg.append('path')
|
||||
.datum(outlinePoints)
|
||||
.attr('d', lineGen)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', '#2D2D44')
|
||||
.attr('stroke-width', 4)
|
||||
.attr('stroke-linecap', 'round');
|
||||
|
||||
// Car group (updated separately)
|
||||
svg.append('g').attr('id', `${containerId}-cars`);
|
||||
|
||||
state[containerId] = { svg, xScale, yScale, driverInfo: driverInfo || {} };
|
||||
|
||||
// Initial positions
|
||||
if (carPositions && Object.keys(carPositions).length) {
|
||||
updatePositions(containerId, carPositions);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePositions(containerId, carPositions) {
|
||||
const s = state[containerId];
|
||||
if (!s) return;
|
||||
|
||||
const { svg, xScale, yScale, driverInfo } = s;
|
||||
const carsG = svg.select(`#${containerId}-cars`);
|
||||
|
||||
const cars = Object.entries(carPositions || {}).map(([num, pos]) => ({
|
||||
num,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
info: driverInfo[num] || {},
|
||||
}));
|
||||
|
||||
// Bind data
|
||||
const sel = carsG.selectAll('.car-marker').data(cars, d => d.num);
|
||||
|
||||
// Enter
|
||||
const enter = sel.enter().append('g').attr('class', 'car-marker');
|
||||
enter.append('circle').attr('r', 7);
|
||||
enter.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', '0.35em')
|
||||
.attr('font-size', 7)
|
||||
.attr('font-weight', '700')
|
||||
.attr('fill', '#fff')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
// Update (enter + update)
|
||||
const merged = enter.merge(sel);
|
||||
merged.transition().duration(500).ease(d3.easeLinear)
|
||||
.attr('transform', d => `translate(${xScale(d.x)},${yScale(d.y)})`);
|
||||
|
||||
merged.select('circle')
|
||||
.attr('fill', d => '#' + (d.info.TeamColour || '666666'))
|
||||
.attr('stroke', '#111')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
merged.select('text')
|
||||
.text(d => d.info.Tla || d.num.slice(0, 3));
|
||||
|
||||
// Exit
|
||||
sel.exit().remove();
|
||||
}
|
||||
|
||||
return { render, updatePositions };
|
||||
})();
|
||||
Reference in New Issue
Block a user