Spaces:
Runtime error
Runtime error
| /* 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(); | |