From 4e1e838771fd8db83459b1bee47560b2450575d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:15:00 +0000 Subject: [PATCH] fix(mobile): director number keys work with the menu open, plus engine fixes Number keys 1-9, 0, - and = jump to beats whether or not the director is open. The engine now draws real river outlines over Esri's inconsistent Hudson imagery, declutters Moment labels, and makes playMoment, exitMoment and setTime(null) safe at any time (each view- cone line owns its material; teardown waits for the frame in flight). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH --- mobile/demo/build-empire-state.mjs | 10 +- mobile/demo/build-midtown-water.mjs | 5 +- mobile/demo/moments/empire-state.json | 10 +- mobile/globe/API.md | 6 +- mobile/globe/index.js | 72 ++++++++++---- mobile/globe/moments.js | 16 +++ mobile/globe/stage.js | 134 ++++++++++++++++++++++++-- mobile/ui/director.js | 15 ++- 8 files changed, 227 insertions(+), 41 deletions(-) diff --git a/mobile/demo/build-empire-state.mjs b/mobile/demo/build-empire-state.mjs index f843989bb..d45adc476 100644 --- a/mobile/demo/build-empire-state.mjs +++ b/mobile/demo/build-empire-state.mjs @@ -887,7 +887,11 @@ const moment = { antennaM: ANTENNA, decks: [DECK_86, DECK_102], }, - stage: { buildings: 'midtown-buildings.json', highlight: '1015862' }, + stage: { + buildings: 'midtown-buildings.json', + water: 'midtown-water.json', + highlight: '1015862', + }, wind: WIND, cameraPath, beats: BEATS.map(([mmss, title]) => ({ t: at(mmss), title })), @@ -906,6 +910,10 @@ const moment = { detail: 'Real footprints and roof heights for Midtown (NYC OTI, dataset 5zhs-2jue).', }, + { + name: 'OpenStreetMap water polygons', + detail: 'Hudson and East River surfaces (ODbL, via OpenFreeMap).', + }, { name: 'Esri World Imagery', detail: 'Base imagery of the real place.', diff --git a/mobile/demo/build-midtown-water.mjs b/mobile/demo/build-midtown-water.mjs index ec0ade126..80c07144e 100644 --- a/mobile/demo/build-midtown-water.mjs +++ b/mobile/demo/build-midtown-water.mjs @@ -55,10 +55,7 @@ const template = meta.tiles[0]; const polys = []; for (let x = tileX(BBOX.west); x <= tileX(BBOX.east); x++) for (let y = tileY(BBOX.north); y <= tileY(BBOX.south); y++) { - const url = template - .replace('{z}', Z) - .replace('{x}', x) - .replace('{y}', y); + const url = template.replace('{z}', Z).replace('{x}', x).replace('{y}', y); const r = await fetch(url); if (!r.ok) throw new Error(`tile ${x}/${y} HTTP ${r.status}`); const tile = new VectorTile(new Pbf(new Uint8Array(await r.arrayBuffer()))); diff --git a/mobile/demo/moments/empire-state.json b/mobile/demo/moments/empire-state.json index 533a38aab..c7e2f70d5 100644 --- a/mobile/demo/moments/empire-state.json +++ b/mobile/demo/moments/empire-state.json @@ -18,7 +18,11 @@ "antennaM": 443, "decks": [320, 373] }, - "stage": { "buildings": "midtown-buildings.json", "highlight": "1015862" }, + "stage": { + "buildings": "midtown-buildings.json", + "water": "midtown-water.json", + "highlight": "1015862" + }, "wind": { "fromDeg": 290, "kt": 14 }, "cameraPath": [ { @@ -21394,6 +21398,10 @@ "name": "NYC Open Data · Building Footprints", "detail": "Real footprints and roof heights for Midtown (NYC OTI, dataset 5zhs-2jue)." }, + { + "name": "OpenStreetMap water polygons", + "detail": "Hudson and East River surfaces (ODbL, via OpenFreeMap)." + }, { "name": "Esri World Imagery", "detail": "Base imagery of the real place." diff --git a/mobile/globe/API.md b/mobile/globe/API.md index 5ae0a8db6..16464157e 100644 --- a/mobile/globe/API.md +++ b/mobile/globe/API.md @@ -223,7 +223,11 @@ Moment = { } ``` -The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`). +`playMoment`, `exitMoment` and `setTime(null)` are safe to call at any time, including mid-playback or while a load is still pending: a later call supersedes an earlier one, and the stage frees its primitives only after the frame in progress. + +`labels()` collision-avoids each label as it will be drawn (`title · detail`). When a Moment label collides, it falls back to callsign plus altitude, then to the callsign alone. Such entries carry `abbreviated: true` and the shortened `title` and `detail`. + +The hero is `mobile/demo/moments/empire-state.json` (id `empire-state`): "Climbers on the Empire State Building", 25 min, 3 helicopters, 8 captures, 120 chat lines and 12 posts. Regenerate it with `node mobile/demo/build-empire-state.mjs`. Its Midtown massing is real NYC Open Data footprints and roof heights in `mobile/demo/midtown-buildings.json` (`node mobile/demo/build-midtown.mjs`). Its river surfaces are OpenStreetMap water polygons in `mobile/demo/midtown-water.json` (`node mobile/demo/build-midtown-water.mjs`). They are drawn over Esri's mismatched water mosaic across the Hudson. ## Entities and dossiers diff --git a/mobile/globe/index.js b/mobile/globe/index.js index 55e9ebc86..d2efcc24f 100644 --- a/mobile/globe/index.js +++ b/mobile/globe/index.js @@ -91,6 +91,7 @@ import { createGhosts } from './ghosts.js'; import { createStage } from './stage.js'; import { loadBuildings, + loadWater, loadHero, momentFromAircraft, momentFromCircling, @@ -393,6 +394,7 @@ export async function createGlobe(container, options = {}) { const recorder = createRecorder({ persist: opts.record !== false }); const detector = createDetector(recorder); let momentState = null; // { moment, idx, prevT, restore } + let momentToken = 0; function timeMode(ms, isLive) { if (momentState) return 'moment'; if (!isLive && recorder.covers(ms, 90_000)) return 'history'; @@ -1518,9 +1520,12 @@ export async function createGlobe(container, options = {}) { idOrMoment, { speed, camera = true, autoplay = true, framing } = {}, ) { + // A later playMoment/exitMoment supersedes this call at every await. + const token = ++momentToken; + stopPlayback(); const m = await resolveMoment(idOrMoment); if (!m) throw new Error(`Unknown moment: ${idOrMoment}`); - stopPlayback(); + if (token !== momentToken) return m; const restore = momentState?.restore || { destination: camera ? Cesium.Cartesian3.clone(scene.camera.positionWC) @@ -1531,9 +1536,11 @@ export async function createGlobe(container, options = {}) { time: timeMs(), }; if (selected) api.select(null); - const buildings = m.stage?.buildings - ? await loadBuildings(m.stage.buildings) - : null; + const [buildings, waterData] = await Promise.all([ + m.stage?.buildings ? loadBuildings(m.stage.buildings) : null, + m.stage?.water ? loadWater(m.stage.water) : null, + ]); + if (token !== momentToken) return m; const lists = [ ['capture', sortedBy(m.captures)], ['chat', sortedBy(m.chat)], @@ -1553,7 +1560,8 @@ export async function createGlobe(container, options = {}) { // The Moment owns the scene: every live layer steps aside. for (const layer of layers.values()) if (layer.root) layer.root.show = false; - await stage.load(m, { buildings }); + await stage.load(m, { buildings, waterData }); + if (token !== momentToken || momentState?.moment !== m) return m; if (Number.isFinite(framing)) stage.setFraming(framing); stage.setCinematic(camera); setClock(m.time.start); @@ -1574,6 +1582,7 @@ export async function createGlobe(container, options = {}) { }, exitMoment({ restoreCamera = true } = {}) { + momentToken++; if (!momentState) return false; const { restore, moment } = momentState; stopPlayback(); @@ -1838,6 +1847,13 @@ export async function createGlobe(container, options = {}) { x: p.x, y: p.y, score: l.score, + // Shorter forms to try when the full label collides. + variants: [ + ...(l.short + ? [{ title: l.short, detail: l.shortDetail || '' }] + : []), + ...(l.short ? [{ title: l.short, detail: '' }] : []), + ], }); } if (ghosts.shown) @@ -1869,26 +1885,42 @@ export async function createGlobe(container, options = {}) { !(pick.meta?.magnitude >= 5.5) ) continue; - const box = { - x0: c.x + 6, - x1: c.x + 20 + pick.title.length * 6.2, - y0: c.y - 10, - y1: c.y + 10, - }; - if ( - placed.some( - (b) => - box.x0 < b.x1 && box.x1 > b.x0 && box.y0 < b.y1 && box.y1 > b.y0, - ) - ) - continue; + // Collision-avoid on the label as drawn (title · detail), trying + // the shorter forms (callsign + altitude, then callsign) before + // giving the spot up. + const forms = [ + { title: pick.title, detail: pick.labelDetail || '' }, + ...(c.variants || []), + ]; + let chosen = null; + let box = null; + for (const f of forms) { + const chars = f.title.length + (f.detail ? f.detail.length + 3 : 0); + const b = { + x0: c.x - 4, + x1: c.x + 22 + chars * 6.6, + y0: c.y - 12, + y1: c.y + 12, + }; + if ( + !placed.some( + (o) => b.x0 < o.x1 && b.x1 > o.x0 && b.y0 < o.y1 && b.y1 > o.y0, + ) + ) { + chosen = f; + box = b; + break; + } + } + if (!chosen) continue; placed.push(box); out.push({ id: pick.id, layer: c.layer.id, kind: pick.kind, - title: pick.title, - ...(pick.labelDetail ? { detail: pick.labelDetail } : {}), + title: chosen.title, + ...(chosen.detail ? { detail: chosen.detail } : {}), + ...(chosen.title !== pick.title ? { abbreviated: true } : {}), ...(pick.pinKind ? { pinKind: pick.pinKind } : {}), x: c.x, y: c.y, diff --git a/mobile/globe/moments.js b/mobile/globe/moments.js index 9410c04b0..6480b36ee 100644 --- a/mobile/globe/moments.js +++ b/mobile/globe/moments.js @@ -28,6 +28,22 @@ export function loadHero() { return heroPromise; } +const WATER_URL = new URL('../demo/midtown-water.json', import.meta.url).href; +let waterPromise = null; + +/** River surfaces drawn over the imagery seam (see build-midtown-water.mjs). */ +export function loadWater(name) { + if (name !== 'midtown-water.json') return Promise.resolve(null); + if (!waterPromise) + waterPromise = fetch(WATER_URL) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => { + waterPromise = null; + return null; + }); + return waterPromise; +} + export function loadBuildings(name) { if (name !== 'midtown-buildings.json') return Promise.resolve(null); if (!buildingsPromise) diff --git a/mobile/globe/stage.js b/mobile/globe/stage.js index 412a9b52c..0d6039300 100644 --- a/mobile/globe/stage.js +++ b/mobile/globe/stage.js @@ -291,9 +291,13 @@ export function createStage({ scene, requestRender, emit }) { root.show = false; let massing = null; // Primitive (buildings) let massingKey = ''; - const lines = root.add(new Cesium.PolylineCollection()); - const glyphs = root.add(new Cesium.BillboardCollection()); + let lines = root.add(new Cesium.PolylineCollection()); + let glyphs = root.add(new Cesium.BillboardCollection()); let coneRoot = root.add(new Cesium.PrimitiveCollection()); + let water = null; // Primitive (river surfaces) + let waterKey = ''; + let gen = 0; + let destroyed = false; let moment = null; let groundH = 0; @@ -310,16 +314,97 @@ export function createStage({ scene, requestRender, emit }) { const toCart = (lon, lat, altM, out) => Cesium.Cartesian3.fromDegrees(lon, lat, altM + altOffset, undefined, out); + /** + * Destroy primitives only after the frame that may still be drawing them: + * Cesium iterates collections during render, so removing (or destroying a + * material) mid-frame can crash sortPolylinesIntoBuckets. + */ + const graveyard = []; + let reaper = null; + function bury(...prims) { + for (const p of prims) { + if (!p) continue; + p.show = false; + graveyard.push(p); + } + if (reaper) return; + const reap = () => { + if (reaper?.off) reaper.off(); + clearTimeout(reaper?.timer); + reaper = null; + for (const p of graveyard.splice(0)) + if (!destroyed && root.contains(p)) root.remove(p); + }; + reaper = { + off: scene.postRender.addEventListener(() => setTimeout(reap, 0)), + timer: setTimeout(reap, 1000), + }; + requestRender(); + } + function clear() { - lines.removeAll(); - glyphs.removeAll(); - root.remove(coneRoot); + // Swap in fresh collections; the old ones are hidden now, freed later. + bury(lines, glyphs, coneRoot); + lines = root.add(new Cesium.PolylineCollection()); + glyphs = root.add(new Cesium.BillboardCollection()); coneRoot = root.add(new Cesium.PrimitiveCollection()); tracks = []; captures = []; subject = null; } + /** Real river surfaces ({ origin, polys:[[outer, …holes]] }) over the imagery seam. */ + function buildWater(data) { + bury(water); + water = null; + if (!data?.polys?.length) return; + const [olon, olat] = data.origin; + const kx = 111_320 * Math.cos(olat * D2R); + const ky = 110_574; + const ring = (flat) => { + const out = []; + for (let i = 0; i + 1 < flat.length; i += 2) + out.push(olon + flat[i] / kx, olat + flat[i + 1] / ky); + return Cesium.Cartesian3.fromDegreesArray(out); + }; + const instances = []; + for (const [outer, ...holes] of data.polys) { + try { + instances.push( + new Cesium.GeometryInstance({ + geometry: new Cesium.PolygonGeometry({ + polygonHierarchy: new Cesium.PolygonHierarchy( + ring(outer), + holes.map((h) => new Cesium.PolygonHierarchy(ring(h))), + ), + // Mean water sits ~15 m below Midtown's streets. + height: groundH - 14, + vertexFormat: + Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + css('#0B1824', 1), + ), + }, + }), + ); + } catch { + /* degenerate ring */ + } + } + water = root.add( + new Cesium.Primitive({ + geometryInstances: instances, + appearance: new Cesium.PerInstanceColorAppearance({ + flat: true, + translucent: false, + }), + asynchronous: true, + }), + ); + } + async function sampleGround(lon, lat) { try { const provider = scene.globe.terrainProvider; @@ -340,7 +425,7 @@ export function createStage({ scene, requestRender, emit }) { /** Real building massing ({ origin, b:[[roofM, groundM, x0,y0,…]] }). */ function buildMassing(data, highlightBin) { if (massing) { - root.remove(massing); + bury(massing); massing = null; } if (!data?.b?.length) return; @@ -701,7 +786,7 @@ export function createStage({ scene, requestRender, emit }) { /** Move everything to time `t` (epoch ms). Returns true when drawn. */ function update(t, { realNow = performance.now() } = {}) { - if (!moment) return false; + if (!moment || destroyed || lines.isDestroyed?.()) return false; const rebuildPath = realNow - lastPathRebuild > 250; if (rebuildPath) lastPathRebuild = realNow; for (const k of tracks) { @@ -795,12 +880,26 @@ export function createStage({ scene, requestRender, emit }) { canvas.addEventListener('wheel', release, { passive: true }); return { - async load(m, { buildings = null } = {}) { + async load(m, { buildings = null, waterData = null } = {}) { + const my = ++gen; clear(); moment = m; cinematic = true; const s = m.subject || { lon: m.lon, lat: m.lat, altM: 0 }; - groundH = await sampleGround(s.lon, s.lat); + const ground = await sampleGround(s.lon, s.lat); + // Unloaded (or another Moment loaded) while we waited: do nothing. + if (my !== gen || destroyed) return; + groundH = ground; + const wkey = m.stage?.water || ''; + if (waterData && wkey !== waterKey) { + waterKey = wkey; + buildWater(waterData); + } else if (!waterData && water) { + bury(water); + water = null; + waterKey = ''; + } + if (water) water.show = true; // Tracks are MSL; draw them relative to the sampled ground. altOffset = Number.isFinite(m.groundMslM) ? groundH - m.groundMslM : 0; const key = m.stage?.buildings || ''; @@ -808,7 +907,7 @@ export function createStage({ scene, requestRender, emit }) { massingKey = key; buildMassing(buildings, m.stage?.highlight); } else if (!buildings && massing) { - root.remove(massing); + bury(massing); massing = null; massingKey = ''; } @@ -821,10 +920,12 @@ export function createStage({ scene, requestRender, emit }) { update(m.time.start); }, unload() { + gen++; clear(); moment = null; root.show = false; if (massing) massing.show = false; + if (water) water.show = false; requestRender(); }, update, @@ -856,6 +957,9 @@ export function createStage({ scene, requestRender, emit }) { kind: 'track', title: k.tr.label, detail: `${Math.round((k.state.altM * 3.28084) / 25) * 25} FT · ${Math.round(k.state.speed / 0.514444)} KT`, + // Crowded fallback: callsign and altitude only. + short: k.tr.label.replace(/\s*\(illustrative\)\s*/i, ''), + shortDetail: `${Math.round((k.state.altM * 3.28084) / 50) * 50} FT`, lon: k.state.lon, lat: k.state.lat, altM: k.state.altM + altOffset, @@ -869,6 +973,8 @@ export function createStage({ scene, requestRender, emit }) { kind: 'capture', title: `@${k.c.handle}`, detail: k.c.firstEyes ? 'FIRST EYES · SEALED' : 'SEALED', + short: `@${k.c.handle}`, + shortDetail: k.c.firstEyes ? 'FIRST EYES' : '', lon: k.c.lon, lat: k.c.lat, altM: groundH + (k.c.altM || 1.5), @@ -879,6 +985,7 @@ export function createStage({ scene, requestRender, emit }) { id: `moment:${moment.id}:subject`, kind: 'subject', title: subject.label || moment.place, + short: (subject.label || moment.place).split(/[ ,]/)[0], lon: subject.lon, lat: subject.lat, altM: groundH + (subject.antennaM ?? subject.altM ?? 0), @@ -944,6 +1051,13 @@ export function createStage({ scene, requestRender, emit }) { return null; }, destroy() { + destroyed = true; + gen++; + if (reaper) { + reaper.off?.(); + clearTimeout(reaper.timer); + reaper = null; + } canvas.removeEventListener('pointerdown', release); canvas.removeEventListener('wheel', release); scene.primitives.remove(root); diff --git a/mobile/ui/director.js b/mobile/ui/director.js index 7f70cc355..bc2371b76 100644 --- a/mobile/ui/director.js +++ b/mobile/ui/director.js @@ -1,8 +1,8 @@ /** * Director (DESIGN.md §12.8): a presenter menu for demos. Press "." (or * triple-tap the Today title) to open it; each beat sets up its state - * deterministically so the demo never depends on luck. Number keys 1–9 jump - * straight to a beat while the menu is closed and the director is armed. + * deterministically so the demo never depends on luck. Number keys 1–9, 0, - and = jump + * straight to a beat, whether or not the menu is open. */ import { icon } from './icons.js'; @@ -23,7 +23,7 @@ export function createDirector({ beats, onReset }) { let current = -1; function render() { - sheet.head.innerHTML = `
${icon('director')}DIRECTOR · DEMO MODE

The story, beat by beat

Each beat sets its own state. Keys 1–9 jump; “.” reopens this menu.
`; + sheet.head.innerHTML = `
${icon('director')}DIRECTOR · DEMO MODE

The story, beat by beat

Each beat sets its own state. Keys 1–9, 0, - and = jump; “.” opens this menu.
`; sheet.body.innerHTML = `
    ${beats .map( ( @@ -71,7 +71,14 @@ export function createDirector({ beats, onReset }) { else open(); return; } - if (armed && /^[1-9]$/.test(e.key) && !sheet.isOpen) run(Number(e.key) - 1); + // 1–9 and 0 jump to beats 1–10, - and = to 11 and 12, menu open or not: + // a presenter reads the menu and presses the number. + const keyBeat = { 0: 9, '-': 10, '=': 11 }[e.key] ?? Number(e.key) - 1; + if (armed && /^[0-9=-]$/.test(e.key)) { + e.preventDefault(); + if (sheet.isOpen) sheet.close(); + run(keyBeat); + } }); function open() {