f1-pit-predictor / dashboard /predict.html
T0MYYY's picture
Deploy full-stack FastAPI + dashboard with CSV batch inference
199ab1e verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>F1 Pit-Stop Predictor · Batch CSV</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #0a0c10;
--panel: #11151c;
--border: #1d242e;
--borderHi: #2a323d;
--ink: #e6edf3;
--ink2: #aeb7c2;
--muted: #6a7480;
--red: #ff4d4d;
--green: #00ff88;
--yellow: #ffd84d;
--mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; background: var(--bg); color: var(--ink); font-family: "Inter", system-ui, sans-serif; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); }
::-webkit-scrollbar-thumb:hover { background: var(--borderHi); }
.topbar {
display: flex; align-items: center; gap: 14px;
padding: 0 16px; height: 48px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.topbar .logo {
width: 22px; height: 22px; background: var(--ink); color: var(--bg);
font-family: var(--mono); font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.topbar .brand { font-size: 13px; font-weight: 600; letter-spacing: 0.2px; }
.topbar .brand span { color: var(--muted); }
.topbar .crumb { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
.topbar .crumb .slash { color: var(--border); margin: 0 10px; }
.topbar .spacer { flex: 1; }
.topbar a.back {
font-family: var(--mono); font-size: 11px; font-weight: 600;
color: var(--ink2); text-decoration: none; padding: 4px 10px;
border: 1px solid var(--borderHi); height: 24px;
display: inline-flex; align-items: center; letter-spacing: 0.5px;
text-transform: uppercase;
}
.topbar a.back:hover { color: var(--ink); border-color: var(--ink2); }
main { max-width: 1200px; margin: 0 auto; padding: 32px 24px 64px; }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; margin: 0 0 6px; }
.sub { color: var(--muted); font-size: 13px; margin-bottom: 28px; }
.panel {
background: var(--panel); border: 1px solid var(--border);
padding: 18px 20px; margin-bottom: 18px;
}
.panel-title {
font-size: 9.5px; letter-spacing: 1.2px; text-transform: uppercase;
color: var(--muted); font-weight: 600; margin-bottom: 12px;
}
.drop {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px;
border: 1.5px dashed var(--borderHi);
padding: 56px 24px;
min-height: 200px;
text-align: center; cursor: pointer;
transition: border-color 120ms, background 120ms, transform 120ms;
background: linear-gradient(180deg, rgba(255,255,255,0.012), transparent);
}
.drop:hover { border-color: var(--ink2); background: rgba(255,255,255,0.025); }
.drop.drag { border-color: var(--green); background: rgba(0,255,136,0.08); transform: scale(1.005); }
.drop input[type=file] { display: none; }
.drop .big-icon {
font-size: 36px; line-height: 1; color: var(--muted);
font-family: var(--mono); font-weight: 300;
}
.drop.drag .big-icon { color: var(--green); }
.drop .primary-text { font-size: 15px; color: var(--ink); font-weight: 500; }
.drop .or { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 1px; }
.drop .browse-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border: 1px solid var(--ink2);
font-family: var(--mono); font-size: 11.5px; font-weight: 600;
letter-spacing: 0.5px; text-transform: uppercase;
color: var(--ink); background: transparent;
}
.drop:hover .browse-cta { border-color: var(--ink); background: rgba(255,255,255,0.04); }
.drop .hint { color: var(--muted); font-size: 11.5px; font-family: var(--mono); margin-top: 4px; }
.filename { font-family: var(--mono); font-size: 12px; color: var(--green); }
/* Global drop overlay — appears when dragging a file anywhere on the page */
.global-overlay {
position: fixed; inset: 0; pointer-events: none;
background: rgba(0,255,136,0.06);
border: 3px dashed var(--green);
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: 18px; color: var(--green);
letter-spacing: 2px; text-transform: uppercase; font-weight: 600;
opacity: 0; transition: opacity 120ms;
z-index: 9999;
}
.global-overlay.show { opacity: 1; }
.row { display: flex; gap: 12px; align-items: center; margin-top: 14px; flex-wrap: wrap; }
button.primary, button.ghost {
background: var(--ink); color: var(--bg); border: none;
padding: 8px 16px; font-family: var(--mono); font-size: 11.5px;
font-weight: 600; letter-spacing: 0.5px; cursor: pointer;
text-transform: uppercase;
}
button.primary:disabled { background: var(--borderHi); color: var(--muted); cursor: not-allowed; }
button.ghost { background: transparent; color: var(--ink2); border: 1px solid var(--borderHi); }
button.ghost:hover { color: var(--ink); border-color: var(--ink2); }
.status { font-family: var(--mono); font-size: 12px; color: var(--ink2); }
.status.err { color: var(--red); }
.status.ok { color: var(--green); }
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); margin-top: 14px; }
.kpi { background: var(--panel); padding: 14px 16px; }
.kpi .l { font-size: 9.5px; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); font-weight: 600; }
.kpi .v { font-family: var(--mono); font-size: 22px; font-weight: 500; margin-top: 4px; letter-spacing: -0.5px; }
.kpi .v.g { color: var(--green); }
.kpi .v.r { color: var(--red); }
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 11.5px; }
table th, table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
table th {
background: #0c1016; color: var(--muted); font-weight: 600;
font-size: 9.5px; letter-spacing: 1px; text-transform: uppercase;
position: sticky; top: 0;
}
td.pit { color: var(--red); font-weight: 600; }
td.stay { color: var(--muted); }
td.num { text-align: right; }
.table-wrap { max-height: 480px; overflow: auto; border: 1px solid var(--border); }
code.cols {
display: block; font-family: var(--mono); font-size: 11px; color: var(--ink2);
background: #0c1016; padding: 10px 12px; margin-top: 8px;
white-space: pre-wrap; word-break: break-all;
}
</style>
</head>
<body>
<div class="topbar">
<div class="logo">P</div>
<div class="brand">pitcall<span>.batch</span></div>
<div class="crumb"><span class="slash">/</span>csv inference</div>
<div class="spacer"></div>
<a class="back" href="/">← back to dashboard</a>
</div>
<main>
<h1>Batch CSV inference</h1>
<p class="sub">Upload a CSV with raw F1 lap data. The XGBoost champion (F1=0.785, ROC-AUC=0.894) runs feature engineering and returns <code style="color:var(--ink2)">pPit</code> + <code style="color:var(--ink2)">pred</code> per row. If <code style="color:var(--ink2)">PitNextLap</code> is present, the macro-F1 score is computed on the fly.</p>
<div class="panel">
<div class="panel-title">1 · Upload CSV</div>
<label class="drop" id="drop">
<input type="file" id="file" accept=".csv,text/csv" />
<div class="big-icon"></div>
<div class="primary-text">Drop a CSV here</div>
<div class="or">— or —</div>
<div class="browse-cta">📁 Browse files</div>
<div class="hint">You can also drop the file anywhere on this page</div>
<div class="filename" id="filename"></div>
</label>
<code class="cols" id="cols">id, Driver, Compound, Race, Year, PitStop, LapNumber, Stint, TyreLife, Position, LapTime (s), LapTime_Delta, Cumulative_Degradation, RaceProgress, Position_Change
optional: PitNextLap (target column — if provided, F1 score is reported)</code>
<div class="row">
<button class="primary" id="submit" disabled>Predict</button>
<button class="ghost" id="sample">Use sample row</button>
<span class="status" id="status"></span>
</div>
</div>
<div class="global-overlay" id="overlay">drop CSV anywhere to upload</div>
<div class="panel" id="results-panel" style="display:none;">
<div class="panel-title">2 · Results</div>
<div class="kpi-row" id="kpis"></div>
<div class="row" style="margin-top:18px;">
<button class="primary" id="openDashboard">▦ Open in dashboard</button>
<button class="ghost" id="download">⬇ Download CSV with pPit + pred</button>
<span class="status" id="loadStatus"></span>
</div>
<div class="table-wrap" style="margin-top:14px;">
<table id="table"></table>
</div>
</div>
</main>
<script>
const drop = document.getElementById('drop');
const fileInput = document.getElementById('file');
const filenameEl = document.getElementById('filename');
const submitBtn = document.getElementById('submit');
const sampleBtn = document.getElementById('sample');
const statusEl = document.getElementById('status');
const resultsPanel = document.getElementById('results-panel');
const kpisEl = document.getElementById('kpis');
const tableEl = document.getElementById('table');
const downloadBtn = document.getElementById('download');
let pickedFile = null;
let lastResult = null;
function setFile(f) {
pickedFile = f;
filenameEl.textContent = f ? `${f.name} · ${(f.size / 1024).toFixed(1)} KB` : '';
submitBtn.disabled = !f;
statusEl.textContent = '';
statusEl.className = 'status';
}
fileInput.addEventListener('change', (e) => setFile(e.target.files[0] || null));
// Local drop zone
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });
drop.addEventListener('dragleave', (e) => {
// Only remove the highlight if leaving the drop zone (not entering a child)
if (e.target === drop) drop.classList.remove('drag');
});
drop.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove('drag');
const f = e.dataTransfer.files[0];
if (f) setFile(f);
});
// Document-level drag-drop — accept the file dropped anywhere on the page.
const overlay = document.getElementById('overlay');
let dragDepth = 0;
const hasFileItem = (e) => {
const items = e.dataTransfer?.items;
if (!items) return true;
for (const it of items) if (it.kind === 'file') return true;
return false;
};
window.addEventListener('dragenter', (e) => {
if (!hasFileItem(e)) return;
e.preventDefault();
dragDepth++;
overlay.classList.add('show');
});
window.addEventListener('dragover', (e) => {
if (!hasFileItem(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
window.addEventListener('dragleave', (e) => {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) overlay.classList.remove('show');
});
window.addEventListener('drop', (e) => {
e.preventDefault();
dragDepth = 0;
overlay.classList.remove('show');
drop.classList.remove('drag');
const f = e.dataTransfer?.files?.[0];
if (f) setFile(f);
});
sampleBtn.addEventListener('click', () => {
// 6 laps of one driver — enough for lag features to populate meaningfully
const sample = `id,Driver,Compound,Race,Year,PitStop,LapNumber,Stint,TyreLife,Position,LapTime (s),LapTime_Delta,Cumulative_Degradation,RaceProgress,Position_Change
VER_1,VER,SOFT,Bahrain Grand Prix,2024,0,1,1,1,1,90.5,0.0,-0.3,0.020,0
VER_2,VER,SOFT,Bahrain Grand Prix,2024,0,2,1,2,1,90.2,-0.3,-0.6,0.039,0
VER_3,VER,SOFT,Bahrain Grand Prix,2024,0,3,1,3,1,90.4,0.2,-0.9,0.059,0
VER_4,VER,SOFT,Bahrain Grand Prix,2024,0,4,1,4,1,90.6,0.2,-1.2,0.078,0
VER_5,VER,SOFT,Bahrain Grand Prix,2024,0,5,1,5,1,90.9,0.3,-1.5,0.098,0
VER_6,VER,SOFT,Bahrain Grand Prix,2024,0,6,1,6,1,91.3,0.4,-1.8,0.118,0
VER_7,VER,SOFT,Bahrain Grand Prix,2024,0,7,1,7,1,91.8,0.5,-2.1,0.137,0
VER_8,VER,SOFT,Bahrain Grand Prix,2024,0,8,1,8,1,92.4,0.6,-2.4,0.157,0
VER_9,VER,SOFT,Bahrain Grand Prix,2024,0,9,1,9,1,93.1,0.7,-2.7,0.176,0
VER_10,VER,SOFT,Bahrain Grand Prix,2024,0,10,1,10,1,93.9,0.8,-3.0,0.196,0
VER_11,VER,SOFT,Bahrain Grand Prix,2024,0,11,1,11,1,94.7,0.8,-3.4,0.216,0
VER_12,VER,SOFT,Bahrain Grand Prix,2024,0,12,1,12,1,95.5,0.8,-3.8,0.235,0
VER_13,VER,SOFT,Bahrain Grand Prix,2024,0,13,1,13,1,96.2,0.7,-4.2,0.255,0`;
const blob = new Blob([sample], { type: 'text/csv' });
setFile(new File([blob], 'sample_VER_13laps.csv', { type: 'text/csv' }));
});
submitBtn.addEventListener('click', async () => {
if (!pickedFile) return;
submitBtn.disabled = true;
statusEl.className = 'status';
statusEl.textContent = 'Uploading and running inference…';
const fd = new FormData();
fd.append('file', pickedFile);
try {
const res = await fetch('/predict/csv', { method: 'POST', body: fd });
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try { const j = await res.json(); if (j.detail) detail = j.detail; } catch {}
throw new Error(detail);
}
const data = await res.json();
lastResult = data;
statusEl.className = 'status ok';
statusEl.textContent = `✓ Predicted ${data.count.toLocaleString()} rows`;
renderResults(data);
} catch (err) {
statusEl.className = 'status err';
statusEl.textContent = `✗ ${err.message}`;
resultsPanel.style.display = 'none';
} finally {
submitBtn.disabled = false;
}
});
function renderResults(data) {
resultsPanel.style.display = 'block';
const rows = data.rows;
const pitCount = rows.filter(r => r.pred === 1).length;
const pPits = rows.map(r => r.pPit);
const maxP = pPits.length ? Math.max(...pPits) : 0;
const meanP = pPits.length ? pPits.reduce((a,b)=>a+b,0)/pPits.length : 0;
kpisEl.innerHTML = '';
const kpis = [
{ l: 'rows', v: data.count.toLocaleString(), c: '' },
{ l: 'pit calls', v: pitCount.toLocaleString(), c: pitCount ? 'r' : '' },
{ l: 'mean p(pit)', v: meanP.toFixed(3), c: '' },
{ l: data.f1_macro != null ? 'macro F1' : 'max p(pit)', v: data.f1_macro != null ? data.f1_macro.toFixed(3) : maxP.toFixed(3), c: 'g' },
];
for (const k of kpis) {
const d = document.createElement('div');
d.className = 'kpi';
d.innerHTML = `<div class="l">${k.l}</div><div class="v ${k.c}">${k.v}</div>`;
kpisEl.appendChild(d);
}
const preview = rows.slice(0, 200);
const cols = ['Driver', 'LapNumber', 'Compound', 'Stint', 'TyreLife', 'Position', 'pPit', 'pred'];
if (rows[0] && 'PitNextLap' in rows[0]) cols.push('PitNextLap');
let html = '<thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr></thead><tbody>';
for (const r of preview) {
html += '<tr>';
for (const c of cols) {
let v = r[c];
let cls = '';
if (c === 'pPit' && typeof v === 'number') { v = v.toFixed(3); cls = 'num'; }
else if (c === 'pred') { cls = v === 1 ? 'pit' : 'stay'; v = v === 1 ? 'PIT' : 'STAY'; }
else if (typeof v === 'number') { cls = 'num'; }
html += `<td class="${cls}">${v ?? ''}</td>`;
}
html += '</tr>';
}
html += '</tbody>';
if (rows.length > preview.length) {
html += `<tfoot><tr><td colspan="${cols.length}" style="color:var(--muted); text-align:center; padding:10px;">showing first ${preview.length} of ${rows.length.toLocaleString()} rows — download CSV for the full set</td></tr></tfoot>`;
}
tableEl.innerHTML = html;
}
// ---- transform CSV rows into the dashboard's row shape ----------------
function normalizeCompound(c) {
if (c == null) return 'M';
const s = String(c).trim().toUpperCase();
if (s.startsWith('S')) return 'S';
if (s.startsWith('H')) return 'H';
if (s.startsWith('I')) return 'I'; // intermediate (rare)
if (s.startsWith('W')) return 'W'; // wet (rare)
return 'M';
}
function num(x, fallback = 0) {
const n = Number(x);
return Number.isFinite(n) ? n : fallback;
}
function buildRacePayload(rows, fallbackName) {
if (!rows.length) return null;
const first = rows[0];
const raceName = first.Race || first.race || fallbackName || 'Uploaded CSV';
const year = num(first.Year ?? first.year, new Date().getFullYear());
// group by driver to derive stints and isStintStart
const byDriver = new Map();
for (const r of rows) {
const d = String(r.Driver ?? r.driver ?? 'UNK');
if (!byDriver.has(d)) byDriver.set(d, []);
byDriver.get(d).push(r);
}
for (const arr of byDriver.values()) {
arr.sort((a, b) => num(a.LapNumber ?? a.lap) - num(b.LapNumber ?? b.lap));
}
const drivers = Array.from(byDriver.keys());
const driverMeta = drivers.map((id) => ({ id, short: id }));
const maxLap = Math.max(...rows.map((r) => num(r.LapNumber ?? r.lap, 1)));
const out = [];
for (const [driver, arr] of byDriver) {
let prevStint = null;
for (const r of arr) {
const stint = num(r.Stint ?? r.stint, 1);
const isStintStart = prevStint !== null && stint !== prevStint;
prevStint = stint;
out.push({
lap: num(r.LapNumber ?? r.lap, 1),
driver,
short: driver,
compound: normalizeCompound(r.Compound ?? r.compound),
stint,
tyreLife: num(r.TyreLife ?? r.tyreLife, 1),
lapTime: num(r['LapTime (s)'] ?? r.LapTime ?? r.lapTime, 0),
lapDelta: num(r.LapTime_Delta ?? r.lapDelta, 0),
cumDeg: num(r.Cumulative_Degradation ?? r.cumDeg, 0),
raceProg: num(r.RaceProgress ?? r.raceProg, 0),
position: num(r.Position ?? r.position, 0),
posChange: num(r.Position_Change ?? r.posChange, 0),
pPit: num(r.pPit, 0),
pred: num(r.pred, 0),
actual: num(r.PitNextLap ?? r.actual, 0),
isStintStart,
});
}
}
// ensure rows are sorted by lap then position for the wall mode
out.sort((a, b) => a.lap - b.lap || a.position - b.position);
// estimate baseTime as median of finite lap times
const lts = out.map((r) => r.lapTime).filter((x) => isFinite(x) && x > 0).sort((a,b)=>a-b);
const baseTime = lts.length ? lts[Math.floor(lts.length / 2)] : 90;
return {
id: 'uploaded',
name: raceName,
year,
laps: maxLap,
seed: 0,
baseTime,
focus: drivers[0],
compoundBias: drivers.map(() => 'M'),
drivers,
driverMeta,
stints: drivers.map(() => []),
rows: out,
uploadedAt: new Date().toISOString(),
uploadedFilename: pickedFile ? pickedFile.name : null,
};
}
const openDashBtn = document.getElementById('openDashboard');
const loadStatusEl = document.getElementById('loadStatus');
openDashBtn.addEventListener('click', () => {
if (!lastResult) return;
const payload = buildRacePayload(lastResult.rows, pickedFile?.name);
if (!payload) {
loadStatusEl.className = 'status err';
loadStatusEl.textContent = '✗ No rows to load';
return;
}
try {
localStorage.setItem('pp.uploadedRace', JSON.stringify(payload));
} catch (err) {
loadStatusEl.className = 'status err';
loadStatusEl.textContent = `✗ localStorage write failed: ${err.message}`;
return;
}
window.location.assign('/?race=uploaded');
});
downloadBtn.addEventListener('click', () => {
if (!lastResult) return;
const rows = lastResult.rows;
if (!rows.length) return;
const keys = Object.keys(rows[0]);
const esc = (v) => {
if (v === null || v === undefined) return '';
const s = String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const lines = [keys.join(',')];
for (const r of rows) lines.push(keys.map(k => esc(r[k])).join(','));
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (pickedFile?.name?.replace(/\.csv$/i, '') || 'data') + '_predictions.csv';
a.click();
URL.revokeObjectURL(url);
});
</script>
</body>
</html>