flowtwin / frontend /js /panels.js
FlowTwin
FlowTwin — AI crowd digital twin for Formula 1 venues
3e2e16c
Raw
History Blame Contribute Delete
21.9 kB
/* Rendering of every panel that is not the map. Pure functions of state:
* each takes a frame (or a strategy result) and writes DOM. */
import { LEVEL_COLOURS } from './map.js';
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export const clock = t => {
const s = Math.max(0, Math.round(t));
return `T+${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};
const n0 = v => v == null ? '—' : Math.round(v).toLocaleString();
const n1 = (v, d = 2) => v == null ? '—' : Number(v).toFixed(d);
const mmss = s => s == null ? '—' : `${Math.floor(s / 60)}m ${String(Math.round(s % 60)).padStart(2, '0')}s`;
/* ── metrics strip ─────────────────────────────────────────────────── */
export function renderMetrics(frame, venue) {
const m = frame.metrics || {};
const critical = venue?.critical_density ?? 3;
const warning = venue?.warning_density ?? 2;
const d = m.current_peak_density ?? 0;
const densityClass = d >= critical ? 'is-critical' : d >= warning ? 'is-warning' : '';
const done = m.completion_pct ?? 0;
const cells = [
{ label: 'In venue', val: n0((m.agents_waiting ?? 0) + (m.agents_moving ?? 0)),
sub: `${n0(m.agents_waiting)} waiting · ${n0(m.agents_moving)} moving` },
{ label: 'Dispersed', val: n0(m.agents_arrived),
sub: `${done.toFixed(0)}% of ${n0(m.agents_total)}`, cls: done > 90 ? 'is-good' : '' },
{ label: 'Peak density', val: n1(d), sub: 'p/m² · mean over corridor', cls: densityClass },
{ label: 'Peak queue', val: n0(m.current_max_queue), sub: 'people held at a gate',
cls: m.current_max_queue > 3000 ? 'is-critical' : m.current_max_queue > 1200 ? 'is-warning' : '' },
{ label: 'Avg journey', val: mmss(m.avg_travel_time_s), sub: `p95 ${mmss(m.p95_travel_time_s)}` },
{ label: 'Critical time', val: n0(m.critical_edge_seconds), sub: 'corridor-seconds',
cls: m.critical_edge_seconds > 0 ? 'is-warning' : 'is-good' },
{ label: 'Rerouted', val: n0(m.rerouted_agents), sub: 'have changed route so far' },
{ label: 'Seed', val: frame.seed ?? '—', sub: 'run is reproducible' },
];
const strip = $('metrics-strip');
// Build once, then write values in place. Replacing the markup five times a
// second makes the numbers shimmer and defeats text selection.
if (strip.childElementCount !== cells.length) {
strip.innerHTML = cells.map(c => `
<div class="metric">
<label>${esc(c.label)}</label>
<div class="val"></div>
<div class="sub"></div>
</div>`).join('');
}
const nodes = strip.children;
cells.forEach((c, i) => {
const el = nodes[i];
const cls = 'metric ' + (c.cls || '');
if (el.className !== cls) el.className = cls;
const val = String(c.val), sub = String(c.sub);
if (el.children[1].textContent !== val) el.children[1].textContent = val;
if (el.children[2].textContent !== sub) el.children[2].textContent = sub;
});
}
/* ── alerts ────────────────────────────────────────────────────────── */
const _sig = {};
/** Re-render only when the rendered content would actually differ.
* Frames arrive five times a second; rewriting a panel on every one of them
* restarts its entry animation and leaves it permanently mid-fade. */
function changed(key, value) {
if (_sig[key] === value) return false;
_sig[key] = value;
return true;
}
export function renderAlerts(frame, onSelect) {
const list = $('alerts-list');
const alerts = frame.alerts || [];
// Structure only: which assets are alerting, and at what severity. Every
// other field carries live numbers that change on almost every frame, and
// rebuilding the cards that often restarts their entry animation — which
// leaves the panel permanently mid-fade and effectively invisible.
const sig = alerts.map(a => `${a.base_id}:${a.severity}`).join(',');
if (changed('alerts', sig)) {
$('alert-count').textContent = alerts.length;
$('alert-count').className = 'tag' + (alerts.some(a => a.severity === 'critical') ? ' warn' : '');
if (!alerts.length) {
list.innerHTML = '<p class="empty">Network nominal — no element above the watch threshold.</p>';
return;
}
list.innerHTML = alerts.map(a => `
<article class="alert ${a.severity}" data-base="${esc(a.base_id)}">
<div class="alert-top">
<span class="alert-sev">${esc(a.severity)}</span>
<span class="alert-ttc"></span>
</div>
<div class="alert-name">${esc(a.headline)}</div>
<div class="alert-detail"></div>
<ul class="alert-causes"></ul>
</article>`).join('');
list.querySelectorAll('.alert').forEach(el =>
el.addEventListener('click', () => onSelect?.(el.dataset.base)));
}
// Live values are written into the existing cards.
for (const a of alerts) {
const el = list.querySelector(`.alert[data-base="${CSS.escape(a.base_id)}"]`);
if (!el) continue;
const ttc = el.querySelector('.alert-ttc');
const ttcText = a.time_to_critical_s == null
? `risk ${n1(a.risk)}`
: (a.time_to_critical_s <= 0 ? 'CRITICAL NOW' : `critical in ${a.time_to_critical_s}s`);
if (ttc.textContent !== ttcText) {
ttc.textContent = ttcText;
ttc.style.color = a.time_to_critical_s == null ? 'var(--text-faint)' : '';
}
const detail = el.querySelector('.alert-detail');
if (detail.textContent !== a.detail) detail.textContent = a.detail;
const causes = el.querySelector('.alert-causes');
const causeSig = (a.causes || []).join('|');
if (causes.dataset.sig !== causeSig) {
causes.dataset.sig = causeSig;
causes.innerHTML = (a.causes || []).map(c => `<li>${esc(c)}</li>`).join('');
}
}
}
/* ── prediction ────────────────────────────────────────────────────── */
export function renderPrediction(frame, venue) {
const pred = frame.prediction || {};
const rows = pred.top || [];
const critical = venue?.critical_density ?? 3;
const warning = venue?.warning_density ?? 2;
const tag = $('pred-source');
tag.textContent = pred.source === 'trained_model' ? 'ML model' : 'physics';
tag.className = 'tag ' + (pred.source === 'trained_model' ? 'busy' : '');
tag.title = pred.label || '';
const box = $('prediction-list');
if (!rows.length) { box.innerHTML = '<p class="empty">No projection yet.</p>'; return; }
const sig = JSON.stringify(rows.slice(0, 4).map(r =>
[r.base_id, Math.round(r.current * 12),
Object.values(r.horizons || {}).map(v => Math.round(v * 12)),
r.time_to_critical_s == null ? null : Math.round(r.time_to_critical_s / 10)]));
if (!changed('prediction', sig)) return;
const scaleMax = Math.max(critical * 1.12, ...rows.flatMap(r =>
[r.current, ...Object.values(r.horizons || {})]));
box.innerHTML = rows.slice(0, 4).map(r => {
const cells = [['now', r.current], ...Object.entries(r.horizons || {}).map(([h, v]) => [`+${h}s`, v])];
const ttc = r.time_to_critical_s;
return `
<div class="pred-row" data-base="${esc(r.base_id)}">
<div class="pred-head">
<span class="pred-name">${esc(r.name)}</span>
<span class="pred-ttc ${ttc == null ? 'safe' : ''}" data-trend="${ttc == null && r.peak_projected > r.current + 0.12 ? 'rising' : ''}">
${ttc != null
? (ttc <= 0 ? 'critical now' : `critical in ${Math.round(ttc)}s`)
: (r.peak_projected > r.current + 0.12 ? 'rising' : 'stable')}
</span>
</div>
<div class="pred-track">
${cells.map(([label, v], i) => {
const h = Math.max(3, Math.round((v / scaleMax) * 26));
const col = v >= critical ? LEVEL_COLOURS.critical
: v >= warning ? LEVEL_COLOURS.warning
: v >= warning * 0.55 ? LEVEL_COLOURS.busy : LEVEL_COLOURS.clear;
return `<div class="pred-cell ${i === 0 ? 'now' : ''}">
<i style="height:${h}px;background:${col};opacity:${i === 0 ? 1 : 0.55 + 0.12 * i}"></i>
<b>${esc(label)}</b></div>`;
}).join('')}
</div>
</div>`;
}).join('');
}
/* ── scenario / briefing / timeline / provenance ───────────────────── */
export function renderBriefing(scenario, venue, config) {
$('scenario-name').textContent = scenario.name;
$('scenario-headline').textContent = scenario.headline || '';
$('venue-kind').textContent = venue.kind === 'reconstruction' ? 'Reconstruction' : 'Fictional venue';
$('venue-kind').className = 'tag' + (venue.kind === 'reconstruction' ? ' warn' : '');
$('venue-name').textContent = venue.name;
$('venue-subtitle').textContent = venue.subtitle || '';
$('briefing-list').innerHTML = (scenario.briefing || []).map(b => {
const cls = /^FACT\b/i.test(b) ? 'fact' : /^ASSUMPTION\b/i.test(b) ? 'assume' : '';
return `<li class="${cls}">${esc(b.replace(/^(FACT|ASSUMPTION)\s*·\s*/i, ''))}</li>`;
}).join('');
$('run-config').innerHTML = [
['Crowd', n0(config.crowd_size)],
['Seed', config.seed],
['Routing', (config.routing_policy || '').replace(/_/g, ' ')],
['Duration', mmss(scenario.duration_s)],
].map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
}
export function renderTimeline(frame, scenario) {
const fired = new Map((frame.events || []).map(e => [e.index, e]));
const items = (scenario.timeline || []).map((ev, i) => {
const done = fired.has(i);
const at = done ? fired.get(i).t_s : ev.t_s;
return `<li class="${done ? 'fired' : 'pending'} sev-${esc(ev.severity)}">
<span class="t">${clock(at)}</span>
<div class="body">
<div class="label">${esc(ev.label)}</div>
${ev.detail ? `<div class="detail">${esc(ev.detail)}</div>` : ''}
</div>
</li>`;
});
for (const iv of frame.interventions || []) {
items.push(`<li class="fired sev-info">
<span class="t">${clock(iv.t_s)}</span>
<div class="body">
<div class="label">Intervention · ${esc(iv.label || iv.strategy_id)}</div>
<div class="detail">${n0(iv.agents_affected)} people accepted the instruction</div>
</div>
</li>`);
}
if (!changed('timeline', items.join('|'))) return;
$('timeline-list').innerHTML = items.join('') || '<li class="pending"><div class="body"><div class="detail">No scripted events.</div></div></li>';
}
export function renderProvenance(venue) {
const panel = $('provenance-panel');
const p = venue.provenance || {};
const has = (p.facts || []).length || (p.assumptions || []).length;
panel.hidden = !has;
if (!has) return;
$('prov-disclaimer').textContent = p.disclaimer || '';
const item = i => `<li>
<strong>${esc(i.claim)}</strong>
${i.detail ? `<span>${esc(i.detail)}</span>` : ''}
${i.source ? `<cite>Source: ${esc(i.source)}</cite>` : ''}
${i.basis ? `<cite>${esc(i.basis)}</cite>` : ''}
</li>`;
$('prov-facts').innerHTML = (p.facts || []).map(item).join('');
$('prov-assumptions').innerHTML = (p.assumptions || []).map(item).join('');
}
/* ── strategy table, recommendation, explainability ────────────────── */
const COLUMNS = [
{ key: 'peak_density', label: 'Peak density', fmt: v => n1(v), lower: true },
{ key: 'critical_duration_s', label: 'Critical time', fmt: v => `${Math.round(v)}s`, lower: true },
{ key: 'max_queue', label: 'Max queue', fmt: n0, lower: true },
{ key: 'avg_travel_time_s', label: 'Avg journey', fmt: v => mmss(v), lower: true },
{ key: 'throughput', label: 'Dispersed', fmt: n0, lower: false },
{ key: 'rerouted_agents', label: 'Rerouted', fmt: n0, lower: true, nodelta: true },
];
export function renderStrategyTable(result, onSelect) {
const table = $('strategy-table');
const strategies = result.strategies || [];
const baseline = strategies.find(s => s.id === 'no_action');
table.querySelector('thead').innerHTML = `<tr>
<th>Strategy</th>
${COLUMNS.map(c => `<th>${esc(c.label)}</th>`).join('')}
<th>Score J</th>
</tr>`;
table.querySelector('tbody').innerHTML = strategies.map(s => {
const m = s.metrics;
return `<tr class="${s.recommended ? 'recommended' : ''} ${s.id === 'no_action' ? 'baseline' : ''}"
data-id="${esc(s.id)}">
<td>
<div class="st-name">
${s.recommended ? '<span class="st-star">★</span>' : ''}
<span>${esc(s.label)}</span>
</div>
<span class="st-family">${esc(s.family_label)}</span>
</td>
${COLUMNS.map(c => {
const v = m[c.key];
const b = baseline ? baseline.metrics[c.key] : null;
return `<td>${c.fmt(v)}${deltaHtml(v, b, c, s.id === 'no_action')}</td>`;
}).join('')}
<td><strong>${n1(s.score, 3)}</strong></td>
</tr>`;
}).join('');
$('table-note').innerHTML =
`Measured over a ${Math.round(result.horizon_s)} s roll-out from an identical clone of the ` +
`crowd state at ${clock(result.t_s)}, seed <code>${esc(result.seed)}</code>. ` +
`Density and queue figures are for <strong>${esc(result.bottleneck?.name || 'the primary bottleneck')}</strong>. ` +
`${result.counterfactual_runs} counterfactual runs in ${Math.round(result.compute_ms)} ms.`;
table.querySelectorAll('tbody tr').forEach(tr =>
tr.addEventListener('click', () => {
table.querySelectorAll('tbody tr').forEach(x => x.classList.remove('selected'));
tr.classList.add('selected');
onSelect?.(tr.dataset.id);
}));
}
function deltaHtml(v, b, col, isBaseline) {
if (col.nodelta || isBaseline || b == null || v == null) return '';
const diff = v - b;
if (Math.abs(diff) < 1e-9 || (b !== 0 && Math.abs(diff / b) < 0.005)) {
return '<span class="delta flat">—</span>';
}
const better = col.lower ? diff < 0 : diff > 0;
const cls = col.neutral ? 'flat' : better ? 'good' : 'bad';
const pctv = b !== 0 ? ` ${Math.abs(Math.round(100 * diff / b))}%` : '';
return `<span class="delta ${cls}">${diff > 0 ? '+' : '−'}${col.fmt(Math.abs(diff))}${pctv}</span>`;
}
export function renderWhy(result) {
const rec = result.recommendation;
if (!rec) { $('why-panel').innerHTML = ''; return; }
const b = rec.bottleneck || {};
const p = rec.prediction || {};
const ttc = p.time_to_critical_s;
$('why-panel').innerHTML = `
<div class="why-block">
<div class="k">Primary bottleneck</div>
<div class="v">${esc(b.name || '—')}</div>
</div>
<div class="why-block">
<div class="k">Predicted critical time</div>
<div class="v big">${ttc == null ? 'not within horizon' : ttc <= 0 ? 'now' : `${Math.round(ttc)} s`}</div>
</div>
<div class="why-block">
<div class="k">Recommended intervention</div>
<div class="v">${esc(rec.strategy_label)}</div>
<div class="k" style="margin-top:5px;text-transform:none;letter-spacing:0;font-size:11px;color:var(--text-dim)">
${esc(rec.instruction || '')}
</div>
</div>
<div class="why-block">
<div class="k">Reason — measured against no action</div>
<ul class="why-reasons">
${(rec.reasons || []).slice(0, 6).map(r => `
<li class="${r.improved ? 'good' : 'bad'}">
<span class="metric-name">${esc(r.label)}</span>
<span class="metric-val">
<span class="arrow">${r.change_pct < 0 ? '↓' : r.change_pct > 0 ? '↑' : '→'}</span>
${Math.abs(r.change_pct).toFixed(0)}%
</span>
</li>`).join('')}
</ul>
</div>
${rec.margin_over_runner_up_pct != null ? `
<div class="why-block">
<div class="k">Margin</div>
<div class="v">${n1(rec.margin_over_runner_up_pct, 1)}% better than ${esc(rec.runner_up || '—')}</div>
</div>` : ''}
<p class="why-method">${esc(rec.method || '')}</p>`;
}
export function renderRecommendation(result, { onApply, onOpen }) {
const box = $('recommendation-card');
const rec = result?.recommendation;
if (!rec) { box.innerHTML = ''; return; }
box.innerHTML = `
<div class="rec-card">
<div class="rec-label">Recommended · lowest J</div>
<h4>${esc(rec.strategy_label)}</h4>
<p class="rec-instr">${esc(rec.instruction || '')}</p>
<ul>${(rec.headline || []).slice(0, 4).map(h =>
`<li class="${/accepted cost/.test(h) ? 'cost' : ''}">${esc(h.replace(' (accepted cost)', ''))}</li>`).join('')}</ul>
<div class="rec-actions">
<button class="btn-primary sm" id="btn-apply">Apply intervention</button>
<button class="btn-ghost sm" id="btn-open-drawer">Details</button>
</div>
</div>`;
$('btn-apply').addEventListener('click', () => onApply(rec.strategy_id));
$('btn-open-drawer').addEventListener('click', onOpen);
}
export function renderApplied(applied) {
$('recommendation-card').innerHTML = `
<div class="applied-banner">
<div class="rec-label">Intervention active</div>
<h4>${esc(applied.strategy?.label || applied.strategy?.id || '')}</h4>
<p class="rec-instr">${n0(applied.agents_affected)} people accepted the instruction at ${clock(applied.t_s)}.
The crowd is redistributing — watch the map and the queue metric.</p>
</div>`;
}
/* ── modal helpers ─────────────────────────────────────────────────── */
export function showModal(title, html) {
$('modal-title').textContent = title;
$('modal-body').innerHTML = html;
$('modal').hidden = false;
}
export function hideModal() { $('modal').hidden = true; }
export function toast(message, kind = '') {
const el = document.createElement('div');
el.className = 'toast ' + kind;
el.textContent = message;
$('toasts').appendChild(el);
setTimeout(() => {
el.style.transition = 'opacity .3s, transform .3s';
el.style.opacity = '0';
el.style.transform = 'translateY(6px)';
setTimeout(() => el.remove(), 320);
}, 4200);
}
export function flashEvent(label, detail) {
const el = $('map-flash');
el.innerHTML = `<div><strong>${esc(label)}</strong><span>${esc(detail || '')}</span></div>`;
el.hidden = false;
clearTimeout(el._t);
el._t = setTimeout(() => { el.hidden = true; }, 3700);
}
export { esc, n0, n1, mmss };
/* ── Hugging Face perception ───────────────────────────────────────── */
export function perceptionHtml(status) {
const chain = (status.candidates || []).map((c, i) => `
<li>
<strong>${esc(c.repo_id)}</strong>
<span>${esc(c.label)}</span>
<span class="muted">${esc(c.note)}</span>
</li>`).join('');
const loaded = status.loaded
? `<div class="perc-state ok">
<div class="k">Active model</div>
<div class="v">${esc(status.model)}</div>
<p>${esc(status.note || '')}</p>
</div>`
: `<div class="perc-state bad">
<div class="k">No model loaded</div>
<p>${esc(status.error || 'Not attempted yet.')}</p>
<p>Run <code>python scripts/fetch_hf_model.py</code> with network access to
download one. FlowTwin reports perception as unavailable rather than
returning a fabricated count.</p>
</div>`;
const attempts = (status.attempts || []).length
? `<h3>Load attempts</h3><ul class="perc-chain">${status.attempts.map(a =>
`<li><strong>${esc(a.repo_id)}</strong><span class="muted">${esc(a.error)}</span></li>`).join('')}</ul>`
: '';
return `
<p>FlowTwin has two ways of learning where people are. Synthetic agents give
exact ground truth for benchmarking; a Hugging Face crowd model turns a real
camera frame into the same observation. Both converge on one schema, so
density, risk, prediction and strategy are identical whichever is feeding
them.</p>
<pre class="ascii">camera frame ─┐
├─► crowd observation ─► Crowd State Engine ─► prediction ─► strategy
synthetic agents ─┘</pre>
${loaded}
<h3>Analyse an image</h3>
<p>Upload a crowd photograph. Give the zone area if you know it and the count
is converted into a density observation in the engine's units.</p>
<div class="perc-form">
<input type="file" id="perc-file" accept="image/*">
<input type="number" id="perc-area" placeholder="zone area m² (optional)" min="1">
<button class="btn-primary sm" id="perc-run">Analyse</button>
</div>
<div id="perc-result"></div>
<h3>Candidate chain</h3>
<p>Tried in order; the first that loads is used. The first two are the models
named in the project specification.</p>
<ul class="perc-chain">${chain}</ul>
${attempts}`;
}
export function perceptionResultHtml(res) {
const o = res.observation, m = res.model;
return `
<div class="perc-state ok">
<div class="k">Observation</div>
<div class="v big">${n0(o.people)} people</div>
${o.density != null ? `<div class="v">${n1(o.density)} p/m² over ${n0(o.zone_area_m2)} m²</div>` : ''}
<p>${esc(m.repo_id)} · ${esc(res.detail?.method || '')} · ${Math.round(res.latency_ms)} ms</p>
<p class="muted">${esc(res.caveat || '')}</p>
</div>`;
}