mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
777 lines
23 KiB
JavaScript
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')}`;
|
|
}
|