/* 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 =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 => `
`).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 = '
Network nominal — no element above the watch threshold.
';
return;
}
list.innerHTML = alerts.map(a => `
${esc(a.severity)}
${esc(a.headline)}
`).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 => `
${n0(applied.agents_affected)} people accepted the instruction at ${clock(applied.t_s)}.
The crowd is redistributing — watch the map and the queue metric.
Run python scripts/fetch_hf_model.py with network access to
download one. FlowTwin reports perception as unavailable rather than
returning a fabricated count.
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.
camera frame ─┐
├─► crowd observation ─► Crowd State Engine ─► prediction ─► strategy
synthetic agents ─┘
${loaded}
Analyse an image
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.
Candidate chain
Tried in order; the first that loads is used. The first two are the models
named in the project specification.
${chain}
${attempts}`;
}
export function perceptionResultHtml(res) {
const o = res.observation, m = res.model;
return `
Observation
${n0(o.people)} people
${o.density != null ? `
${n1(o.density)} p/m² over ${n0(o.zone_area_m2)} m²
` : ''}
${esc(m.repo_id)} · ${esc(res.detail?.method || '')} · ${Math.round(res.latency_ms)} ms