flowtwin / frontend /js /app.js
FlowTwin
FlowTwin — AI crowd digital twin for Formula 1 venues
3e2e16c
Raw
History Blame Contribute Delete
18.3 kB
/* FlowTwin Race Control — application shell.
*
* Owns: session lifecycle, the frame stream, and wiring between the map and
* the panels. All rendering lives in map.js / panels.js / charts.js. */
import { api, FrameStream } from './api.js';
import { VenueMap, LAYERS, LEVEL_COLOURS } from './map.js';
import { drawStrategyChart } from './charts.js';
import * as ui from './panels.js';
const $ = id => document.getElementById(id);
const state = {
meta: null,
venues: [],
scenarios: [],
scenario: null,
venue: null,
session: null,
stream: null,
frame: null,
strategyResult: null,
applied: null,
busy: false,
seenEvents: new Set(),
};
let map = null;
/* ── boot ──────────────────────────────────────────────────────────── */
async function boot() {
map = new VenueMap($('map'), $('map-tooltip'));
buildLayerToggles();
buildSpeedButtons();
buildLegend();
wireControls();
ui.renderMetrics({ metrics: {} }, null);
try {
const [meta, venues, scenarios] = await Promise.all([
api.meta(), api.venues(), api.scenarios(),
]);
state.meta = meta;
state.venues = venues.venues;
state.scenarios = scenarios.scenarios;
} catch (err) {
ui.toast(`Cannot reach the FlowTwin backend: ${err.message}`, 'error');
return;
}
buildScenarioSwitch();
await selectScenario(state.scenarios[0].id, { autorun: false });
}
function buildScenarioSwitch() {
const wrap = $('scenario-switch');
wrap.innerHTML = state.scenarios.map((s, i) => `
<button data-id="${s.id}" aria-pressed="false">
<span class="idx">${String(i + 1).padStart(2, '0')}</span>${s.name.replace(/^Simulation \d+ · /, '')}
</button>`).join('');
wrap.querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => selectScenario(b.dataset.id, { autorun: true })));
}
function buildSpeedButtons() {
const speeds = [1, 2, 5, 10, 20, 40];
$('speed-group').innerHTML = speeds.map(s =>
`<button data-speed="${s}" aria-pressed="${s === 10}">${s}×</button>`).join('');
$('speed-group').querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => setSpeed(Number(b.dataset.speed))));
}
function buildLayerToggles() {
$('layer-toggles').innerHTML = LAYERS.map(l =>
`<button data-layer="${l.id}" aria-pressed="${l.on}">
<span class="sw" style="background:${l.sw}"></span>${l.label}</button>`).join('');
$('layer-toggles').querySelectorAll('button').forEach(b =>
b.addEventListener('click', () => {
const on = b.getAttribute('aria-pressed') !== 'true';
b.setAttribute('aria-pressed', String(on));
map.setLayer(b.dataset.layer, on);
}));
}
function buildLegend() {
const stops = [
['#2a3750', '0'], [LEVEL_COLOURS.clear, ''], [LEVEL_COLOURS.busy, ''],
[LEVEL_COLOURS.warning, ''], [LEVEL_COLOURS.critical, ''],
];
$('legend-density').innerHTML = stops.map(([c]) => `<i style="background:${c}"></i>`).join('');
}
/* ── scenario selection ────────────────────────────────────────────── */
async function selectScenario(scenarioId, { autorun }) {
const scenario = state.scenarios.find(s => s.id === scenarioId);
if (!scenario) return;
state.scenario = scenario;
$('scenario-switch').querySelectorAll('button').forEach(b =>
b.setAttribute('aria-pressed', String(b.dataset.id === scenarioId)));
state.venue = await api.venue(scenario.venue_id);
map.setVenue(state.venue);
ui.renderProvenance(state.venue);
applyWhatIfDefaults(scenario);
ui.renderBriefing(scenario, state.venue, currentConfig());
ui.renderTimeline({ events: [], interventions: [] }, scenario);
$('map-scale').querySelector('span').style.width = `${Math.round(map.scaleBarPx())}px`;
if (autorun) await runSimulation();
else $('map-empty').hidden = !!state.session;
}
function applyWhatIfDefaults(scenario) {
const w = scenario.what_if || {};
$('in-crowd').value = w.crowd_size ?? scenario.crowd_size;
$('in-ramp').value = w.release_ramp_s ?? scenario.release?.ramp_s ?? 600;
$('in-compliance').value = Math.round((w.compliance_scale ?? 1) * 100);
$('in-seed').value = scenario.default_seed;
$('in-policy').value = 'shortest_path';
const capEvent = (scenario.timeline || []).find(t => t.type === 'capacity');
const capField = $('capacity-field');
if (capEvent) {
capField.hidden = false;
$('capacity-label').textContent = `${capEvent.target.replace(/_/g, ' ')} capacity`;
$('in-capacity').value = Math.round((capEvent.factor ?? 0.5) * 100);
$('in-capacity').dataset.target = capEvent.target;
} else {
capField.hidden = true;
delete $('in-capacity').dataset.target;
}
syncWhatIfOutputs();
}
function syncWhatIfOutputs() {
$('out-crowd').textContent = Number($('in-crowd').value).toLocaleString();
$('out-ramp').textContent = `${Math.round($('in-ramp').value / 60)} min`;
$('out-compliance').textContent = `${$('in-compliance').value}%`;
$('out-capacity').textContent = `${$('in-capacity').value}% of nominal`;
}
function currentConfig() {
const s = state.scenario;
return {
venue_id: s.venue_id,
scenario_id: s.id,
seed: Number($('in-seed').value) || s.default_seed,
crowd_size: Number($('in-crowd').value),
release_ramp_s: Number($('in-ramp').value),
compliance_scale: Number($('in-compliance').value) / 100,
routing_policy: $('in-policy').value,
// The capacity slider retunes the scripted failure itself, so what the
// operator dialled in is what the timeline event actually does when it fires.
event_factor_overrides: capacityOverride(),
speed: currentSpeed(),
autoplay: true,
};
}
function capacityOverride() {
const input = $('in-capacity');
const target = input.dataset.target;
if (!target || $('capacity-field').hidden) return {};
return { [target]: Number(input.value) / 100 };
}
function currentSpeed() {
const active = $('speed-group').querySelector('[aria-pressed="true"]');
return active ? Number(active.dataset.speed) : 10;
}
/* ── session lifecycle ─────────────────────────────────────────────── */
async function runSimulation() {
if (state.busy) return;
setBusy(true, 'Building crowd…');
try {
if (state.stream) { state.stream.close(); state.stream = null; }
if (state.session) {
// Drop the reference before the new session exists, so a transport
// control clicked mid-switch cannot be sent to a session that has just
// been stopped.
const stale = state.session.session_id;
state.session = null;
api.stop(stale).catch(() => {});
}
state.strategyResult = null;
state.applied = null;
state.seenEvents = new Set();
$('recommendation-card').innerHTML = '';
$('drawer').hidden = true;
$('strategy-state').textContent = 'idle';
$('strategy-state').className = 'tag';
const payload = currentConfig();
const res = await api.start(payload);
state.session = res.session;
ui.renderBriefing(state.scenario, state.venue, {
...payload, crowd_size: res.session.crowd_size, seed: res.session.seed,
});
handleFrame(res.frame);
$('map-empty').hidden = true;
state.stream = new FrameStream(state.session.session_id, {
onFrame: handleFrame,
onStrategy: handleStrategyResult,
onStatus: setConnection,
onError: msg => ui.toast(msg, 'error'),
});
if (res.session.kind === 'replay') {
ui.toast('Loaded a recorded run for this scenario.', '');
}
setPlaying(true);
} catch (err) {
ui.toast(`Could not start: ${err.message}`, 'error');
setConnection('offline');
} finally {
setBusy(false);
}
}
function handleFrame(frame) {
state.frame = frame;
map.setFrame(frame);
$('clock').textContent = ui.clock(frame.t_s);
$('phase-label').textContent = frame.phase || '—';
ui.renderMetrics(frame, state.venue);
ui.renderAlerts(frame, focusAsset);
ui.renderPrediction(frame, state.venue);
ui.renderTimeline(frame, state.scenario);
setPlaying(frame.playing);
for (const ev of frame.events || []) {
const key = `${ev.index}:${ev.t_s}`;
if (state.seenEvents.has(key)) continue;
state.seenEvents.add(key);
if (ev.severity === 'info') continue;
ui.flashEvent(ev.label, ev.detail);
}
if (frame.finished) {
$('btn-play').setAttribute('data-playing', 'false');
}
if (frame.error) ui.toast(frame.error, 'error');
}
function focusAsset(baseId) {
map.focusEdge = baseId;
}
/* ── transport controls ────────────────────────────────────────────── */
async function setPlaying(playing) {
$('btn-play').setAttribute('data-playing', String(!!playing));
}
async function togglePlay() {
if (state.busy) return;
if (!state.session) { await runSimulation(); return; }
const playing = $('btn-play').getAttribute('data-playing') === 'true';
try {
await api.control(state.session.session_id, { action: playing ? 'pause' : 'play' });
setPlaying(!playing);
} catch (err) { ui.toast(err.message, 'error'); }
}
async function setSpeed(speed) {
$('speed-group').querySelectorAll('button').forEach(b =>
b.setAttribute('aria-pressed', String(Number(b.dataset.speed) === speed)));
// The button state is the source of truth; a run started later picks it up
// from currentConfig(), so there is nothing to send if no session exists yet.
if (!state.session || state.busy) return;
try { await api.control(state.session.session_id, { action: 'speed', speed }); }
catch (err) { ui.toast(err.message, 'error'); }
}
function setConnection(status) {
const dot = $('conn-dot'), label = $('conn-label');
dot.className = 'dot' + (status === 'live' ? ' live' : status === 'offline' ? ' error' : '');
label.textContent = status;
}
function setBusy(busy, label) {
state.busy = busy;
const btn = $('btn-run');
btn.disabled = busy;
btn.innerHTML = busy ? `<span class="spinner"></span>${label || 'Working…'}` : 'Run simulation';
}
/* ── strategy simulation ───────────────────────────────────────────── */
async function simulateStrategies() {
if (!state.session) { ui.toast('Start a simulation first.', 'error'); return; }
const btn = $('btn-simulate');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Simulating…';
$('strategy-state').textContent = 'running';
$('strategy-state').className = 'tag busy';
try {
const result = await api.simulateStrategies(state.session.session_id, { horizon_s: 300 });
handleStrategyResult(result);
} catch (err) {
ui.toast(`Strategy simulation failed: ${err.message}`, 'error');
$('strategy-state').textContent = 'error';
} finally {
btn.disabled = false;
btn.textContent = 'Simulate strategies';
}
}
function handleStrategyResult(result) {
if (!result || !result.available) {
$('strategy-state').textContent = 'idle';
$('strategy-state').className = 'tag';
ui.toast(result?.reason || 'Nothing to act on yet — let the crowd build.', '');
return;
}
result.bottleneck_critical_density = state.venue?.critical_density;
state.strategyResult = result;
$('strategy-state').textContent = `${result.counterfactual_runs} runs`;
$('strategy-state').className = 'tag live';
$('drawer-sub').textContent =
`${result.counterfactual_runs} counterfactual runs from an identical clone of the state at ${ui.clock(result.t_s)}`;
ui.renderStrategyTable(result, id => {
drawStrategyChart($('strategy-chart'), result, $('chart-legend'), id);
});
ui.renderWhy(result);
ui.renderRecommendation(result, { onApply: applyStrategy, onOpen: openDrawer });
openDrawer();
drawStrategyChart($('strategy-chart'), result, $('chart-legend'), null);
}
async function applyStrategy(strategyId) {
if (!state.session) return;
const btn = $('btn-apply');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>Applying…'; }
try {
const res = await api.applyStrategy(state.session.session_id, strategyId);
state.applied = res;
ui.renderApplied(res);
ui.toast(`Intervention applied — ${res.agents_affected.toLocaleString()} people rerouted.`, 'ok');
$('drawer').hidden = true;
await api.control(state.session.session_id, { action: 'play' });
} catch (err) {
ui.toast(`Could not apply: ${err.message}`, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Apply intervention'; }
}
}
function openDrawer() {
$('drawer').hidden = false;
requestAnimationFrame(() => {
if (state.strategyResult) {
drawStrategyChart($('strategy-chart'), state.strategyResult, $('chart-legend'), null);
}
});
}
/* ── modals ────────────────────────────────────────────────────────── */
function showPredictionDetail() {
const p = state.meta?.prediction || {};
if (!p.available) {
ui.showModal('Prediction model', `
<p>The predictor is currently running the <strong>analytic mass-balance
projection</strong>: density is extrapolated from the measured net flow on
each corridor, damped as the corridor approaches jam density.</p>
<p>To train and validate the machine-learning predictor against simulator
ground truth, run <code>python scripts/train_predictor.py</code>.</p>`);
return;
}
const horizons = Object.keys(p.mae_model || {});
ui.showModal('Prediction model', `
<h3>Model</h3>
<p>${ui.esc(p.label)}</p>
<h3>Held-out accuracy</h3>
<p>Trained on seeds <code>${(p.train_seeds || []).join(', ')}</code> and evaluated on
<em>disjoint</em> seeds <code>${(p.test_seeds || []).join(', ')}</code> —
${ui.n0(p.n_train)} training rows, ${ui.n0(p.n_test)} held-out rows.</p>
<table>
<thead><tr><th>Horizon</th><th>MAE — model</th><th>MAE — physics baseline</th><th>Improvement</th><th>R²</th></tr></thead>
<tbody>${horizons.map(h => `
<tr><td>+${h}s</td>
<td>${Number(p.mae_model[h]).toFixed(4)}</td>
<td>${Number(p.mae_baseline[h]).toFixed(4)}</td>
<td>${Number(p.improvement_pct[h]).toFixed(1)}%</td>
<td>${Number(p.r2_model[h]).toFixed(3)}</td></tr>`).join('')}
</tbody>
</table>
<p>Errors are in p/m². The model is only used at inference time if it beats
the physics baseline on held-out seeds; otherwise FlowTwin falls back to the
baseline rather than presenting an unvalidated prediction.</p>`);
}
async function showPerception() {
ui.showModal('Crowd perception · Hugging Face',
'<p class="empty">Checking the model chain…</p>');
let status;
try {
status = await api.perceptionStatus();
} catch (err) {
ui.showModal('Crowd perception · Hugging Face',
`<p>Could not reach the perception endpoint: ${err.message}</p>`);
return;
}
ui.showModal('Crowd perception · Hugging Face', ui.perceptionHtml(status));
$('perc-run').addEventListener('click', async () => {
const file = $('perc-file').files?.[0];
if (!file) { ui.toast('Choose an image first.', 'error'); return; }
const btn = $('perc-run');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Analysing…';
const fd = new FormData();
fd.append('file', file);
const area = Number($('perc-area').value);
let url = '';
if (area > 0) url = `?zone_area_m2=${area}`;
try {
const res = await fetch(`/api/perception/analyze${url}`, { method: 'POST', body: fd })
.then(async r => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.detail || 'perception unavailable');
return body;
});
$('perc-result').innerHTML = ui.perceptionResultHtml(res);
} catch (err) {
$('perc-result').innerHTML = `<div class="perc-state bad"><p>${err.message}</p></div>`;
} finally {
btn.disabled = false;
btn.textContent = 'Analyse';
}
});
}
/* ── wiring ────────────────────────────────────────────────────────── */
function wireControls() {
$('btn-play').addEventListener('click', togglePlay);
$('btn-run').addEventListener('click', runSimulation);
$('btn-simulate').addEventListener('click', simulateStrategies);
$('btn-drawer-collapse').addEventListener('click', () => { $('drawer').hidden = true; });
$('btn-pred-detail').addEventListener('click', showPredictionDetail);
$('btn-perception').addEventListener('click', showPerception);
$('modal-close').addEventListener('click', ui.hideModal);
$('modal').addEventListener('click', e => { if (e.target.id === 'modal') ui.hideModal(); });
['in-crowd', 'in-ramp', 'in-compliance', 'in-capacity'].forEach(id =>
$(id).addEventListener('input', syncWhatIfOutputs));
$('btn-reseed').addEventListener('click', () => {
$('in-seed').value = Math.floor(Math.random() * 900000) + 1000;
});
$('btn-reset-whatif').addEventListener('click', () => {
applyWhatIfDefaults(state.scenario);
});
window.addEventListener('keydown', e => {
if (e.target.matches('input, select, textarea')) return;
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
if (e.key === 's' || e.key === 'S') simulateStrategies();
if (e.key === 'Escape') { ui.hideModal(); $('drawer').hidden = true; }
if (/^[1-6]$/.test(e.key)) setSpeed([1, 2, 5, 10, 20, 40][Number(e.key) - 1]);
});
window.addEventListener('resize', () => {
$('map-scale').querySelector('span').style.width = `${Math.round(map.scaleBarPx())}px`;
if (state.strategyResult && !$('drawer').hidden) {
drawStrategyChart($('strategy-chart'), state.strategyResult, $('chart-legend'), null);
}
});
}
boot();