Skip to content

Commit 559f911

Browse files
authored
feat(ux): embed the full civic analyst as the Risk lens (ADR-0035) (#122)
The Risk lens was a condensed subset of the civic risk app with a link out to /civic/. It now embeds the FULL analyst in-shell — briefing + Presentation Mode (3D + holographic info board) + analyze/verify — as a full-bleed, same-origin <iframe src="/civic/?embed=1">, so there's no subset and zero re-implementation. Visual/integration only; golden numbers byte-identical (584 green / 1 skipped, no test edits). map.html (embed mode via ?embed=1): - hide the "‹ UrbanOS" homelink (the shell's lens rail is the way back). - bridge keyboard lens-switching to the parent: 1–4 + Escape postMessage to the shell (parent-window keys don't fire while focus is inside the iframe), guarded vs typing/chords. - gate the Presentation camera moves (flyTo/easeTo pitch) on prefers-reduced-motion (CSS guards don't collapse MapLibre JS animations). os.html: - Risk lens lazily creates the iframe (kept the condensed panel markup verbatim — a11y/test markers + a fallback — but hides it under the embed; dropped the now-redundant drill-down link). - #civ-embed full-bleed at z-index 5 (below the dock/topbar at z 6/5, clearing top:52/left:108); explicit calc() size since an <iframe> is a replaced element (width:auto → 300×150 intrinsic). - teardown at the single setLens chokepoint (covers click / keyboard / postMessage), so only one WebGL context is ever live; restores the panel + moves focus to the incoming dock button. - origin-checked message listener receives the iframe's 1–4 / Escape bridge. Verified with Playwright: the Risk lens shows the full civic app (briefing + analyze 500 Bloor → Food 0.126 LOW / Activity 0.381 MED + 5 click-to-verify); homelink suppressed; dock/topbar clickable above the iframe; 1 (in-iframe) and Escape both switch lens + tear down the iframe; panel restored on exit; 0 console errors.
1 parent 7a7ab5a commit 559f911

2 files changed

Lines changed: 66 additions & 8 deletions

File tree

src/urbanos/kernel/static/os.html

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@
7373
box-shadow: var(--shadow-3); }
7474
#panel.swap { animation: swap .28s ease; }
7575
@keyframes swap { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: none; } }
76+
/* The Risk lens embeds the full civic analyst full-bleed. z-index 5 keeps it BELOW the
77+
dock + topbar (z 6/5) so the lens rail and help stay clickable; top:52px clears the
78+
topbar, left:108px clears the dock. Created/removed by the safety lens / setLens. */
79+
/* Explicit calc() size: an <iframe> is a replaced element, so width/height:auto would
80+
resolve to its intrinsic 300×150 default — insets alone don't stretch it. */
81+
#civ-embed { position: fixed; top: 52px; left: 108px; width: calc(100% - 108px);
82+
height: calc(100% - 52px); border: 0; z-index: 5; background: var(--map-bg); }
83+
/* While the embed is up, hide the shell's condensed panel (z 6 would otherwise float
84+
over the iframe). The panel markup still ships in the JS source (test markers); the
85+
embedded civic app provides the real Risk panel. */
86+
body.risk-embed #panel { display: none; }
7687
/* .ptitle is an <h2> (per-lens section heading); the class keeps its visual size. */
7788
.ptitle { font-size: 18px; font-weight: 800; margin: 0 0 2px; color: var(--text-strong); }
7889
.psub { font-size: 12px; color: var(--text-muted); margin: 0 0 12px; }
@@ -848,13 +859,20 @@ <h3>Appearance</h3>
848859
'<button class="gh" id="hot-btn" aria-pressed="false">◉ Risk hotspots</button></div>'+
849860
'<div id="hotbox"></div>'+
850861
'<h3 class="panel-h">Highest-risk addresses<span class="hint" style="text-transform:none;font-weight:400"> · ranked by most-elevated index</span></h3><ul id="addrlist">'+rows+'</ul>'+
851-
'<div id="detail"><p class="hint">Pick a pin or address for its grounded risk read.</p></div>'+
852-
// Drill-down to the full civic Risk view (same product, /civic/) — its
853-
// free-text search, city briefing & 3D presentation aren't in this panel.
854-
'<p class="hint" style="margin-top:14px">'+
855-
'<a href="/civic/" style="color:var(--accent-link);font-weight:700;text-decoration:none">Open the full Risk view ↗</a>'+
856-
' — address search, city briefing &amp; 3D presentation</p>'
862+
'<div id="detail"><p class="hint">Pick a pin or address for its grounded risk read.</p></div>'
857863
);
864+
// The condensed panel above is kept as markup (a11y/test markers + a fallback if
865+
// the embed fails) but is covered by the full civic analyst: the Risk lens embeds
866+
// the real /civic/ app full-bleed (briefing + Presentation 3D + analyze/verify),
867+
// so there's no subset. Same-origin relative src (stays offline-safe); appended to
868+
// <body> (not #panel) so paint() can't clobber it; lazily created once.
869+
if (!document.getElementById('civ-embed')) {
870+
const f = document.createElement('iframe');
871+
f.id = 'civ-embed'; f.src = '/civic/?embed=1';
872+
f.title = 'Risk lens — full civic analyst';
873+
document.body.appendChild(f);
874+
}
875+
document.body.classList.add('risk-embed'); // hides #panel under the iframe; torn down in setLens
858876
panel.querySelector('#addrlist').addEventListener('click', e => { const b = e.target.closest('button[data-label]'); if (b) analyzeAddress(b.dataset.label); });
859877
panel.querySelectorAll('.gh[data-label]').forEach(b => b.addEventListener('click', () => analyzeAddress(b.dataset.label)));
860878
const hb = document.getElementById('hot-btn'); if (hb) hb.addEventListener('click', () => toggleHotspots(hb));
@@ -974,6 +992,18 @@ <h3>Appearance</h3>
974992
function setLens(id) {
975993
if (LENS === id) return;
976994
LENS = id;
995+
// Tear down the Risk-lens civic embed at this single chokepoint (covers click /
996+
// keyboard / postMessage switch paths), so only one map/WebGL context is ever live
997+
// and the panel's aria-live is restored before the next lens enters.
998+
const emb = document.getElementById('civ-embed');
999+
if (emb) {
1000+
emb.remove();
1001+
// Focus was inside the (now-removed) iframe — move it to the incoming lens's dock
1002+
// button so keyboard / screen-reader users aren't dropped to <body>.
1003+
const btn = document.querySelector('.lens[data-lens="' + id + '"]');
1004+
if (btn) try { btn.focus(); } catch (_) {}
1005+
}
1006+
document.body.classList.remove('risk-embed');
9771007
// ADR-0026 Phase 2: cluster overlay belongs to the Safety lens — clear it on any
9781008
// switch so hotspot circles never linger on the City/Flow map.
9791009
HOTSPOTS_ON = false; vis('civ-clusters', false); vis('civ-clusters-lbl', false);
@@ -1207,6 +1237,14 @@ <h3>Appearance</h3>
12071237
const id = LENS_KEYS[e.key];
12081238
if (id && LENSES[id]) { setLens(id); e.preventDefault(); }
12091239
});
1240+
// Keyboard bridge from the embedded Risk-lens iframe: parent-window keys don't fire while
1241+
// focus is inside the iframe, so the embedded /civic/ page postMessages 1–4 / Escape here.
1242+
// Strict same-origin check (message-injection guard).
1243+
addEventListener('message', e => {
1244+
if (e.origin !== location.origin || !e.data) return;
1245+
if (e.data.type === 'os-lens' && LENS_KEYS[e.data.key]) setLens(LENS_KEYS[e.data.key]);
1246+
else if (e.data.type === 'os-exit-risk') setLens('city');
1247+
});
12101248

