Files
box-box/internal/web/assets/app.js
2026-05-24 20:56:00 -04:00

777 lines
23 KiB
JavaScript

/* ============================================================
box-box — Alpine.js page components
============================================================ */
// ---- Shared helpers ----
// Map OpenF1 3-letter country codes → ISO 3166-1 alpha-2 for flag emoji.
const CODE3_TO_2 = {
AUS:'AU', AUT:'AT', AZE:'AZ', BRN:'BH', BEL:'BE', BRA:'BR', CHN:'CN',
NED:'NL', FRA:'FR', GER:'DE', HUN:'HU', ITA:'IT', JPN:'JP', MON:'MC',
MEX:'MX', QAT:'QA', KSA:'SA', SGP:'SG', ESP:'ES', UAE:'AE', GBR:'GB',
USA:'US', CAN:'CA', RSA:'ZA',
};
// Convert country code (2- or 3-letter) → emoji flag.
function flagEmoji(code) {
if (!code) return '🏁';
let iso2 = code.toUpperCase();
if (iso2.length === 3) iso2 = CODE3_TO_2[iso2] || '';
if (iso2.length !== 2) return '🏁';
const offset = 0x1F1E6 - 65;
return String.fromCodePoint(
iso2.charCodeAt(0) + offset,
iso2.charCodeAt(1) + offset
);
}
// Per-country accent colours for GP cards.
// Keys cover both ISO 3166-1 alpha-2 AND the 3-letter codes OpenF1 sometimes returns.
const COUNTRY_COLORS = {
// 2-letter ISO
AU: '#C0392B', AT: '#ED2939', AZ: '#0092BC', BH: '#CE1126',
BE: '#F0B400', BR: '#009C3B', CN: '#DE2910', NL: '#FF6600',
FR: '#002395', DE: '#CC0000', HU: '#3A6B35', IT: '#009246',
JP: '#BC002D', MC: '#CE1126', MX: '#006847', QA: '#8D1B3D',
SA: '#006C35', SG: '#EF3340', ES: '#C60B1E', AE: '#009A44',
GB: '#012169', US: '#B22234', CA: '#FF0000',
// 3-letter OpenF1 codes
AUS: '#C0392B', // Australia
AUT: '#ED2939', // Austria
AZE: '#0092BC', // Azerbaijan / Baku
BRN: '#CE1126', // Bahrain
BEL: '#F0B400', // Belgium
BRA: '#009C3B', // Brazil
CHN: '#DE2910', // China
NED: '#FF6600', // Netherlands
FRA: '#002395', // France
GER: '#CC0000', // Germany
HUN: '#3A6B35', // Hungary
ITA: '#009246', // Italy
JPN: '#BC002D', // Japan
MON: '#CE1126', // Monaco
MEX: '#006847', // Mexico
QAT: '#8D1B3D', // Qatar
KSA: '#006C35', // Saudi Arabia
SGP: '#EF3340', // Singapore
ESP: '#C60B1E', // Spain
UAE: '#009A44', // Abu Dhabi
GBR: '#012169', // Great Britain
USA: '#B22234', // USA
CAN: '#FF0000', // Canada
RSA: '#007A4D', // South Africa
};
// Return the accent hex for a country code (2- or 3-letter), or null.
function countryAccent(code) {
return COUNTRY_COLORS[code?.toUpperCase()] || null;
}
// Build the full inline `style` string for a meeting card so the background
// gradient is applied directly (CSS vars containing gradient strings don't work
// reliably across all browsers when used in background-image).
function meetingCardStyle(code) {
const accent = countryAccent(code) || '#2a2a44';
// Parse the hex to RGB for use in rgba()
const r = parseInt(accent.slice(1, 3), 16);
const g = parseInt(accent.slice(3, 5), 16);
const b = parseInt(accent.slice(5, 7), 16);
return [
`--country-accent:${accent}`,
`--country-r:${r}`,
`--country-g:${g}`,
`--country-b:${b}`,
`background:linear-gradient(145deg, rgba(${r},${g},${b},0.18) 0%, rgba(${r},${g},${b},0.07) 55%, var(--surface-1) 100%)`,
].join(';');
}
// Tinted gradient string (kept for backward compat, not used on cards now).
function countryGradient(code) {
const accent = countryAccent(code) || '#2a2a44';
const r = parseInt(accent.slice(1, 3), 16);
const g = parseInt(accent.slice(3, 5), 16);
const b = parseInt(accent.slice(5, 7), 16);
return `linear-gradient(145deg, rgba(${r},${g},${b},0.18) 0%, rgba(${r},${g},${b},0.07) 55%, transparent 100%)`;
}
// Current F1 season year derived from today's date.
function currentSeason() {
return new Date().getFullYear();
}
function parseScheduleTime(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function sortSessionsByStart(sessions) {
return [...sessions].sort((a, b) => {
const left = parseScheduleTime(a.date_start);
const right = parseScheduleTime(b.date_start);
return (left?.getTime() || 0) - (right?.getTime() || 0);
});
}
function pickRelevantMeeting(meetings, now = new Date()) {
const started = meetings
.map(meeting => ({
meeting,
start: parseScheduleTime(meeting.date_start),
end: parseScheduleTime(meeting.date_end) || null,
}))
.filter(entry => entry.start && entry.start.getTime() <= now.getTime())
.filter(entry => !entry.end || now.getTime() <= entry.end.getTime() + 24 * 60 * 60 * 1000)
.sort((a, b) => b.start.getTime() - a.start.getTime());
if (started.length) return started[0].meeting;
return meetings.find(meeting => {
const start = parseScheduleTime(meeting.date_start);
return start && start.getTime() > now.getTime();
}) || null;
}
function pickCurrentAndNextSession(sessions, now = new Date()) {
for (const session of sessions) {
const start = parseScheduleTime(session.date_start);
const end = parseScheduleTime(session.date_end) || (start ? new Date(start.getTime() + 3 * 60 * 60 * 1000) : null);
if (!start || !end) continue;
if (now.getTime() >= start.getTime() && now.getTime() < end.getTime()) {
return { current: session, next: null };
}
if (now.getTime() < start.getTime()) {
return { current: null, next: session };
}
}
return { current: null, next: null };
}
// ---- 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,
nextSession: null,
currentSession: null,
heroLabel: 'NEXT RACE',
heroMeta: '',
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 : [];
const meeting = pickRelevantMeeting(this.meetings);
this.nextRace = meeting;
this.nextSession = null;
this.currentSession = null;
this.heroLabel = meeting && parseScheduleTime(meeting.date_start)?.getTime() <= Date.now()
? 'CURRENT WEEKEND'
: 'NEXT RACE';
this.heroMeta = '';
if (!meeting) return;
const sessionsResp = await fetch(`/api/v1/sessions?meeting_key=${meeting.meeting_key}`);
const sessionsData = await sessionsResp.json();
const sessions = Array.isArray(sessionsData) ? sortSessionsByStart(sessionsData) : [];
const { current, next } = pickCurrentAndNextSession(sessions);
this.currentSession = current;
this.nextSession = next;
if (current) {
this.heroLabel = 'LIVE NOW';
this.heroMeta = current.session_name;
} else if (next) {
this.heroLabel = 'NEXT SESSION';
this.heroMeta = next.session_name;
}
} 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 update = () => {
const targetSource = this.currentSession || this.nextSession || this.nextRace;
const targetTime = this.currentSession
? parseScheduleTime(this.currentSession.date_end)?.getTime()
: parseScheduleTime(targetSource?.date_start)?.getTime();
if (!targetTime) {
this.countdown = '';
return;
}
const diff = targetTime - Date.now();
if (this.currentSession) {
if (diff <= 0) {
this.countdown = 'Session complete';
return;
}
this.countdown = `Ends in ${this.formatDuration(diff)}`;
return;
}
if (diff <= 0) {
this.countdown = this.nextSession ? 'Session starting' : 'Race day!';
return;
}
const prefix = this.nextSession ? 'Starts in ' : '';
this.countdown = prefix + this.formatDuration(diff);
};
update();
this._countdownTimer = setInterval(update, 1000);
},
formatDuration(diff) {
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);
return d > 0
? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`
: `${pad(h)}:${pad(m)}:${pad(s)}`;
},
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,
countryAccent,
meetingCardStyle,
isPast(m) { return new Date(m.date_start).getTime() < Date.now(); },
roundNum(m) {
// Find 1-based index within the meetings list
const idx = this.meetings.indexOf(m);
return idx >= 0 ? String(idx + 1).padStart(2, '0') : '—';
},
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() {
const merged = {};
for (const num of Object.keys(this.drivers)) {
merged[num] = { ...this.drivers[num] };
}
for (const num of Object.keys(this.driverInfo)) {
if (!merged[num]) {
merged[num] = { RacingNumber: num, Position: 0 };
}
}
const all = Object.values(merged);
all.sort((a, b) => {
const pi = a.Position || 0;
const pj = b.Position || 0;
if (pi > 0 && pj > 0) return pi - pj;
if (pi > 0) return -1;
if (pj > 0) return 1;
const ti = a.BestLapTime || '';
const tj = b.BestLapTime || '';
if (ti && tj) return ti.localeCompare(tj);
if (ti) return -1;
if (tj) return 1;
const ni = parseInt(a.RacingNumber) || 0;
const nj = parseInt(b.RacingNumber) || 0;
return ni - nj;
});
all.forEach((d, i) => {
if (!d.Position) {
d.Position = i + 1;
}
});
return all;
},
driverTla(num) {
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 = []; }
},
standingsBarWidth(pts, list) {
if (!list || !list.length) return 0;
const max = list[0].points_current;
return max > 0 ? Math.round((pts / max) * 100) : 0;
},
teamColor(name) {
// Map known constructor names to brand colors
const map = {
'Red Bull Racing': '#3671C6',
'Mercedes': '#27F4D2',
'Ferrari': '#E8002D',
'McLaren': '#FF8000',
'Aston Martin': '#229971',
'Alpine': '#FF87BC',
'Williams': '#64C4FF',
'Racing Bulls': '#6692FF',
'Kick Sauber': '#52E252',
'Haas F1 Team': '#B6BABD',
};
for (const key of Object.keys(map)) {
if (name && name.includes(key.split(' ')[0])) return map[key];
}
return '#666688';
},
};
}
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')}`;
}