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() {