12111249
async function boot() {
12121250
try {

src/urbanos/risk/api/static/map.html

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,26 @@ <h2 id="addrhead" hidden>Browse addresses by risk</h2>
240240
maplibregl.addProtocol('pmtiles', protocol.tile);
241241
const PMTILES = `pmtiles://${location.origin}/static/toronto.pmtiles`;
242242

243+
// Embedded mode: this page runs full-bleed inside the UrbanOS shell's Risk lens
244+
// (<iframe src="/civic/?embed=1">). Hide the home link (the shell's lens rail is the
245+
// way back; a homelink here would nest the shell in itself), and bridge keyboard
246+
// lens-switching to the parent (1–4 + Escape) since parent-window keys don't fire
247+
// while focus is inside the iframe. Same-origin → postMessage to location.origin only.
248+
const EMBED = new URLSearchParams(location.search).has('embed');
249+
if (EMBED) {
250+
document.querySelector('.homelink')?.setAttribute('hidden', '');
251+
addEventListener('keydown', (e) => {
252+
const t = e.target;
253+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
254+
if (e.altKey || e.ctrlKey || e.metaKey) return;
255+
if (['1', '2', '3', '4'].includes(e.key)) window.parent.postMessage({ type: 'os-lens', key: e.key }, location.origin);
256+
else if (e.key === 'Escape') window.parent.postMessage({ type: 'os-exit-risk' }, location.origin);
257+
});
258+
}
259+
// Reduced motion: CSS guards don't collapse MapLibre JS camera animations, so gate
260+
// the Presentation fly/pitch moves on the OS setting (applies embedded + standalone).
261+
const RM = (() => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } })();
262+
243263
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
244264
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
245265
// Risk band: a non-color cue (shape + word) so risk isn't conveyed by color alone.
@@ -511,7 +531,7 @@ <h2 id="addrhead" hidden>Browse addresses by risk</h2>
511531
'fill-extrusion-opacity': 0.85 } }, 'risk-pins');
512532
} catch (e) { /* basemap may lack a buildings layer */ }
513533
}
514-
map.easeTo({ pitch: 55, bearing: -18, duration: 900 });
534+
map.easeTo({ pitch: RM ? 0 : 55, bearing: RM ? 0 : -18, duration: RM ? 0 : 900 });
515535
}
516536
function exit3D() {
517537
clearFocus();
@@ -562,7 +582,7 @@ <h2 id="addrhead" hidden>Browse addresses by risk</h2>
562582
// its info card — the band-coloured pin dot already marks the spot.
563583
clearFocus();
564584
showBoard(row, d);
565-
map.flyTo({ center: [row.lng, row.lat], zoom: 17.3, pitch: 64, bearing: -22, duration: 1200 });
585+
map.flyTo({ center: [row.lng, row.lat], zoom: 17.3, pitch: RM ? 0 : 64, bearing: RM ? 0 : -22, duration: RM ? 0 : 1200 });
566586
}
567587
function setPresentation(on) {
568588
PRES = on;

0 commit comments

Comments
 (0)