/* 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 => `
  • ${esc(c)}
  • `).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 = '

    No projection yet.

    '; 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 `
    ${esc(r.name)} ${ttc != null ? (ttc <= 0 ? 'critical now' : `critical in ${Math.round(ttc)}s`) : (r.peak_projected > r.current + 0.12 ? 'rising' : 'stable')}
    ${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 `
    ${esc(label)}
    `; }).join('')}
    `; }).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 `
  • ${esc(b.replace(/^(FACT|ASSUMPTION)\s*·\s*/i, ''))}
  • `; }).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]) => `
    ${esc(k)}
    ${esc(v)}
    `).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 `
  • ${clock(at)}
    ${esc(ev.label)}
    ${ev.detail ? `
    ${esc(ev.detail)}
    ` : ''}
  • `; }); for (const iv of frame.interventions || []) { items.push(`
  • ${clock(iv.t_s)}
    Intervention · ${esc(iv.label || iv.strategy_id)}
    ${n0(iv.agents_affected)} people accepted the instruction
  • `); } if (!changed('timeline', items.join('|'))) return; $('timeline-list').innerHTML = items.join('') || '
  • No scripted events.
  • '; } 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 => `
  • ${esc(i.claim)} ${i.detail ? `${esc(i.detail)}` : ''} ${i.source ? `Source: ${esc(i.source)}` : ''} ${i.basis ? `${esc(i.basis)}` : ''}
  • `; $('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 = ` Strategy ${COLUMNS.map(c => `${esc(c.label)}`).join('')} Score J `; table.querySelector('tbody').innerHTML = strategies.map(s => { const m = s.metrics; return `
    ${s.recommended ? '' : ''} ${esc(s.label)}
    ${esc(s.family_label)} ${COLUMNS.map(c => { const v = m[c.key]; const b = baseline ? baseline.metrics[c.key] : null; return `${c.fmt(v)}${deltaHtml(v, b, c, s.id === 'no_action')}`; }).join('')} ${n1(s.score, 3)} `; }).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 ${esc(result.seed)}. ` + `Density and queue figures are for ${esc(result.bottleneck?.name || 'the primary bottleneck')}. ` + `${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 ''; } 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 `${diff > 0 ? '+' : '−'}${col.fmt(Math.abs(diff))}${pctv}`; } 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 = `
    Primary bottleneck
    ${esc(b.name || '—')}
    Predicted critical time
    ${ttc == null ? 'not within horizon' : ttc <= 0 ? 'now' : `${Math.round(ttc)} s`}
    Recommended intervention
    ${esc(rec.strategy_label)}
    ${esc(rec.instruction || '')}
    Reason — measured against no action
    ${rec.margin_over_runner_up_pct != null ? `
    Margin
    ${n1(rec.margin_over_runner_up_pct, 1)}% better than ${esc(rec.runner_up || '—')}
    ` : ''}

    ${esc(rec.method || '')}

    `; } export function renderRecommendation(result, { onApply, onOpen }) { const box = $('recommendation-card'); const rec = result?.recommendation; if (!rec) { box.innerHTML = ''; return; } box.innerHTML = `
    Recommended · lowest J

    ${esc(rec.strategy_label)}

    ${esc(rec.instruction || '')}

    `; $('btn-apply').addEventListener('click', () => onApply(rec.strategy_id)); $('btn-open-drawer').addEventListener('click', onOpen); } export function renderApplied(applied) { $('recommendation-card').innerHTML = `
    Intervention active

    ${esc(applied.strategy?.label || applied.strategy?.id || '')}

    ${n0(applied.agents_affected)} people accepted the instruction at ${clock(applied.t_s)}. The crowd is redistributing — watch the map and the queue metric.

    `; } /* ── 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 = `
    ${esc(label)}${esc(detail || '')}
    `; 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) => `
  • ${esc(c.repo_id)} ${esc(c.label)} ${esc(c.note)}
  • `).join(''); const loaded = status.loaded ? `
    Active model
    ${esc(status.model)}

    ${esc(status.note || '')}

    ` : `
    No model loaded

    ${esc(status.error || 'Not attempted yet.')}

    Run python scripts/fetch_hf_model.py with network access to download one. FlowTwin reports perception as unavailable rather than returning a fabricated count.

    `; const attempts = (status.attempts || []).length ? `

    Load attempts

    ` : ''; return `

    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.

    ${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

    ${esc(res.caveat || '')}

    `; }