Initial UI

This commit is contained in:
2026-03-27 20:12:57 -04:00
parent c4e6c634d1
commit 15faf07691
9 changed files with 3039 additions and 5 deletions

527
internal/web/assets/app.js Normal file
View 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')}`;
}

View 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 &nbsp; 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 };
})();

View 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&#x2011;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>

View 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;
}

View 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 };
})();