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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 08:15:00 +00:00
1 parent 070b549990
commit 4e1e838771
8 files changed
+227 -41

No files matched your search

+9 -1
View File
@@ -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.',
+1 -4
View File
@@ -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())));
+9 -1
View File
@@ -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."
+5 -1
View File
@@ -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
+52 -20
View File
@@ -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,
+16
View File
@@ -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)
+124 -10
View File
@@ -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);
+11 -4
View File
@@ -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 = `<div class="dir-kicker tele">${icon('director')}DIRECTOR · DEMO MODE</div><h2 class="sheet-title">The story, beat by beat</h2><div class="sheet-sub">Each beat sets its own state. Keys 1–9 jump; “.” reopens this menu.</div>`;
sheet.head.innerHTML = `<div class="dir-kicker tele">${icon('director')}DIRECTOR · DEMO MODE</div><h2 class="sheet-title">The story, beat by beat</h2><div class="sheet-sub">Each beat sets its own state. Keys 1–9, 0, - and = jump; “.” opens this menu.</div>`;
sheet.body.innerHTML = `<ol class="dir-beats">${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() {