/* 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) => ` `).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 => ``).join(''); $('speed-group').querySelectorAll('button').forEach(b => b.addEventListener('click', () => setSpeed(Number(b.dataset.speed)))); } function buildLayerToggles() { $('layer-toggles').innerHTML = LAYERS.map(l => ``).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]) => ``).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 ? `${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 = '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 = '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', `

The predictor is currently running the analytic mass-balance projection: density is extrapolated from the measured net flow on each corridor, damped as the corridor approaches jam density.

To train and validate the machine-learning predictor against simulator ground truth, run python scripts/train_predictor.py.

`); return; } const horizons = Object.keys(p.mae_model || {}); ui.showModal('Prediction model', `

Model

${ui.esc(p.label)}

Held-out accuracy

Trained on seeds ${(p.train_seeds || []).join(', ')} and evaluated on disjoint seeds ${(p.test_seeds || []).join(', ')} — ${ui.n0(p.n_train)} training rows, ${ui.n0(p.n_test)} held-out rows.

${horizons.map(h => ` `).join('')}
HorizonMAE — modelMAE — physics baselineImprovement
+${h}s ${Number(p.mae_model[h]).toFixed(4)} ${Number(p.mae_baseline[h]).toFixed(4)} ${Number(p.improvement_pct[h]).toFixed(1)}% ${Number(p.r2_model[h]).toFixed(3)}

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.

`); } async function showPerception() { ui.showModal('Crowd perception · Hugging Face', '

Checking the model chain…

'); let status; try { status = await api.perceptionStatus(); } catch (err) { ui.showModal('Crowd perception · Hugging Face', `

Could not reach the perception endpoint: ${err.message}

`); 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 = '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 = `

${err.message}

`; } 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();