mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Initial UI
This commit is contained in:
527
internal/web/assets/app.js
Normal file
527
internal/web/assets/app.js
Normal file
@@ -0,0 +1,527 @@
|
||||
/* ============================================================
|
||||
box-box — Alpine.js page components
|
||||
============================================================ */
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 country code → emoji flag (mirrors TUI countryFlag).
|
||||
function flagEmoji(code) {
|
||||
if (!code || code.length !== 2) return '🏁';
|
||||
const offset = 0x1F1E6 - 65; // Regional Indicator A offset
|
||||
return String.fromCodePoint(
|
||||
code.toUpperCase().charCodeAt(0) + offset,
|
||||
code.toUpperCase().charCodeAt(1) + offset
|
||||
);
|
||||
}
|
||||
|
||||
// Current F1 season year derived from today's date.
|
||||
function currentSeason() {
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
|
||||
// ---- Root state: routing + live check ----
|
||||
|
||||
function appState() {
|
||||
return {
|
||||
page: 'home',
|
||||
isLive: false,
|
||||
staleWarning: false,
|
||||
|
||||
init() {
|
||||
this.parseRoute();
|
||||
window.addEventListener('hashchange', () => this.parseRoute());
|
||||
this.checkLive();
|
||||
setInterval(() => this.checkLive(), 30000);
|
||||
},
|
||||
|
||||
parseRoute() {
|
||||
const hash = window.location.hash || '#/';
|
||||
const path = hash.slice(1) || '/';
|
||||
if (path === '/' || path === '') {
|
||||
this.page = 'home';
|
||||
} else if (path.startsWith('/race/')) {
|
||||
this.page = 'race';
|
||||
} else if (path === '/live') {
|
||||
this.page = 'live';
|
||||
} else if (path === '/standings') {
|
||||
this.page = 'standings';
|
||||
} else {
|
||||
this.page = 'home';
|
||||
}
|
||||
},
|
||||
|
||||
async checkLive() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/live/state');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
this.isLive = !!data.is_live;
|
||||
} catch (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Home page ----
|
||||
|
||||
function homePage() {
|
||||
return {
|
||||
loading: true,
|
||||
meetings: [],
|
||||
champDrivers: [],
|
||||
nextRace: null,
|
||||
countdown: '',
|
||||
season: currentSeason(),
|
||||
_countdownTimer: null,
|
||||
|
||||
async init() {
|
||||
this.loading = true;
|
||||
await Promise.all([
|
||||
this.loadMeetings(),
|
||||
this.loadChampionship(),
|
||||
]);
|
||||
this.loading = false;
|
||||
this.startCountdown();
|
||||
},
|
||||
|
||||
async loadMeetings() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/meetings?year=${this.season}`);
|
||||
const data = await r.json();
|
||||
this.meetings = Array.isArray(data) ? data : [];
|
||||
// Find next race
|
||||
const now = Date.now();
|
||||
this.nextRace = this.meetings.find(m => new Date(m.date_start).getTime() > now) || null;
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async loadChampionship() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/championship/drivers?year=${this.season}`);
|
||||
const data = await r.json();
|
||||
this.champDrivers = Array.isArray(data)
|
||||
? [...data].sort((a, b) => a.position_current - b.position_current)
|
||||
: [];
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
startCountdown() {
|
||||
if (!this.nextRace) return;
|
||||
const target = new Date(this.nextRace.date_start).getTime();
|
||||
const update = () => {
|
||||
const diff = target - Date.now();
|
||||
if (diff <= 0) { this.countdown = 'Race day!'; return; }
|
||||
const d = Math.floor(diff / 86400000);
|
||||
const h = Math.floor((diff % 86400000) / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
const s = Math.floor((diff % 60000) / 1000);
|
||||
this.countdown = d > 0
|
||||
? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`
|
||||
: `${pad(h)}:${pad(m)}:${pad(s)}`;
|
||||
};
|
||||
update();
|
||||
this._countdownTimer = setInterval(update, 1000);
|
||||
},
|
||||
|
||||
barWidth(pts) {
|
||||
if (!this.champDrivers.length) return 0;
|
||||
const max = this.champDrivers[0].points_current;
|
||||
return max > 0 ? Math.round((pts / max) * 100) : 0;
|
||||
},
|
||||
|
||||
flagEmoji,
|
||||
isPast(m) { return new Date(m.date_start).getTime() < Date.now(); },
|
||||
fmtDate(d) { return d ? new Date(d).toLocaleDateString('en-GB', {day:'numeric',month:'short'}) : ''; },
|
||||
|
||||
destroy() { clearInterval(this._countdownTimer); },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Race page ----
|
||||
|
||||
function racePage() {
|
||||
return {
|
||||
meetingKey: 0,
|
||||
meetingName: '',
|
||||
year: 2025,
|
||||
sessions: [],
|
||||
activeSession: null,
|
||||
tab: 'results',
|
||||
drivers: [],
|
||||
|
||||
// Results
|
||||
results: [],
|
||||
resultsLoading: false,
|
||||
|
||||
// Strategy
|
||||
strategyData: null,
|
||||
strategyLoading: false,
|
||||
strategyNote: '',
|
||||
|
||||
// Laps comparison
|
||||
lapsData: null,
|
||||
lapsLoading: false,
|
||||
|
||||
// Track
|
||||
trackData: null,
|
||||
trackLoading: false,
|
||||
|
||||
// Telemetry
|
||||
telemetryDrivers: [],
|
||||
telemetryData: [],
|
||||
telemetryLoading: false,
|
||||
|
||||
// Resize observers
|
||||
_observers: [],
|
||||
|
||||
async init() {
|
||||
const hash = window.location.hash || '';
|
||||
const m = hash.match(/#\/race\/(\d+)/);
|
||||
if (!m) return;
|
||||
this.meetingKey = parseInt(m[1]);
|
||||
|
||||
// Load sessions
|
||||
try {
|
||||
const r = await fetch(`/api/v1/sessions?meeting_key=${this.meetingKey}`);
|
||||
this.sessions = await r.json();
|
||||
if (this.sessions.length) {
|
||||
// Prefer Race session, else last session
|
||||
const race = this.sessions.find(s => s.session_type === 'Race')
|
||||
|| this.sessions[this.sessions.length - 1];
|
||||
await this.selectSession(race);
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async selectSession(sess) {
|
||||
this.activeSession = sess;
|
||||
// Extract year from date_start
|
||||
this.year = sess.date_start ? parseInt(sess.date_start.slice(0, 4)) : 2025;
|
||||
// Clear all lazy-loaded data
|
||||
this.strategyData = null;
|
||||
this.strategyNote = '';
|
||||
this.lapsData = null;
|
||||
this.trackData = null;
|
||||
this.telemetryData = [];
|
||||
this.telemetryDrivers = [];
|
||||
|
||||
await Promise.all([
|
||||
this.loadResults(sess.session_key),
|
||||
this.loadDrivers(sess.session_key),
|
||||
]);
|
||||
},
|
||||
|
||||
async loadResults(sk) {
|
||||
this.resultsLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/results?session_key=${sk}`);
|
||||
const data = await r.json();
|
||||
this.results = Array.isArray(data)
|
||||
? [...data].sort((a, b) => (a.position || 99) - (b.position || 99))
|
||||
: [];
|
||||
this.meetingName = '';
|
||||
if (this.sessions.length) {
|
||||
const sess = this.sessions.find(s => s.session_key === sk);
|
||||
this.meetingName = sess?.meeting_key ? `Round — Meeting ${this.meetingKey}` : '';
|
||||
}
|
||||
} catch (_) { this.results = []; }
|
||||
this.resultsLoading = false;
|
||||
},
|
||||
|
||||
async loadDrivers(sk) {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/drivers?session_key=${sk}`);
|
||||
this.drivers = await r.json();
|
||||
// Set default meeting name from sessions list
|
||||
const sess = this.sessions.find(s => s.session_key === sk);
|
||||
if (!this.meetingName && this.sessions.length) {
|
||||
this.meetingName = `Meeting ${this.meetingKey}`;
|
||||
}
|
||||
} catch (_) { this.drivers = []; }
|
||||
},
|
||||
|
||||
async loadStrategy() {
|
||||
if (this.strategyData || this.strategyLoading || !this.activeSession) return;
|
||||
this.strategyLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/strategy?session_key=${this.activeSession.session_key}`);
|
||||
const data = await r.json();
|
||||
if (data.note) {
|
||||
this.strategyNote = data.note;
|
||||
} else {
|
||||
this.strategyData = data;
|
||||
}
|
||||
} catch (_) { this.strategyNote = 'Failed to load strategy data.'; }
|
||||
this.strategyLoading = false;
|
||||
},
|
||||
|
||||
renderStrategy() {
|
||||
if (!this.strategyData) return;
|
||||
this.$nextTick(() => Charts.renderStrategy('strategy-chart', this.strategyData));
|
||||
},
|
||||
|
||||
async loadLaps() {
|
||||
if (this.lapsData || this.lapsLoading || !this.activeSession) return;
|
||||
this.lapsLoading = true;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/laps/comparison?session_key=${this.activeSession.session_key}`);
|
||||
this.lapsData = await r.json();
|
||||
} catch (_) {}
|
||||
this.lapsLoading = false;
|
||||
},
|
||||
|
||||
renderLapTimes() {
|
||||
if (!this.lapsData) return;
|
||||
this.$nextTick(() => Charts.renderLapTimes('laps-chart', this.lapsData));
|
||||
},
|
||||
|
||||
async loadTrack() {
|
||||
if (this.trackData || this.trackLoading || !this.activeSession) return;
|
||||
this.trackLoading = true;
|
||||
try {
|
||||
const ck = this.activeSession.circuit_key;
|
||||
const yr = this.year;
|
||||
const r = await fetch(`/api/v1/track-outline?circuit_key=${ck}&year=${yr}`);
|
||||
const data = await r.json();
|
||||
if (data.points && data.points.length > 0) {
|
||||
this.trackData = data;
|
||||
}
|
||||
} catch (_) {}
|
||||
this.trackLoading = false;
|
||||
},
|
||||
|
||||
renderTrack() {
|
||||
if (!this.trackData) return;
|
||||
this.$nextTick(() => Track.render('track-chart', this.trackData.points, [], {}));
|
||||
},
|
||||
|
||||
async toggleTelemetryDriver(driverNumber) {
|
||||
if (this.telemetryDrivers.includes(driverNumber)) {
|
||||
this.telemetryDrivers = this.telemetryDrivers.filter(n => n !== driverNumber);
|
||||
} else {
|
||||
if (this.telemetryDrivers.length >= 3) return; // max 3
|
||||
this.telemetryDrivers.push(driverNumber);
|
||||
}
|
||||
await this.loadTelemetry();
|
||||
},
|
||||
|
||||
async loadTelemetry() {
|
||||
if (!this.activeSession || this.telemetryDrivers.length === 0) {
|
||||
this.telemetryData = [];
|
||||
return;
|
||||
}
|
||||
this.telemetryLoading = true;
|
||||
const sk = this.activeSession.session_key;
|
||||
const results = await Promise.all(
|
||||
this.telemetryDrivers.map(dn =>
|
||||
fetch(`/api/v1/telemetry?session_key=${sk}&driver_number=${dn}`)
|
||||
.then(r => r.json())
|
||||
.then(data => ({ driverNumber: dn, data: Array.isArray(data) ? data : [] }))
|
||||
.catch(() => ({ driverNumber: dn, data: [] }))
|
||||
)
|
||||
);
|
||||
this.telemetryData = results.map(r => {
|
||||
const driver = this.drivers.find(d => d.driver_number === r.driverNumber);
|
||||
return {
|
||||
driverNumber: r.driverNumber,
|
||||
nameAcronym: driver?.name_acronym || String(r.driverNumber),
|
||||
teamColour: driver?.team_colour || '888888',
|
||||
data: r.data,
|
||||
};
|
||||
});
|
||||
this.telemetryLoading = false;
|
||||
},
|
||||
|
||||
renderTelemetry() {
|
||||
if (!this.telemetryData.length) return;
|
||||
this.$nextTick(() => Charts.renderTelemetry('telemetry-chart', this.telemetryData));
|
||||
},
|
||||
|
||||
fmtDuration(v) {
|
||||
if (v === null || v === undefined) return '-';
|
||||
if (Array.isArray(v)) return v.map(t => fmtSecs(t)).join(' / ');
|
||||
return fmtSecs(v);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Live page ----
|
||||
|
||||
function livePage() {
|
||||
return {
|
||||
isLive: false,
|
||||
drivers: {},
|
||||
driverInfo: {},
|
||||
tyres: {},
|
||||
stints: {},
|
||||
rcMessages: [],
|
||||
trackStatus: '',
|
||||
currentLap: 0,
|
||||
totalLaps: 0,
|
||||
clock: '',
|
||||
clockRefTime: null,
|
||||
clockExtrapolating: false,
|
||||
session: {},
|
||||
_es: null,
|
||||
_clockTimer: null,
|
||||
clockDisplay: '',
|
||||
|
||||
init() {
|
||||
this.connectSSE();
|
||||
this._clockTimer = setInterval(() => this.updateClock(), 1000);
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
if (this._es) this._es.close();
|
||||
clearInterval(this._clockTimer);
|
||||
},
|
||||
|
||||
connectSSE() {
|
||||
const es = new EventSource('/api/v1/live/stream');
|
||||
this._es = es;
|
||||
|
||||
es.addEventListener('snapshot', e => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
this.isLive = !!msg.is_live;
|
||||
if (msg.data) this.applySnapshot(msg.data);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
es.addEventListener('heartbeat', () => {});
|
||||
|
||||
es.onerror = () => {
|
||||
this.isLive = false;
|
||||
setTimeout(() => this.connectSSE(), 5000);
|
||||
es.close();
|
||||
};
|
||||
},
|
||||
|
||||
applySnapshot(d) {
|
||||
if (d.Drivers) this.drivers = d.Drivers;
|
||||
if (d.DriverInfo) this.driverInfo = d.DriverInfo;
|
||||
if (d.Tyres) this.tyres = d.Tyres;
|
||||
if (d.Stints) this.stints = d.Stints;
|
||||
if (d.RCMessages) this.rcMessages = d.RCMessages;
|
||||
if (d.TrackStatus) this.trackStatus = d.TrackStatus;
|
||||
if (d.CurrentLap) this.currentLap = d.CurrentLap;
|
||||
if (d.TotalLaps) this.totalLaps = d.TotalLaps;
|
||||
if (d.Clock) this.clock = d.Clock;
|
||||
if (d.ClockRefTime) this.clockRefTime = new Date(d.ClockRefTime);
|
||||
if (d.ClockExtrapolating !== undefined) this.clockExtrapolating = d.ClockExtrapolating;
|
||||
if (d.Session) this.session = d.Session;
|
||||
},
|
||||
|
||||
get sortedDrivers() {
|
||||
return Object.values(this.drivers)
|
||||
.filter(d => d.Position > 0)
|
||||
.sort((a, b) => a.Position - b.Position);
|
||||
},
|
||||
|
||||
driverTla(num) {
|
||||
return this.driverInfo[num]?.Tla || num;
|
||||
},
|
||||
|
||||
driverTeamColor(num) {
|
||||
return this.driverInfo[num]?.TeamColour || '666666';
|
||||
},
|
||||
|
||||
tyreLabel(num) {
|
||||
const t = this.tyres[num];
|
||||
if (!t) return '?';
|
||||
return `${t.Compound?.charAt(0) || '?'} +${t.Age || 0}`;
|
||||
},
|
||||
|
||||
tyreClass(num) {
|
||||
const t = this.tyres[num];
|
||||
if (!t) return 'tyre-unknown';
|
||||
return 'tyre-' + (t.Compound || 'unknown').toLowerCase();
|
||||
},
|
||||
|
||||
posDelta(d) {
|
||||
if (!d.PrevPosition || d.PrevPosition === d.Position) return '';
|
||||
return d.PrevPosition > d.Position ? '▲' : '▼';
|
||||
},
|
||||
|
||||
trackStatusText() {
|
||||
const map = {'1':'GREEN','2':'YELLOW','4':'SC','5':'RED','6':'VSC'};
|
||||
return map[this.trackStatus] || this.trackStatus;
|
||||
},
|
||||
|
||||
trackStatusClass() {
|
||||
const map = {'1':'track-green','2':'track-yellow','4':'track-sc','5':'track-red','6':'track-vsc'};
|
||||
return map[this.trackStatus] || '';
|
||||
},
|
||||
|
||||
updateClock() {
|
||||
if (!this.clock || !this.clockExtrapolating || !this.clockRefTime) {
|
||||
this.clockDisplay = this.clock || '';
|
||||
return;
|
||||
}
|
||||
// Extrapolate: remaining = clock - elapsed since clockRefTime
|
||||
const [h, m, s] = this.clock.split(':').map(Number);
|
||||
const totalSecs = h * 3600 + m * 60 + s;
|
||||
const elapsed = (Date.now() - this.clockRefTime.getTime()) / 1000;
|
||||
const remaining = Math.max(0, totalSecs - elapsed);
|
||||
const rh = Math.floor(remaining / 3600);
|
||||
const rm = Math.floor((remaining % 3600) / 60);
|
||||
const rs = Math.floor(remaining % 60);
|
||||
this.clockDisplay = `${pad(rh)}:${pad(rm)}:${pad(rs)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Standings page ----
|
||||
|
||||
function standingsPage() {
|
||||
return {
|
||||
year: currentSeason(),
|
||||
view: 'drivers',
|
||||
loading: false,
|
||||
driverStandings: [],
|
||||
teamStandings: [],
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async setYear(y) {
|
||||
this.year = y;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
await Promise.all([this.loadDrivers(), this.loadTeams()]);
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDrivers() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/championship/drivers?year=${this.year}`);
|
||||
const data = await r.json();
|
||||
this.driverStandings = Array.isArray(data)
|
||||
? [...data].sort((a, b) => a.position_current - b.position_current)
|
||||
: [];
|
||||
} catch (_) { this.driverStandings = []; }
|
||||
},
|
||||
|
||||
async loadTeams() {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/championship/teams?year=${this.year}`);
|
||||
const data = await r.json();
|
||||
this.teamStandings = Array.isArray(data)
|
||||
? [...data].sort((a, b) => a.position_current - b.position_current)
|
||||
: [];
|
||||
} catch (_) { this.teamStandings = []; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function fmtSecs(s) {
|
||||
if (!s) return '-';
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = (s - m * 60).toFixed(3);
|
||||
return `${m}:${rem.padStart(6, '0')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user