Spaces:
Runtime error
Runtime error
| /* 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 => ` | |
| <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>`; | |
| } | |