Merge origin/main into fix/realtime-debug-log-hardening

# Conflicts:
#	CHANGELOG.md
#	src/tooling/localServices.test.mjs
This commit is contained in:
Sameh Khamis committed 2026-09-20 19:19:49 -07:00
commit a948274326
161 files changed
+19838 -6545

No files matched your search

+1 -1
View File
@@ -117,9 +117,9 @@ export function createApplicationCatalog({
createApplicationTraffic({ source: sources.traffic }),
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationTransit({ surface, source: sources.transit }),
vessels,
installations,
createApplicationAwareness({
+5
View File
@@ -41,6 +41,11 @@ test('catalogs construct distinct layers and classification from their supplied
});
assert.equal(first.layers.length, 21);
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1),
['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'],
);
assert.ok(first.get('bhote-koshi-2026'));
assert.ok(first.get('bhote-koshi-locator'));
const lifecycle = new LayerLifecycle({});
+2
View File
@@ -21,6 +21,7 @@ export function createApplicationTools({
voice = {},
startChrome,
onSceneDirector,
sceneDataPacks,
signal,
defer,
}) {
@@ -28,6 +29,7 @@ export function createApplicationTools({
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks,
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
+8 -3
View File
@@ -1,3 +1,4 @@
import { LayerBindings } from './ui/layerBindings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
@@ -302,8 +303,8 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'this._navigation.destroy();',
'this._removeWorldRequestFocusListener?.();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
@@ -312,12 +313,16 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
test('teardown synchronously closes immediate camera entry points', () => {
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
ordered(LayerBindings.prototype.stop.toString(), [
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
], 'camera bindings close synchronously');
const navigation = body(
ui,
+4 -2
View File
@@ -408,7 +408,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
}
assert.match(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/);
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
const callback = { 1: shellMethod('enterCockpit').toString() };
assert.ok(callback, 'Cockpit onEntered callback is missing');
assert.match(
@@ -438,7 +439,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
'normal Context must not reopen over Cockpit',
);
assert.match(ui, /onExited: \(\) => this\._panelChrome\.exitCockpit\(\)/);
assert.match(ui, /onExited: \(\) => this\.exitPanels\(\)/);
assert.match(ui, /exitPanels: \(\) => this\._panelChrome\.exitCockpit\(\)/);
const exitCallback = { 1: shellMethod('exitCockpit').toString() };
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
assert.match(
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aisStreamRows,
ingestAisStreamEnvelope,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
// ground in 0.1-degree units, reserving the top code of each field for "not
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
// Both are finite numbers, so they reach consumers as ordinary readings unless
// the store rejects them here.
const positionReport = (mmsi, body) => ({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
true,
);
assert.equal(rowFor('244060365').speed, null);
});
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
true,
);
assert.equal(rowFor('245382000').course, null);
});
test('genuine speed and course readings survive the unavailable check', () => {
ingestAisStreamEnvelope(
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
);
const row = rowFor('341260000');
assert.equal(row.speed, 12.9);
assert.equal(row.course, 74.6);
});
test('a stopped vessel keeps its zero speed and zero course', () => {
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
const row = rowFor('244374235');
assert.equal(row.speed, 0);
assert.equal(row.course, 0);
});
test('the fastest encodable reading below the unavailable code is kept', () => {
ingestAisStreamEnvelope(
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
);
const row = rowFor('244060366');
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
+9 -1
View File
@@ -595,8 +595,15 @@ export function createBhoteKoshiEmbeddedMedia({
facebookLoader = loadFacebookSdk,
youtubeLoader = loadYouTubeApi,
} = {}) {
if (!documentRef?.createElement || !viewer) {
// Pinokio externalizes HTTPS iframe navigation, including hidden preloads.
// Use the existing source-card/local-clip fallback before allocating provider
// resources. Browsers visiting the same Pinokio-launched server keep embeds.
const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test(
globalRef.navigator?.userAgent || '',
);
if (pinokioShell || !documentRef?.createElement || !viewer) {
return {
supportsPlayback: false,
warm: () => false,
show: () => false,
play: () => false,
@@ -1078,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
return {
supportsPlayback: true,
warm,
show,
getPlaybackState: () => record?.youtubeSession?.getState() || null,
+51
View File
@@ -10,6 +10,57 @@ import {
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
test('Pinokio fallback never creates provider DOM or loads SDKs', async () => {
const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); };
const media = createBhoteKoshiEmbeddedMedia({
viewer: {},
documentRef: { createElement: unexpected },
globalRef: {
navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' },
setTimeout: unexpected,
requestAnimationFrame: unexpected,
},
facebookLoader: unexpected,
youtubeLoader: unexpected,
xLoader: unexpected,
});
for (const sourceUrl of [
'https://www.youtube.com/watch?v=abcdefghijk',
'https://www.facebook.com/watch/?v=123456789',
'https://x.com/example/status/123456789',
]) {
const options = { observation: { media: { sourceUrl } }, autoplay: true };
assert.equal(media.warm(options), false, 'no hidden preload');
assert.equal(media.show(options), false, 'retain the existing fallback card');
assert.equal(media.play(), false);
assert.equal(media.pause(), false);
media.hide();
}
media.destroy();
media.hide();
await Promise.resolve();
});
test('ordinary browsers retain real preloads on the same launcher URL', () => {
for (const userAgent of [
'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
'Mozilla/5.0 Version/18.0 Safari/605.1.15',
'Mozilla/5.0 Electron/38.0.0',
'',
]) {
const { media, root } = preloadFixture({ globalRef: {
navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' },
} });
assert.equal(media.warm({ observation: { media: {
sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk',
} } }), true);
assert.equal(root.children.length, 1);
media.pause();
assert.equal(root.children.length, 0);
media.destroy();
}
});
function trimmedPlaybackFixture() {
let now = 0;
let nextId = 0;
+47 -1
View File
@@ -1000,6 +1000,8 @@ export function createBhoteKoshiEventLayer({
let _sceneMediaPlaybackBeatId = null;
let _sceneMediaPlaybackCompletedBeatId = null;
let _sceneMediaPlaybackTimer = null;
let _sceneMediaOwner = null;
let _removeSceneMediaAbort = null;
let _witnessEmbedActive = false;
let _sceneRevealAnimationFrame = null;
let _sceneRevealHoldActive = false;
@@ -1261,8 +1263,41 @@ export function createBhoteKoshiEventLayer({
return true;
}
// Playback authority is supplied by the Director, never restored from params.
function setSceneMediaPlayback(owner = null) {
_removeSceneMediaAbort?.();
_removeSceneMediaAbort = null;
_sceneMediaOwner = owner;
stopSceneMediaPlayback();
releaseEvidenceVideo({ restorePoster: false });
_embeddedMedia?.hide?.({ immediate: true });
_embeddedEvidenceIndex = -1;
_sceneMediaPlaybackCompletedBeatId = null;
if (owner?.token?.signal) {
const signal = owner.token.signal;
const cancel = () => setSceneMediaPlayback();
if (signal.aborted) cancel();
else {
signal.addEventListener('abort', cancel, { once: true });
_removeSceneMediaAbort = () =>
signal.removeEventListener('abort', cancel);
}
}
}
function sceneMediaPlaybackActive() {
return (
_sceneMediaOwner != null &&
!_sceneMediaOwner.token.cancelled &&
!_sceneMediaOwner.token.signal?.aborted &&
_sceneMediaOwner.sceneId === _sceneContext?.sceneId &&
_sceneMediaOwner.shotId === _sceneContext?.shotId
);
}
function sceneEvidenceMediaAutoplays() {
return (
sceneMediaPlaybackActive() &&
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
_sceneControls.evidenceMediaAutoplay === true &&
@@ -1333,7 +1368,7 @@ export function createBhoteKoshiEventLayer({
stopSceneMediaPlayback({ pauseMedia: false });
_sceneMediaPlaybackBeatId = beatId;
_embeddedMedia?.play?.();
if (sceneUsesTrimmedMedia(beatId)) {
if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) {
const deadline = Date.now() + 18000;
const check = () => {
if (
@@ -1415,6 +1450,14 @@ export function createBhoteKoshiEventLayer({
!sceneUsesTrimmedMedia(beatId)
)
return null;
const media = _evidenceTimeline.find(
(item) => item.observation?.id === beatId,
)?.observation?.media;
// Deliberate provider suppression is a source card, not a player waiting
// to load. Returning no gate selects the Director's authored card dwell.
// A local clip still owns playback; a starting supported provider still waits.
if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath)
return null;
return {
pending:
_sceneMediaPlaybackCompletedBeatId !== beatId ||
@@ -2103,6 +2146,7 @@ export function createBhoteKoshiEventLayer({
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
const warmingEvidence =
sceneMediaPlaybackActive() &&
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT;
// Keep the same preload across camera frames; stop/disable still cancels it.
@@ -3476,6 +3520,7 @@ export function createBhoteKoshiEventLayer({
}
async function disable() {
setSceneMediaPlayback();
_enabled = false;
++_comparisonSurfaceGeneration;
_mapStackController?.clearTerrainComparison?.();
@@ -3770,6 +3815,7 @@ export function createBhoteKoshiEventLayer({
setParams,
getParams,
getSceneShotMediaHold,
setSceneMediaPlayback,
getStats() {
return {
count: _enabled ? 1 : 0,
+96 -1
View File
@@ -30,6 +30,8 @@ import {
} from './bhoteKoshiEvent.js';
import { SCENE_RECIPES } from '../scenes/recipes.js';
import { LayerLifecycle } from './lifecycle.js';
import { createBhoteKoshiEmbeddedMedia } from './bhoteKoshiEmbeddedMedia.js';
import { SceneDirector } from '../scenes/director.js';
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
@@ -277,8 +279,9 @@ test('trimmed scene media holds through provider startup, playback and fade, the
try {
const viewer = eventViewer();
await layer.init(viewer);
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { holdSec: 7.65 },
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true);
@@ -301,6 +304,94 @@ test('trimmed scene media holds through provider startup, playback and fade, the
}
});
test('Pinokio source-card fallback selects authored Director dwell, not an impossible player hold', async () => {
const previous = { document: globalThis.document, window: globalThis.window };
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async url => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
embeddedMediaFactory: options => createBhoteKoshiEmbeddedMedia({ ...options,
globalRef: { navigator: { userAgent: 'Chrome Pinokio/8.0.40' } },
documentRef: { createElement() { assert.fail('unsupported host must allocate no provider DOM'); } },
}),
});
const token = { cancelled: false, signal: new AbortController().signal };
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, mediaPlaybackHoldSec: 7, cameraSettled: true } };
try {
const viewer = eventViewer();
await layer.init(viewer);
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token });
layer.setParams(params, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
const waits = [];
await SceneDirector.prototype._holdShot.call({
_layerStatesForShot: () => ({ [layer.id]: { enabled: true, params } }),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
_effectiveShotHoldSec: () => 7.65,
_sleep: async ms => waits.push(ms),
stopScene: () => assert.fail('a source card must not time out'),
}, { id: 'scene' }, { id: 'shot' }, token);
assert.deepEqual(waits, [7650], 'use the authored dwell instead of completing instantly or polling 20 seconds');
event.evidenceSpine.find(item => item.id === 'mailung-bazzar').media.videoPath = 'fixture-approved.mp4';
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
'an approved local clip remains a real playback owner even with the provider suppressed');
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
});
test('saved autoplay controls cannot authorize media; live shot ownership is cancellable and never serialized', async () => {
const previous = { document: globalThis.document, window: globalThis.window };
installEventDom();
const timers = new Map(); let timerId = 0;
globalThis.window = { setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
const calls = [];
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async url => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
embeddedMediaFactory: () => ({ supportsPlayback: true, warm: () => true,
show: options => { calls.push(options.autoplay); return true; },
getPlaybackState: () => null, play() {}, pause() {}, hide() {}, destroy() {} }),
});
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaPlaybackHoldSec: 7 } };
try {
const viewer = eventViewer(); await layer.init(viewer);
layer.setParams(params, { origin: 'local-restore' }); await layer.enable(viewer);
assert.ok(calls.length); assert.ok(calls.every(value => value === false));
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
const saved = layer.getParams();
const abort = new AbortController();
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false, signal: abort.signal } });
layer.setParams(params, { origin: 'scene' });
assert.equal(calls.at(-1), true);
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
'a temporarily null supported-provider state is still startup, not unsupported');
assert.deepEqual(layer.getParams(), saved, 'live playback authority must not enter saved params');
const late = [...timers.values()];
abort.abort(); late.forEach(fn => fn());
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
layer.setParams(params, { origin: 'local-restore' });
assert.equal(calls.at(-1), false, 'late callbacks and restored flags cannot restart revoked playback');
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'different-shot', token: { cancelled: false } });
layer.setParams(params, { origin: 'scene' });
assert.equal(calls.at(-1), false, 'a different shot cannot borrow this playback owner');
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
});
test('Bhote Koshi timeline helpers clamp untrusted UI values', () => {
assert.equal(clampUnit(-1), 0);
assert.equal(clampUnit(0.42), 0.42);
@@ -1695,7 +1786,9 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
sceneControls: { evidenceMediaAutoplay: true },
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
sceneContext: { sceneId: 'scene', shotId: 'shot' },
}, { origin: 'scene' });
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(videos.length, 1);
const video = videos[0];
@@ -1874,6 +1967,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
sceneSurface: 'evidence-beat',
beatId: 'gyirong-border-gate',
beatReveal: 0.36,
sceneContext: { sceneId: 'scene', shotId: 'shot' },
sceneControls: {
evidenceSequence: true,
evidenceSequenceDurationSec: 0.9,
@@ -1886,6 +1980,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
try {
await layer.init(viewer);
layer.setParams(params, { origin: 'scene' });
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
await layer.enable(viewer, { origin: 'scene' });
const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate');
const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core');
+5
View File
@@ -5,6 +5,8 @@ import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKos
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
import {
anchoredCalloutPresentation,
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
@@ -503,11 +505,14 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
+132 -52
View File
@@ -21,23 +21,70 @@ export function trailAlpha(ageMs) {
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const heads = scene.primitives.add(new Cesium.PolylineCollection());
const backing = heads.add({
positions: [],
width: 4,
show: false,
material: Cesium.Material.fromType('Color', {
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
}),
});
const head = heads.add({
positions: [],
width: 2,
show: false,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.WHITE.clone(),
}),
});
const ground =
!!scene.frameState?.context &&
Cesium.GroundPolylinePrimitive.isSupported(scene);
const collection = ground ? scene.groundPrimitives : scene.primitives;
// One 25 m corridor is clipped in material coordinates. Only subdivision
// crossings build geometry; frame updates write a scalar, never vertex buffers.
const headMaterial = (alpha) =>
new Cesium.Material({
fabric: {
type: 'TransitTrailHead',
uniforms: {
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
fraction: 0,
backing: true,
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
if (materialInput.st.s > fraction) discard;
czm_material m = czm_getDefaultMaterial(materialInput);
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
m.alpha = color.a;
return m;
}`,
},
translucent: true,
});
const material = headMaterial(0.8),
depthMaterial = headMaterial(0.55);
depthMaterial.uniforms.backing = false;
const head = { positions: [], show: false, width: 3, material };
const backing = { positions: [], show: false, width: 5 };
let headPrimitive = null,
headRebuilds = 0;
function geometry(positions, width, material = false) {
return ground
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
: new Cesium.PolylineGeometry({
positions,
width,
arcType: Cesium.ArcType.NONE,
vertexFormat: material
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
});
}
function primitive(instances, appearance, depthFailAppearance) {
return ground
? new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance,
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
asynchronous: true,
show: visible,
})
: new Cesium.Primitive({
geometryInstances: instances,
appearance,
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
});
}
let body = null,
segments = [],
revision = -1,
@@ -68,7 +115,10 @@ export function createContactTrailRenderer(scene) {
completed = -1;
lastSecond = -1;
active = null;
if (body) scene.primitives.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
head.show = backing.show = false;
if (body) collection.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
@@ -80,18 +130,13 @@ export function createContactTrailRenderer(scene) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: new Cesium.PolylineGeometry({
positions: segment.positions,
width: lane ? 2 : 4,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
geometry: geometry(segment.positions, lane ? 3 : 5),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
tint(0.55),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
@@ -100,20 +145,12 @@ export function createContactTrailRenderer(scene) {
}
}
if (instances.length) {
const depthFailAppearance = new Cesium.PolylineColorAppearance({
translucent: true,
fragmentShaderSource:
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
});
body = scene.primitives.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
}),
body = collection.add(
primitive(
instances,
new Cesium.PolylineColorAppearance({ translucent: true }),
new Cesium.PolylineColorAppearance({ translucent: true }),
),
);
rebuilds++;
}
@@ -122,6 +159,7 @@ export function createContactTrailRenderer(scene) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
if (headPrimitive) headPrimitive.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
@@ -152,8 +190,10 @@ export function createContactTrailRenderer(scene) {
attributeIds[i][lane],
);
if (!attributes) continue;
attributes.show[0] = i <= done ? 1 : 0;
attributes.show = attributes.show;
// Cesium getters return copies. Write back the array we changed.
const show = attributes.show;
show[0] = i <= done ? 1 : 0;
attributes.show = show;
}
}
completed = done;
@@ -166,16 +206,20 @@ export function createContactTrailRenderer(scene) {
attributeIds[i][lane],
);
if (!attributes) continue;
const rgba = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.color,
rgba,
);
attributes.color = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.depthFailColor,
);
attributes.depthFailColor = attributes.depthFailColor;
attributes.color = rgba;
if (!ground) {
const depthRgba = attributes.depthFailColor;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(0.55),
depthRgba,
);
attributes.depthFailColor = depthRgba;
}
}
}
lastSecond = second;
@@ -183,16 +227,43 @@ export function createContactTrailRenderer(scene) {
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (headPrimitive) headPrimitive.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = collection.add(
primitive(
new Cesium.GeometryInstance({
geometry: geometry(segment.positions, 5, true),
}),
new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
new Cesium.PolylineMaterialAppearance({
material: depthMaterial,
translucent: true,
}),
),
);
headRebuilds++;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
material.uniforms.fraction = Math.max(
0,
Math.min(
1,
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
),
);
depthMaterial.uniforms.fraction = material.uniforms.fraction;
Cesium.Color.clone(tint(0.8), material.uniforms.color);
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
}
return {
replaceHistory,
@@ -205,12 +276,15 @@ export function createContactTrailRenderer(scene) {
},
setVisible(next) {
visible = next;
heads.show = next;
if (headPrimitive) headPrimitive.show = next && head.show;
if (body) body.show = next;
},
destroy() {
if (body) scene.primitives.remove(body);
scene.primitives.remove(heads);
if (body) collection.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
material.destroy();
depthMaterial.destroy();
body = null;
segments = [];
},
@@ -222,6 +296,12 @@ export function createContactTrailRenderer(scene) {
body,
head,
backing,
headPrimitive,
headStrategy: ground
? 'draped corridor with material clip'
: 'depth-fail corridor with material clip',
ground,
headRebuilds,
entitiesAdded: 0,
};
},
+169 -22
View File
@@ -1,12 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { readFileSync } from 'node:fs';
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
import {
createContactTrailRenderer,
trailAlpha,
} from './contactTrailRenderer.js';
function fixture(t) {
function fixture(t, ground = false) {
for (const key of [
'HTMLCanvasElement',
'HTMLImageElement',
@@ -20,9 +22,21 @@ function fixture(t) {
else globalThis[key] = prior;
});
}
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
const primitives = new Cesium.PrimitiveCollection();
t.after(() => primitives.destroy());
return { primitives };
const groundPrimitives = new Cesium.PrimitiveCollection();
t.after(() => groundPrimitives.destroy());
return {
primitives,
groundPrimitives,
frameState: { context: { depthTexture: ground } },
globe: { show: false },
};
}
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
const scene = fixture(t),
@@ -62,7 +76,9 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
for (const instance of body.geometryInstances) {
assert.deepEqual(
instance.attributes.depthFailColor.value,
instance.attributes.color.value,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
}
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
@@ -81,7 +97,18 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
);
// Simulate Cesium's completed upload; exercise public attribute writes.
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
body.getGeometryInstanceAttributes = (id) => {
const stored = attributes.get(id),
accessor = {};
for (const name of ['show', 'color', 'depthFailColor'])
Object.defineProperty(accessor, name, {
get: () => stored[name].slice(),
set: (value) => {
stored[name] = value.slice();
},
});
return accessor;
};
const marker = Cesium.Cartesian3.lerp(
positions[1],
positions[2],
@@ -95,10 +122,19 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
[1, 1, 0, 0],
);
for (const a of attributes.values())
assert.deepEqual(a.depthFailColor, a.color);
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
const result = renderer.diagnostics();
assert.equal(result.body, body);
assert.equal(result.rebuilds, 1);
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
assert.equal(result.head.material.uniforms.fraction, 0.5);
assert.equal(result.head.width, 3);
assert.equal(result.backing.width, 5);
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.head.positions.length, 2);
renderer.setDisplaySample(
@@ -120,24 +156,130 @@ test('trail age curve uses the specified four alpha anchors', () => {
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
});
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
const geometry = Cesium.PolylineGeometry.createGeometry(
new Cesium.PolylineGeometry({
positions: [
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
],
width: 2,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
readFileSync(
new URL(
import.meta
.resolve('@cesium/engine/Source/Assets/approximateTerrainHeights.json'),
),
),
);
const bytes =
Object.values(geometry.attributes).reduce(
(total, attribute) => total + (attribute?.values?.byteLength || 0),
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
const start = performance.now();
const instances = async (count) =>
Promise.all(
Array.from(
{ length: count },
async (_, i) =>
new Cesium.GeometryInstance({
id: i,
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
new Cesium.GroundPolylineGeometry({
positions,
width: i % 2 ? 3 : 5,
granularity: 0,
}),
),
}),
),
);
const pipelineBytes = async (count) => {
// Match GroundPolylinePrimitive's internal Primitive options and the
// application's morph-capable scene, including batch IDs and encoding.
const result = Cesium.PrimitivePipeline.combineGeometry({
instances: await instances(count),
projection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
elementIndexUintSupported: true,
scene3DOnly: false,
vertexCacheOptimize: false,
compressVertices: false,
createPickOffsets: false,
});
return result.geometries.reduce(
(sum, geometry) =>
sum +
Object.values(geometry.attributes).reduce(
(n, a) => n + (a?.values?.byteLength || 0),
0,
) +
geometry.indices.byteLength,
0,
) + geometry.indices.byteLength;
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
);
};
const bytes = await pipelineBytes(1);
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
const bodyBytes = await pipelineBytes(bodyInstances);
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
assert.equal(bodyBytes, 2014128);
const instanceReserve = 64 * bodyInstances;
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
assert.ok(
bytes <= 2048,
'subdivision crossings stay below 2 KiB of head geometry',
);
console.log(
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
);
});
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
const scene = fixture(t, true),
renderer = createContactTrailRenderer(scene);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
renderer.replaceHistory({
revision: 1,
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
});
const first = renderer.diagnostics();
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
assert.ok(scene.groundPrimitives.contains(first.body));
for (const instance of first.body.geometryInstances)
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
for (let i = 1; i <= 120; i++)
renderer.setDisplaySample(
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
positions[1],
);
const result = renderer.diagnostics();
assert.equal(result.body, first.body);
assert.equal(result.headRebuilds, 1);
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
assert.equal(result.head.material.uniforms.fraction, 0.6);
assert.match(result.head.material.shaderSource, /discard/);
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
scene.globe.show = stack !== 'Google';
renderer.setDisplaySample(
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
positions[1],
);
const retained = renderer.diagnostics();
assert.equal(retained.body, result.body, `${stack}: retained history`);
assert.equal(retained.headPrimitive, result.headPrimitive);
for (const primitive of [retained.body, retained.headPrimitive]) {
assert.equal(
primitive.classificationType,
Cesium.ClassificationType.BOTH,
stack,
);
assert.equal(primitive.show, true, stack);
}
}
renderer.setVisible(false);
assert.equal(result.body.show, false);
assert.equal(result.headPrimitive.show, false);
renderer.destroy();
assert.equal(scene.groundPrimitives.length, 0);
assert.equal(scene.primitives.length, 0);
});
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
@@ -206,5 +348,10 @@ test('subdivision crossings only write the changed show range and age colors onc
display(1200);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
for (const a of attributes.values())
assert.deepEqual(a.color, a.depthFailColor);
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
),
);
});
+11
View File
@@ -42,6 +42,17 @@ export const DATA_CREDITS = [
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
{
key: 'adsbdb',
html:
'Aircraft type, registration &amp; flight routes: ' +
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
'Guillaume Michel · route data is the work of ' +
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
'copied, published, or incorporated into other databases without the ' +
'explicit permission of David J Taylor, Edinburgh',
},
{
key: 'aisstream',
html:
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
assert.equal(
new Set(keys).size,
keys.length,
'a duplicate key would silently shadow one provider’s credit',
);
for (const entry of DATA_CREDITS) {
assert.ok(entry.key, 'a credit without a key cannot be registered');
assert.ok(
entry.html && entry.html.trim().length > 0,
`credit ${entry.key} has nothing to show`,
);
}
});
test('adsbdb is credited and carries its published route-data restriction', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
assert.ok(
credit,
'adsbdb supplies aircraft type and routes and must be credited',
);
// adsbdb publishes this restriction for its route data. Pin the provider's
// credits and restriction here so a later edit cannot silently remove them.
assert.match(credit.html, /David Taylor, Edinburgh/);
assert.match(credit.html, /Jim Mason, Glasgow/);
assert.match(
credit.html,
/may not be\s+copied, published, or incorporated into other databases/,
);
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
assert.match(credit.html, /PlaneBase/);
assert.match(credit.html, /Guillaume Michel/);
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
+2 -1
View File
@@ -3,7 +3,7 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
@@ -50,5 +50,6 @@ export function layerFeedState(stats = {}) {
state.available === false
)
return 'degraded';
if (state.partial === true) return 'partial';
return 'nominal';
}
+46 -4
View File
@@ -123,12 +123,51 @@ export function deriveFetchCenter({
return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' };
}
/**
* Wrapped longitude span between two meridians, in degrees.
*
* Longitude is cyclic, so a box crossing the antimeridian (±180°) has
* `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west`
* goes negative in that case; wrap through 360° instead.
*
* @param {number} west - West edge longitude (degrees).
* @param {number} east - East edge longitude (degrees).
* @returns {number} Span in degrees, always >= 0.
*/
function longitudeSpanDeg(west, east) {
const raw = east - west;
return raw >= 0 ? raw : raw + 360;
}
/** Keep a monotonic longitude range inside the traffic source's legal domain. */
function boundedLongitudeRange(center, span) {
let west = center - span / 2;
let east = center + span / 2;
if (east > 180) {
west -= east - 180;
east = 180;
}
if (west < -180) {
east += -180 - west;
west = -180;
}
return { west, east };
}
/**
* Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`.
*
* Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km)
* but centers the box on the derived look-at point instead of the view
* rectangle's midpoint. Idempotent when `center` is the box's own midpoint.
* rectangle's midpoint. At the antimeridian the whole box shifts just far
* enough to remain inside [-180, 180], because the road source accepts one
* monotonic Overpass bounding box. Idempotent when `center` is the box's own
* midpoint.
*
* Longitude is handled as a cyclic axis for the span calculation, so the
* input span wraps correctly across the antimeridian. Output remains
* monotonic because callers such as `getBoundsCenter` and `boundsOverlap`
* rely on `west <= east`.
*
* @param {{south:number, west:number, north:number, east:number}} bounds
* Source bounds (span donor).
@@ -138,11 +177,14 @@ export function deriveFetchCenter({
*/
export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) {
const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg);
const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg);
const lonSpan = Math.min(
longitudeSpanDeg(bounds.west, bounds.east),
maxSpanDeg,
);
const longitude = boundedLongitudeRange(center.lon, lonSpan);
return {
south: center.lat - latSpan / 2,
north: center.lat + latSpan / 2,
west: center.lon - lonSpan / 2,
east: center.lon + lonSpan / 2,
...longitude,
};
}
+16
View File
@@ -108,3 +108,19 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re-
const twice = clampBoundsAroundCenter(once, midpoint, 0.05);
assert.deepEqual(twice, once);
});
test('span clamp keeps antimeridian bounds monotonic and request-safe', () => {
for (const centerLon of [179.99, -179.99]) {
const result = clampBoundsAroundCenter({
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
}, { lat: 0, lon: centerLon }, 0.05);
assert.ok(result.west >= -180 && result.east <= 180);
assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`);
assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12);
assert.ok(result.west <= centerLon && centerLon <= result.east);
}
});
+13 -12
View File
@@ -189,20 +189,21 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
let uri = _iconCache.get(key);
if (!uri) {
const size = VIEW + 2 * frame.pad;
// Sensor signatures use a broad rounded envelope, without dark interior
// joints or a second outline eating into the hot core at display resolution.
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
const halfH = 48;
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
const body = profile === 'mono' ? capsule : BODIES[k];
// Reuse the exact outer path. Sensors fill its interior without panel ink.
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1];
const body =
profile === 'mono'
? `<path d="${silhouette}" fill="white" />`
: BODIES[k];
const halo =
frame.units > 0
? body.replace(
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
(_m, d) =>
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
)
? profile === 'mono'
? `<path d="${silhouette}" fill="none" stroke="#05080C" stroke-opacity="1" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`
: body.replace(
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
(_m, d) =>
`<path d="${d}" fill="none" stroke="#05080C" stroke-opacity="0.95" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
)
: '';
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
+128 -24
View File
@@ -32,10 +32,10 @@ test('a haloed glyph draws the dark ring under the shipped white body', () => {
assert.notEqual(plain, haloed);
assert.match(
haloed,
/stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/,
/stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/,
);
assert.ok(
haloed.indexOf('#000000') < haloed.indexOf('fill="white"'),
haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'),
'ring first, body on top',
);
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
@@ -97,40 +97,144 @@ test('all 36 variants retain the specified final halo at both display sizes', as
assert.equal(transitIconCacheSize(), 36);
});
test('sensor bodies cover sixty percent of the padded display with opaque white', async () => {
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
const sharp = (await import('sharp')).default;
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
const rasterBody = async (svg) => {
// Compare fills in the same frame; the separately tested external halo is excluded.
const d =
[...svg.matchAll(/<path d="([^"]+)" fill="white"/g)][0]?.[1] ??
svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
const { data, info } = await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
),
)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const mask = [];
let minX = info.width,
minY = info.height,
maxX = 0,
maxY = 0;
for (let y = 0; y < info.height; y++)
for (let x = 0; x < info.width; x++) {
const alpha = data[(y * info.width + x) * 4 + 3];
mask.push(alpha);
if (alpha > 127) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
};
for (const selected of [false, true])
for (const kind of TRANSIT_ICON_KINDS) {
const normal = await rasterBody(
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })),
);
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
const mono = await rasterBody(svg);
assert.deepEqual(
mono.mask,
normal.mask,
`${kind}/${style}: no envelope`,
);
assert.ok(
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
`${kind}/${style}: aspect`,
);
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected);
assert.equal(display, crt);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
const crtFrame = haloFrame(
presetSpriteOutlinePx('retro', selected),
crt,
);
assert.equal(
(display * frame.ratio) / frame.ratio,
(crt * crtFrame.ratio) / crtFrame.ratio,
'unpadded silhouette matches CRT; only external halo padding grows',
);
assert.equal(
(svg.match(/<path /g) || []).length,
2,
'only outer halo and opaque silhouette',
);
assert.match(svg, /fill="white" \/>/);
}
}
});
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
const sharp = (await import('sharp')).default;
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
const { reduceSensorContrast } =
await import('../layers/transit/qaMetrics.js');
const sides = {
bus: 17,
tram: 10,
subway: 11.5,
rail: 10.5,
ferry: 12.5,
unknown: 10,
};
for (const selected of [false, true]) {
const pixels = [];
for (const kind of TRANSIT_ICON_KINDS) {
const display =
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
assert.equal(display, selected ? 36 : 30);
assert.equal(presetSpriteOutlinePx('thermal', selected), 3);
const frame = haloFrame(
presetSpriteOutlinePx('thermal', selected),
display,
);
const size = Math.round(display * frame.ratio * 8);
const svg = decode(
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
);
const frame = haloFrame(3, display);
const width = Math.round(display * frame.ratio);
const { data, info } = await sharp(Buffer.from(svg))
.resize(width, width)
.ensureAlpha()
.resize(size, size)
.flatten({ background: '#ffffff' })
.raw()
.toBuffer({ resolveWithObject: true });
let white = 0;
for (let i = 0; i < data.length; i += 4)
if (
data[i] >= 240 &&
data[i + 1] >= 240 &&
data[i + 2] >= 240 &&
data[i + 3] >= 240
)
white++;
assert.ok(
white / (info.width * info.height) >= 0.6,
`${kind}/${display}: white ${white / (info.width * info.height)}`,
);
const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4;
assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]);
const sample = (offsetPx) => {
const x = Math.floor(
size / 2 + (offsetPx * size) / (display * frame.ratio),
);
const i = (Math.floor(size / 2) * info.width + x) * info.channels;
return (
(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255
);
};
// The body stays at CRT size; the ring must cover this fixed external band.
const ringMin = sample((sides[kind] * display) / 96 + 1.25);
pixels.push({
key: kind,
verified: true,
centre: sample(0),
ringMin,
ringMax: 1 - ringMin,
background: 1,
});
}
const white = reduceSensorContrast(pixels, 'white', 'thermal');
assert.ok(white.pass, JSON.stringify({ selected, pixels, white }));
const black = reduceSensorContrast(
pixels.map((p) => ({ ...p, centre: 1 - p.centre })),
'black',
'thermal',
);
assert.ok(black.pass, JSON.stringify({ selected, black }));
}
});
+3 -5
View File
@@ -12,7 +12,7 @@
* Encoding per profile, the traffic-dot precedent applied to a glyph:
* - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the
* sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow
* ramp, and the brightest phosphor under NVG — drawn larger, with a dark
* ramp, and the brightest phosphor under NVG — at CRT size, with a thin dark
* halo sized in SCREEN pixels so it keeps local contrast on a bright road
* through any luma mapping. Black-hot FLIR inverts it, deliberately: a
* black sprite with a light ring is what a hot object looks like there.
@@ -52,8 +52,8 @@ const PROFILE_BY_STYLE = Object.freeze({
const SPRITE_STYLE = Object.freeze({
mono: Object.freeze({
rgba: Object.freeze([255, 255, 255, 1]),
scale: 1.5,
outlinePx: 3,
scale: 1.3,
outlinePx: 2,
}),
crt: Object.freeze({
scale: 1.3,
@@ -111,8 +111,6 @@ export function presetSpriteRgba(styleName, mode) {
* @returns {number} 1 under the normal profile.
*/
export function presetSpriteScale(styleName, selected = false) {
if (transitStyleProfile(styleName) === 'mono')
return selected ? 36 / 30 : 30 / 20;
return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1;
}
+7 -7
View File
@@ -45,13 +45,13 @@ test('mono: every mode is a white core, larger, with a dark halo in screen pixel
`${style} ${mode}`,
);
}
assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`);
// At least a whole screen pixel and a half of ring around a five-pixel
// body: thinner than that vanishes through blur and pixelation.
assert.ok(
presetSpriteOutlinePx(style) >= 1.5,
`${style} halo is ${presetSpriteOutlinePx(style)} px`,
);
for (const selected of [false, true]) {
assert.equal(
presetSpriteScale(style, selected),
presetSpriteScale('retro', selected),
);
assert.equal(presetSpriteOutlinePx(style, selected), 2);
}
}
});
+54
View File
@@ -0,0 +1,54 @@
import { parseSceneDocument, stringifySceneDocument } from './document.js';
/** Build a validated immutable edit while preserving IDs, existing content and provenance. */
export function editSceneDetails(
project,
sceneId,
shotId,
sceneDetails,
shotDetails,
) {
const copy = parseSceneDocument(stringifySceneDocument(project));
const scene = copy.scenes.find((s) => s.id === sceneId),
shot = scene?.shots.find((s) => s.id === shotId);
if (!scene || !shot) throw new Error('Select a scene and shot first');
for (const key of Object.keys(sceneDetails))
if (!['anchors', 'dataPacks'].includes(key))
throw new Error('Unsupported scene detail');
for (const key of Object.keys(shotDetails))
if (
![
'camera',
'move',
'durationSec',
'holdSec',
'dataPackIds',
'interactions',
].includes(key)
)
throw new Error('Unsupported shot detail');
for (const key of ['anchors', 'dataPacks']) {
delete scene[key];
if (Object.hasOwn(sceneDetails, key)) scene[key] = sceneDetails[key];
}
for (const key of [
'camera',
'move',
'durationSec',
'holdSec',
'dataPackIds',
'interactions',
]) {
delete shot[key];
if (Object.hasOwn(shotDetails, key)) shot[key] = shotDetails[key];
}
return parseSceneDocument(stringifySceneDocument(copy));
}
/** Export exactly one authored scene; no live state or service configuration is consulted. */
export function selectSceneDocument(project, sceneId) {
const copy = parseSceneDocument(stringifySceneDocument(project));
const scene = copy.scenes.find((s) => s.id === sceneId);
if (!scene) throw new Error('Select a scene first');
return { ...copy, scenes: [scene] };
}
+54
View File
@@ -0,0 +1,54 @@
/** Portable camera poses and authored motion. Angles are degrees; heights are ellipsoidal meters. */
/** Resolve an inline pose or a scene-local geographic anchor without modifying authored state. */
export function resolveCameraPose(scene, camera) {
if (!camera) return null;
const position = camera.anchorId
? scene?.anchors?.find(({ id }) => id === camera.anchorId)
: camera;
if (!position) throw new Error('Camera anchor is unavailable');
return {
lat: position.lat,
lon: position.lon,
alt: position.alt,
heading: camera.heading ?? 0,
pitch: camera.pitch ?? -35,
roll: camera.roll ?? 0,
};
}
/** Resolve both authored endpoints. An explicit move never depends on the live viewport. */
export function resolveCameraMove(scene, shot) {
if (!shot?.move) return null;
return {
from: resolveCameraPose(scene, shot.move.from),
to: resolveCameraPose(scene, shot.camera),
easing: shot.move.easing,
durationSec: shot.durationSec,
};
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
if (t === 0) return { ...move.from };
if (t === 1) return { ...move.to };
const eased =
move.easing === 'linear'
? t
: t < 0.5
? 4 * t ** 3
: 1 - (-2 * t + 2) ** 3 / 2;
const lerp = (a, b) => a + (b - a) * eased;
const angle = (a, b) =>
a + (((((b - a + 540) % 360) + 360) % 360) - 180) * eased;
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
roll: angle(from.roll, to.roll),
};
}
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from './document.js';
import { normalizeProject } from '../scenes/project.js';
import { resolveCameraMove, sampleCameraMove } from './camera.js';
import { sceneSeekState } from './timeline.js';
const fixture = () => ({
version: 4,
scenes: [
{
id: 'scene',
title: 'Cross the dateline',
anchors: [
{
id: 'west',
title: 'Start',
lat: 10,
lon: 179,
alt: 400,
altitudeReference: 'ellipsoid',
},
{
id: 'east',
title: 'End',
lat: 12,
lon: -179,
alt: 800,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Cross',
durationSec: 4,
holdSec: 2,
camera: { anchorId: 'east', heading: 10, pitch: 0, roll: 0 },
move: {
from: { anchorId: 'west', heading: 350, pitch: -40, roll: 0 },
easing: 'linear',
},
},
],
},
],
});
test('version 4 retains anchor identity, references and explicit move edits through normalization', () => {
const raw = fixture();
const project = normalizeProject(parseSceneDocument(JSON.stringify(raw)));
assert.deepEqual(project.scenes[0].anchors, raw.scenes[0].anchors);
assert.deepEqual(
project.scenes[0].shots[0].move,
raw.scenes[0].shots[0].move,
);
assert.deepEqual(
project.scenes[0].shots[0].camera,
raw.scenes[0].shots[0].camera,
);
assert.deepEqual(
normalizeProject(parseSceneDocument(stringifySceneDocument(project))),
project,
);
});
test('v3 unversioned bloom already uses scale 2 and is not migrated again in later formats', () => {
const project = normalizeProject(
parseSceneDocument(
JSON.stringify({
version: 3,
scenes: [
{ shots: [{ visual: { bloom: { enabled: true, intensity: 50 } } }] },
],
}),
),
);
assert.equal(project.version, 6);
assert.equal(project.scenes[0].shots[0].visual.bloom.intensity, 50);
});
test('move endpoints, easing, shortest arcs and hold agree with scene seeking', () => {
const scene = normalizeProject(parseSceneDocument(JSON.stringify(fixture())))
.scenes[0];
const move = resolveCameraMove(scene, scene.shots[0]);
const midpoint = sampleCameraMove(move, 0.5);
assert.equal(midpoint.lon, -180);
assert.equal(midpoint.heading, 360);
assert.equal(midpoint.lat, 11);
assert.equal(midpoint.alt, 600);
assert.equal(midpoint.pitch, -20);
const seek = (p) =>
sceneSeekState(
scene,
p,
(_, shot) => shot.durationSec + shot.holdSec,
(_, shot) => shot.holdSec,
);
assert.deepEqual(seek(1 / 3).camera, midpoint);
assert.deepEqual(seek(0).camera, move.from);
assert.deepEqual(seek(5 / 6).camera, move.to);
assert.equal(seek(5 / 6).holdElapsedSec, 1);
assert.deepEqual(
seek(1 / 3).camera,
midpoint,
'backward seek is independent of previous viewport state',
);
assert.equal(
sampleCameraMove({ ...move, easing: 'cubic-in-out' }, 0.25).lat,
10.125,
);
assert.deepEqual(sampleCameraMove(move, 1), move.to);
});
test('unknown anchors, duplicate IDs, mixed references, bad easing and unspecified altitude are rejected', () => {
for (const mutate of [
(s) => {
s.shots[0].camera.anchorId = 'missing';
},
(s) => {
s.anchors[1].id = 'west';
},
(s) => {
s.anchors[0].altitudeReference = 'terrain';
},
(s) => {
delete s.anchors[0].altitudeReference;
},
(s) => {
s.shots[0].camera.lon = 1;
},
(s) => {
s.shots[0].move.easing = 'bounce';
},
(s) => {
s.shots[0].move.from = { lat: 1, lon: 2, alt: 3 };
},
(s) => {
s.shots[0].durationSec = 0;
},
(s) => {
delete s.shots[0].holdSec;
},
]) {
const project = fixture();
mutate(project.scenes[0]);
assert.throws(() => parseSceneDocument(JSON.stringify(project)), {
name: 'SceneDocumentError',
});
}
const old = fixture();
old.version = 3;
assert.throws(
() => parseSceneDocument(JSON.stringify(old)),
/unsupported field/,
);
});
+88
View File
@@ -0,0 +1,88 @@
import {
fields,
string,
number,
optional,
array,
fail,
uniqueId,
} from './documentFields.js';
const POSITION = { lat: [-90, 90], lon: [-180, 180], alt: [-12000, 1e9] };
const ORIENTATION = {
heading: [-360, 360],
pitch: [-90, 90],
roll: [-360, 360],
};
function coordinates(value, path, bounds, required, legacy) {
for (const [key, range] of Object.entries(bounds)) {
if (required || Object.hasOwn(value, key))
number(value[key], `${path}.${key}`, ...range, legacy);
}
}
function reference(value, path) {
if (value !== 'ellipsoid')
fail(
path,
'expected ellipsoid height in meters; terrain-relative heights are not supported',
);
}
function pose(value, path, version, anchorIds, explicit = false) {
const anchored = version >= 4 && Object.hasOwn(value || {}, 'anchorId');
fields(value, path, [
...Object.keys(ORIENTATION),
...(anchored
? ['anchorId']
: [
...Object.keys(POSITION),
...(version >= 4 ? ['altitudeReference'] : []),
]),
]);
coordinates(value, path, ORIENTATION, false, version < 3);
if (anchored) {
string(value.anchorId, `${path}.anchorId`);
if (!anchorIds.has(value.anchorId))
fail(`${path}.anchorId`, 'unknown scene anchor');
} else {
coordinates(value, path, POSITION, explicit, version < 3);
if (explicit || Object.hasOwn(value, 'altitudeReference'))
reference(value.altitudeReference, `${path}.altitudeReference`);
}
}
/** Validate scene-local anchor IDs, camera references and explicit motion without reading terrain or applying state. */
export function validateSceneCameras(scene, path, version) {
const anchorIds = new Set();
optional(scene, 'anchors', path, (anchors, field) => {
array(anchors, field, 1024);
anchors.forEach((anchor, i) => {
const at = `${field}[${i}]`;
fields(anchor, at, [
'id',
'title',
...Object.keys(POSITION),
'altitudeReference',
]);
string(anchor.id, `${at}.id`);
uniqueId(anchor, at, anchorIds);
optional(anchor, 'title', at, (v, p) => string(v, p, 4096));
coordinates(anchor, at, POSITION, true, false);
reference(anchor.altitudeReference, `${at}.altitudeReference`);
});
});
scene.shots.forEach((shot, index) => {
const at = `${path}.shots[${index}]`;
optional(shot, 'move', at, (move, field) => {
fields(move, field, ['from', 'easing']);
if (!['linear', 'cubic-in-out'].includes(move.easing))
fail(`${field}.easing`, 'expected linear or cubic-in-out');
pose(move.from, `${field}.from`, version, anchorIds, true);
pose(shot.camera, `${at}.camera`, version, anchorIds, true);
number(shot.durationSec, `${at}.durationSec`, 0.2, 86400, false);
number(shot.holdSec, `${at}.holdSec`, 0, 86400, false);
});
optional(shot, 'camera', at, (camera, field) =>
pose(camera, field, version, anchorIds),
);
});
}
+301
View File
@@ -0,0 +1,301 @@
const clamp01 = (value) => Math.max(0, Math.min(1, value));
/** Own playback timers and scene-clock subscriptions independently of renderer and editor state. */
export function createPlaybackClock(options) {
return new PlaybackClock(options);
}
class PlaybackClock {
constructor({
isRunning,
timingForShot,
onProgress,
now = () => Date.now(),
schedule = (callback, ms) => setInterval(callback, ms),
cancel = (timer) => clearInterval(timer),
}) {
this.isRunning = isRunning;
this.timingForShot = timingForShot;
this.onProgress = onProgress;
this.now = now;
this.schedule = schedule;
this.cancel = cancel;
this._progressTimer = null;
this._shotProgressTimer = null;
this._sceneClockTimer = null;
this._sceneClockListeners = new Set();
this._sceneClockSnapshot = null;
this._destroyed = false;
this._waitGeneration = 0;
this._shotGeneration = 0;
this._sceneGeneration = 0;
this._waiters = new Set();
}
get snapshot() {
return this._sceneClockSnapshot ? { ...this._sceneClockSnapshot } : null;
}
get activeTimers() {
return (
[
this._progressTimer,
this._shotProgressTimer,
this._sceneClockTimer,
].filter((timer) => timer !== null).length + this._waiters.size
);
}
stopShot() {
this._shotGeneration++;
this.cancel(this._shotProgressTimer);
this._shotProgressTimer = null;
}
stopSceneTimer() {
this._sceneGeneration++;
this.cancel(this._sceneClockTimer);
this._sceneClockTimer = null;
}
finish() {
this.cancel(this._progressTimer);
this._progressTimer = null;
this.stopSceneTimer();
}
stop() {
this.cancelWaits();
this.stopShot();
this.finish();
if (
!this._sceneClockSnapshot ||
this._sceneClockSnapshot.stopped ||
this._destroyed
)
return;
this._sceneClockSnapshot = {
...this._sceneClockSnapshot,
running: false,
stopped: true,
};
for (const listener of this._sceneClockListeners) {
try {
listener(this.snapshot);
} catch {
/* Observers cannot block cancellation. */
}
}
}
destroy() {
this.cancelWaits();
this._destroyed = true;
this.stopShot();
this.finish();
this._sceneClockListeners.clear();
this._sceneClockSnapshot = null;
}
cancelWaits() {
this._waitGeneration++;
for (const finish of [...this._waiters]) finish();
}
/** Own cancellable hold delays; Stop and destroy settle pending waiters immediately. */
async wait(ms, token) {
const generation = this._waitGeneration;
const endAt = this.now() + ms;
const current = () =>
!this._destroyed &&
generation === this._waitGeneration &&
!token.cancelled &&
!token.signal?.aborted;
while (current() && this.now() < endAt) {
await new Promise((resolve) => {
const finish = () => {
clearTimeout(timer);
token.signal?.removeEventListener('abort', finish);
this._waiters.delete(finish);
resolve();
};
const timer = setTimeout(finish, 70);
this._waiters.add(finish);
token.signal?.addEventListener('abort', finish, { once: true });
});
}
}
subscribe(listener) {
if (this._destroyed || typeof listener !== 'function') return () => {};
this._sceneClockListeners.add(listener);
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
return () => this._sceneClockListeners.delete(listener);
}
publish(
scene,
shot,
sceneElapsedSec,
{ running = this.isRunning(), seeking = false } = {},
) {
if (this._destroyed || !scene || !shot) return;
const timing = this.timingForShot(scene, shot);
const elapsedSec = Math.max(
0,
Math.min(timing.totalSec, Number(sceneElapsedSec) || 0),
);
this._sceneClockSnapshot = {
sceneId: scene.id,
shotId: shot.id,
shotIndex: timing.shotIndex,
shotCount: scene.shots.length,
sceneElapsedSec: elapsedSec,
sceneDurationSec: timing.totalSec,
sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0,
running: Boolean(running),
seeking: Boolean(seeking),
};
for (const listener of this._sceneClockListeners) {
try {
listener({ ...this._sceneClockSnapshot });
} catch (error) {
console.warn('[Scenes] Scene clock listener failed:', error);
}
}
}
startRunProgress(totalSec) {
this.cancel(this._progressTimer);
if (this._destroyed) return;
const startMs = this.now();
const totalMs = Math.max(1000, totalSec * 1000);
const timer = this.schedule(() => {
if (this._destroyed || this._progressTimer !== timer || !this.isRunning())
return;
const elapsedMs = this.now() - startMs;
this.onProgress(elapsedMs / totalMs);
}, 100);
this._progressTimer = timer;
}
startShotProgress(token, seconds, from, to, sceneClock = null) {
this.stopShot();
const generation = this._shotGeneration;
if (
generation !== this._shotGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
this.isRunning()
)
return;
this.onProgress(from);
if (generation !== this._shotGeneration || this._destroyed) return;
if (sceneClock) {
this.publish(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedFrom,
{ running: false },
);
}
if (
generation !== this._shotGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
this.isRunning()
)
return;
if (seconds <= 0) {
this.onProgress(to);
if (generation !== this._shotGeneration || this._destroyed) return;
if (sceneClock) {
this.publish(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedTo,
{ running: false },
);
}
return;
}
const startedAt = this.now();
const timer = this.schedule(() => {
if (this._shotProgressTimer !== timer) return;
if (
generation !== this._shotGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
this.isRunning()
) {
this.cancel(timer);
if (this._shotProgressTimer === timer) this._shotProgressTimer = null;
return;
}
const fraction = clamp01((this.now() - startedAt) / (seconds * 1000));
this.onProgress(from + (to - from) * fraction);
if (generation !== this._shotGeneration || this._destroyed) return;
if (sceneClock) {
this.publish(
sceneClock.scene,
sceneClock.shot,
sceneClock.sceneElapsedFrom +
(sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) *
fraction,
{ running: false },
);
}
if (fraction >= 1) {
this.cancel(timer);
if (this._shotProgressTimer === timer) this._shotProgressTimer = null;
}
}, 100);
timer.unref?.();
this._shotProgressTimer = timer;
}
startScene(scene, shot, token) {
this.stopSceneTimer();
const generation = this._sceneGeneration;
const timing = this.timingForShot(scene, shot);
if (
generation !== this._sceneGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted
)
return timing;
const startedAt = this.now();
this.publish(scene, shot, timing.startElapsedSec, {
running: true,
});
if (
generation !== this._sceneGeneration ||
this._destroyed ||
token.cancelled ||
token.signal?.aborted
)
return timing;
const timer = this.schedule(() => {
if (this._sceneClockTimer !== timer) return;
if (
this._destroyed ||
token.cancelled ||
token.signal?.aborted ||
!this.isRunning()
) {
this.cancel(timer);
if (this._sceneClockTimer === timer) this._sceneClockTimer = null;
return;
}
const shotElapsedSec = Math.min(
timing.durationSec,
(this.now() - startedAt) / 1000,
);
this.publish(scene, shot, timing.startElapsedSec + shotElapsedSec, {
running: true,
});
}, 50);
timer.unref?.();
this._sceneClockTimer = timer;
return timing;
}
}
+173
View File
@@ -0,0 +1,173 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPlaybackClock } from './clock.js';
const scene = { id: 'scene', shots: [{ id: 'shot' }] };
const shot = scene.shots[0];
const timing = {
shotIndex: 0,
totalSec: 4,
durationSec: 4,
startElapsedSec: 0,
};
function fixture() {
const timers = new Map();
let id = 0,
now = 0,
running = true;
const progress = [];
const clock = createPlaybackClock({
isRunning: () => running,
timingForShot: () => timing,
onProgress: (value) => progress.push(value),
now: () => now,
schedule: (callback) => {
timers.set(++id, callback);
return id;
},
cancel: (handle) => timers.delete(handle),
});
return {
clock,
timers,
progress,
time: (value) => {
now = value;
},
running: (value) => {
running = value;
},
};
}
test('Stop releases every clock timer and queued callbacks cannot publish later', () => {
const f = fixture();
const observed = [];
f.clock.subscribe((snapshot) => observed.push(snapshot));
f.clock.startRunProgress(4);
f.clock.startScene(scene, shot, { cancelled: false });
f.time(2000);
for (const tick of f.timers.values()) tick();
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
const stale = [...f.timers.values()];
f.clock.stop();
assert.equal(f.clock.activeTimers, 0);
assert.equal(f.timers.size, 0);
assert.equal(f.clock.snapshot.stopped, true);
const count = observed.length,
progressCount = f.progress.length;
f.time(3000);
for (const tick of stale) tick();
assert.equal(observed.length, count);
assert.equal(f.progress.length, progressCount);
});
test('replacing a shot clock rejects the old callback without clearing the replacement', () => {
const f = fixture();
f.clock.startScene(scene, shot, {});
const stale = [...f.timers.values()][0];
f.time(1000);
f.clock.startScene(scene, shot, {});
f.time(2000);
stale();
assert.equal(f.clock.snapshot.sceneElapsedSec, 0);
assert.equal(f.clock.activeTimers, 1);
[...f.timers.values()][0]();
assert.equal(f.clock.snapshot.sceneElapsedSec, 1);
f.clock.destroy();
});
test('direct-load progress finishes once and snapshots cannot mutate the clock', () => {
const f = fixture();
f.running(false);
f.clock.startShotProgress({}, 2, 0.25, 0.75, {
scene,
shot,
sceneElapsedFrom: 1,
sceneElapsedTo: 3,
});
const tick = [...f.timers.values()][0];
f.time(1000);
tick();
assert.equal(f.progress.at(-1), 0.5);
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
const copy = f.clock.snapshot;
copy.sceneElapsedSec = 99;
assert.equal(f.clock.snapshot.sceneElapsedSec, 2);
f.time(2000);
tick();
assert.equal(f.progress.at(-1), 0.75);
assert.equal(f.clock.activeTimers, 0);
const count = f.progress.length;
tick();
assert.equal(f.progress.length, count);
});
test('aborted starts and destruction during initial notification leave no timers or listeners', () => {
const f = fixture();
const controller = new AbortController();
controller.abort();
f.clock.startScene(scene, shot, { signal: controller.signal });
assert.equal(f.timers.size, 0);
let calls = 0;
f.clock.subscribe(() => {
calls++;
f.clock.destroy();
});
f.clock.startScene(scene, shot, {});
assert.equal(calls, 1);
assert.equal(f.clock.activeTimers, 0);
f.clock.startRunProgress(4);
f.clock.publish(scene, shot, 1);
f.clock.subscribe(() => calls++);
assert.equal(calls, 1);
assert.equal(f.timers.size, 0);
});
test('Stop, abort and destroy settle long holds immediately and release their deadlines', async () => {
for (const action of ['stop', 'abort', 'destroy']) {
const clock = createPlaybackClock({
isRunning: () => true,
timingForShot: () => timing,
onProgress() {},
});
const abort = new AbortController();
const wait = clock.wait(60000, { signal: abort.signal });
assert.equal(clock.activeTimers, 1);
if (action === 'abort') abort.abort();
else clock[action]();
await wait;
assert.equal(clock.activeTimers, 0);
clock.destroy();
}
});
test('a subscriber can Stop or replace the initial clock without the old start acquiring a timer', () => {
for (const action of ['stop', 'replace']) {
const f = fixture();
let first = true;
f.clock.subscribe(() => {
if (!first) return;
first = false;
if (action === 'stop') f.clock.stop();
else f.clock.startScene(scene, { id: 'replacement' }, {});
});
f.clock.startScene(scene, shot, {});
assert.equal(f.timers.size, action === 'stop' ? 0 : 1);
assert.equal(
f.clock.snapshot.shotId,
action === 'stop' ? shot.id : 'replacement',
);
f.clock.destroy();
}
const f = fixture();
f.running(false);
f.clock.subscribe(() => f.clock.stop());
f.clock.startShotProgress({}, 10, 0, 1, {
scene,
shot,
sceneElapsedFrom: 0,
sceneElapsedTo: 4,
});
assert.equal(f.timers.size, 0);
});
+186
View File
@@ -0,0 +1,186 @@
/** Bounded, renderer-independent validation for authored scene documents. */
import {
SCENE_DOCUMENT_LIMITS,
fail,
object,
fields,
string,
number,
optional,
array,
ids,
uniqueId,
jsonTree,
} from './documentFields.js';
import { validateSceneDataPacks } from './packs/manifest.js';
import { validateSceneCameras } from './cameraDocument.js';
export { SCENE_DOCUMENT_LIMITS, SceneDocumentError } from './documentFields.js';
import { validateSceneInteractions } from './interactions/document.js';
export const SCENE_DOCUMENT_VERSION = 6;
function visual(value, path, legacy) {
fields(value, path, [
'style',
'bloom',
'sharpen',
'hud',
'detection',
'scope',
'mapStack',
'styleParams',
]);
for (const key of ['style', 'mapStack']) optional(value, key, path, string);
optional(value, 'styleParams', path, object);
const specs = {
bloom: { enabled: 'boolean', intensity: [-100, 10000], version: [1, 100] },
sharpen: { enabled: 'boolean', intensity: [0, 100] },
hud: { visible: 'boolean', variant: 'string' },
detection: {
mode: 'string',
density: [0, 100],
allocation: 'string',
fadePct: [0, 100],
outsideOpacityPct: [0, 100],
},
scope: { enabled: 'boolean', featherPct: [0, 100] },
};
for (const [key, spec] of Object.entries(specs))
optional(value, key, path, (entry, field) => {
fields(entry, field, Object.keys(spec));
for (const [name, type] of Object.entries(spec))
optional(entry, name, field, (item, at) => {
if (Array.isArray(type)) number(item, at, ...type, legacy);
else if (type === 'string') string(item, at);
else if (typeof item !== 'boolean') fail(at, 'expected a boolean');
});
});
}
/** Validate a parsed scene project without mutating it or applying any state. */
export function validateSceneDocument(project) {
jsonTree(project, '$', { nodes: 0 });
fields(project, '$', [
'version',
'createdAt',
'updatedAt',
'installedBuiltInSceneIds',
'scenes',
]);
const version = Object.hasOwn(project, 'version') ? project.version : 1;
if (![1, 2, 3, 4, 5, 6].includes(version))
fail('$.version', 'unsupported scene project version');
const legacy = version < 3;
for (const key of ['createdAt', 'updatedAt'])
optional(project, key, '$', string);
optional(project, 'installedBuiltInSceneIds', '$', ids);
array(project.scenes, '$.scenes', SCENE_DOCUMENT_LIMITS.scenes);
const sceneIds = new Set();
let shotCount = 0;
project.scenes.forEach((scene, index) => {
const path = `$.scenes[${index}]`;
fields(scene, path, [
'id',
'title',
'releaseLayerIds',
'appliedShotPacks',
'shots',
...(version >= 4 ? ['anchors'] : []),
...(version >= 5 ? ['dataPacks'] : []),
]);
uniqueId(scene, path, sceneIds);
optional(scene, 'title', path, (v, p) => string(v, p, 4096));
optional(scene, 'releaseLayerIds', path, ids);
optional(scene, 'appliedShotPacks', path, (packs, field) => {
array(packs, field);
const packIds = new Set();
packs.forEach((pack, i) => {
const at = `${field}[${i}]`;
fields(pack, at, ['id', 'version', 'shotBindings']);
string(pack.id, `${at}.id`);
uniqueId(pack, at, packIds);
optional(pack, 'version', at, (v, p) =>
number(v, p, 1, 1000000, legacy),
);
optional(pack, 'shotBindings', at, (bindings, p) => {
object(bindings, p);
for (const [title, id] of Object.entries(bindings))
string(id, `${p}.${title}`);
});
});
});
array(scene.shots, `${path}.shots`);
shotCount += scene.shots.length;
if (shotCount > SCENE_DOCUMENT_LIMITS.shots)
fail(`${path}.shots`, 'too many shots in project');
const shotIds = new Set();
scene.shots.forEach((shot, i) => {
const at = `${path}.shots[${i}]`;
fields(shot, at, [
'id',
'title',
'durationSec',
'holdSec',
'camera',
'visual',
'layers',
'sourcePackId',
'sourcePackVersion',
...(version >= 4 ? ['move'] : []),
...(version >= 5 ? ['dataPackIds'] : []),
...(version >= 6 ? ['interactions'] : []),
]);
uniqueId(shot, at, shotIds);
optional(shot, 'title', at, (v, p) => string(v, p, 4096));
optional(shot, 'sourcePackId', at, string);
optional(shot, 'sourcePackVersion', at, (v, p) =>
number(v, p, 1, 1000000, legacy),
);
optional(shot, 'durationSec', at, (v, p) =>
number(v, p, 0, 86400, legacy),
);
optional(shot, 'holdSec', at, (v, p) => number(v, p, 0, 86400, legacy));
optional(shot, 'visual', at, (v, p) => visual(v, p, legacy));
optional(shot, 'layers', at, (layers, field) => {
object(layers, field);
for (const [id, entry] of Object.entries(layers)) {
string(id, field);
if (typeof entry === 'boolean') continue;
const p = `${field}.${id}`;
fields(entry, p, ['enabled', 'params']);
if (typeof entry.enabled !== 'boolean')
fail(`${p}.enabled`, 'expected a boolean');
optional(entry, 'params', p, object);
}
});
});
validateSceneCameras(scene, path, version);
validateSceneDataPacks(scene, path);
validateSceneInteractions(scene, path);
});
return project;
}
/** Read bounded JSON. No URLs, modules or actions are executed during import. */
export function parseSceneDocument(text) {
if (
typeof text !== 'string' ||
text.length > SCENE_DOCUMENT_LIMITS.bytes ||
new TextEncoder().encode(text).byteLength > SCENE_DOCUMENT_LIMITS.bytes
)
fail('$', 'file exceeds 5 MiB');
let parsed;
try {
parsed = JSON.parse(text);
} catch {
fail('$', 'invalid JSON');
}
return validateSceneDocument(parsed);
}
/** Export authored project fields only; callers pass the project, never run state. */
export function stringifySceneDocument(project) {
// Round-trip drops optional undefined fields created by the editor.
const text = JSON.stringify(project, null, 2);
parseSceneDocument(text);
return text;
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseSceneDocument,
stringifySceneDocument,
validateSceneDocument,
SCENE_DOCUMENT_LIMITS,
} from './document.js';
import { createDefaultProject, normalizeProject } from '../scenes/project.js';
const fixture = () => ({
version: 3,
scenes: [
{
id: 'scene',
title: 'My scene',
shots: [
{
id: 'shot',
title: 'My shot',
durationSec: 3,
holdSec: 0,
camera: { lat: 1, lon: 2, alt: 42, pitch: 0 },
layers: {
traffic: { enabled: true, params: { nested: [1, 'two', null] } },
},
},
],
},
],
});
test('all built-in authored content exports and normalizes without edit loss', () => {
const project = createDefaultProject();
const migrated = normalizeProject(
parseSceneDocument(stringifySceneDocument(project)),
);
assert.deepEqual(
JSON.parse(stringifySceneDocument(migrated)),
JSON.parse(stringifySceneDocument(project)),
);
const edited = normalizeProject(fixture());
edited.scenes[0].appliedShotPacks = [
{ id: 'pack', version: 3, shotBindings: { Original: 'shot' } },
];
assert.deepEqual(
normalizeProject(parseSceneDocument(stringifySceneDocument(edited))),
edited,
);
assert.equal(edited.scenes[0].shots[0].camera.pitch, 0);
assert.equal(edited.scenes[0].shots[0].camera.alt, 42);
});
test('v1/v2 bloom migrates once; IDs, edits, pack bindings and zero holds survive', () => {
for (const version of [undefined, 1, 2]) {
const project = fixture();
project.version = version;
project.scenes[0].shots[0].visual = {
bloom: { enabled: true, intensity: 25 },
};
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.equal(migrated.version, 6);
assert.equal(migrated.scenes[0].shots[0].visual.bloom.intensity, 150);
assert.equal(migrated.scenes[0].shots[0].id, 'shot');
assert.deepEqual(
normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))),
migrated,
);
}
});
test('an intentionally empty project stays empty', () => {
assert.deepEqual(
normalizeProject(parseSceneDocument('{"version":3,"scenes":[]}')).scenes,
[],
);
});
test('missing legacy IDs become stable after the first saved migration', () => {
const project = { scenes: [{ shots: [{}] }] };
const migrated = normalizeProject(validateSceneDocument(project));
assert.ok(migrated.scenes[0].id);
assert.ok(migrated.scenes[0].shots[0].id);
assert.deepEqual(
normalizeProject(parseSceneDocument(stringifySceneDocument(migrated))),
migrated,
);
});
test('invalid shapes, versions, unknown fields and unsafe keys fail with field paths', () => {
for (const text of [
'null',
'[]',
'{}',
'{"version":99,"scenes":[]}',
'{"version":3,"scenes":[],"run":{}}',
'{"scenes":[],"__proto__":{}}',
]) {
assert.throws(() => parseSceneDocument(text), {
name: 'SceneDocumentError',
});
}
const project = fixture();
project.scenes[0].shots[0].camera.lat = 91;
assert.throws(
() => validateSceneDocument(project),
/\$\.scenes\[0\]\.shots\[0\]\.camera.lat/,
);
project.scenes[0].shots[0].camera.lat = 1;
project.scenes[0].shots.push(structuredClone(project.scenes[0].shots[0]));
assert.throws(() => validateSceneDocument(project), /duplicate ID/);
});
test('document byte, nesting, collection, finite-number and string bounds are enforced', () => {
assert.throws(
() => parseSceneDocument(' '.repeat(SCENE_DOCUMENT_LIMITS.bytes + 1)),
/5 MiB/,
);
assert.throws(
() =>
parseSceneDocument(
JSON.stringify({
scenes: [],
createdAt: 'é'.repeat(SCENE_DOCUMENT_LIMITS.bytes / 2),
}),
),
/5 MiB/,
);
const project = fixture();
project.scenes[0].shots[0].camera.lat = Infinity;
assert.throws(() => validateSceneDocument(project), /JSON value/);
project.scenes[0].shots[0].camera.lat = 1;
let nested = project.scenes[0].shots[0].layers.traffic.params;
for (let i = 0; i < 30; i++) nested = nested.child = {};
assert.throws(() => validateSceneDocument(project), /complexity/);
assert.throws(
() => validateSceneDocument({ scenes: Array(257).fill({ shots: [] }) }),
/256/,
);
});
test('captured scope and extended detection edits survive migration', () => {
const project = fixture();
project.scenes[0].shots[0].visual = {
scope: { enabled: true, featherPct: 11 },
detection: {
mode: 'DENSE',
density: 75,
allocation: 'ELASTIC',
fadePct: 7,
outsideOpacityPct: 1,
},
};
const migrated = normalizeProject(
parseSceneDocument(JSON.stringify(project)),
);
assert.deepEqual(
migrated.scenes[0].shots[0].visual.scope,
project.scenes[0].shots[0].visual.scope,
);
assert.deepEqual(
migrated.scenes[0].shots[0].visual.detection,
project.scenes[0].shots[0].visual.detection,
);
});
+94
View File
@@ -0,0 +1,94 @@
/** Shared bounded JSON and field checks for scene formats. */
export const SCENE_DOCUMENT_LIMITS = Object.freeze({
bytes: 5 * 1024 * 1024,
scenes: 256,
shots: 10000,
collection: 10000,
depth: 24,
nodes: 200000,
string: 65536,
});
/** A project error identifies a field without echoing its supplied value. */
export class SceneDocumentError extends Error {
constructor(path, reason) {
super(`${path}: ${reason}`);
this.name = 'SceneDocumentError';
this.path = path;
}
}
export const fail = (path, reason) => {
throw new SceneDocumentError(path, reason);
};
export const object = (value, path) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
fail(path, 'expected an object');
};
export function fields(value, path, allowed) {
object(value, path);
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) fail(`${path}.${key}`, 'unsupported field');
}
}
export function string(value, path, max = 256) {
if (typeof value !== 'string' || !value.trim() || value.length > max)
fail(path, `expected nonempty text, at most ${max} characters`);
}
export function number(value, path, min, max, legacy) {
const numeric =
legacy && typeof value === 'string' && value.trim() ? Number(value) : value;
if (
typeof numeric !== 'number' ||
!Number.isFinite(numeric) ||
numeric < min ||
numeric > max
)
fail(path, `expected a number from ${min} to ${max}`);
}
export function optional(value, key, path, check) {
if (Object.hasOwn(value, key)) check(value[key], `${path}.${key}`);
}
export function array(value, path, max = SCENE_DOCUMENT_LIMITS.collection) {
if (!Array.isArray(value) || value.length > max)
fail(path, `expected an array of at most ${max} entries`);
}
export function ids(value, path) {
array(value, path);
value.forEach((id, index) => string(id, `${path}[${index}]`));
}
export function uniqueId(value, path, seen) {
if (!Object.hasOwn(value, 'id')) return; // Legacy omissions receive an ID once during migration.
string(value.id, `${path}.id`);
if (seen.has(value.id)) fail(`${path}.id`, 'duplicate ID');
seen.add(value.id);
}
export function jsonTree(value, path, budget, depth = 0) {
if (
++budget.nodes > SCENE_DOCUMENT_LIMITS.nodes ||
depth > SCENE_DOCUMENT_LIMITS.depth
)
fail(path, 'project complexity limit exceeded');
if (value === null || typeof value === 'boolean') return;
if (typeof value === 'string') {
if (value.length > SCENE_DOCUMENT_LIMITS.string)
fail(path, 'text is too long');
return;
}
if (typeof value === 'number' && Number.isFinite(value)) return;
if (!value || typeof value !== 'object') fail(path, 'expected a JSON value');
if (
Object.getPrototypeOf(value) !== Object.prototype &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) !== null
)
fail(path, 'expected a JSON object');
const entries = Object.entries(value);
if (entries.length > SCENE_DOCUMENT_LIMITS.collection)
fail(path, 'too many entries');
for (const [key, child] of entries) {
if (['__proto__', 'constructor', 'prototype'].includes(key))
fail(path, 'unsafe object key');
if (key.length > 256) fail(path, 'field name is too long');
jsonTree(child, `${path}.${key}`, budget, depth + 1);
}
}
+96
View File
@@ -0,0 +1,96 @@
import {
array,
fields,
string,
optional,
uniqueId,
fail,
} from '../documentFields.js';
/** Validate inert, scene-local actions. Feature existence is checked after asset loading. */
export function validateSceneInteractions(scene, path) {
const packs = new Map((scene.dataPacks || []).map((p) => [p.id, p]));
const anchors = new Set((scene.anchors || []).map((a) => a.id));
const shots = new Map(scene.shots.map((s) => [s.id, s]));
scene.shots.forEach((shot, index) => {
optional(shot, 'interactions', `${path}.shots[${index}]`, (items, at) => {
array(items, at, 64);
const seen = new Set();
items.forEach((item, i) => {
const field = `${at}[${i}]`;
fields(item, field, ['id', 'label', 'target', 'action']);
string(item.id, field);
uniqueId(item, field, seen);
string(item.label, field, 256);
fields(item.target, `${field}.target`, ['packId', 'featureId']);
string(item.target.packId, field);
string(item.target.featureId, field);
if (
packs.get(item.target.packId)?.format !== 'geojson' ||
!shot.dataPackIds?.includes(item.target.packId)
)
fail(field, 'target must belong to a selected GeoJSON pack');
const a = item.action;
const specs = {
card: ['text', 'url'],
focus: ['anchorId'],
shot: ['shotId'],
layer: ['layerId', 'enabled'],
};
if (!a || !Object.hasOwn(specs, a.type))
fail(field, 'unsupported action');
string(a.type, `${field}.action.type`);
fields(a, `${field}.action`, ['type', ...specs[a.type]]);
const reference = {
focus: 'anchorId',
shot: 'shotId',
layer: 'layerId',
}[a.type];
if (reference) string(a[reference], `${field}.action.${reference}`);
if (a.type === 'card') {
string(a.text, field, 4096);
optional(a, 'url', field, (url, at) => {
string(url, at, 2048);
let parsed;
try {
parsed = new URL(url);
} catch {
fail(at, 'expected HTTPS source URL');
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
fail(
at,
'expected HTTPS source URL without credentials, query or fragment',
);
});
} else if (a.type === 'focus' && !anchors.has(a.anchorId))
fail(field, 'unknown anchor');
else if (a.type === 'shot') {
const target = shots.get(a.shotId);
if (!target) fail(field, 'unknown shot');
// A transition must restore every layer this shot can change.
for (const entry of items)
if (
entry?.action?.type === 'layer' &&
!Object.hasOwn(target.layers || {}, entry.action.layerId)
)
fail(
field,
'target shot must declare interactive layer baseline',
);
} else if (a.type === 'layer') {
if (!Object.hasOwn(shot.layers || {}, a.layerId))
fail(field, 'layer must have an explicit shot baseline');
if (typeof a.enabled !== 'boolean')
fail(field, 'expected enabled boolean');
}
});
});
});
}
@@ -0,0 +1,247 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
import { normalizeProject } from '../../scenes/project.js';
import { createInteractionSession } from './session.js';
const fixture = () => ({
version: 6,
scenes: [
{
id: 'scene',
anchors: [
{
id: 'view',
lat: 1,
lon: 2,
alt: 3000,
altitudeReference: 'ellipsoid',
},
],
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'test.json' },
attribution: { text: 'Example', license: 'CC0' },
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'one',
layers: { traffic: false },
dataPackIds: ['data'],
interactions: [
{
id: 'card',
label: 'Read',
target: { packId: 'data', featureId: 'point' },
action: {
type: 'card',
text: '<script>text only</script>',
url: 'https://example.org/source',
},
},
{
id: 'focus',
label: 'Focus',
target: { packId: 'data', featureId: 'point' },
action: { type: 'focus', anchorId: 'view' },
},
{
id: 'layer',
label: 'Traffic',
target: { packId: 'data', featureId: 'point' },
action: { type: 'layer', layerId: 'traffic', enabled: true },
},
{
id: 'next',
label: 'Next',
target: { packId: 'data', featureId: 'point' },
action: { type: 'shot', shotId: 'two' },
},
],
},
{ id: 'two', layers: { traffic: false } },
],
},
],
});
test('all four inert actions survive validation, migration and export without executing content', () => {
const input = fixture();
const output = JSON.parse(
stringifySceneDocument(
normalizeProject(parseSceneDocument(JSON.stringify(input))),
),
);
assert.deepEqual(
output.scenes[0].shots[0].interactions,
input.scenes[0].shots[0].interactions,
);
});
test('reject unknown fields, executable syntax, invalid references and missing reset baselines', () => {
const mutations = [
(p) => (p.version = 5),
(p) => {
p.scenes[0].shots[0].layers.undefined = false;
delete p.scenes[0].shots[0].interactions[2].action.layerId;
},
(p) => (p.scenes[0].shots[0].interactions[0].action.type = ['card']),
(p) => (p.scenes[0].shots[0].interactions[2].action.layerId = ['traffic']),
(p) => (p.scenes[0].shots[0].interactions = null),
(p) =>
p.scenes[0].shots[0].interactions.push(
p.scenes[0].shots[0].interactions[0],
),
(p) => (p.scenes[0].shots[0].interactions[0].action.code = 'alert(1)'),
(p) =>
(p.scenes[0].shots[0].interactions[0].action.url = 'javascript:alert(1)'),
(p) =>
(p.scenes[0].shots[0].interactions[0].action.url =
'https://user:[email protected]/'),
(p) =>
(p.scenes[0].shots[0].interactions[0].action.url =
'https://example.org/?token=secret'),
(p) => (p.scenes[0].shots[0].interactions[0].target.packId = 'missing'),
(p) => (p.scenes[0].shots[0].dataPackIds = []),
(p) => (p.scenes[0].shots[0].interactions[1].action.anchorId = 'missing'),
(p) => (p.scenes[0].shots[0].interactions[2].action.layerId = 'missing'),
(p) => (p.scenes[0].shots[0].interactions[2].action.enabled = 'yes'),
(p) => (p.scenes[0].shots[0].interactions[3].action.shotId = 'missing'),
(p) => (p.scenes[0].shots[1].layers = {}),
(p) =>
(p.scenes[0].shots[0].interactions = Array.from(
{ length: 65 },
(_, i) => ({ ...p.scenes[0].shots[0].interactions[0], id: String(i) }),
)),
];
for (const mutate of mutations) {
const p = fixture();
mutate(p);
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
}
});
test('pending actions cancel promptly, refuse overlap and cannot update a replacement session', async () => {
let resolve,
signal,
calls = 0;
const session = createInteractionSession({
execute: (_, s) => {
calls++;
signal = s;
return new Promise((r) => (resolve = r));
},
});
session.activate([{ id: 'old' }]);
const work = session.dispatch('old');
await Promise.resolve();
assert.equal(await session.dispatch('old'), false);
assert.equal(await session.dispatch('missing'), false);
session.activate([{ id: 'new' }]);
assert.equal(signal.aborted, true);
assert.equal(await work, false);
resolve(true);
await Promise.resolve();
assert.deepEqual(session.getState(), {
active: true,
busy: false,
selected: null,
count: 1,
});
assert.equal(calls, 1);
session.clear();
assert.equal(await session.dispatch('new'), false);
});
test('synchronous stop before execution prevents any side effect; rejection unlocks retry', async () => {
let calls = 0;
const session = createInteractionSession({
execute: () => {
calls++;
throw new Error('refused');
},
});
session.activate([{ id: 'a' }]);
const work = session.dispatch('a');
session.clear();
assert.equal(await work, false);
assert.equal(calls, 0);
session.activate([{ id: 'a' }]);
assert.equal(await session.dispatch('a'), false);
assert.equal(session.getState().busy, false);
assert.equal(await session.dispatch('a'), false);
assert.equal(calls, 2);
});
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
const { SceneDirector } = await import('../../scenes/director.js');
const shot = { id: 'shot', dataPackIds: ['pack'] };
const director = Object.assign(Object.create(SceneDirector.prototype), {
_destroyed: false,
_loadedSceneId: 'scene',
_selectedShotId: 'shot',
_claimCameraOwnership: () =>
assert.fail('Must reload the released pack before camera seek'),
});
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
});
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
const { SceneDirector } = await import('../../scenes/director.js');
const owner = new AbortController();
const director = Object.assign(Object.create(SceneDirector.prototype), {
_running: false,
_destroyed: false,
_interactionTransitions: 64,
_getShot: () => ({ scene: { id: 'scene' }, shot: { id: 'shot' } }),
_claimCameraOwnership: () => false,
_setCameraView: () => assert.fail('Refused camera cannot move'),
_updateStatus: () => {},
_loadShot: () => assert.fail('Transition budget exceeded'),
dataManager: {
getAll: () => [{ id: 'traffic' }],
setEnabled: (id, enabled, options) => {
assert.equal(id, 'traffic');
assert.equal(enabled, true);
assert.equal(options.signal, owner.signal);
assert.equal(options.origin, 'scene');
return false;
},
},
});
assert.equal(
await director._executeInteraction({ type: 'focus' }, owner.signal),
false,
);
assert.equal(
await director._executeInteraction(
{ type: 'shot', shotId: 'next' },
owner.signal,
),
false,
);
assert.equal(
await director._executeInteraction(
{ type: 'layer', layerId: 'traffic', enabled: true },
owner.signal,
),
false,
);
assert.equal(
await director._executeInteraction(
{ type: 'layer', layerId: 'unknown', enabled: true },
owner.signal,
),
false,
);
owner.abort();
assert.equal(
await director._executeInteraction({ type: 'focus' }, owner.signal),
false,
);
});
+61
View File
@@ -0,0 +1,61 @@
/** Own one user-triggered action at a time; cancellation settles even an uncooperative adapter. */
export function createInteractionSession({ execute, changed = () => {} }) {
let controller,
active = false,
busy = false,
selected = null;
let actions = new Map();
const state = () => ({ active, busy, selected, count: actions.size });
function clear() {
active = false;
busy = false;
selected = null;
actions.clear();
controller?.abort();
controller = null;
changed(state());
}
return {
clear,
getState: state,
activate(items) {
clear();
actions = new Map(items.map((item) => [item.id, item]));
active = !!actions.size;
changed(state());
},
async dispatch(id) {
const item = actions.get(id);
if (!active || busy || !item) return false;
const current = new AbortController();
controller = current;
busy = true;
selected = id;
changed(state());
let abort;
try {
const cancelled = new Promise((resolve) => {
abort = () => resolve(false);
current.signal.addEventListener('abort', abort, { once: true });
});
const work = Promise.resolve().then(() => {
if (current.signal.aborted) return false;
return execute(item, current.signal);
});
return (
(await Promise.race([work, cancelled])) !== false &&
!current.signal.aborted
);
} catch {
return false;
} finally {
current.signal.removeEventListener('abort', abort);
if (controller === current) {
busy = false;
controller = null;
changed(state());
}
}
},
};
}
+62
View File
@@ -0,0 +1,62 @@
import { PACK_LIMITS } from './manifest.js';
/** Decode a bounded geometry-only GeoJSON collection; properties never become HTML, styles or URLs. */
export function decodePackGeoJSON(bytes) {
const value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
);
if (
value?.type !== 'FeatureCollection' ||
!Array.isArray(value.features) ||
value.features.length > PACK_LIMITS.features
)
throw new Error('Expected a bounded FeatureCollection');
let positions = 0;
const ids = new Set();
function position(p) {
if (
!Array.isArray(p) ||
![2, 3].includes(p.length) ||
p.some((v) => !Number.isFinite(v)) ||
Math.abs(p[0]) > 180 ||
Math.abs(p[1]) > 90 ||
(p.length === 3 && (p[2] < -12000 || p[2] > 1e9)) ||
++positions > PACK_LIMITS.positions
)
throw new Error('Invalid geographic position');
return [p[0], p[1], p[2] ?? 0];
}
function line(points, ring = false) {
if (!Array.isArray(points) || points.length < (ring ? 4 : 2))
throw new Error('Invalid line');
const normalized = points.map(position);
if (ring && normalized[0].some((v, i) => v !== normalized.at(-1)[i]))
throw new Error('Unclosed ring');
return normalized;
}
return value.features.map((feature) => {
const id = feature?.id;
if (
feature?.type !== 'Feature' ||
typeof id !== 'string' ||
!id.trim() ||
id.length > 256 ||
ids.has(id)
)
throw new Error('Features require distinct string IDs');
ids.add(id);
const g = feature.geometry;
let coordinates;
if (g?.type === 'Point') coordinates = position(g.coordinates);
else if (g?.type === 'LineString') coordinates = line(g.coordinates);
else if (
g?.type === 'Polygon' &&
Array.isArray(g.coordinates) &&
g.coordinates.length &&
g.coordinates.length <= 128
)
coordinates = g.coordinates.map((ring) => line(ring, true));
else throw new Error('Unsupported geometry');
return { id, type: g.type, coordinates };
});
}
+138
View File
@@ -0,0 +1,138 @@
import {
fields,
string,
number,
array,
optional,
fail,
} from '../documentFields.js';
export const PACK_LIMITS = Object.freeze({
packs: 8,
bytes: 8 * 1024 * 1024,
totalBytes: 32 * 1024 * 1024,
features: 2000,
positions: 50000,
});
/** Asset names are relative directory paths, never requests or executable modules. */
export function validateAssetPath(value, path = 'source.path') {
string(value, path, 1024);
if (
!value
.split('/')
.every((part) => /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(part))
)
fail(
path,
'expected a relative asset path without URL syntax or traversal',
);
}
/** Validate one declarative pack; loading requires separately registered application adapters. */
export function validateDataPack(pack, path, anchorIds) {
fields(pack, path, [
'id',
'version',
'format',
'source',
'attribution',
'placement',
'byteLength',
'sha256',
]);
string(pack.id, `${path}.id`);
if (pack.version !== 1) fail(`${path}.version`, 'unsupported pack version');
if (!['geojson', 'image', 'media'].includes(pack.format))
fail(`${path}.format`, 'unsupported pack format');
fields(pack.source, `${path}.source`, ['adapter', 'path']);
string(pack.source.adapter, `${path}.source.adapter`);
validateAssetPath(pack.source.path, `${path}.source.path`);
fields(pack.attribution, `${path}.attribution`, ['text', 'license', 'url']);
string(pack.attribution.text, `${path}.attribution.text`, 4096);
string(pack.attribution.license, `${path}.attribution.license`, 4096);
optional(pack.attribution, 'url', `${path}.attribution`, (url, at) => {
string(url, at, 2048);
let parsed;
try {
parsed = new URL(url);
} catch {
fail(at, 'expected an HTTPS source link');
}
if (
parsed.protocol !== 'https:' ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
fail(
at,
'expected an HTTPS source link without credentials, query or fragment',
);
});
optional(pack, 'byteLength', path, (v, at) => {
number(v, at, 1, PACK_LIMITS.bytes, false);
if (!Number.isInteger(v)) fail(at, 'expected an integer');
});
optional(pack, 'sha256', path, (v, at) => {
if (typeof v !== 'string' || !/^[a-f0-9]{64}$/.test(v))
fail(at, 'expected a lowercase SHA-256 digest');
});
const p = pack.placement,
at = `${path}.placement`;
fields(
p,
at,
pack.format === 'image'
? ['bounds', 'height', 'altitudeReference']
: pack.format === 'media'
? ['anchorId']
: ['altitudeReference'],
);
if (pack.format === 'media') {
if (!anchorIds.has(p.anchorId))
fail(`${at}.anchorId`, 'unknown scene anchor');
} else {
if (p.altitudeReference !== 'ellipsoid')
fail(`${at}.altitudeReference`, 'expected ellipsoid height in meters');
if (pack.format === 'image') {
array(p.bounds, `${at}.bounds`, 4);
if (p.bounds.length !== 4)
fail(`${at}.bounds`, 'expected west, south, east, north');
p.bounds.forEach((v, i) =>
number(
v,
`${at}.bounds[${i}]`,
i % 2 ? -90 : -180,
i % 2 ? 90 : 180,
false,
),
);
if (p.bounds[0] >= p.bounds[2] || p.bounds[1] >= p.bounds[3])
fail(`${at}.bounds`, 'expected increasing non-dateline bounds');
number(p.height, `${at}.height`, -12000, 1e9, false);
}
}
}
/** Validate scene-local pack IDs and shot references without acquiring assets. */
export function validateSceneDataPacks(scene, path) {
const packs = Object.hasOwn(scene, 'dataPacks') ? scene.dataPacks : [];
array(packs, `${path}.dataPacks`, PACK_LIMITS.packs);
const anchors = new Set((scene.anchors || []).map((a) => a.id));
const seen = new Set();
packs.forEach((pack, i) => {
validateDataPack(pack, `${path}.dataPacks[${i}]`, anchors);
if (seen.has(pack.id))
fail(`${path}.dataPacks[${i}].id`, 'duplicate pack ID');
seen.add(pack.id);
});
scene.shots.forEach((shot, i) => {
optional(shot, 'dataPackIds', `${path}.shots[${i}]`, (ids, at) => {
array(ids, at, PACK_LIMITS.packs);
if (new Set(ids).size !== ids.length || ids.some((id) => !seen.has(id)))
fail(at, 'expected distinct scene pack IDs');
});
});
}
+365
View File
@@ -0,0 +1,365 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
import { normalizeProject } from '../../scenes/project.js';
import { createDataPackSession } from './session.js';
import { createAssetDirectorySource } from './source.js';
import { decodePackGeoJSON } from './geojson.js';
const pack = () => ({
id: 'outline',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'example/outline.geojson' },
attribution: { text: 'Example author', license: 'CC0-1.0' },
placement: { altitudeReference: 'ellipsoid' },
});
const project = () => ({
version: 5,
scenes: [
{
id: 'scene',
dataPacks: [pack()],
shots: [{ id: 'shot', dataPackIds: ['outline'] }],
},
],
});
const asset = () => ({
bytes: new Uint8Array([1, 2, 3]),
mimeType: 'application/json',
});
const deferred = () => {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
};
test('v5 packs and shot references round trip through project migration without acquiring bytes', () => {
const normalized = normalizeProject(
parseSceneDocument(JSON.stringify(project())),
);
assert.deepEqual(
JSON.parse(stringifySceneDocument(normalized)).scenes[0].dataPacks,
project().scenes[0].dataPacks,
);
assert.deepEqual(normalized.scenes[0].shots[0].dataPackIds, ['outline']);
for (const version of [1, 2, 3, 4]) {
const old = normalizeProject(
parseSceneDocument(
JSON.stringify({ version, scenes: [{ id: 'old', shots: [] }] }),
),
);
assert.equal(old.version, 6);
assert.equal(old.scenes[0].dataPacks, undefined);
}
});
test('manifest rejects duplicate/unknown IDs, unsupported placement and request or credential syntax', () => {
const mutations = [
(s) => {
s.dataPacks = null;
},
(s) => s.dataPacks.push(pack()),
(s) => s.shots[0].dataPackIds.push('missing'),
(s) => s.shots[0].dataPackIds.push('outline'),
(s) => (s.dataPacks[0].placement.altitudeReference = 'terrain'),
(s) => (s.dataPacks[0].source.headers = { Authorization: 'secret' }),
(s) => (s.dataPacks[0].attribution.url = 'javascript:alert(1)'),
(s) => (s.dataPacks[0].byteLength = 0),
(s) => (s.dataPacks[0].sha256 = 'wrong'),
...[
'../x',
'/api/status',
'https://example.com/x',
'x?a=1',
'x#key',
'%2e%2e/x',
'a\\b',
'a//b',
].map((path) => (s) => (s.dataPacks[0].source.path = path)),
];
for (const mutate of mutations) {
const p = project();
mutate(p.scenes[0]);
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
}
const old = project();
old.version = 4;
assert.throws(() => parseSceneDocument(JSON.stringify(old)));
});
test('image bounds and media anchor references are explicit and validated', () => {
const p = project(),
s = p.scenes[0],
a = s.dataPacks[0];
a.format = 'image';
a.placement = {
bounds: [-98, 30, -97, 31],
height: 200,
altitudeReference: 'ellipsoid',
};
assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p)));
a.placement.bounds[2] = -99;
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
a.format = 'media';
a.placement = { anchorId: 'one' };
assert.throws(() => parseSceneDocument(JSON.stringify(p)));
s.anchors = [
{ id: 'one', lat: 30, lon: -97, alt: 200, altitudeReference: 'ellipsoid' },
];
assert.doesNotThrow(() => parseSceneDocument(JSON.stringify(p)));
});
test('directory source confines paths, strips credentials and rejects redirects, oversized streaming bodies and missing assets', async () => {
const requests = [];
const source = createAssetDirectorySource({
baseUrl: 'https://assets.example.org/packs/',
fetchImpl: async (...args) => {
requests.push(args);
return new Response(new Uint8Array([1, 2, 3]), {
headers: { 'Content-Type': 'application/json' },
});
},
});
assert.deepEqual(
(await source({ path: 'a/data.json' })).bytes,
asset().bytes,
);
assert.equal(requests[0][0], 'https://assets.example.org/packs/a/data.json');
assert.equal(requests[0][1].credentials, 'omit');
assert.equal(requests[0][1].redirect, 'error');
assert.equal(requests[0][1].referrerPolicy, 'no-referrer');
await assert.rejects(source({ path: '../api/test' }));
assert.equal(requests.length, 1);
await assert.rejects(source({ path: 'a', maxBytes: 2 }), /byte limit/);
const missing = createAssetDirectorySource({
baseUrl: 'https://example.org/packs/',
fetchImpl: async () => new Response('', { status: 404 }),
});
await assert.rejects(missing({ path: 'a' }), /unavailable/);
});
test('GeoJSON preserves stable geometry IDs but never properties or remote style hints', () => {
const data = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'p',
properties: { title: '<script>', marker: 'https://bad.example/x' },
geometry: { type: 'Point', coordinates: [-97, 30, 300] },
},
],
};
const decode = () =>
decodePackGeoJSON(new TextEncoder().encode(JSON.stringify(data)));
assert.deepEqual(decode(), [
{ id: 'p', type: 'Point', coordinates: [-97, 30, 300] },
]);
data.features.push(data.features[0]);
assert.throws(decode, /IDs/);
data.features.pop();
data.features[0].geometry.coordinates[1] = 91;
assert.throws(decode, /position/);
data.features[0].geometry = {
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[2, 2],
],
],
};
assert.throws(decode, /ring/);
});
test('pack session removes presentations and cancels the transport on Stop', async () => {
let disposed = 0,
signal;
const session = createDataPackSession({
sources: {
assets: async (options) => {
signal = options.signal;
return asset();
},
},
adapters: {
geojson: async () => ({
dispose() {
disposed++;
},
}),
},
});
await session.load([pack()]);
assert.deepEqual(session.getState(), { status: 'ready', count: 1 });
session.clear();
session.destroy();
assert.equal(disposed, 1);
assert.equal(signal.aborted, true);
assert.equal(session.getState().count, 0);
});
test('replacing a pending source settles promptly and ignores its late bytes', async () => {
const first = deferred();
let mounted = 0,
calls = 0;
const session = createDataPackSession({
sources: { assets: () => (++calls === 1 ? first.promise : asset()) },
adapters: {
geojson: () => {
mounted++;
return {
dispose() {
mounted--;
},
};
},
},
});
const old = session.load([pack()]);
await session.load([pack()]);
assert.equal(await old, false);
first.resolve(asset());
await new Promise((r) => setImmediate(r));
assert.equal(mounted, 1);
session.destroy();
assert.equal(mounted, 0);
});
test('late renderer resources are disposed after cancellation without mutating a replacement', async () => {
const rendering = deferred();
let disposed = 0;
const entered = deferred();
const session = createDataPackSession({
sources: { assets: asset },
adapters: {
geojson: () => {
entered.resolve();
return rendering.promise;
},
},
});
const load = session.load([pack()]);
await entered.promise;
session.clear();
assert.equal(await load, false);
rendering.resolve({
dispose() {
disposed++;
},
});
await new Promise((r) => setImmediate(r));
assert.equal(disposed, 1);
});
test('an abort between renderer settlement and continuation cannot leak the returned resource', async () => {
const controller = new AbortController();
let disposed = 0;
const session = createDataPackSession({
sources: { assets: asset },
adapters: {
geojson: () => {
queueMicrotask(() => controller.abort());
return {
dispose() {
disposed++;
},
};
},
},
});
assert.equal(
await session.load([pack()], { signal: controller.signal }),
false,
);
assert.equal(disposed, 1);
assert.equal(session.getState().count, 0);
});
test('timeout settles an uncooperative adapter and failed packs roll back earlier resources', async () => {
const session = createDataPackSession({
timeoutMs: 10,
sources: { assets: () => new Promise(() => {}) },
adapters: { geojson: () => assert.fail() },
});
await assert.rejects(session.load([pack()]), /could not load/);
assert.equal(session.getState().status, 'idle');
let disposed = 0,
called = 0;
const rollback = createDataPackSession({
sources: {
assets: () => {
if (++called === 2) throw new Error('secret URL');
return asset();
},
},
adapters: {
geojson: () => ({
dispose() {
disposed++;
},
}),
},
});
await assert.rejects(
rollback.load([pack(), { ...pack(), id: 'second' }]),
(e) => !e.message.includes('secret') && /could not load/.test(e.message),
);
assert.equal(disposed, 1);
assert.equal(rollback.getState().count, 0);
});
test('byte and integrity checks run before rendering; adapter names never resolve inherited properties', async () => {
let mounted = 0;
const session = createDataPackSession({
sources: { assets: asset },
adapters: {
geojson: () => {
mounted++;
return { dispose() {} };
},
},
});
for (const overrides of [
{ byteLength: 2 },
{ sha256: '0'.repeat(64) },
{ source: { adapter: 'toString', path: 'a' } },
])
await assert.rejects(session.load([{ ...pack(), ...overrides }]));
assert.equal(mounted, 0);
const digest = await crypto.subtle.digest('SHA-256', asset().bytes);
const sha256 = Buffer.from(digest).toString('hex');
await session.load([{ ...pack(), byteLength: 3, sha256 }]);
assert.equal(mounted, 1);
session.destroy();
});
test('failed responses release their body and an already-cancelled source sends no request', async () => {
let cancelled = 0,
requests = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://example.org/packs/',
fetchImpl: async () => {
requests++;
return new Response(
new ReadableStream({
cancel() {
cancelled++;
},
}),
{ status: 403 },
);
},
});
await assert.rejects(source({ path: 'a.json' }), /unavailable/);
assert.equal(cancelled, 1);
const controller = new AbortController();
controller.abort();
await assert.rejects(source({ path: 'a.json', signal: controller.signal }));
assert.equal(requests, 1);
});
+159
View File
@@ -0,0 +1,159 @@
import { PACK_LIMITS, validateDataPack } from './manifest.js';
// Also settle adapters that fail to observe abort; dispose any late resource they return.
function untilAbort(promise, signal, late = () => {}) {
return new Promise((resolve, reject) => {
let ended = false;
const abort = () => {
if (!ended) {
ended = true;
reject(signal.reason);
}
};
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
Promise.resolve(promise).then(
(value) => {
signal.removeEventListener('abort', abort);
if (ended) late(value);
else {
ended = true;
resolve(value);
}
},
(error) => {
signal.removeEventListener('abort', abort);
if (!ended) {
ended = true;
reject(error);
}
},
);
});
}
/** Own bounded pack acquisition and disposable presentations for a single shot. */
export function createDataPackSession({
sources = {},
adapters = {},
timeoutMs = 15000,
} = {}) {
const sourceMap = new Map(Object.entries(sources));
const adapterMap = new Map(Object.entries(adapters));
let active = null,
disposed = false;
function clear() {
const run = active;
if (!run) return;
active = null;
run.controller.abort();
run.detach();
clearTimeout(run.timer);
for (const handle of run.handles.splice(0).reverse()) handle.dispose();
}
return {
clear,
destroy() {
disposed = true;
clear();
},
getState() {
return {
status: active?.status || 'idle',
count: active?.handles.length || 0,
};
},
async load(packs, { anchors = [], signal } = {}) {
clear();
if (!Array.isArray(packs) || packs.length > PACK_LIMITS.packs)
throw new Error('Too many data packs');
const anchorIds = new Set(anchors.map((anchor) => anchor.id));
packs.forEach((pack, i) =>
validateDataPack(pack, `packs[${i}]`, anchorIds),
);
if (disposed || signal?.aborted) return false;
if (!packs.length) return true;
const controller = new AbortController();
const run = {
controller,
handles: [],
status: 'loading',
detach: () => signal?.removeEventListener('abort', cancel),
};
const cancel = () => {
if (active === run) clear();
};
active = run;
signal?.addEventListener('abort', cancel, { once: true });
run.timer = setTimeout(
() => controller.abort(new Error('Asset load timed out')),
timeoutMs,
);
let total = 0;
try {
for (const pack of packs) {
const source = sourceMap.get(pack.source.adapter),
adapter = adapterMap.get(pack.format);
if (!source || !adapter)
throw new Error('Data pack adapter unavailable');
const asset = await untilAbort(
source({
path: pack.source.path,
signal: controller.signal,
maxBytes: pack.byteLength || PACK_LIMITS.bytes,
}),
controller.signal,
);
controller.signal.throwIfAborted();
const { bytes } = asset;
if (
!(bytes instanceof Uint8Array) ||
!bytes.length ||
bytes.length > PACK_LIMITS.bytes ||
(pack.byteLength && bytes.length !== pack.byteLength)
)
throw new Error('Asset byte length invalid');
total += bytes.length;
if (total > PACK_LIMITS.totalBytes)
throw new Error('Shot assets exceed byte limit');
if (pack.sha256) {
const digest = await untilAbort(
crypto.subtle.digest('SHA-256', bytes),
controller.signal,
);
const hex = Array.from(new Uint8Array(digest), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
if (hex !== pack.sha256)
throw new Error('Asset integrity mismatch');
}
const handle = await untilAbort(
adapter({ pack, asset, anchors, signal: controller.signal }),
controller.signal,
(late) => late?.dispose(),
);
if (!handle || typeof handle.dispose !== 'function')
throw new Error('Data pack adapter requires disposal');
if (active !== run || controller.signal.aborted) {
handle.dispose();
controller.signal.throwIfAborted();
return false;
}
run.handles.push(handle);
}
controller.signal.throwIfAborted();
clearTimeout(run.timer);
run.status = 'ready';
return true;
} catch (error) {
const superseded = active !== run || signal?.aborted || disposed;
if (active === run) clear();
if (superseded) return false;
// Source errors may include URLs; only stable messages reach the UI.
throw new Error(
'Data pack could not load: check its source, format, size or integrity',
);
}
},
};
}
+67
View File
@@ -0,0 +1,67 @@
import { PACK_LIMITS, validateAssetPath } from './manifest.js';
/** Register a fixed asset directory. Scene files cannot replace its origin or escape its path. */
export function createAssetDirectorySource({
baseUrl,
fetchImpl = globalThis.fetch,
}) {
const base = new URL(baseUrl);
if (
!['https:', 'http:'].includes(base.protocol) ||
base.username ||
base.password ||
base.search ||
base.hash ||
!base.pathname.endsWith('/')
)
throw new TypeError(
'Asset source requires an explicit HTTP(S) directory URL',
);
return async ({ path, signal, maxBytes = PACK_LIMITS.bytes }) => {
validateAssetPath(path);
signal?.throwIfAborted();
const response = await fetchImpl(new URL(path, base).href, {
signal,
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
cache: 'no-store',
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error('Asset unavailable');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('Asset stream unavailable');
const chunks = [];
let length = 0;
try {
if (Number(response.headers.get('content-length')) > maxBytes)
throw new Error('Asset exceeds byte limit');
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
chunks.push(value);
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return {
bytes,
mimeType: (response.headers.get('content-type') || '')
.split(';')[0]
.trim()
.toLowerCase(),
};
};
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Build an ordered shot queue, starting at a scene and optionally wrapping.
* Scene and shot objects are opaque to the runner apart from their identifiers.
* @param {Array<{id:string, shots:Object[]}>} scenes
* @param {string} startSceneId
* @param {{single?:boolean}} [options]
* @returns {Array<{scene:Object, shot:Object}>}
*/
export function buildPlaybackQueue(
scenes,
startSceneId,
{ single = false } = {},
) {
const start = Math.max(
0,
scenes.findIndex((scene) => scene.id === startSceneId),
);
const ordered = single
? scenes.slice(start, start + 1)
: [...scenes.slice(start), ...scenes.slice(0, start)];
return ordered.flatMap((scene) =>
scene.shots.map((shot) => ({ scene, shot })),
);
}
/**
* Run ordered shots through an application-supplied playback adapter.
*
* Each phase receives {scene, shot, index, total, token}. The adapter owns
* camera/rendering resources and must observe token.signal or token.cancelled
* while awaiting work. The runner rechecks both after every phase; it cannot
* interrupt an adapter that ignores cancellation. No timers, UI, storage,
* renderer or authored content are imported here.
*
* releaseScene(scene, token?) leaves a previously loaded scene before playback
* (false refuses that initial handoff). Between scenes and during final cleanup
* it receives no cancelled token, so resource release can finish after Stop.
* An empty/already-cancelled queue acquires and releases no resources.
*
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
* @param {Object} options
* @param {{cancelled?:boolean, signal?:AbortSignal}} options.token Run lifetime.
* @param {Object} options.adapter selectShot/applyVisual/applyLayers/travel/
* settle/hold/completeShot callbacks, releaseScene and optional complete.
* @param {Object|null} [options.previousScene=null] Previously loaded scene.
* @param {boolean} [options.releaseOnFinish=true] Release the final scene.
* @returns {Promise<{status:'completed'|'cancelled', completedShots:number}>}
*/
export async function playSceneQueue(
queue,
{ token, adapter, previousScene = null, releaseOnFinish = true },
) {
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
let activeScene = null;
let completedShots = 0;
try {
if (!queue.length || cancelled()) {
return {
status: cancelled() ? 'cancelled' : 'completed',
completedShots,
};
}
if (previousScene && previousScene.id !== queue[0].scene.id) {
const released = await adapter.releaseScene(previousScene, token);
if (!released)
throw new Error(`Could not leave scene: ${previousScene.title}`);
}
for (let index = 0; index < queue.length && !cancelled(); index++) {
const { scene, shot } = queue[index];
if (activeScene && activeScene.id !== scene.id) {
await adapter.releaseScene(activeScene);
activeScene = null;
if (cancelled()) break;
}
activeScene = scene;
const context = { scene, shot, index, total: queue.length, token };
for (const phase of SHOT_PHASES) {
if (cancelled()) break;
await adapter[phase](context);
}
if (!cancelled()) completedShots++;
}
if (!cancelled()) await adapter.complete?.();
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
} finally {
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
}
}
const SHOT_PHASES = [
'selectShot',
'applyVisual',
'applyLayers',
'travel',
'settle',
'hold',
'completeShot',
];
export {
sceneTimingForShot,
sceneSeekState,
cameraAtProgress,
} from './timeline.js';
export { createPlaybackClock } from './clock.js';
export {
parseSceneDocument,
validateSceneDocument,
stringifySceneDocument,
SceneDocumentError,
SCENE_DOCUMENT_VERSION,
SCENE_DOCUMENT_LIMITS,
} from './document.js';
export {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
export { createAssetDirectorySource } from './packs/source.js';
export { createDataPackSession } from './packs/session.js';
export { validateDataPack, PACK_LIMITS } from './packs/manifest.js';
export { createInteractionSession } from './interactions/session.js';
export {
parseSceneShare,
readSceneShare,
createSceneBundle,
createBundleAssets,
BUNDLE_SOURCE,
SHARE_LIMITS,
} from './sharing/bundle.js';
export { describeSceneShare } from './sharing/preview.js';
export { editSceneDetails, selectSceneDocument } from './authoring.js';
+198
View File
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildPlaybackQueue, playSceneQueue } from './playback.js';
const scenes = [
{ id: 'a', title: 'A', shots: [{ id: 'a1' }, { id: 'a2' }] },
{ id: 'empty', shots: [] },
{ id: 'b', title: 'B', shots: [{ id: 'b1' }] },
];
const phases = [
'selectShot',
'applyVisual',
'applyLayers',
'travel',
'settle',
'hold',
'completeShot',
];
function fixture(overrides = {}) {
const events = [];
const abort = new AbortController();
const token = { cancelled: false, signal: abort.signal };
const adapter = {
...Object.fromEntries(
phases.map((phase) => [
phase,
({ shot, token: received }) => {
assert.equal(received, token);
events.push(`${shot.id}:${phase}`);
},
]),
),
releaseScene(scene, received) {
events.push(`release:${scene.id}:${received ? 'handoff' : 'cleanup'}`);
return true;
},
complete() {
events.push('complete');
},
...overrides,
};
return { events, abort, token, adapter };
}
test('queues rotate scenes, skip empty scenes, preserve shot identity and support a single scene', () => {
const queue = buildPlaybackQueue(scenes, 'b');
assert.deepEqual(
queue.map(({ shot }) => shot.id),
['b1', 'a1', 'a2'],
);
assert.equal(queue[0].shot, scenes[2].shots[0]);
assert.deepEqual(buildPlaybackQueue(scenes, 'empty', { single: true }), []);
assert.deepEqual(buildPlaybackQueue([], 'missing'), []);
assert.deepEqual(
buildPlaybackQueue(scenes, 'missing').map(({ shot }) => shot.id),
['a1', 'a2', 'b1'],
);
});
test('playback sequences phases, releases only at scene changes, then releases the final scene', async () => {
const f = fixture();
const result = await playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
assert.deepEqual(result, { status: 'completed', completedShots: 3 });
assert.deepEqual(f.events, [
...phases.map((phase) => `a1:${phase}`),
...phases.map((phase) => `a2:${phase}`),
'release:a:cleanup',
...phases.map((phase) => `b1:${phase}`),
'complete',
'release:b:cleanup',
]);
});
for (const phase of phases) {
for (const cancellation of ['flag', 'signal']) {
test(`${cancellation} cancellation while awaiting ${phase} stops subsequent work and releases resources`, async () => {
let entered;
let resume;
const started = new Promise((resolve) => {
entered = resolve;
});
const pending = new Promise((resolve) => {
resume = resolve;
});
const f = fixture({
[phase]: async () => {
entered();
await pending;
},
});
const run = playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
await started;
if (cancellation === 'flag') f.token.cancelled = true;
else f.abort.abort();
resume();
assert.deepEqual(await run, { status: 'cancelled', completedShots: 0 });
assert.deepEqual(f.events, [
...phases.slice(0, phases.indexOf(phase)).map((name) => `a1:${name}`),
'release:a:cleanup',
]);
});
}
test(`failure in ${phase} propagates after release, without later shots`, async () => {
const failure = new Error('adapter failed');
const f = fixture({
[phase]: () => {
throw failure;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
(error) => error === failure,
);
assert.equal(f.events.at(-1), 'release:a:cleanup');
assert.ok(
!f.events.some(
(event) => event.startsWith('a2:') || event === 'complete',
),
);
});
}
test('empty and pre-aborted runs never acquire or release adapter resources', async () => {
const f = fixture();
assert.deepEqual(await playSceneQueue([], f), {
status: 'completed',
completedShots: 0,
});
f.abort.abort();
assert.deepEqual(
await playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
...f,
previousScene: scenes[2],
}),
{ status: 'cancelled', completedShots: 0 },
);
assert.deepEqual(f.events, []);
});
test('a prior scene must release before playback; a refused handoff starts no shot', async () => {
const f = fixture();
await playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), {
...f,
previousScene: scenes[2],
});
assert.equal(f.events[0], 'release:b:handoff');
const refused = fixture({ releaseScene: () => false });
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
...refused,
previousScene: scenes[2],
}),
/Could not leave scene: B/,
);
assert.deepEqual(refused.events, []);
});
test('cancellation during an inter-scene release never acquires the next scene', async () => {
const f = fixture();
const release = f.adapter.releaseScene;
f.adapter.releaseScene = (...args) => {
f.abort.abort();
return release(...args);
};
const result = await playSceneQueue(buildPlaybackQueue(scenes, 'a'), f);
assert.deepEqual(result, { status: 'cancelled', completedShots: 2 });
assert.equal(f.events.at(-1), 'release:a:cleanup');
assert.equal(
f.events.filter((event) => event.startsWith('release')).length,
1,
);
});
test('non-preview playback retains the final scene but still releases preceding scenes', async () => {
const f = fixture();
await playSceneQueue(buildPlaybackQueue(scenes, 'a'), {
...f,
releaseOnFinish: false,
});
assert.deepEqual(
f.events.filter((event) => event.startsWith('release')),
['release:a:cleanup'],
);
});
test('a cleanup failure rejects for the caller to restore its own controls', async () => {
const failure = new Error('cleanup failed');
const f = fixture({
releaseScene: () => {
throw failure;
},
});
await assert.rejects(
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
(error) => error === failure,
);
});
+221
View File
@@ -0,0 +1,221 @@
import { parseSceneDocument, stringifySceneDocument } from '../document.js';
import { withShareSignal } from './lifetime.js';
import { fields, array, fail } from '../documentFields.js';
import { PACK_LIMITS, validateAssetPath } from '../packs/manifest.js';
export const BUNDLE_SOURCE = 'scene-bundle';
export const SHARE_LIMITS = Object.freeze({
bytes: 50 * 1024 * 1024,
assets: 64,
});
const MIME = new Set([
'application/json',
'application/geo+json',
'image/png',
'video/mp4',
'video/webm',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
]);
const packsOf = (project) =>
project.scenes.flatMap((scene) => scene.dataPacks || []);
const checkAbort = (signal) => signal?.throwIfAborted();
const digest = async (bytes) =>
Array.from(
new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
(n) => n.toString(16).padStart(2, '0'),
).join('');
function encode(bytes) {
const chunks = [];
for (let i = 0; i < bytes.length; i += 32768)
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 32768)));
return btoa(chunks.join(''));
}
function decode(value) {
if (
typeof value !== 'string' ||
!value.length ||
value.length > Math.ceil(PACK_LIMITS.bytes / 3) * 4 ||
value.length % 4 !== 0 ||
/[^A-Za-z0-9+/=]/.test(value) ||
(value.includes('=') && !/^[A-Za-z0-9+/]+={1,2}$/.test(value))
)
fail('assets', 'invalid or oversized base64 asset');
return Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
}
function checkBytes(bytes, total) {
if (
!(bytes instanceof Uint8Array) ||
!bytes.length ||
bytes.length > PACK_LIMITS.bytes ||
total > PACK_LIMITS.totalBytes
)
fail('assets', 'asset byte limit exceeded');
}
function checkMime(mimeType) {
if (!MIME.has(mimeType)) fail('assets', 'unsupported media type');
}
/** Read inert scene JSON or a bounded asset bundle, verifying bytes before admission. */
export async function parseSceneShare(text, { signal } = {}) {
checkAbort(signal);
if (
typeof text !== 'string' ||
text.length > SHARE_LIMITS.bytes ||
new TextEncoder().encode(text).length > SHARE_LIMITS.bytes
)
fail('$', 'share exceeds 50 MiB');
let input;
try {
input = JSON.parse(text);
} catch {
fail('$', 'invalid JSON');
}
if (input?.format !== 'gev-scene-bundle')
return { project: parseSceneDocument(text), assets: new Map() };
fields(input, '$', ['format', 'version', 'project', 'assets']);
if (input.version !== 1) fail('version', 'unsupported bundle version');
const project = parseSceneDocument(JSON.stringify(input.project));
array(input.assets, 'assets', SHARE_LIMITS.assets);
const assets = new Map();
let total = 0;
for (const entry of input.assets) {
checkAbort(signal);
fields(entry, 'assets', ['path', 'mimeType', 'base64', 'sha256']);
validateAssetPath(entry.path);
checkMime(entry.mimeType);
if (assets.has(entry.path)) fail('assets', 'duplicate asset path');
const bytes = decode(entry.base64);
total += bytes.length;
checkBytes(bytes, total);
const hash = await digest(bytes);
checkAbort(signal);
if (entry.sha256 !== hash) fail('assets', 'asset integrity mismatch');
assets.set(entry.path, { bytes, mimeType: entry.mimeType, sha256: hash });
}
const used = new Set();
for (const pack of packsOf(project)) {
if (pack.source.adapter !== BUNDLE_SOURCE)
fail('project', 'bundle must include every declared pack');
const asset = assets.get(pack.source.path);
used.add(pack.source.path);
if (
!asset ||
pack.byteLength !== asset.bytes.length ||
pack.sha256 !== asset.sha256
)
fail('project', 'missing or mismatched bundle asset');
}
if (used.size !== assets.size) fail('assets', 'unreferenced bundle asset');
return { project, assets };
}
/** File reads have a known budget before text decoding; large bundles use a distinct suffix. */
export async function readSceneShare(file, options) {
const limit = file.name?.endsWith('.gevbundle.json')
? SHARE_LIMITS.bytes
: 5 * 1024 * 1024;
if (file.size > limit)
fail(
'$',
limit === SHARE_LIMITS.bytes
? 'share exceeds 50 MiB'
: 'file exceeds 5 MiB',
);
checkAbort(options?.signal);
const text = await withShareSignal(file.text(), options?.signal);
checkAbort(options?.signal);
return parseSceneShare(text, options);
}
/** Export only explicitly supplied pack bytes; never fetch external assets or alter the source project. */
export async function createSceneBundle(
project,
resolveAsset,
{ signal } = {},
) {
const copy = parseSceneDocument(stringifySceneDocument(project));
const assets = [],
known = new Map();
let total = 0;
for (const pack of packsOf(copy)) {
checkAbort(signal);
const key = JSON.stringify([pack.source.adapter, pack.source.path]);
let entry = known.get(key);
if (!entry) {
if (assets.length >= SHARE_LIMITS.assets)
fail('assets', 'too many bundled assets');
const asset = await withShareSignal(
resolveAsset(pack, { signal }),
signal,
);
checkAbort(signal);
if (!asset) fail('assets', 'select a file for every declared data pack');
const bytes = asset.bytes;
total += bytes?.length || 0;
checkBytes(bytes, total);
checkMime(asset.mimeType);
const sha256 = await digest(bytes);
checkAbort(signal);
if (
(pack.byteLength && pack.byteLength !== bytes.length) ||
(pack.sha256 && pack.sha256 !== sha256)
)
fail('assets', 'selected file does not match declared integrity');
const path = `files/${assets.length}-${pack.source.path.split('/').at(-1).slice(0, 160)}`;
entry = {
path,
mimeType: asset.mimeType,
base64: encode(bytes),
sha256,
byteLength: bytes.length,
};
known.set(key, entry);
assets.push(entry);
} else if (
(pack.byteLength && pack.byteLength !== entry.byteLength) ||
(pack.sha256 && pack.sha256 !== entry.sha256)
)
fail('assets', 'conflicting shared asset integrity');
pack.source = { adapter: BUNDLE_SOURCE, path: entry.path };
pack.byteLength = entry.byteLength;
pack.sha256 = entry.sha256;
}
const text = JSON.stringify({
format: 'gev-scene-bundle',
version: 1,
project: copy,
assets: assets.map(({ byteLength, ...entry }) => entry),
});
if (new TextEncoder().encode(text).length > SHARE_LIMITS.bytes)
fail('$', 'share exceeds 50 MiB');
return text;
}
/** Keep bundled bytes in this project lifetime only; never fall back to a network source. */
export function createBundleAssets() {
let assets = new Map();
return {
replace(next = new Map()) {
assets = new Map(next);
},
clear() {
assets.clear();
},
snapshot: () => new Map(assets),
getState: () => ({
count: assets.size,
bytes: [...assets.values()].reduce((n, a) => n + a.bytes.length, 0),
}),
source({ path, signal, maxBytes = PACK_LIMITS.bytes }) {
checkAbort(signal);
validateAssetPath(path);
const asset = assets.get(path);
if (!asset || asset.bytes.length > maxBytes)
throw new Error('Bundle asset unavailable — reimport the bundle');
return { bytes: asset.bytes.slice(), mimeType: asset.mimeType };
},
};
}
+26
View File
@@ -0,0 +1,26 @@
/** Settle cancelled file/adaptor work promptly; late results never regain ownership. */
export function withShareSignal(work, signal) {
if (!signal) return Promise.resolve(work);
return new Promise((resolve, reject) => {
const abort = () => {
signal.removeEventListener('abort', abort);
reject(signal.reason);
};
if (signal.aborted) {
Promise.resolve(work).catch(() => {});
abort();
return;
}
signal.addEventListener('abort', abort, { once: true });
Promise.resolve(work).then(
(value) => {
signal.removeEventListener('abort', abort);
signal.aborted ? reject(signal.reason) : resolve(value);
},
(error) => {
signal.removeEventListener('abort', abort);
reject(error);
},
);
});
}
+43
View File
@@ -0,0 +1,43 @@
import { BUNDLE_SOURCE } from './bundle.js';
/** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare(
{ project, assets },
{ sourceIds = [], layerIds = [] } = {},
) {
const sources = new Set(sourceIds),
layers = new Set(layerIds);
const packs = project.scenes.flatMap((scene) =>
(scene.dataPacks || []).map((pack) => ({
scene: scene.title || scene.id,
id: pack.id,
path: pack.source.path,
attribution: pack.attribution,
status:
pack.source.adapter === BUNDLE_SOURCE
? assets.has(pack.source.path)
? 'Included in bundle'
: 'Missing bundle file — reimport its bundle'
: sources.has(pack.source.adapter)
? 'Source configured; file checked when loaded'
: 'Source unavailable',
})),
);
return {
scenes: project.scenes.length,
shots: project.scenes.reduce((n, s) => n + s.shots.length, 0),
packs,
missingLayers: [
...new Set(
project.scenes.flatMap((s) =>
s.shots.flatMap((shot) => Object.keys(shot.layers || {})),
),
),
].filter((id) => !layers.has(id)),
externalContent: project.scenes.some(
(s) =>
s.appliedShotPacks?.length || s.shots.some((shot) => shot.sourcePackId),
),
bundledBytes: [...assets.values()].reduce((n, a) => n + a.bytes.length, 0),
};
}
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createSceneBundle,
parseSceneShare,
readSceneShare,
createBundleAssets,
BUNDLE_SOURCE,
SHARE_LIMITS,
} from './bundle.js';
import { describeSceneShare } from './preview.js';
import { editSceneDetails, selectSceneDocument } from '../authoring.js';
const bytes = new TextEncoder().encode(
JSON.stringify({ type: 'FeatureCollection', features: [] }),
);
const fixture = () => ({
version: 6,
scenes: [
{
id: 'one',
title: 'Example',
dataPacks: [
{
id: 'data',
version: 1,
format: 'geojson',
source: { adapter: 'assets', path: 'test/data.geojson' },
attribution: {
text: 'Example author',
license: 'CC0-1.0',
url: 'https://example.org/source',
},
placement: { altitudeReference: 'ellipsoid' },
},
],
shots: [
{
id: 'shot',
title: 'Original',
durationSec: 3,
holdSec: 2,
camera: { lat: 1, lon: 2, alt: 300, pitch: -60 },
layers: { traffic: false },
dataPackIds: ['data'],
},
],
},
{ id: 'other', shots: [] },
],
});
const asset = () => ({
bytes: bytes.slice(),
mimeType: 'application/geo+json',
});
test('selected-scene bundles round trip bytes and attribution without mutating the project or fetching', async () => {
const original = fixture(),
before = JSON.stringify(original);
let reads = 0;
const text = await createSceneBundle(
selectSceneDocument(original, 'one'),
() => {
reads++;
return asset();
},
);
const parsed = await parseSceneShare(text);
assert.equal(reads, 1);
assert.equal(JSON.stringify(original), before);
assert.equal(parsed.project.scenes.length, 1);
const pack = parsed.project.scenes[0].dataPacks[0];
assert.equal(pack.source.adapter, BUNDLE_SOURCE);
assert.equal(pack.byteLength, bytes.length);
assert.deepEqual(
pack.attribution,
original.scenes[0].dataPacks[0].attribution,
);
assert.deepEqual(parsed.assets.get(pack.source.path).bytes, bytes);
});
test('bundle rejects malformed bytes, unknown fields, traversal, duplicates, missing files and broken integrity', async () => {
const valid = JSON.parse(await createSceneBundle(fixture(), () => asset()));
const mutations = [
(b) => (b.version = 2),
(b) => (b.assets[0].path = '../secret'),
(b) => (b.assets[0].base64 = 'A==='),
(b) => (b.assets[0].base64 = '____'),
(b) => (b.assets[0].base64 = btoa('different')),
(b) => (b.assets[0].headers = {}),
(b) => (b.assets[0].mimeType = 'text/html'),
(b) => b.assets.push(b.assets[0]),
(b) => (b.assets = []),
(b) => (b.project.scenes[0].dataPacks[0].source.adapter = 'assets'),
(b) => (b.project.scenes[0].dataPacks = []),
(b) => (b.project.scenes[0].dataPacks[0].sha256 = '0'.repeat(64)),
(b) => (b.assets = Array.from({ length: 65 }, () => b.assets[0])),
];
for (const mutate of mutations) {
const b = structuredClone(valid);
mutate(b);
await assert.rejects(parseSceneShare(JSON.stringify(b)));
}
});
test('asset caps and declared integrity are enforced before creating a downloadable bundle', async () => {
await assert.rejects(
createSceneBundle(fixture(), () => ({
bytes: new Uint8Array(8 * 1024 * 1024 + 1),
mimeType: 'image/png',
})),
/limit/,
);
const p = fixture();
p.scenes[0].dataPacks[0].sha256 = '0'.repeat(64);
await assert.rejects(
createSceneBundle(p, () => asset()),
/integrity/,
);
await assert.rejects(
createSceneBundle(fixture(), () => null),
/select a file/,
);
});
test('duplicate pack paths share one asset and reject conflicting integrity', async () => {
const p = fixture();
p.scenes[0].dataPacks.push({
...structuredClone(p.scenes[0].dataPacks[0]),
id: 'second',
});
let reads = 0;
const text = await createSceneBundle(p, () => {
reads++;
return asset();
});
assert.equal(reads, 1);
assert.equal((await parseSceneShare(text)).assets.size, 1);
p.scenes[0].dataPacks[1].byteLength = 1;
await assert.rejects(
createSceneBundle(p, () => asset()),
/integrity/,
);
});
test('preview reports unavailable sources/layers and missing bundle assets without applying anything', () => {
const p = fixture();
const report = describeSceneShare(
{ project: p, assets: new Map() },
{ sourceIds: [], layerIds: [] },
);
assert.equal(report.packs[0].status, 'Source unavailable');
assert.deepEqual(report.missingLayers, ['traffic']);
p.scenes[0].dataPacks[0].source.adapter = BUNDLE_SOURCE;
assert.match(
describeSceneShare({ project: p, assets: new Map() }).packs[0].status,
/Missing bundle/,
);
});
test('bundle byte owner releases replacement data and has no network fallback', async () => {
const parsed = await parseSceneShare(
await createSceneBundle(fixture(), () => asset()),
);
const store = createBundleAssets();
store.replace(parsed.assets);
const path = parsed.project.scenes[0].dataPacks[0].source.path;
const first = store.source({ path });
first.bytes[0] = 0;
assert.deepEqual(store.source({ path }).bytes, bytes);
const controller = new AbortController();
controller.abort();
assert.throws(() => store.source({ path, signal: controller.signal }));
store.clear();
assert.deepEqual(store.getState(), { count: 0, bytes: 0 });
assert.throws(() => store.source({ path }), /unavailable/);
});
test('oversized files fail before reading and cancellation settles a stalled file without a late result', async () => {
let reads = 0;
await assert.rejects(
readSceneShare({
name: 'large.json',
size: 6 * 1024 * 1024,
text: () => {
reads++;
},
}),
/5 MiB/,
);
await assert.rejects(
readSceneShare({
name: 'large.gevbundle.json',
size: SHARE_LIMITS.bytes + 1,
text: () => {
reads++;
},
}),
/50 MiB/,
);
assert.equal(reads, 0);
let resolve;
const owner = new AbortController();
const work = readSceneShare(
{ name: 'pending.json', text: () => new Promise((r) => (resolve = r)) },
{ signal: owner.signal },
);
owner.abort();
await assert.rejects(work, /abort/i);
resolve(JSON.stringify(fixture()));
});
test('cancelled bundle export never resolves another asset or produces partial output', async () => {
let resolve;
const owner = new AbortController();
const work = createSceneBundle(
fixture(),
() => new Promise((r) => (resolve = r)),
{ signal: owner.signal },
);
owner.abort();
await assert.rejects(work, /abort/i);
resolve(asset());
});
test('details editing preserves content IDs, layers and provenance, rejects invalid drafts atomically', () => {
const p = fixture(),
before = JSON.stringify(p);
p.scenes[0].shots[0].sourcePackId = 'existing';
const scene = p.scenes[0],
shot = scene.shots[0];
const edited = editSceneDetails(
p,
'one',
'shot',
{
anchors: [
{ id: 'a', lat: 1, lon: 2, alt: 400, altitudeReference: 'ellipsoid' },
],
dataPacks: scene.dataPacks,
},
{
camera: { anchorId: 'a' },
durationSec: 4,
holdSec: 0,
dataPackIds: ['data'],
},
);
assert.equal(edited.scenes[0].shots[0].sourcePackId, 'existing');
assert.deepEqual(edited.scenes[0].shots[0].layers, shot.layers);
assert.equal(edited.scenes[0].shots[0].id, 'shot');
assert.throws(() =>
editSceneDetails(
p,
'one',
'shot',
{ anchors: [] },
{ camera: { anchorId: 'missing' } },
),
);
assert.throws(() =>
editSceneDetails(p, 'one', 'shot', { source: 'remote' }, {}),
);
assert.throws(() =>
editSceneDetails(p, 'one', 'shot', {}, { sourcePackId: 'replacement' }),
);
delete p.scenes[0].shots[0].sourcePackId;
assert.equal(JSON.stringify(p), before);
});
test('long valid source filenames still produce an importable bundle', async () => {
const p = fixture();
p.scenes[0].dataPacks[0].source.path = 'x'.repeat(1024);
const parsed = await parseSceneShare(
await createSceneBundle(p, () => asset()),
);
assert.equal(parsed.assets.size, 1);
});
+110
View File
@@ -0,0 +1,110 @@
import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
/** Calculate one shot's boundaries using the supplied content duration resolver. */
export function sceneTimingForShot(scene, shot, durationForShot) {
const durations =
scene?.shots?.map((item) => durationForShot(scene, item)) || [];
const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const startElapsedSec = durations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const durationSec = shotIndex >= 0 ? durations[shotIndex] : 0;
const endElapsedSec = Math.min(totalSec, startElapsedSec + durationSec);
return {
shotIndex,
totalSec,
durationSec,
startElapsedSec,
endElapsedSec,
startProgress: totalSec > 0 ? startElapsedSec / totalSec : 0,
endProgress: totalSec > 0 ? endElapsedSec / totalSec : 1,
durationProgress: totalSec > 0 ? durationSec / totalSec : 0,
};
}
/** Interpolate the existing cubic camera pose, including shortest-angle orientation. */
export function cameraAtProgress(fromCamera, toCamera, progress) {
const target = toCamera || fromCamera;
const source = fromCamera || target;
if (!source || !target) return target || source || null;
const t = clamp01(Number(progress) || 0);
const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
roll: lerpAngle(source.roll, target.roll),
};
}
/** Resolve a normalized scene time to a shot, flight/hold phase and camera pose. */
export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
if (!scene?.shots?.length) return null;
const normalized = clamp01(Number(progress) || 0);
const durations = scene.shots.map((shot) => durationForShot(scene, shot));
const totalSec = durations.reduce((sum, duration) => sum + duration, 0);
const targetSec = normalized * Math.max(0, totalSec);
let startElapsedSec = 0;
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
);
const holdElapsedSec = Math.max(0, shotElapsedSec - flightDurationSec);
const holdProgress =
holdDurationSec > 0 ? clamp01(holdElapsedSec / holdDurationSec) : 1;
const targetCamera = resolveCameraPose(scene, shot.camera);
const previousCamera = resolveCameraPose(
scene,
scene.shots[shotIndex - 1]?.camera || shot.camera,
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
shotElapsedSec,
shotProgress: clamp01(shotElapsedSec / shotDurationSec),
flightDurationSec,
holdDurationSec,
holdElapsedSec,
cameraProgress,
holdProgress,
camera: move
? sampleCameraMove(move, cameraProgress)
: cameraAtProgress(previousCamera, targetCamera, cameraProgress),
};
}
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
sceneTimingForShot,
sceneSeekState,
cameraAtProgress,
} from './timeline.js';
const camera = (lon, heading) => ({
lat: 10,
lon,
alt: 1000,
heading,
pitch: -40,
roll: 0,
});
const scene = {
shots: [
{ id: 'a', durationSec: 2, holdSec: 1, camera: camera(20, 350) },
{ id: 'b', durationSec: 4, holdSec: 3, camera: camera(24, 10) },
],
};
const duration = (_, shot) => shot.durationSec + shot.holdSec;
const hold = (_, shot) => shot.holdSec;
test('scene time accounts for flight and hold; exact boundaries select the next shot', () => {
const time = sceneTimingForShot(scene, scene.shots[1], duration);
assert.equal(time.startElapsedSec, 3);
assert.equal(time.endElapsedSec, 10);
const seek = (progress) => sceneSeekState(scene, progress, duration, hold);
assert.equal(seek(-1).shot.id, 'a');
assert.equal(seek(0.3).shot.id, 'b');
assert.equal(seek(0.3).cameraProgress, 0);
assert.equal(seek(0.5).cameraProgress, 0.5);
assert.equal(seek(0.5).camera.lon, 22);
assert.equal(seek(0.8).holdElapsedSec, 1);
assert.equal(seek(2).holdProgress, 1);
assert.equal(seek(0).sceneElapsedSec, 0);
assert.equal(sceneSeekState({ shots: [] }, 1, duration, hold), null);
});
test('camera seeking preserves cubic easing and shortest-angle orientation', () => {
assert.equal(
cameraAtProgress(camera(20, 350), camera(24, 10), 0.5).heading,
360,
);
assert.equal(
cameraAtProgress(camera(20, 350), camera(24, 10), 0.25).lon,
20.25,
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
+23
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createTrafficSource } from './source.js';
import { clampBoundsAroundCenter } from '../../data/trafficBounds.js';
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
const fixture = readFileSync(
new URL(
@@ -87,6 +88,28 @@ test('road requests have finite bounds and retain the two-pass query', async ()
/residential/,
);
});
test('antimeridian clamps produce road bounds accepted on either side', async () => {
const calls = [];
const source = createTrafficSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"elements":[]}');
},
});
for (const centerLon of [179.99, -179.99]) {
const clamped = clampBoundsAroundCenter(
{
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
},
{ lat: 0, lon: centerLon },
);
await source.requestRoads(clamped);
}
assert.equal(calls.length, 2);
});
test('malformed availability is an unavailable source rather than a keyless response', async () => {
const source = createTrafficSource({
fetchImpl: async () => new Response('{}'),
+317 -7
View File
@@ -2178,7 +2178,7 @@ test('under FLIR and NVG every sprite is white-hot with a dark halo, and selecti
assert.notEqual(entry.marker.image, plainImage, 'a haloed raster');
assert.match(
Buffer.from(entry.marker.image.split(',')[1], 'base64').toString(),
/#000000/,
/#05080C/,
'with the dark ring',
);
assert.ok(entry.marker.width > plainWidth * 1.2, 'and larger');
@@ -2547,7 +2547,7 @@ test('selected trail and marker share revised start floors, with unknown history
assert.ok(
Cesium.Cartographic.fromCartesian(entry.marker.position).height > 30,
);
assert.ok(entry.trailVertices <= 2048);
assert.ok(entry.trailVertices <= 640);
parts.selection.clearSelection();
assert.equal(parts.trails.diagnostics(), null);
});
@@ -2659,7 +2659,7 @@ test('a long selected history shortens its oldest geometry before sacrificing th
parts.rendering.sampleIdle(entry);
parts.selection.selectVehicle(entry.key);
assert.equal(entry.trailTruncated, true);
assert.ok(entry.trailVertices <= 2048);
assert.ok(entry.trailVertices <= 640);
assert.ok(
entry.displayPaths.has(entry.sample.fromSeq),
'active path is prepared first',
@@ -2671,6 +2671,63 @@ test('a long selected history shortens its oldest geometry before sacrificing th
);
});
test('sparse rail reports preserve a bounded marker corridor with and without selection', async (t) => {
const app = harness(t);
const parts = app.layer._transitPartsForTest();
const { seek } = await import('../../data/contactPlayback.js');
let latitude = 42.36 - 16000 / 111320;
let timestamp = reported();
app.serve('mbta', () => ({
status: 200,
body: snapshot(
'mbta',
'MBTA',
[vehicle('sparse', latitude, -71.06, timestamp, { routeId: 'CR-1' })],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
app.settle();
app.advance(180000);
latitude = 42.36;
timestamp += 180;
await app.layer.update();
app.settle();
const entry = app.vehicles()[0];
assert.equal(entry.mode, 'rail');
assert.equal(entry.track.count, 2, 'both 88.9 m/s reports pass admission');
assert.equal(entry.fixes[1].t - entry.fixes[0].t, 180000);
for (let i = 0; i < 1500; i++)
app.ground._warm.set(app.ground._key(42.2 + i * 0.0002, -71.06), 12);
seek(entry.track, entry.fixes[0].t + 90000, {
wallNowMs: Date.now(),
monoNowMs: performance.now(),
});
parts.rendering.sampleIdle(entry);
for (const selected of [false, true, false]) {
if (selected) parts.selection.selectVehicle(entry.key);
else parts.selection.clearSelection();
parts.trails.prepareEntry(entry);
const path = entry.displayPaths.get(entry.sample.fromSeq);
assert.ok(path, 'active corridor survives the body cap');
assert.ok(path.positions.length <= 640);
const position = parts.trails.samplePosition(
entry,
entry.sample,
new Cesium.Cartesian3(),
true,
);
assert.ok(
position && [position.x, position.y, position.z].every(Number.isFinite),
);
parts.rendering.placeSample(entry, true);
assert.equal(entry.marker.show, true);
assert.ok(Cesium.Cartesian3.equals(entry.marker.position, position));
assert.ok(entry.trailVertices <= 640);
}
});
test('missing rectangle still rejects the far side, and frustum rejection wins', async (t) => {
const app = harness(t, { altitude: 100_000 });
app.viewer.camera.computeViewRectangle = () => undefined;
@@ -2783,13 +2840,13 @@ test('every styling path retains the mode palette and exact unpadded size', asyn
parts.rendering.paintMode(entry, 'subway');
check('#FF4538', 30);
app.style('thermal');
check('#FFFFFF', 36);
check('#FFFFFF', 39);
parts.selection.clearSelection();
check('#FFFFFF', 30);
check('#FFFFFF', 26);
app.vision('nvg', true);
check('#FFFFFF', 30);
check('#FFFFFF', 26);
parts.selection.selectVehicle(entry.key);
check('#FFFFFF', 36);
check('#FFFFFF', 39);
app.vision('normal', false);
check('#FF4538', 30);
parts.selection.clearSelection();
@@ -3673,3 +3730,256 @@ test('transit polls the supplied source and cancels it when disabled', async (t)
await app.layer.update();
assert.equal(calls.length, before);
});
test('selected trail QA fixture survives repeated live CapMetro snapshots', async (t) => {
const { TRAIL_VISIBILITY_FEED } =
await import('../../../scripts/qa-transit-scenes.mjs');
const { getRegisteredTransitFeed } =
await import('../../data/transitFeeds.js');
const app = harness(t, { at: { lat: 30.267, lon: -97.7431 } });
app.layer.enable(app.viewer);
const { ingestion, selection } = app.layer._transitPartsForTest();
const now = Date.now();
ingestion.applySnapshot(
TRAIL_VISIBILITY_FEED,
{
fetchedAt: now,
vehicles: [vehicle('trail-visible', 30.267, -97.7431, now / 1000)],
},
{ stale: false },
);
const key = `${TRAIL_VISIBILITY_FEED.id}:trail-visible`;
selection.selectVehicle(key);
assert.equal(app.state()._selectedKey, key);
for (let poll = 0; poll <= MISSED_POLLS_TO_DROP; poll++) {
ingestion.applySnapshot(
getRegisteredTransitFeed('capmetro-austin'),
{
fetchedAt: now + poll,
vehicles: [vehicle('live-bus', 30.268, -97.7431, now / 1000)],
},
{ stale: false },
);
}
assert.ok(
app.state()._vehicles.has(key),
'live polls must not evict the selected fixture',
);
assert.equal(app.state()._selectedKey, key);
assert.ok(
app.state()._vehicles.has('capmetro-austin:live-bus'),
'live ingestion still runs',
);
});
for (const jump of ['setView', 'flyTo']) {
test(`${jump} recovers a cold hidden vehicle within one visibility interval`, async (t) => {
const app = harness(t, { floorAt: () => undefined });
let floor;
app.viewer.scene.globe.show = true;
app.viewer.scene.globe.getHeight = () => floor;
app.serve('mbta', () => ({
status: 200,
body: snapshot('mbta', 'MBTA', [
vehicle('cold', 42.36, -71.06, reported()),
]),
}));
app.layer.enable(app.viewer);
await app.layer.update();
const entry = app.vehicles()[0];
assert.equal(entry.marker.show, false);
assert.equal(entry.heightPending, true);
// Surface tiles are now loaded at the camera destination. No poll, floor
// timer or preRender is allowed to bootstrap the visibility recovery.
clearTimeout(app.state()._floorTimer);
app.state()._floorTimer = null;
app.viewer.camera[jump] = () => {
floor = 12;
app.viewer.camera.changed.raiseEvent();
};
app.viewer.camera[jump]();
app.advance(250);
assert.equal(entry.marker.show, true);
assert.equal(entry.heightPending, false);
assert.equal(entry.surfaceReady, true);
assert.equal(entry.visibility.heightPending, false);
assert.equal(entry.visibility.surfaceReady, true);
});
}
test('selected history is prepared while its head is hidden and remains shown outside the head frustum', async (t) => {
const app = harness(t);
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(1, BOSTON);
const entry = app.vehicles()[0],
parts = app.layer._transitPartsForTest();
entry.marker.show = false;
app.state()._visible.delete(entry);
app.state()._moving.delete(entry);
parts.selection.selectVehicle(entry.key);
const trail = parts.trails.diagnostics();
assert.ok(trail.segments > 0);
assert.ok(trail.body);
assert.equal(trail.body.show, true);
parts.trails.update();
assert.equal(trail.body.show, true);
app.state()._vehicles.delete(entry.key);
parts.trails.update();
assert.equal(trail.body.show, false);
app.state()._vehicles.set(entry.key, entry);
});
test('selected draped history prepares geometry before surface heights resolve', async (t) => {
const app = harness(t, { floorAt: () => undefined });
app.layer.enable(app.viewer);
app.layer._loadTransitFleetForTest(1, BOSTON);
const entry = app.vehicles()[0],
parts = app.layer._transitPartsForTest();
delete entry.qaFloorM;
entry.marker.show = false;
entry.heightPending = true;
// Exercise the real GroundPolylinePrimitive branch, without a WebGL context.
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
app.viewer.scene.frameState = { context: { depthTexture: true } };
app.viewer.scene.groundPrimitives = app.viewer.scene.primitives;
parts.selection.selectVehicle(entry.key);
const trail = parts.trails.diagnostics();
assert.equal(trail.ground, true);
assert.ok(trail.body instanceof Cesium.GroundPolylinePrimitive);
assert.ok(trail.segments > 0);
assert.equal(trail.body.show, true);
assert.equal(
entry.displayPaths.size,
0,
'unresolved marker corridors remain unavailable',
);
});
// A camera pose can change without a viewport-membership revision (notably
// a top-down orbit). Exercise the actual frame pass with an otherwise idle fleet.
test('parked transit reprojects held and reported courses at the bounded camera cadence', async (t) => {
const { createRendering } = await import('./rendering.js');
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
const position = Cesium.Cartesian3.fromDegrees(0, 0, 20);
const cameraPosition = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const scene = {
frameState: { mode: Cesium.SceneMode.SCENE3D },
canvas: { clientWidth: 1280, clientHeight: 800 },
camera: {
positionWC: cameraPosition,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
frustum: new Cesium.PerspectiveFrustum({
fov: Math.PI / 3,
aspectRatio: 1.6,
near: 1,
far: 1e7,
}),
},
};
const pose = (heading) => {
const camera = scene.camera;
camera.heading = heading;
camera.rightWC = new Cesium.Cartesian3(
0,
Math.cos(heading),
-Math.sin(heading),
);
camera.upWC = new Cesium.Cartesian3(
0,
Math.sin(heading),
Math.cos(heading),
);
camera.viewMatrix = Cesium.Matrix4.computeView(
cameraPosition,
new Cesium.Cartesian3(-1, 0, 0),
camera.upWC,
camera.rightWC,
new Cesium.Matrix4(),
);
};
pose(0);
const make = (key, courseDeg, bearing) => ({
key,
courseDeg,
record: { bearing },
sample: { segmentCourseDeg: NaN },
marker: { position, rotation: 0.7, show: true },
});
const held = make('held', 90, 270);
const reportedOnly = make('reported', null, 90);
const unknown = make('unknown', null, null);
const hidden = make('hidden', 90, 90);
const state = {
_viewer: { scene },
_enabled: true,
_vehicles: new Map(
[held, reportedOnly, unknown, hidden].map((e) => [e.key, e]),
),
_visible: new Set([held, reportedOnly, unknown]),
_moving: new Set(),
_heightDirty: new Set(),
_rotationAt: 0,
_rotationRevision: -1,
_cameraRevision: 0,
_rotationDirty: false,
};
const noop = () => {};
const rendering = createRendering({
state,
services: {
render: {
governorRequestRender: noop,
holdContinuousRender: noop,
releaseContinuousRender: noop,
},
},
parts: { height: { nearGround: () => false }, trails: { update: noop } },
});
rendering.onPreRender();
const before = [held.marker.rotation, reportedOnly.marker.rotation];
pose(Math.PI / 2);
t.mock.timers.tick(100);
rendering.onPreRender();
assert.equal(
held.marker.rotation,
before[0],
'no rotation pass before 200 ms',
);
t.mock.timers.tick(100);
rendering.onPreRender();
for (const [i, entry] of [held, reportedOnly].entries()) {
const delta = Math.atan2(
Math.sin(entry.marker.rotation - before[i]),
Math.cos(entry.marker.rotation - before[i]),
);
assert.ok(
Math.abs(delta - Math.PI / 2) < 0.01,
`${entry.key}: rotation delta ${delta}`,
);
assert.equal(entry.courseDeg, 90);
}
assert.equal(
unknown.marker.rotation,
0,
'no course means screen-up, never a stale angle',
);
assert.equal(
hidden.marker.rotation,
0.7,
'hidden vehicles are not projected',
);
state._selectedKey = unknown.key;
unknown.marker.rotation = 0.7;
rendering.onPreRender();
assert.equal(
unknown.marker.rotation,
0,
'selected unknown course also stays screen-up',
);
});
+4 -1
View File
@@ -264,7 +264,10 @@ export function displayCourse(entry, nowMs) {
const target = Number.isFinite(entry.sample?.segmentCourseDeg)
? entry.sample.segmentCourseDeg
: null;
if (target === null) return entry.courseDeg ?? null;
if (target === null) {
if (Number.isFinite(entry.courseDeg)) return entry.courseDeg;
return Number.isFinite(entry.record?.bearing) ? entry.record.bearing : null;
}
if (entry.courseDeg === null || !Number.isFinite(entry.courseEvalAt)) {
return target;
}
+19
View File
@@ -144,3 +144,22 @@ test('mesh-floor sampling owns fully initialized numeric scratch before the firs
assert.deepEqual(Object.keys(value), shapes[i]),
);
});
test('course fallback prefers held course, then finite reported bearing, then unknown', () => {
for (const [held, bearing, expected] of [
[90, 270, 90],
[null, 90, 90],
[NaN, 0, 0],
[null, null, null],
[null, NaN, null],
[null, Infinity, null],
]) {
const e = {
courseDeg: held,
sample: { segmentCourseDeg: NaN },
record: { bearing },
};
applyDisplayCourse(e, 100);
assert.equal(e.courseDeg, expected);
}
});
+72
View File
@@ -329,3 +329,75 @@ export function reduceTrailHead(anchor, target) {
selection: anchor?.selection ?? null,
};
}
/** Repeated off/on patches isolate stable, trail-correlated framebuffer changes. */
export function reduceTrailPixels(on, off, evidence = {}) {
const { onAgain, offAgain, samples = [], sprite, tilesReady } = evidence;
const finitePoint = (p) =>
p && [p.x, p.y, p.radius].every(Number.isFinite) && p.radius > 0;
const stable = [],
eligible = [];
const hits = Array.from({ length: 8 }, (_, i) => {
const point = samples[i];
eligible[i] = !!(
tilesReady === true &&
finitePoint(point) &&
point.inFront === true &&
finitePoint(sprite) &&
Math.hypot(point.x - sprite.x, point.y - sprite.y) >
point.radius + sprite.radius &&
samples
.slice(0, i)
.every(
(prior) =>
!finitePoint(prior) ||
Math.hypot(point.x - prior.x, point.y - prior.y) >
point.radius + prior.radius,
)
);
const a = on?.[i],
b = off?.[i],
c = onAgain?.[i],
d = offAgain?.[i];
stable[i] = false;
if (
!eligible[i] ||
!a?.length ||
a.length % 4 ||
[b, c, d].some((p) => !p || p.length !== a.length)
)
return false;
let matches = 0;
const contrast = (lit, baseline, k) => {
const delta = Math.hypot(
lit[k] - baseline[k],
lit[k + 1] - baseline[k + 1],
lit[k + 2] - baseline[k + 2],
);
const green = lit[k + 1] > lit[k] + 30 && lit[k + 1] > lit[k + 2] + 18;
const dark =
lit[k] + lit[k + 1] + lit[k + 2] <
0.6 * (baseline[k] + baseline[k + 1] + baseline[k + 2]);
return delta >= 40 && (green || dark);
};
for (let k = 0; k < a.length; k += 4) {
for (let channel = 0; channel < 3; channel++) {
const j = k + channel;
if (
![a[j], b[j], c[j], d[j]].every(
(v) => Number.isFinite(v) && v >= 0 && v <= 255,
) ||
Math.abs(b[j] - d[j]) > 8 ||
Math.abs(a[j] - c[j]) > 8 ||
Math.abs(a[j] - b[j] - (c[j] - d[j])) > 8
)
return false;
}
if (contrast(a, b, k) && contrast(c, d, k)) matches++;
}
stable[i] = true;
return matches >= 2;
});
const present = hits.filter(Boolean).length;
return { hits, stable, eligible, present, total: 8, pass: present >= 6 };
}
+103
View File
@@ -328,3 +328,106 @@ test('trail-head acceptance names every missing and failed condition', async ()
false,
);
});
const trailPatch = (rgb) =>
Array.from({ length: 49 }, () => [...rgb, 255]).flat();
const trailEvidence = (on, off) => ({
onAgain: on,
offAgain: off,
tilesReady: true,
samples: Array.from({ length: 8 }, (_, i) => ({
x: i * 20 + 20,
y: 20,
radius: 3,
inFront: true,
})),
sprite: { x: 300, y: 20, radius: 24 },
});
test('trail pixels require repeatable contrast at six distinct samples outside the sprite', async () => {
const { reduceTrailPixels } = await import('./qaMetrics.js');
const off = Array.from({ length: 8 }, () => trailPatch([150, 150, 150]));
const on = off.map((p, i) => (i < 6 ? trailPatch([94, 240, 138]) : p));
const evidence = trailEvidence(on, off);
assert.equal(reduceTrailPixels(on, off, evidence).pass, true);
const five = on.map((p, i) => (i === 5 ? off[i] : p));
assert.equal(
reduceTrailPixels(five, off, trailEvidence(five, off)).pass,
false,
);
for (const rgb of [
[5, 8, 12],
[126, 126, 126],
]) {
const patches = off.map(() => trailPatch(rgb));
assert.equal(
reduceTrailPixels(patches, off, trailEvidence(patches, off)).present,
rgb[0] === 5 ? 8 : 0,
);
assert.equal(
reduceTrailPixels(patches, patches, trailEvidence(patches, patches))
.present,
0,
);
}
assert.equal(
reduceTrailPixels(on, off).pass,
false,
'two reads are insufficient',
);
assert.equal(
reduceTrailPixels(on, off, { ...evidence, tilesReady: false }).pass,
false,
);
assert.equal(
reduceTrailPixels(on, off, {
...evidence,
samples: Array(8).fill(evidence.samples[0]),
}).pass,
false,
);
assert.equal(
reduceTrailPixels(on, off, {
...evidence,
sprite: { x: 80, y: 20, radius: 200 },
}).pass,
false,
);
assert.equal(reduceTrailPixels([], [], trailEvidence([], [])).pass, false);
});
test('background refinement without a trail cannot pass repeated off/on acceptance', async () => {
const { reduceTrailPixels } = await import('./qaMetrics.js');
const grey = (n) => Array.from({ length: 8 }, () => trailPatch([n, n, n]));
const off = grey(100),
on = grey(30);
const evidence = trailEvidence(on, off);
assert.equal(
reduceTrailPixels(on, off, { ...evidence, offAgain: grey(30) }).pass,
false,
'one background transition is not a trail',
);
assert.equal(
reduceTrailPixels(on, off, {
...evidence,
offAgain: grey(170),
onAgain: grey(100),
}).pass,
false,
'equal deltas on a drifting baseline are not a trail',
);
assert.equal(
reduceTrailPixels(on, off, { ...evidence, onAgain: off }).pass,
false,
'contrast must repeat',
);
const first = off.map((p) => p.map((v, i) => (i < 8 ? on[0][i] : v)));
const second = off.map((p) =>
p.map((v, i) => (i >= 8 && i < 16 ? on[0][i] : v)),
);
assert.equal(
reduceTrailPixels(first, off, { ...evidence, onAgain: second }).pass,
false,
'the same pixels must change twice',
);
});
+35 -20
View File
@@ -1,5 +1,8 @@
import * as Cesium from 'cesium';
import { perspectiveProjectedRotation } from '../../data/iconOrientation.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../data/iconOrientation.js';
import {
updatePlayback,
applyDisplayCourse,
@@ -97,6 +100,7 @@ export function createRendering({ state, services, parts }) {
nearGround,
);
entry.surfaceReady = !!known;
entry.heightPending = !known;
entry.marker.show = !!known && vehicleInView(entry);
if (known) {
entry.marker.position = state._scratchCartesian;
@@ -307,20 +311,27 @@ export function createRendering({ state, services, parts }) {
const scene = state._viewer?.scene;
if (!scene?.camera) return;
if (now - state._rotationAt < ROTATION_REFRESH_MS) return;
if (
state._rotationRevision === state._cameraRevision &&
!state._rotationDirty &&
state._moving.size === 0
)
return;
state._rotationAt = now;
const cameraChanged = state._rotationRevision !== state._cameraRevision;
const pose = cameraPoseSignature(scene.camera);
const cameraChanged =
state._rotationPose !== pose ||
state._rotationRevision !== state._cameraRevision;
if (!cameraChanged && !state._rotationDirty && state._moving.size === 0)
return;
state._rotationPose = pose;
state._rotationRevision = state._cameraRevision;
state._rotationDirty = false;
const monoNow = performance.now();
for (const entry of state._visible) {
// Idle contacts still need a course when their first usable bearing arrives.
if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow);
if (entry.key === state._selectedKey) continue;
const course = entry.courseDeg;
if (course === null) continue;
if (!Number.isFinite(course)) {
entry.marker.rotation = 0;
entry.rotationCourse = null;
continue;
}
const position = entry.marker.position;
if (
!cameraChanged &&
@@ -416,17 +427,15 @@ export function createRendering({ state, services, parts }) {
parts.trails.update();
refreshRotations(now);
const selected = state._vehicles.get(state._selectedKey);
if (
selected &&
state._visible.has(selected) &&
selected.courseDeg !== null
) {
selected.marker.rotation = perspectiveProjectedRotation(
state._viewer.scene,
selected.marker.position,
selected.courseDeg,
selected.marker.rotation,
);
if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation(
state._viewer.scene,
selected.marker.position,
selected.courseDeg,
selected.marker.rotation,
)
: 0;
}
if (state._heightDirty.size) governorRequestRender('transit-height-batch');
syncRenderHold();
@@ -523,6 +532,10 @@ export function createRendering({ state, services, parts }) {
else state._visible.delete(entry);
if (visible && entry.track?.count) {
if (!wasVisible && !entry.qaFixture) syncPlayback(entry, now);
// Re-entry can advance into an unprepared corridor; loaded tiles can
// also resolve a cold path without a poll or a render-loop wakeup.
if (!wasVisible || entry.surfaceReady === false || entry.heightPending)
parts.trails.prepareEntry(entry);
// Surface completion must recover while requestRenderMode is idle.
// Otherwise surfaceReady=false prevents the very frame that clears it.
if (!state._moving.has(entry)) {
@@ -532,6 +545,8 @@ export function createRendering({ state, services, parts }) {
}
entry.marker.show =
visible && !entry.heightPending && entry.surfaceReady !== false;
visibility.heightPending = !!entry.heightPending;
visibility.surfaceReady = entry.surfaceReady ?? null;
schedulePlayback(entry);
changed ||= visible !== wasVisible || wasShown !== entry.marker.show;
}
+33 -12
View File
@@ -18,7 +18,9 @@ const MODE_COLOR = {
ferry: '#5FD6FF',
unknown: '#D8DDE5',
};
export const TRAIL_VERTEX_LIMIT = 2048;
// Final Cesium batching uses 3,152 bytes per two-lane edge.
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
export const TRAIL_VERTEX_LIMIT = 640;
const STEP_M = 25;
/** Shared prepared paths. Floors are aligned to work with Google 3D tiles. */
@@ -63,6 +65,7 @@ export function createTrails({ state, services, parts, source }) {
entry.trailSegments = [];
entry.trailVertices = 0;
}
const draped = entry === selected && ensureRenderer()?.diagnostics().ground;
const paths = new Map(),
all = [],
a = {},
@@ -113,16 +116,28 @@ export function createTrails({ state, services, parts, source }) {
if (entry === selected)
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
entry.trailTruncated = false;
let markerVertices = 0;
let markerIntervals = Math.max(0, entry.track.count - 1 - activeIndex);
for (const i of order) {
readFix(entry.track, i, a);
readFix(entry.track, i + 1, b);
const markerCorridor = i >= activeIndex;
if (markerCorridor) markerIntervals--;
if (a.epoch !== b.epoch || b.flags & FIX_FLAGS.BREAK) continue;
if (entry !== selected && b.t < entry.sample.displayT) continue;
const n = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
if (vertices + n + 1 > TRAIL_VERTEX_LIMIT) {
entry.trailTruncated = true;
continue;
}
const desired = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
// Reserve endpoints for every future marker interval. Sparse reports
// coarsen the active path instead of losing the vehicle to the body cap.
const n = markerCorridor
? Math.min(
desired,
TRAIL_VERTEX_LIMIT - markerVertices - 2 * markerIntervals - 1,
)
: desired;
const retainBody = vertices + n + 1 <= TRAIL_VERTEX_LIMIT;
if (!retainBody || n < desired) entry.trailTruncated = true;
if (!markerCorridor && !retainBody) continue;
if (markerCorridor) markerVertices += n + 1;
const positions = [],
heights = [];
let valid = true;
@@ -136,17 +151,21 @@ export function createTrails({ state, services, parts, source }) {
heights.push(h);
if (h === null) {
valid = false;
positions.push(null);
// Ground polylines consume longitude/latitude and drape themselves.
// Unknown floors still disqualify the marker corridor.
positions.push(
draped ? Cesium.Cartesian3.fromDegrees(point.lon, point.lat) : null,
);
} else
positions.push(
Cesium.Cartesian3.fromDegrees(point.lon, point.lat, h),
);
}
signature += `${a.seq}/${b.seq}:${heights.join(',')};`;
vertices += n + 1;
if (!valid) continue;
paths.set(a.seq, { positions, toSeq: b.seq });
if (entry === selected && fixDistanceM(a, b) > 0) {
if (retainBody) vertices += n + 1;
if (valid) paths.set(a.seq, { positions, toSeq: b.seq });
if (!valid && !draped) continue;
if (entry === selected && retainBody && fixDistanceM(a, b) > 0) {
for (let k = 0; k < n; k++)
all.push({
fromSeq: a.seq,
@@ -216,7 +235,9 @@ export function createTrails({ state, services, parts, source }) {
}
function update() {
if (!selected || !renderer) return;
renderer.setVisible(state._enabled && selected.marker?.show !== false);
renderer.setVisible(
state._enabled && state._vehicles.get(selected.key) === selected,
);
renderer.setDisplaySample(selected.sample, selected.marker?.position);
}
function select(entry) {
+1
View File
@@ -25,6 +25,7 @@ export function createEvidence({
state.feed.loaded = true;
state.feed.error = null;
state.feed.stale = false;
state.feed.partial = false;
state.feed.lastUpdate = Date.now();
state.feed.transportStatus = 'synthetic';
state.feed.lastMessageAt = null;
+4 -1
View File
@@ -102,6 +102,7 @@ export function createIngestion({
feed.lastMessageAt = snapshot.lastMessageAt;
feed.rawRowCount = snapshot.rawRowCount;
feed.acceptedRowCount = snapshot.acceptedRowCount;
feed.partial = payload?.complete === false;
if (snapshot.acceptedRowCount === 0) {
feed.count = readCount();
@@ -133,7 +134,8 @@ export function createIngestion({
feed.count = readCount();
feed.stale =
Boolean(payload?.refreshing) ||
payload?.complete === false ||
payload?.freshness === 'stale' ||
snapshot.transportStatus === 'stale' ||
payload?.freshness === 'unknown';
feed.newestPositionAt = payload?.newestPositionAt || null;
// Not unconditionally null: a degraded feed keeps its reason even though the
@@ -190,6 +192,7 @@ export function createVesselFeed() {
loading: false,
loaded: false,
stale: false,
partial: false,
error: null,
loadingLabel: '',
lastUpdate: null,
+1
View File
@@ -100,6 +100,7 @@ export function createLifecycle({
state.feed.loading = false;
state.feed.loaded = false;
state.feed.stale = false;
state.feed.partial = false;
state.feed.error = null;
state.feed.loadingLabel = '';
state.feed.lastUpdate = null;
+61 -1
View File
@@ -124,7 +124,8 @@ test('partial observations retain missing contacts only until their receipt dead
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
await layer.update();
assert.equal(layer.hasContact('222'), true);
assert.equal(layer.getStats().stale, true);
assert.equal(layer.getStats().stale, false);
assert.equal(layer.getStats().partial, true);
assert.equal(layer.getStats().rawRowCount, 2);
assert.equal(layer.getStats().acceptedRowCount, 1);
assert.equal(layer.getStats().lastUpdate, 500);
@@ -301,3 +302,62 @@ test('refresh keeps pick identity and renderer geometry separate, and eviction r
assert.equal(primitives.has(primitive), false);
assert.equal(layer.findByQuery('111'), null);
});
test('partial vessel snapshots preserve freshness and recover after a complete update', async (t) => {
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
const { vesselSnapshot } = await import('../../sources/live/vessels.js');
const { layerFeedState } = await import('../../data/feedState.js');
const valid = {
mmsi: '123456789',
lat: 51.93,
lon: 4.05,
last_position_epoch: 1,
};
let current = vesselSnapshot({
rows: [valid, { ...valid }, { mmsi: '987654321', lat: 200, lon: 4 }],
status: 'live',
});
const { layer } = setup({ getSnapshot: async () => current });
await layer.update();
assert.equal(layer.getStats().partial, true);
assert.equal(layer.getStats().stale, false);
assert.equal(layer.getStats().rawRowCount, 3);
assert.equal(layer.getStats().acceptedRowCount, 1);
assert.equal(layerFeedState(layer.getStats()), 'partial');
assert.equal(layer.hasContact('123456789'), true);
for (const extra of [
{ stale: true },
{ freshness: 'stale' },
{ freshness: 'unknown', observedAtMs: null },
{ transportStatus: 'stale' },
]) {
const original = current;
current = { ...original, ...extra };
await layer.update();
assert.equal(layer.getStats().partial, true);
assert.equal(layerFeedState(layer.getStats()), 'stale');
current = original;
}
current = { ...current, transportStatus: 'reconnecting' };
await layer.update();
assert.equal(layerFeedState(layer.getStats()), 'degraded');
assert.match(layer.getStats().error, /reconnecting/);
current = vesselSnapshot({ rows: [valid], status: 'live' });
await layer.update();
assert.equal(layer.getStats().partial, false);
assert.equal(layer.getStats().stale, false);
assert.equal(layer.getStats().error, null);
assert.equal(layerFeedState(layer.getStats()), 'nominal');
current = { ...current, complete: false };
await layer.update();
assert.equal(layer.getStats().partial, true);
layer.destroy();
assert.equal(layer.getStats().partial, false);
});
+1
View File
@@ -508,6 +508,7 @@ export function createQueries({
: state.feed.loadingLabel,
error: state.feed.error,
stale: state.feed.stale,
partial: state.feed.partial,
status:
state.feed.firstConnectPhase === 'unavailable'
? 'unavailable'
+1
View File
@@ -37,6 +37,7 @@ export function createTesting({
state.feed.loaded = options.loaded === true;
state.feed.loading = options.loading === true;
state.feed.stale = options.stale === true;
state.feed.partial = options.partial === true;
state.feed.error = options.error || null;
state.feed.lastUpdate = options.lastUpdate ?? null;
state.records.all = records;
+3 -2
View File
@@ -1,3 +1,4 @@
import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
@@ -18,7 +19,7 @@ const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
const realtime = readFileSync(new URL('./voice/realtimeController.js', import.meta.url), 'utf8');
const realtime = readRealtimeSource();
const voice = readFileSync(new URL('./voice/actionSchemas.js', import.meta.url), 'utf8') + '\n' + ['toolDescriptions', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
const css = readStylesheet(new URL('../style.css', import.meta.url));
@@ -334,7 +335,7 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
assert.match(ui, /contextRadioDetailsBtn/);
const syncStart = ui.indexOf('\n _syncPanelCollapseButton(panelEl)');
const syncMethod = ui.slice(syncStart, ui.indexOf('\n /**', syncStart + 10));
const syncMethod = ui.slice(syncStart, ui.indexOf('\n }', syncStart + 10));
assert.doesNotMatch(syncMethod, /contextRadioDetailsBtn[\s\S]*?(?:aria-label|textContent|\.title)/);
});
+2 -1
View File
@@ -253,8 +253,9 @@ test('detection-on-by-default is a default, not an operator override', () => {
const displayActions = uiSource.slice(uiSource.indexOf('this._displayControls ='));
const detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:'));
assert.match(detectionButton, /cycleDetectionMode\(\)/, 'the button still invokes the detection action');
assert.match(detectionButton, /this\._detectionUserOverridden = true;/,
assert.match(detectionButton, /this\.claimDetection\(\);/,
'and the detection control still claims the override when the operator uses it');
assert.match(uiSource, /claimDetection: \(\) => \{\s*this\._visualSettings\._detectionUserOverridden = true;/);
// Style-switch semantics are unchanged: Normal is still not a preset owner,
// so switching TO Normal does not re-apply or clear anything.
+70
View File
@@ -0,0 +1,70 @@
import { sampleCameraMove } from '../director/camera.js';
/** Own one authored camera animation and settle it immediately on cancellation or replacement. */
export function createCameraMotion({
applyPose,
now = () => performance.now(),
requestFrame = (callback) => requestAnimationFrame(callback),
cancelFrame = (handle) => cancelAnimationFrame(handle),
} = {}) {
let active = null;
let disposed = false;
const cancel = () => active?.finish(false);
return {
get active() {
return active !== null;
},
cancel,
destroy() {
disposed = true;
cancel();
},
play(move, token) {
cancel();
if (disposed || token?.cancelled || token?.signal?.aborted)
return Promise.resolve(false);
if (!Number.isFinite(move.durationSec) || move.durationSec <= 0)
throw new RangeError('Camera move duration must be positive');
return new Promise((resolve, reject) => {
const run = { handle: null, finish: null };
const startedAt = now();
const onAbort = () => run.finish(false);
run.finish = (completed, error) => {
if (active !== run) return;
active = null;
if (run.handle !== null) cancelFrame(run.handle);
token?.signal?.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve(completed);
};
active = run;
token?.signal?.addEventListener('abort', onAbort, { once: true });
const tick = () => {
if (active !== run) return;
run.handle = null;
if (disposed || token?.cancelled || token?.signal?.aborted) {
run.finish(false);
return;
}
try {
const progress = Math.min(
1,
Math.max(0, (now() - startedAt) / (move.durationSec * 1000)),
);
if (applyPose(sampleCameraMove(move, progress)) === false) {
run.finish(false);
return;
}
// Applying a pose can synchronously transfer camera ownership.
if (active !== run) return;
if (progress >= 1) run.finish(true);
else run.handle = requestFrame(tick);
} catch (error) {
run.finish(false, error);
}
};
tick();
});
},
};
}
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCameraMotion } from './cameraMotion.js';
const move = {
durationSec: 2,
easing: 'linear',
from: { lat: 0, lon: 0, alt: 100, heading: 0, pitch: 0, roll: 0 },
to: { lat: 2, lon: 2, alt: 200, heading: 0, pitch: 0, roll: 0 },
};
function harness(apply) {
let time = 0;
let next = 0;
const frames = new Map();
const poses = [];
const owner = createCameraMotion({
now: () => time,
requestFrame: (cb) => {
const id = ++next;
frames.set(id, cb);
return id;
},
cancelFrame: (id) => frames.delete(id),
applyPose: (pose) => {
poses.push(pose);
return apply?.(pose);
},
});
return {
owner,
frames,
poses,
tick(ms) {
time = ms;
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((cb) => cb());
},
};
}
test('motion uses the authored curve and releases the frame on completion', async () => {
const h = harness();
const done = h.owner.play(move, { cancelled: false });
assert.deepEqual(h.poses[0], move.from);
h.tick(1000);
assert.equal(h.poses.at(-1).alt, 150);
h.tick(2000);
assert.equal(await done, true);
assert.deepEqual(h.poses.at(-1), move.to);
assert.equal(h.frames.size, 0);
assert.equal(h.owner.active, false);
});
test('abort and replacement settle immediately and revoke already queued callbacks', async () => {
const h = harness();
const abort = new AbortController();
const first = h.owner.play(move, { signal: abort.signal });
const stale = [...h.frames.values()][0];
abort.abort();
assert.equal(await first, false);
const second = h.owner.play(move, {});
const count = h.poses.length;
stale();
assert.equal(h.poses.length, count);
h.owner.destroy();
assert.equal(await second, false);
assert.equal(h.frames.size, 0);
assert.equal(await h.owner.play(move, {}), false);
});
test('synchronous cancellation from pose application schedules no abandoned frame', async () => {
let owner;
const h = harness(() => owner.cancel());
owner = h.owner;
assert.equal(await owner.play(move, {}), false);
assert.equal(h.frames.size, 0);
});
test('failed pose application settles and invalid durations cannot schedule a loop', async () => {
const h = harness(() => {
throw new Error('camera unavailable');
});
await assert.rejects(h.owner.play(move, {}), /camera unavailable/);
assert.equal(h.frames.size, 0);
assert.throws(
() => h.owner.play({ ...move, durationSec: 0 }, {}),
/duration/,
);
});
+23
View File
@@ -0,0 +1,23 @@
import { createDataPackSession } from '../../director/packs/session.js';
import { createPackPresentations } from './presentation.js';
/** Bind selected shot packs to one viewer and expose copied lifecycle diagnostics. */
export function createSceneDataPacks(viewer, { sources = {} } = {}) {
const targets = new Map();
const session = createDataPackSession({
sources,
adapters: createPackPresentations(viewer, targets),
});
return {
...session,
sourceIds: () => Object.keys(sources),
getTargets: () => new Map(targets),
apply(scene, shot, token) {
const wanted = new Set(shot.dataPackIds || []);
return session.load(
(scene.dataPacks || []).filter((pack) => wanted.has(pack.id)),
{ anchors: scene.anchors, signal: token?.signal },
);
},
};
}
+77
View File
@@ -0,0 +1,77 @@
import * as Cesium from 'cesium';
import { decodePackGeoJSON } from '../../director/packs/geojson.js';
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Render a bounded geometry pack in owned batches; no asynchronous entity geometry or update loop. */
export function renderPackGeometry({ asset, pack }, handle) {
if (!['application/json', 'application/geo+json'].includes(asset.mimeType))
throw new Error('Expected GeoJSON');
const features = decodePackGeoJSON(asset.bytes);
const polygons = [];
let lines;
for (const feature of features) {
const id = { packId: pack.id, featureId: feature.id };
if (feature.type === 'Point') {
const entity = handle.add({
name: `${pack.id} / ${feature.id}`,
position: xyz(feature.coordinates),
point: {
pixelSize: 12,
color: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
},
});
handle.feature?.(id, entity);
} else if (feature.type === 'LineString') {
handle.feature?.(id, id);
if (!lines) {
lines = new Cesium.PolylineCollection();
handle.primitive(lines);
}
lines.add({
id,
positions: feature.coordinates.map(xyz),
width: 4,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.CYAN,
}),
});
} else {
handle.feature?.(id, id);
polygons.push(
new Cesium.GeometryInstance({
id,
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
feature.coordinates[0].map(xyz),
feature.coordinates
.slice(1)
.map((ring) => new Cesium.PolygonHierarchy(ring.map(xyz))),
),
perPositionHeight: true,
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.GEODESIC,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.CYAN.withAlpha(0.35),
),
},
}),
);
}
}
if (polygons.length)
handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: polygons,
appearance: new Cesium.PerInstanceColorAppearance({
translucent: true,
flat: true,
closed: false,
}),
}),
);
}
+224
View File
@@ -0,0 +1,224 @@
import * as Cesium from 'cesium';
import { renderPackGeometry } from './geometry.js';
function caption(viewer, pack) {
const card = document.createElement('section');
card.dataset.directorPack = pack.id;
Object.assign(card.style, {
color: '#e3faff',
background: '#08141eed',
border: '1px solid #2491a8',
padding: '8px',
margin: '4px',
maxWidth: '260px',
font: '12px sans-serif',
});
const title = document.createElement('div');
title.textContent = `${pack.id} · ${pack.attribution.text} · ${pack.attribution.license}`;
card.append(title);
if (pack.attribution.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.href = pack.attribution.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.color = '#6eeaff';
card.append(link);
}
return card;
}
const xyz = (p) => Cesium.Cartesian3.fromDegrees(...p);
/** Create rendering adapters; data acquisition and authored placement stay independent. */
export function createPackPresentations(viewer, targets = new Map()) {
let panel;
function addCard(pack) {
if (!panel) {
panel = document.createElement('div');
panel.dataset.directorPacks = '';
Object.assign(panel.style, {
position: 'absolute',
right: '10px',
bottom: '70px',
maxHeight: '40vh',
overflowY: 'auto',
zIndex: '30',
});
viewer.container.append(panel);
}
const card = caption(viewer, pack);
panel.append(card);
return card;
}
function resource(pack) {
const entities = [],
primitives = [],
materials = [],
urls = [];
const featureKeys = [];
let card,
destroyed = false;
return {
feature(id, pickedId) {
const key = JSON.stringify([id.packId, id.featureId]);
targets.set(key, pickedId);
featureKeys.push(key);
},
add(spec) {
const entity = viewer.entities.add(spec);
entities.push(entity);
viewer.scene.requestRender();
return entity;
},
primitive(value) {
primitives.push(viewer.scene.primitives.add(value));
if (value.appearance?.material)
materials.push(value.appearance.material);
viewer.scene.requestRender();
},
card() {
return (card ||= addCard(pack));
},
url(blob) {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
},
dispose() {
if (destroyed) return;
destroyed = true;
for (const key of featureKeys.splice(0)) targets.delete(key);
for (const media of card?.querySelectorAll('video,audio') || []) {
media.pause();
media.removeAttribute('src');
media.load();
}
card?.remove();
if (panel && !panel.childElementCount) {
panel.remove();
panel = null;
}
for (const entity of entities.splice(0)) viewer.entities.remove(entity);
for (const primitive of primitives.splice(0))
viewer.scene.primitives.remove(primitive);
for (const material of materials.splice(0)) {
if (!material.isDestroyed()) material.destroy();
}
for (const url of urls.splice(0)) URL.revokeObjectURL(url);
viewer.scene.requestRender();
},
};
}
function guarded(render) {
return async (context) => {
const handle = resource(context.pack);
const abort = () => handle.dispose();
context.signal.addEventListener('abort', abort, { once: true });
try {
context.signal.throwIfAborted();
await render(context, handle);
context.signal.throwIfAborted();
handle.card();
return handle;
} catch (error) {
handle.dispose();
throw error;
} finally {
context.signal.removeEventListener('abort', abort);
}
};
}
return {
geojson: guarded(renderPackGeometry),
image: guarded(async ({ asset, pack, signal }, handle) => {
if (asset.mimeType !== 'image/png') throw new Error('Expected PNG image');
const bytes = asset.bytes;
if (
bytes.length < 24 ||
![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].every(
(v, i) => bytes[i] === v,
)
)
throw new Error('Invalid PNG header');
const header = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
const width = header.getUint32(16),
height = header.getUint32(20);
if (!width || !height || width > 4096 || height > 4096)
throw new Error('Image dimensions exceed limit');
const image = new Image();
image.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
const abort = () => {
image.src = '';
};
signal.addEventListener('abort', abort, { once: true });
try {
await image.decode();
} finally {
signal.removeEventListener('abort', abort);
}
signal.throwIfAborted();
if (image.naturalWidth * image.naturalHeight > 16 * 1024 * 1024)
throw new Error('Image dimensions exceed limit');
handle.primitive(
new Cesium.Primitive({
asynchronous: false,
geometryInstances: new Cesium.GeometryInstance({
id: { packId: pack.id },
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds),
height: pack.placement.height,
vertexFormat:
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
}),
appearance: new Cesium.MaterialAppearance({
material: Cesium.Material.fromType('Image', { image }),
materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED,
flat: true,
faceForward: true,
translucent: true,
}),
}),
);
}),
media: guarded(({ asset, pack, anchors }, handle) => {
if (
![
'video/mp4',
'video/webm',
'audio/mpeg',
'audio/ogg',
'audio/wav',
'audio/webm',
].includes(asset.mimeType)
)
throw new Error('Unsupported media');
const anchor = anchors.find(
(item) => item.id === pack.placement.anchorId,
);
if (!anchor) throw new Error('Missing media anchor');
handle.add({
name: pack.id,
position: xyz([anchor.lon, anchor.lat, anchor.alt]),
point: { pixelSize: 16, color: Cesium.Color.YELLOW },
label: {
text: pack.id,
font: '14px sans-serif',
pixelOffset: new Cesium.Cartesian2(0, -24),
},
});
const media = document.createElement(
asset.mimeType.startsWith('video/') ? 'video' : 'audio',
);
media.controls = true;
media.preload = 'none';
media.style.width = '240px';
media.src = handle.url(new Blob([asset.bytes], { type: asset.mimeType }));
handle.card().append(media);
}),
};
}
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPackPresentations } from './presentation.js';
test('abort releases an in-flight image URL before the decoder settles or the viewer is destroyed', async () => {
let settle;
const pending = new Promise((resolve) => {
settle = resolve;
});
const priorImage = globalThis.Image;
const priorRevoke = URL.revokeObjectURL;
const revoked = [];
globalThis.Image = class {
decode() {
return pending;
}
};
URL.revokeObjectURL = (url) => {
revoked.push(url);
priorRevoke(url);
};
const viewer = {
scene: { requestRender() {} },
entities: {
remove() {
assert.fail('No entity should be acquired');
},
},
};
const signalOwner = new AbortController();
const bytes = new Uint8Array(24);
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
const header = new DataView(bytes.buffer);
header.setUint32(16, 8);
header.setUint32(20, 8);
try {
const rendering = createPackPresentations(viewer).image({
pack: {},
asset: { bytes, mimeType: 'image/png' },
signal: signalOwner.signal,
});
const rejected = assert.rejects(rendering, /abort/i);
signalOwner.abort();
assert.equal(
revoked.length,
1,
'object URL must be released synchronously with cancellation',
);
settle();
await rejected;
assert.equal(revoked.length, 1, 'late decode cannot dispose twice');
} finally {
settle();
globalThis.Image = priorImage;
URL.revokeObjectURL = priorRevoke;
}
});
+460 -974
View File
File diff suppressed because it is too large. Load diff
+190 -3
View File
@@ -614,10 +614,11 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
removeEventListener() {},
body: { classList: noopClassList, appendChild() {} },
};
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
globalThis.localStorage = {
getItem: () => JSON.stringify(project),
setItem() {},
removeItem() {},
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => stored.set(key, value),
removeItem: (key) => stored.delete(key),
};
return () => {
@@ -730,6 +731,73 @@ function makeDirector(options = {}) {
return { director, viewer, styleManager, dataManager, restore };
}
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
const { director, dataManager, restore } = makeDirector();
let owner = null;
const owners = [];
dataManager.layers = new Map([['flights', { module: {
setSceneMediaPlayback(value = null) { owner = value; if (value) owners.push(value.shotId); },
} }]]);
try {
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
assert.equal(owner, null, 'passive LOAD is not playback');
await director.replayShot('scene-1', 'shot-a');
assert.equal(owner?.shotId, 'shot-a');
assert.equal(owner.token.cancelled, false);
await director.loadShot('missing-scene', 'missing-shot');
assert.equal(owner?.shotId, 'shot-a', 'rejected navigation does not revoke another action');
director._running = true;
await director.loadShot('scene-1', 'shot-b');
assert.equal(owner?.shotId, 'shot-a', 'LOAD refused during a run cannot stop its media');
director._running = false;
await director.loadShot('scene-1', 'shot-b', { flyDuration: 0 });
assert.equal(owner, null, 'replacement LOAD revokes the prior replay');
await director.replayShot('scene-1', 'shot-a');
director.stopScene();
assert.equal(owner, null);
director._sleep = async () => {};
owners.length = 0;
await director.startScene('scene-1', { single: true, preview: false });
assert.deepEqual(owners, ['shot-a'], 'only enabled opt-in layers receive playback authority');
assert.equal(owner, null, 'run completion releases the last shot owner');
} finally { await director.destroy(); restore(); }
});
test('cross-scene replay grants media ownership only after the previous scene releases its layers', async () => {
const { director, dataManager, restore } = makeDirector();
let owner = null;
const events = [];
const media = { setSceneMediaPlayback(value = null) { owner = value; } };
dataManager.layers = new Map([['flights', { module: media }]]);
const setEnabled = dataManager.setEnabled.bind(dataManager);
dataManager.setEnabled = async (id, enabled, options) => {
if (id === 'flights') {
events.push({ enabled, owner: owner?.shotId ?? null });
if (!enabled) media.setSceneMediaPlayback();
}
return setEnabled(id, enabled, options);
};
try {
const previous = director._project.scenes[0];
previous.releaseLayerIds = ['flights'];
const target = structuredClone(previous);
target.id = 'other-media-scene';
director._project.scenes.push(target);
await director.loadShot(previous.id, 'shot-a', { flyDuration: 0 });
events.length = 0;
const result = await director.replayShot(target.id, 'shot-a');
assert.equal(result.started, true);
assert.deepEqual(events.slice(0, 2), [
{ enabled: false, owner: null },
{ enabled: true, owner: 'shot-a' },
], 'old-scene disable must finish before granting the new owner');
assert.equal(owner?.sceneId, target.id);
assert.equal(owner?.token.cancelled, false);
director.stopScene();
assert.equal(owner, null);
} finally { await director.destroy(); restore(); }
});
test('scene camera waits for provider completion and fade rather than the saved media hold', async () => {
const { director, viewer, dataManager, restore } = makeDirector();
let release;
@@ -1397,3 +1465,122 @@ test('Scene load outcomes exclude superseded and disposed completions', async ()
assert.equal(seen.length, count);
} finally { restore(); }
});
test('invalid and unsupported imports retain the current project, selection and saved bytes', async () => {
const { director, restore } = makeDirector();
try {
const project = director._project;
const selected = director._selectedShotId;
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
for (const value of ['{}', '{"version":99,"scenes":[]}', '{"scenes":[{"shots":"bad"}]}']) {
await director.importProjectFile({ name: 'bad.json', text: async () => value });
assert.equal(director._project, project);
assert.equal(director._selectedShotId, selected);
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
assert.match(director._presentation.status, /Import failed: \$/);
}
let read = false;
await director.importProjectFile({ size: 6 * 1024 * 1024, text: async () => { read = true; } });
assert.equal(read, false);
assert.equal(director._project, project);
} finally { restore(); }
});
test('newer imports win delayed file reads, and an empty project is preserved', async () => {
const { director, restore } = makeDirector();
try {
let release;
const older = director.importProjectFile({ name: 'old.json', text: () => new Promise((resolve) => { release = resolve; }) });
await director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
release(JSON.stringify(PROJECT_FIXTURE));
await older;
assert.deepEqual(director._project.scenes, []);
assert.equal(director._selectedSceneId, null);
assert.deepEqual(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes, []);
} finally { restore(); }
});
test('unsupported stored documents cannot be overwritten by fallback edits', async () => {
const { director, restore } = makeDirector({ project: { version: 99, scenes: [] } });
try {
const saved = localStorage.getItem('godsEyeView.sceneProject.v2');
assert.ok(director._storageReadError);
director._project.scenes[0].title = 'Fallback edit';
director._saveProject();
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), saved);
await director.importProjectFile({ name: 'valid.json', text: async () => JSON.stringify(PROJECT_FIXTURE) });
assert.equal(director._storageReadError, null);
assert.equal(JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).version, 6);
} finally { restore(); }
});
test('valid import settles a cancelled load before replacing the project', async () => {
const { director, styleManager, restore } = makeDirector();
let release;
styleManager.applyVisualState = () => new Promise((resolve) => { release = resolve; });
try {
const before = director._project;
const load = director.loadShot('scene-1', 'shot-a');
await settle();
const importing = director.importProjectFile({ name: 'empty.json', text: async () => '{"version":3,"scenes":[]}' });
await settle();
assert.equal(director._project, before, 'old work still owns cleanup');
release(true);
await Promise.all([load, importing]);
assert.deepEqual(director._project.scenes, []);
assert.equal(director._pendingWork.size, 0);
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
} finally { restore(); }
});
test('a delayed import cannot publish after disposal', async () => {
const { director, restore } = makeDirector();
let release;
try {
const before = director._project;
const reading = director.importProjectFile({ name: 'empty.json', text: () => new Promise((resolve) => { release = resolve; }) });
await director.destroy();
release('{"version":3,"scenes":[]}');
await reading;
assert.equal(director._project, before);
} finally { restore(); }
});
test('invalid authored edits cannot persist an unreadable project over a good save', () => {
const { director, restore } = makeDirector();
try {
const before = localStorage.getItem('godsEyeView.sceneProject.v2');
let notice;
director._toastStorageError = (message) => { notice = message; };
director._project.scenes[0].shots[0].camera.lat = 91;
director._saveProject();
assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before);
assert.match(notice, /camera.lat/);
} finally { restore(); }
});
test('zero camera pitch is preserved by both immediate placement and ordinary flight', async () => {
const { director, viewer, restore } = makeDirector();
try {
let placed;
viewer.camera.setView = (options) => { placed = options; };
const pose = { lat: 10, lon: 20, alt: 500, heading: 0, pitch: 0, roll: 0 };
director._setCameraView(pose);
assert.equal(placed.orientation.pitch, 0);
await director._flyCamera(pose, 0.2, { cancelled: false });
assert.equal(viewer.flights.at(-1).orientation.pitch, 0);
} finally { restore(); }
});
test('camera refusal starts no authored frame or playback clock', async () => {
const { director, styleManager, viewer, restore } = makeDirector();
try {
const shot = director._project.scenes[0].shots[0];
shot.move = { from: { ...shot.camera, altitudeReference: 'ellipsoid' }, easing: 'linear' };
styleManager.runImmediateNavigation = () => false;
assert.equal((await director.startScene('scene-1', { single: true })).reason, 'camera-unavailable');
assert.equal(director._cameraMotion.active, false);
assert.equal(director.getPlaybackTimingState().activeTimers, 0);
assert.deepEqual(viewer.flights, []);
} finally { restore(); }
});
+165
View File
@@ -0,0 +1,165 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
import { createInteractionSession } from '../director/interactions/session.js';
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
export function createSceneInteractions(
viewer,
{ execute, available = () => true },
) {
let panel,
status,
card,
handler,
items = [],
targets = new Map();
const buttons = new Map();
const session = createInteractionSession({
execute: (item, signal) => {
if (!isPointerFree() || !available()) return false;
card.replaceChildren();
if (item.action.type === 'card') {
const text = document.createElement('p');
text.textContent = item.action.text;
card.append(text);
if (item.action.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.style.color = '#6eeaff';
link.href = item.action.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
card.append(link);
}
return true;
}
return execute(item.action, signal);
},
changed(state) {
for (const [id, button] of buttons) {
button.disabled = state.busy;
button.setAttribute('aria-pressed', String(state.selected === id));
button.style.outline = state.selected === id ? '2px solid #6eeaff' : '';
}
},
});
function clear() {
session.clear();
handler?.destroy();
handler = null;
panel?.remove();
panel = null;
status = null;
card = null;
buttons.clear();
items = [];
targets.clear();
}
async function dispatch(id) {
const current = panel;
const ok = await session.dispatch(id);
if (current && current === panel && status)
status.textContent = ok
? 'Action complete'
: 'Action unavailable or cancelled';
return ok;
}
return {
clear,
destroy: clear,
getState: session.getState,
activate(shot, registered) {
clear();
items = shot.interactions || [];
if (!items.length) return;
targets = registered;
if (
items.some(
(item) =>
!targets.has(
JSON.stringify([item.target.packId, item.target.featureId]),
),
)
) {
items = [];
targets.clear();
throw new Error('Interaction feature is missing from the loaded pack');
}
panel = document.createElement('section');
panel.dataset.directorInteractions = '';
panel.setAttribute('aria-label', 'Scene actions');
Object.assign(panel.style, {
position: 'absolute',
left: '12px',
bottom: '80px',
zIndex: '31',
maxWidth: '300px',
maxHeight: '40vh',
overflowY: 'auto',
background: '#08141eed',
color: '#e3faff',
border: '1px solid #2491a8',
padding: '10px',
font: '13px sans-serif',
});
const owner = panel;
const title = document.createElement('strong');
title.textContent = 'Scene actions';
panel.append(title);
status = document.createElement('p');
status.setAttribute('role', 'status');
status.textContent =
'Select a feature or use Tab and Enter to choose an action.';
panel.append(status);
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.label;
button.dataset.directorAction = item.id;
Object.assign(button.style, {
display: 'block',
background: '#10242e',
color: '#e3faff',
border: '1px solid #397080',
borderRadius: '3px',
padding: '6px 10px',
cursor: 'pointer',
font: 'inherit',
margin: '6px 0',
maxWidth: '100%',
whiteSpace: 'normal',
});
button.addEventListener('click', () => {
if (panel === owner) void dispatch(item.id);
});
buttons.set(item.id, button);
panel.append(button);
}
card = document.createElement('div');
card.dataset.directorActionCard = '';
panel.append(card);
viewer.container.append(panel);
session.activate(items);
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((event) => {
if (panel !== owner || !isPointerFree() || !available()) return;
const picked = viewer.scene.pick(event.position)?.id;
const matches = items.filter(
(item) =>
targets.get(
JSON.stringify([item.target.packId, item.target.featureId]),
) === picked,
);
if (!matches.length) return;
for (const item of items) {
const button = buttons.get(item.id);
button.style.outline = matches.includes(item)
? '2px solid #6eeaff'
: '';
}
status.textContent = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
buttons.get(matches[0].id)?.focus({ preventScroll: true });
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
};
}
+11
View File
@@ -0,0 +1,11 @@
import { SCENE_APPEND_RECIPES } from '../recipes.js';
import { createScenePackRegistry } from './registry.js';
import { nepalScenePresentation } from './nepal.js';
/** Compose the installed scene recipes and their trusted presentation rules. */
export function createDefaultScenePacks() {
return createScenePackRegistry({
recipes: SCENE_APPEND_RECIPES,
adapters: [nepalScenePresentation],
});
}
+26
View File
@@ -0,0 +1,26 @@
import { INCIDENT_OVERVIEW_HOLD_SEC } from '../../data/bhoteKoshiIncidentPlaces.js';
/** Existing Nepal presentation rules; authored assets and saved shots stay untouched. */
export const nepalScenePresentation = {
minimumHoldSec(states) {
const overview = states['bhote-koshi-locator'];
return overview?.enabled &&
overview.params?.presentation === 'bhote-koshi-incident-places'
? INCIDENT_OVERVIEW_HOLD_SEC
: 0;
},
resolveVisual(shot, visual, isMapStackAvailable) {
const isNepalShot =
shot?.layers?.['bhote-koshi-2026']?.enabled ||
shot?.layers?.['bhote-koshi-locator']?.enabled;
// A credential is not proof of a loaded tileset. Resolve actual capability at playback time.
return isNepalShot &&
visual.mapStack === 'photoreal' &&
!isMapStackAvailable('photoreal')
? { ...visual, mapStack: 'esri-imagery' }
: visual;
},
cancelMotion(getLayerModule) {
getLayerModule('bhote-koshi-locator')?.cancelSceneMotion?.();
},
};
+24
View File
@@ -0,0 +1,24 @@
/** Register trusted scene-pack recipes and presentation adapters at composition time. */
export function createScenePackRegistry({ recipes = [], adapters = [] } = {}) {
const byId = new Map(recipes.map((recipe) => [recipe.id, recipe]));
const rules = [...adapters];
return {
recipeForShot: (shot) => byId.get(shot?.sourcePackId) || null,
minimumHoldSec(states) {
return Math.max(
0,
...rules.map((rule) => rule.minimumHoldSec?.(states) || 0),
);
},
resolveVisual(shot, visual, isMapStackAvailable) {
return rules.reduce(
(result, rule) =>
rule.resolveVisual?.(shot, result, isMapStackAvailable) || result,
visual,
);
},
cancelMotion(getLayerModule) {
for (const rule of rules) rule.cancelMotion?.(getLayerModule);
},
};
}
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScenePackRegistry } from './registry.js';
import {
effectiveShotHoldSec,
shotRuntimeDurationSec,
layerStatesForShot,
visualStateForShot,
} from '../shotPresentation.js';
test('registered pack rules apply to another source without editing the Director or saved shots', () => {
const recipe = {
id: 'sample',
runtimeHoldSecByBeat: { arrival: 6 },
runtimeControlsByBeat: {
arrival: { minimumHoldSec: 8, deferEvidenceUntilCameraSettled: true },
},
};
let cancelled = 0;
const packs = createScenePackRegistry({
recipes: [recipe],
adapters: [
{
resolveVisual: (_, visual) => ({ ...visual, mapStack: 'osm' }),
cancelMotion: (getModule) => getModule('sample').cancelSceneMotion(),
},
],
});
const shot = {
id: 'a',
sourcePackId: 'sample',
durationSec: 2,
holdSec: 1,
visual: { style: 'normal' },
layers: {
sample: {
enabled: true,
params: { presentation: 'scene-beat', beatId: 'arrival' },
},
},
};
const scene = { id: 'scene', shots: [shot], releaseLayerIds: ['other'] };
const before = structuredClone(shot);
assert.equal(shotRuntimeDurationSec(scene, shot, packs), 10);
assert.equal(effectiveShotHoldSec(scene, shot, packs), 8);
const states = layerStatesForShot(scene, shot, packs, {
cameraSettled: true,
});
assert.equal(states.sample.params.sceneControls.cameraSettled, true);
assert.equal(states.sample.params.sceneContext.holdSec, 8);
assert.equal(states.other.enabled, false);
assert.equal(visualStateForShot(shot, packs, () => true).mapStack, 'osm');
packs.cancelMotion(() => ({ cancelSceneMotion: () => cancelled++ }));
assert.equal(cancelled, 1);
assert.deepEqual(shot, before);
});
+78
View File
@@ -0,0 +1,78 @@
/** Connect the portable Director runner to the existing scene presentation. */
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
let timing;
let travel;
return {
async releaseScene(scene, token) {
const released = await director._releaseSceneLayers(scene, token);
if (!token || released) director._loadedSceneId = null;
return released;
},
selectShot({ scene, shot, index, total }) {
director._selectedSceneId = scene.id;
director._selectedShotId = shot.id;
director._renderSceneSelect();
director._renderShotList();
director._updateStatus(
`Running ${index + 1}/${total}: ${scene.title} / ${shot.title}`,
);
director._logEvent('shot_start', {
sceneId: scene.id,
shotId: shot.id,
title: shot.title,
index,
});
},
applyVisual({ shot, token }) {
return director.styleManager.applyVisualState(
director._visualStateForShot(shot),
{ isCurrent: () => !token.cancelled && !token.signal?.aborted },
);
},
async applyLayers({ scene, shot, token }) {
director._setSceneMediaPlayback(scene, shot, token);
const result = await director._applyLayerStates(
director._layerStatesForShot(scene, shot),
token,
);
if (
!token.cancelled &&
!result.refused.length &&
!(await director._applyDataPacks(scene, shot, token))
)
throw new Error('Data packs unavailable');
return result;
},
travel({ scene, shot, token }) {
director._loadedSceneId = scene.id;
const duration = shot.durationSec || defaultShotDurationSec;
timing = director._startSceneClockTicker(scene, shot, token);
travel = director._beginShotTravel(scene, shot, duration);
const flight = director._flyShotCamera(scene, shot, duration, token);
director._publishShotTravel(scene, shot, travel);
return flight;
},
settle({ scene, shot, token }) {
director._settleShotLayerStates(scene, shot, token, travel);
},
hold({ scene, shot, token }) {
return director._holdShot(scene, shot, token);
},
completeShot({ scene, shot, index }) {
director._clock.stopSceneTimer();
director._publishSceneClock(scene, shot, timing.endElapsedSec, {
running: true,
});
director._logEvent('shot_end', {
sceneId: scene.id,
shotId: shot.id,
index,
});
},
complete() {
director._setProgress(1);
director._updateStatus('Scene run complete');
director._logEvent('scene_run_complete', {});
},
};
}
+404
View File
@@ -0,0 +1,404 @@
/** Authored project defaults and legacy migrations; no editor or playback state. */
import { SCENE_DOCUMENT_VERSION } from '../director/document.js';
import { SCENE_RECIPES } from './recipes.js';
import { MAP_STACKS } from '../maps/catalog.js';
import {
BLOOM_INTENSITY_DEFAULT,
BLOOM_SCALE_VERSION,
decodeBloomIntensity,
} from '../bloom.js';
/** @constant {number} Current schema version for project migration */
export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION;
/** @constant {number} Fallback camera flight duration per shot (seconds) */
export const DEFAULT_SHOT_DURATION_SEC = 4;
/** @constant {number} Default hold/pause after a shot completes (seconds) */
export const DEFAULT_HOLD_SEC = 0.9;
const SCENE_MAP_STACK_IDS = new Set(MAP_STACKS.map(({ id }) => id));
/**
* Generate a short random identifier with the given prefix.
* @param {string} prefix - e.g. 'shot', 'scene'
* @returns {string} Identifier like "shot-a1b2c3d4"
*/
export function uid(prefix) {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Deep-clone a JSON-serializable value via round-trip stringify/parse.
* @param {*} value
* @returns {*}
*/
export function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
/**
* Normalize a raw layer state entry into a canonical { enabled, params? } shape.
* Accepts both boolean shorthand and full object forms.
* @param {boolean|Object} entry - Raw layer state (boolean or { enabled, params })
* @returns {{ enabled: boolean, params?: Object }}
*/
export function normalizeLayerEntry(entry) {
if (entry && typeof entry === 'object') {
return {
enabled: !!entry.enabled,
params:
entry.params && typeof entry.params === 'object'
? deepClone(entry.params)
: undefined,
};
}
return { enabled: !!entry };
}
/**
* Normalize a raw bloom post-processing state, migrating intensity values
* across bloom scale versions so older saved projects render correctly.
* @param {Object} rawBloom - Raw bloom state from storage or recipe
* @param {Object} [options]
* @param {number} [options.projectVersion] - Schema version of the source project
* @param {number} [options.fallbackIntensity] - Default intensity if not stored
* @returns {{ enabled: boolean, intensity: number, version: number }}
*/
function normalizeBloomState(
rawBloom = {},
{ projectVersion = PROJECT_VERSION, fallbackIntensity = 50 } = {},
) {
// Determine which bloom scale the stored value was encoded under.
// Older projects (version < 3) used scale version 1.
const explicitVersion = Number(rawBloom.version);
const bloomVersion = Number.isFinite(explicitVersion)
? explicitVersion
: projectVersion >= 3
? BLOOM_SCALE_VERSION
: 1;
const rawIntensity = Number.isFinite(Number(rawBloom.intensity))
? Number(rawBloom.intensity)
: fallbackIntensity;
return {
enabled: !!rawBloom.enabled,
intensity: decodeBloomIntensity(rawIntensity, bloomVersion),
version: BLOOM_SCALE_VERSION,
};
}
/**
* Convert a static scene recipe (from recipes.js) into a mutable scene object
* with fully normalized shots. Each keyframe in the recipe's cameraPath becomes
* one shot, inheriting the recipe's style, post, and layer configuration.
* @param {Object} recipe - A SCENE_RECIPES entry
* @returns {{ id: string, title: string, releaseLayerIds: string[], shots: Object[] }}
*/
export function recipeToScene(recipe) {
const post = recipe.post || {};
const ui = recipe.ui || {};
const styleParams =
post.styleParams && typeof post.styleParams === 'object'
? deepClone(post.styleParams)
: {};
// Normalize layer targets from the recipe into canonical form
const layers = {};
for (const [layerId, target] of Object.entries(recipe.layers || {})) {
layers[layerId] = normalizeLayerEntry(target);
}
// Derive HUD visibility/variant from recipe's ui.hudMode string
const hudMode = ui.hudMode || 'minimal';
const hudVisible = hudMode !== 'off';
const hudVariant = hudMode === 'minimal' ? 'minimal' : 'tactical';
// Convert each cameraPath keyframe into a shot with shared visual state
const path = recipe.cameraPath || [];
const shots = path.map((keyframe, idx) => {
const keyframeLayers = deepClone(layers);
for (const [layerId, target] of Object.entries(keyframe.layers || {})) {
keyframeLayers[layerId] = normalizeLayerEntry(target);
}
const mapStack = keyframe.mapStack || post.mapStack;
return {
id: uid('shot'),
title: keyframe.title || `Shot ${idx + 1}`,
durationSec: Math.max(
0.2,
keyframe.duration || DEFAULT_SHOT_DURATION_SEC,
),
holdSec: Math.max(0, keyframe.hold || 0),
camera: {
lat: keyframe.lat,
lon: keyframe.lon,
alt: keyframe.alt,
heading: keyframe.heading || 0,
pitch: keyframe.pitch || -40,
roll: keyframe.roll || 0,
},
visual: {
style: recipe.style || 'normal',
bloom: {
enabled:
typeof post.bloom === 'number' ? post.bloom > 0 : !!post.bloom,
intensity:
typeof post.bloom === 'number'
? decodeBloomIntensity(post.bloom, 1)
: BLOOM_INTENSITY_DEFAULT,
version: BLOOM_SCALE_VERSION,
},
sharpen: {
enabled:
typeof post.sharpen === 'boolean' ? post.sharpen : !!post.sharpen,
intensity: 65,
},
hud: {
visible: hudVisible,
variant: hudVariant,
},
detection: {
mode: post.detectionMode || 'OFF',
density: 35,
},
...(SCENE_MAP_STACK_IDS.has(mapStack) ? { mapStack } : {}),
styleParams,
},
layers: keyframeLayers,
...(recipe.id ? { sourcePackId: recipe.id } : {}),
...(recipe.version ? { sourcePackVersion: recipe.version } : {}),
};
});
return {
id: recipe.id || uid('scene'),
title: recipe.title || 'Untitled Scene',
releaseLayerIds: [
...new Set(
(Array.isArray(recipe.releaseLayerIds)
? recipe.releaseLayerIds
: []
).filter((layerId) => typeof layerId === 'string' && layerId.trim()),
),
],
appliedShotPacks: [],
shots,
};
}
/**
* Create a fresh default project by converting all built-in SCENE_RECIPES.
* @returns {Object} A new project object with version, timestamps, and scenes
*/
export function createDefaultProject() {
return {
version: PROJECT_VERSION,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
installedBuiltInSceneIds: SCENE_RECIPES.filter(
(recipe) => typeof recipe.installAlongsideSceneId === 'string',
).map((recipe) => recipe.id),
scenes: SCENE_RECIPES.map(recipeToScene),
};
}
/**
* Normalize a raw shot object from storage or capture into a fully validated
* shape with safe defaults for every field. Handles version migration for
* bloom intensity and coerces all numeric fields.
* @param {Object} rawShot - Raw shot data (may be incomplete or from an older schema)
* @param {number} [index=0] - Positional index used for fallback title
* @param {Object} [options]
* @param {number} [options.projectVersion] - Schema version of the enclosing project
* @returns {Object} Fully normalized shot
*/
export function normalizeShot(
rawShot,
index = 0,
{ projectVersion = PROJECT_VERSION } = {},
) {
const camera = rawShot?.camera || {};
const visual = rawShot?.visual || {};
const bloom = visual.bloom || {};
const sharpen = visual.sharpen || {};
const hud = visual.hud || {};
const detection = visual.detection || {};
return {
id: rawShot?.id || uid('shot'),
title: rawShot?.title || `Shot ${index + 1}`,
durationSec: Math.max(
0.2,
Number(rawShot?.durationSec) || DEFAULT_SHOT_DURATION_SEC,
),
holdSec: Math.max(0, Number(rawShot?.holdSec) || 0),
camera: camera.anchorId
? {
anchorId: camera.anchorId,
heading: Number(camera.heading) || 0,
pitch: Number.isFinite(Number(camera.pitch))
? Number(camera.pitch)
: -35,
roll: Number(camera.roll) || 0,
}
: {
lat: Number(camera.lat) || 0,
lon: Number(camera.lon) || 0,
alt: Number.isFinite(Number(camera.alt)) ? Number(camera.alt) : 800,
heading: Number(camera.heading) || 0,
pitch: Number.isFinite(Number(camera.pitch))
? Number(camera.pitch)
: -35,
roll: Number(camera.roll) || 0,
...(camera.altitudeReference || rawShot?.move
? { altitudeReference: camera.altitudeReference || 'ellipsoid' }
: {}),
},
...(rawShot?.interactions
? { interactions: deepClone(rawShot.interactions) }
: {}),
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
visual: {
style: visual.style || 'normal',
bloom: normalizeBloomState(bloom, {
projectVersion,
fallbackIntensity: 50,
}),
sharpen: {
enabled: !!sharpen.enabled,
intensity: Math.max(
0,
Math.min(
100,
Number.isFinite(Number(sharpen.intensity))
? Number(sharpen.intensity)
: 65,
),
),
},
hud: {
visible: typeof hud.visible === 'boolean' ? hud.visible : true,
variant: typeof hud.variant === 'string' ? hud.variant : 'tactical',
},
detection: {
...(typeof detection.allocation === 'string'
? { allocation: detection.allocation }
: {}),
...(Number.isFinite(Number(detection.fadePct))
? { fadePct: Number(detection.fadePct) }
: {}),
...(Number.isFinite(Number(detection.outsideOpacityPct))
? { outsideOpacityPct: Number(detection.outsideOpacityPct) }
: {}),
mode: typeof detection.mode === 'string' ? detection.mode : 'OFF',
density: Math.max(
0,
Math.min(
100,
Number.isFinite(Number(detection.density))
? Number(detection.density)
: 35,
),
),
},
...(visual.scope && typeof visual.scope === 'object'
? { scope: deepClone(visual.scope) }
: {}),
...(SCENE_MAP_STACK_IDS.has(visual.mapStack)
? { mapStack: visual.mapStack }
: {}),
styleParams:
visual.styleParams && typeof visual.styleParams === 'object'
? deepClone(visual.styleParams)
: {},
},
layers: Object.fromEntries(
Object.entries(rawShot?.layers || {}).map(([layerId, value]) => [
layerId,
normalizeLayerEntry(value),
]),
),
...(typeof rawShot?.sourcePackId === 'string'
? { sourcePackId: rawShot.sourcePackId }
: {}),
...(Number.isFinite(Number(rawShot?.sourcePackVersion))
? { sourcePackVersion: Number(rawShot.sourcePackVersion) }
: {}),
};
}
/**
* Normalize and migrate an entire project object loaded from storage or import.
* Defaults and migrates validated documents; empty projects remain empty.
* @param {Object|null} rawProject - Raw project data (potentially from an older schema)
* @returns {Object} Fully normalized project at the current PROJECT_VERSION
*/
export function normalizeProject(rawProject) {
if (!rawProject || typeof rawProject !== 'object')
return createDefaultProject();
const projectVersion = Number.isFinite(Number(rawProject.version))
? Number(rawProject.version)
: 1;
const scenesRaw = Array.isArray(rawProject.scenes) ? rawProject.scenes : [];
const scenes = scenesRaw
.map((scene, sceneIdx) => {
const shotsRaw = Array.isArray(scene?.shots) ? scene.shots : [];
const shots = shotsRaw.map((shot, shotIdx) =>
normalizeShot(shot, shotIdx, { projectVersion }),
);
return {
id: scene?.id || uid('scene'),
title: scene?.title || `Scene ${sceneIdx + 1}`,
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
releaseLayerIds: [
...new Set(
(Array.isArray(scene?.releaseLayerIds)
? scene.releaseLayerIds
: []
).filter(
(layerId) => typeof layerId === 'string' && layerId.trim(),
),
),
],
appliedShotPacks: (Array.isArray(scene?.appliedShotPacks)
? scene.appliedShotPacks
: []
)
.filter((entry) => typeof entry?.id === 'string')
.map((entry) => ({
id: entry.id,
version: Number(entry.version) || 1,
...(entry.shotBindings && typeof entry.shotBindings === 'object'
? {
shotBindings: Object.fromEntries(
Object.entries(entry.shotBindings).filter(
([title, shotId]) =>
typeof title === 'string' && typeof shotId === 'string',
),
),
}
: {}),
})),
shots,
};
})
// Keep scenes that have shots or at least a title
.filter((scene) => scene.shots.length > 0 || scene.title);
return {
version: PROJECT_VERSION,
createdAt: rawProject.createdAt || new Date().toISOString(),
updatedAt: rawProject.updatedAt || new Date().toISOString(),
installedBuiltInSceneIds: [
...new Set(
(Array.isArray(rawProject.installedBuiltInSceneIds)
? rawProject.installedBuiltInSceneIds
: []
).filter((sceneId) => typeof sceneId === 'string' && sceneId.trim()),
),
],
scenes,
};
}
+404
View File
@@ -0,0 +1,404 @@
import { withShareSignal } from '../director/sharing/lifetime.js';
import {
readSceneShare,
createSceneBundle,
BUNDLE_SOURCE,
} from '../director/sharing/bundle.js';
import { describeSceneShare } from '../director/sharing/preview.js';
import { stringifySceneDocument } from '../director/document.js';
import {
editSceneDetails,
selectSceneDocument,
} from '../director/authoring.js';
import { createSceneDialog, mountSceneSharing } from '../ui/sceneSharing.js';
import { PACK_LIMITS } from '../director/packs/manifest.js';
function download(text, name) {
const url = URL.createObjectURL(
new Blob([text], { type: 'application/json' }),
);
const link = document.createElement('a');
link.href = url;
link.download = name;
document.body.append(link);
try {
link.click();
} finally {
link.remove();
URL.revokeObjectURL(url);
}
}
const subset = (value, keys) =>
Object.fromEntries(
keys
.filter((key) => Object.hasOwn(value, key))
.map((key) => [key, value[key]]),
);
const sceneKeys = ['anchors', 'dataPacks'];
const shotKeys = [
'camera',
'move',
'durationSec',
'holdSec',
'dataPackIds',
'interactions',
];
const json = (value) => JSON.stringify(value, null, 2);
const mimeFor = (file) =>
file.type ||
{
json: 'application/json',
geojson: 'application/geo+json',
png: 'image/png',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
}[file.name.split('.').at(-1)];
/** Own staged imports, author drafts and share bytes separately from playback and project state. */
export function createSceneSharing(director) {
let dialog,
controller,
disposed = false,
staged = null,
busy = false;
const alive = (owner) =>
!disposed && controller === owner && !owner.signal.aborted;
function close() {
controller?.abort();
controller = null;
dialog?.dispose();
dialog = null;
staged = null;
busy = false;
}
function open(title) {
close();
if (disposed) return null;
controller = new AbortController();
dialog = createSceneDialog(title, close);
return controller;
}
async function run(owner, job) {
if (!alive(owner) || busy) return;
busy = true;
try {
await job();
} catch (error) {
if (alive(owner))
dialog.status.textContent =
error?.name === 'SceneDocumentError'
? error.message
: 'Could not complete this action. Check the file, references and selected assets.';
} finally {
if (alive(owner)) busy = false;
}
}
const currentProject = () => json(director._project);
function inventory(input) {
const report = describeSceneShare(input, {
sourceIds: director._dataPacks.sourceIds(),
layerIds: director.dataManager.getAll().map((l) => l.id),
});
dialog.text(
`${report.scenes} scenes · ${report.shots} shots · ${report.packs.length} data packs`,
);
for (const pack of report.packs)
dialog.text(
`${pack.scene} / ${pack.id}: ${pack.status}. File: ${pack.path}. ${pack.attribution.text} · ${pack.attribution.license}`,
);
if (report.missingLayers.length)
dialog.text(`Unavailable layers: ${report.missingLayers.join(', ')}`);
if (report.externalContent)
dialog.text(
'This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.',
);
if (report.bundledBytes)
dialog.text(
`${report.bundledBytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.`,
);
return report;
}
async function preview(file) {
const owner = open('Review scene import');
if (!owner) return;
const expected = currentProject();
dialog.text('Nothing is loaded or changed until you apply this file.');
await run(owner, async () => {
const input = await readSceneShare(file, { signal: owner.signal });
if (!alive(owner)) return;
staged = input;
inventory(input);
dialog.status.textContent = 'Ready to import';
dialog.text(
'Apply replaces the current project. Export your current project first if you want to keep both.',
);
const apply = dialog.button('Apply import', () =>
run(owner, async () => {
if (expected !== currentProject()) throw new Error('Project changed');
apply.disabled = true;
const ok = await director.importProjectFile(file, {
prepared: input,
expectedProject: expected,
signal: owner.signal,
});
if (alive(owner)) {
if (ok) close();
else {
apply.disabled = false;
dialog.status.textContent =
'Import was not applied. The current project may have changed.';
}
}
}),
);
apply.dataset.directorApplyImport = '';
});
}
function edit() {
const scene = director._getSelectedScene(),
shot = scene?.shots.find((s) => s.id === director._selectedShotId);
if (!scene || !shot) {
director._updateStatus('Select a scene and shot first');
return;
}
const owner = open('Edit scene details');
if (!owner) return;
const original = structuredClone(director._project),
expected = currentProject();
dialog.text(
'Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.',
);
const sceneText = dialog.input(
'Anchors and data packs',
json(subset(scene, sceneKeys)),
{ multiline: true },
);
const shotText = dialog.input(
'Shot camera, timing, packs and actions',
json(subset(shot, shotKeys)),
{ multiline: true },
);
const anchorName = dialog.input('New anchor ID', 'anchor-1');
dialog.button(
'Capture camera as anchor',
() =>
run(owner, async () => {
const details = JSON.parse(sceneText.value),
camera = director.styleManager.getCameraState();
if (!camera) throw new Error('Camera unavailable');
const anchors = details.anchors || [];
if (anchors.some((a) => a.id === anchorName.value))
throw new Error('Duplicate anchor');
details.anchors = [
...anchors,
{
id: anchorName.value,
lat: camera.lat,
lon: camera.lon,
alt: camera.alt,
altitudeReference: 'ellipsoid',
},
];
editSceneDetails(
original,
scene.id,
shot.id,
details,
JSON.parse(shotText.value),
);
sceneText.value = json(details);
dialog.status.textContent = 'Anchor added to draft';
}),
dialog.body,
);
dialog.button(
'Set move start to current camera',
() =>
run(owner, async () => {
const details = JSON.parse(shotText.value),
camera = director.styleManager.getCameraState();
if (!camera) throw new Error('Camera unavailable');
details.move = {
from: {
...subset(camera, [
'lat',
'lon',
'alt',
'heading',
'pitch',
'roll',
]),
altitudeReference: 'ellipsoid',
},
easing: 'cubic-in-out',
};
if (!details.camera.anchorId)
details.camera.altitudeReference = 'ellipsoid';
details.durationSec = Math.max(0.2, details.durationSec || 3);
editSceneDetails(
original,
scene.id,
shot.id,
JSON.parse(sceneText.value),
details,
);
shotText.value = json(details);
dialog.status.textContent =
'Move added to draft; destination remains the shot camera';
}),
dialog.body,
);
dialog.button(
'Use ordinary flight',
() => {
if (!alive(owner)) return;
try {
const details = JSON.parse(shotText.value);
delete details.move;
shotText.value = json(details);
} catch {
dialog.status.textContent = 'Invalid shot JSON';
}
},
dialog.body,
);
const apply = dialog.button('Apply details', () =>
run(owner, async () => {
if (expected !== currentProject()) throw new Error('Project changed');
const project = editSceneDetails(
original,
scene.id,
shot.id,
JSON.parse(sceneText.value),
JSON.parse(shotText.value),
);
const ok = await director.importProjectFile(
{
name: 'scene details',
text: async () => stringifySceneDocument(project),
},
{
prepared: { project, assets: director._bundleAssets.snapshot() },
expectedProject: expected,
signal: owner.signal,
selection: { sceneId: scene.id, shotId: shot.id },
},
);
if (alive(owner) && ok) close();
}),
);
apply.dataset.directorApplyDetails = '';
}
function share() {
let project;
try {
project = selectSceneDocument(
director._project,
director._selectedSceneId,
);
} catch {
director._updateStatus('Select a scene first');
return;
}
const owner = open('Share selected scene');
if (!owner) return;
const paths = new Set(
project.scenes.flatMap((s) =>
(s.dataPacks || [])
.filter((p) => p.source.adapter === BUNDLE_SOURCE)
.map((p) => p.source.path),
),
);
const existing = new Map(
[...director._bundleAssets.snapshot()].filter(([path]) =>
paths.has(path),
),
);
staged = { project, assets: existing };
inventory(staged);
dialog.text(
'Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.',
);
dialog.button('Download scene JSON', () => {
if (alive(owner)) download(stringifySceneDocument(project), 'scene.json');
});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
const folder = dialog.input('Or choose a data-pack folder', '', {
type: 'file',
});
folder.multiple = true;
folder.setAttribute('webkitdirectory', '');
dialog.button('Download asset bundle', () =>
run(owner, async () => {
const selected = [...files.files, ...folder.files];
const packs = project.scenes.flatMap((s) => s.dataPacks || []);
const text = await createSceneBundle(
project,
async (pack, { signal }) => {
if (
pack.source.adapter === BUNDLE_SOURCE &&
existing.has(pack.source.path)
)
return existing.get(pack.source.path);
const exact = selected.filter(
(f) =>
f.webkitRelativePath === pack.source.path ||
f.webkitRelativePath?.split('/').slice(1).join('/') ===
pack.source.path,
);
const name = pack.source.path.split('/').at(-1),
matches = exact.length
? exact
: selected.filter((f) => f.name === name);
const keys = new Set(
packs
.filter((p) => p.source.path.split('/').at(-1) === name)
.map((p) => JSON.stringify(p.source)),
);
if (matches.length !== 1 || (!exact.length && keys.size > 1))
throw new Error('Missing or ambiguous file');
const file = matches[0];
if (!file.size || file.size > PACK_LIMITS.bytes)
throw new Error('Asset size limit');
const bytes = new Uint8Array(
await withShareSignal(file.arrayBuffer(), signal),
);
signal.throwIfAborted();
return { bytes, mimeType: mimeFor(file) };
},
{ signal: owner.signal },
);
if (alive(owner)) {
download(text, 'scene.gevbundle.json');
dialog.status.textContent = 'Asset bundle downloaded';
}
}),
);
}
const unmount = () => {};
let removeToolbar = unmount;
return {
preview,
edit,
share,
close,
mount() {
removeToolbar();
removeToolbar = mountSceneSharing({ edit, share });
},
getState: () => ({
open: !!dialog,
busy,
stagedAssets: staged?.assets.size || 0,
}),
destroy() {
disposed = true;
close();
removeToolbar();
},
};
}
+171
View File
@@ -0,0 +1,171 @@
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const deepClone = (value) => JSON.parse(JSON.stringify(value));
/** Prepare scene-owned layer state, timing context and seek/travel controls. */
export function layerStatesForShot(
scene,
shot,
packs,
{ cameraSettled = false, cameraTravel = null, sceneSeek = null } = {},
) {
const states = deepClone(shot?.layers || {});
const shotIndex = scene?.shots?.findIndex(({ id }) => id === shot?.id) ?? -1;
const sceneShotDurations =
scene?.shots?.map((sceneShot) =>
shotRuntimeDurationSec(scene, sceneShot, packs),
) || [];
const sceneDurationSec = sceneShotDurations.reduce(
(sum, duration) => sum + duration,
0,
);
const authoredSceneElapsedSec = sceneShotDurations
.slice(0, Math.max(0, shotIndex))
.reduce((sum, duration) => sum + duration, 0);
const sceneElapsedSec = sceneSeek
? Math.max(
0,
Math.min(sceneDurationSec, Number(sceneSeek.sceneElapsedSec) || 0),
)
: authoredSceneElapsedSec;
const sourceRecipe = packs.recipeForShot(shot);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
state.params.sceneSurface ||= shot.sourcePackId
? 'evidence-beat'
: 'panel-only';
const runtimeControls =
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
...(runtimeControls.deferEvidenceUntilCameraSettled
? { cameraSettled }
: {}),
...(sceneSeek
? {
timelineSeek: {
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
flightDurationSec: Number(sceneSeek.flightDurationSec) || 0,
holdDurationSec: Number(sceneSeek.holdDurationSec) || 0,
holdElapsedSec: Number(sceneSeek.holdElapsedSec) || 0,
cameraProgress: clamp01(Number(sceneSeek.cameraProgress) || 0),
holdProgress: clamp01(Number(sceneSeek.holdProgress) || 0),
},
}
: {}),
};
if (runtimeControls.evidencePathDuringCamera && cameraTravel) {
state.params.sceneControls.cameraTravel = { ...cameraTravel };
}
}
state.params.sceneContext = {
sceneId: scene.id,
sceneTitle: scene.title,
shotId: shot.id,
shotTitle: shot.title,
shotIndex,
shotCount: scene.shots.length,
durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
holdSec: effectiveShotHoldSec(scene, shot, packs, states),
sceneElapsedSec,
sceneDurationSec,
sceneProgress:
sceneDurationSec > 0 ? sceneElapsedSec / sceneDurationSec : 0,
...(sceneSeek
? {
shotElapsedSec: Number(sceneSeek.shotElapsedSec) || 0,
shotProgress: clamp01(Number(sceneSeek.shotProgress) || 0),
seeking: true,
}
: {}),
};
}
for (const layerId of scene?.releaseLayerIds || []) {
if (!Object.hasOwn(states, layerId)) states[layerId] = { enabled: false };
}
return states;
}
/** Resolve recipe map preferences and registered presentation fallbacks. */
export function visualStateForShot(shot, packs, isMapStackAvailable) {
const sourceRecipe = packs.recipeForShot(shot);
const sceneBeatState = Object.values(shot?.layers || {}).find(
(state) => state?.params?.presentation === 'scene-beat',
);
const controls = {
...sceneBeatState?.params?.sceneControls,
...sourceRecipe?.runtimeControlsByBeat?.[sceneBeatState?.params?.beatId],
};
if (controls.imageryComparison === true) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
if (
sourceRecipe?.standaloneSurfaceBeatIds?.includes(
sceneBeatState?.params?.beatId,
)
) {
return { ...(shot.visual || {}), mapStack: 'esri-imagery' };
}
const visual = sourceRecipe?.photorealSurfaceBeatIds?.includes(
sceneBeatState?.params?.beatId,
)
? { ...(shot.visual || {}), mapStack: 'photoreal' }
: shot?.visual || {};
return packs.resolveVisual(shot, visual, isMapStackAvailable);
}
/** Combine authored holds, registered pack overrides and layer reveal requirements. */
export function effectiveShotHoldSec(
scene,
shot,
packs,
resolvedStates = null,
) {
const states = resolvedStates || layerStatesForShot(scene, shot, packs);
const recipe = packs.recipeForShot(shot);
const overrides = Object.values(states)
.filter(
(state) => state?.enabled && state.params?.presentation === 'scene-beat',
)
.map((state) => recipe?.runtimeHoldSecByBeat?.[state.params.beatId])
.filter((value) => Number.isFinite(value) && value >= 0);
const authoredHoldSec = overrides.length
? Math.max(...overrides)
: Number(shot?.holdSec) || 0;
return Object.values(states).reduce(
(holdSec, state) => {
const controls = state?.params?.sceneControls;
return Math.max(
holdSec,
Number(controls?.minimumHoldSec) || 0,
controls?.evidenceSequence === true
? Number(controls.evidenceSequenceDurationSec) || 0
: 0,
);
},
Math.max(packs.minimumHoldSec(states), authoredHoldSec),
);
}
/** Estimate flight plus effective hold without mutating saved layer parameters. */
export function shotRuntimeDurationSec(scene, shot, packs) {
const states = deepClone(shot?.layers || {});
const sourceRecipe = packs.recipeForShot(shot);
for (const state of Object.values(states)) {
if (state?.params?.presentation !== 'scene-beat') continue;
const runtimeControls =
sourceRecipe?.runtimeControlsByBeat?.[state.params.beatId];
if (runtimeControls) {
state.params.sceneControls = {
...state.params.sceneControls,
...runtimeControls,
};
}
}
return (
(shot?.durationSec || DEFAULT_SHOT_DURATION_SEC) +
effectiveShotHoldSec(scene, shot, packs, states)
);
}
+63 -1
View File
@@ -12,6 +12,7 @@ import {
isConfiguredValue,
npmProcessSpec,
readDoctorDotenvValue,
resolveOpenSkyAuthMode,
resolveCredential,
} from '../scripts/setup-doctor.mjs';
@@ -145,6 +146,67 @@ test('doctor reads the dotenv ladder without requiring Vite to be installed', ()
}
});
test('doctor resolves OpenSky auth mode with launcher-compatible precedence', () => {
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-mode-'));
try {
writeFileSync(path.join(root, '.env'), 'OPENSKY_AUTH_MODE=anon\n');
assert.equal(resolveOpenSkyAuthMode({ environment: {}, rootDir: root }), 'anon');
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'oauth' }, rootDir: root }), 'oauth');
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: '' }, rootDir: root }), 'anon');
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'invalid' }, rootDir: root }), 'oauth');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('doctor reports keyless anonymous OpenSky access for explicit anon mode', () => {
const credentials = {
OPENSKY_CLIENT_ID: { configured: true },
OPENSKY_CLIENT_SECRET: { configured: true },
};
assert.equal(
buildCapabilitySummary(credentials, { openSkyAuthMode: 'anon' }).flights,
'OpenSky keyless anonymous access (rate-limited)',
);
});
test('doctor retains OAuth capability wording for a complete client pair', () => {
const credentials = {
OPENSKY_CLIENT_ID: { configured: true },
OPENSKY_CLIENT_SECRET: { configured: true },
};
assert.equal(
buildCapabilitySummary(credentials, { openSkyAuthMode: 'oauth' }).flights,
'OpenSky OAuth credentials present (runtime mode and validity not verified)',
);
});
test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => {
const oauthCredentials = {
OPENSKY_CLIENT_ID: { configured: true },
OPENSKY_CLIENT_SECRET: { configured: true },
};
for (const credentials of [{}, oauthCredentials]) {
assert.equal(
buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights,
'OpenSky Basic mode selected (credential presence and validity not verified)',
);
}
});
test('doctor reports auto mode without assuming its eventual credential choice', () => {
const oauthCredentials = {
OPENSKY_CLIENT_ID: { configured: true },
OPENSKY_CLIENT_SECRET: { configured: true },
};
for (const credentials of [{}, oauthCredentials]) {
assert.equal(
buildCapabilitySummary(credentials, { openSkyAuthMode: 'auto' }).flights,
'OpenSky auto mode selected (runtime credential choice and validity not verified)',
);
}
});
test('doctor describes the credential ladder without exposing values', () => {
const credentials = {
GOOGLE_MAPS_API_KEY: { configured: false },
@@ -163,7 +225,7 @@ test('doctor describes the credential ladder without exposing values', () => {
assert.match(capabilities.map, /Bing and world-terrain stacks/);
assert.equal(capabilities.voice, 'available');
assert.match(capabilities.missions, /token allowance/);
assert.equal(capabilities.flights, 'OpenSky OAuth credentials not configured');
assert.equal(capabilities.flights, 'OpenSky keyless anonymous access (rate-limited)');
const report = formatSetupReport({
ready: true,
+8 -4
View File
@@ -1,3 +1,5 @@
import { DisplayBindings } from './ui/displayBindings.js';
import { LocationNavigation } from './ui/locationNavigation.js';
import { VisualSettings } from './ui/visualSettings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
@@ -700,13 +702,15 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
const firstAwait = disposal.indexOf('await ');
assert.ok(firstAwait > 0);
const synchronous = disposal.slice(0, firstAwait);
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(synchronous, /this\._displayBindings\.destroy\(\)/);
assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/);
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/);
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._displayControls\?\.destroy\(\)/);
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
});
+8
View File
@@ -1,8 +1,16 @@
import { createAssetDirectorySource } from '../director/packs/source.js';
import { createApplicationTools } from '../app/tools.js';
import { startStandaloneChrome } from './startupChrome.js';
export function createStandaloneTools(options) {
return createApplicationTools({
startChrome: startStandaloneChrome,
sceneDataPacks: {
sources: {
assets: createAssetDirectorySource({
baseUrl: new URL('/scene-assets/', window.location.href).href,
}),
},
},
...options,
});
}
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs';
/** Read voice implementations alongside their compatibility/composition surface. */
export function readRealtimeSource() {
return [
'realtimeConnection',
'realtimeCost',
'realtimeDiagnostics',
'realtimeInput',
'realtimeInputPolicy',
'realtimePreferences',
'realtimeProtocol',
'realtimeRadio',
'realtimeTurns',
'realtimeViewport',
'realtimeFacade',
'realtimeController',
]
.map((name) =>
readFileSync(new URL(`../voice/${name}.js`, import.meta.url), 'utf8'),
)
.join('\n');
}
+18
View File
@@ -1,4 +1,10 @@
import { ShellFacade } from '../ui/shellFacade.js';
import { AircraftDisplay } from '../ui/aircraftDisplay.js';
import { LayerBindings } from '../ui/layerBindings.js';
import { DisplayBindings } from '../ui/displayBindings.js';
import { readFileSync } from 'node:fs';
import { CockpitCoordinator } from '../ui/cockpitCoordinator.js';
import { LocationNavigation } from '../ui/locationNavigation.js';
import { StyleManager } from '../ui/applicationShell.js';
import { NavigationController } from '../ui/navigationController.js';
import { ShareRestoration } from '../ui/shareRestoration.js';
@@ -8,10 +14,16 @@ import { PanelChrome } from '../ui/panelChrome.js';
/** Read the state owners as well as the compatibility/composition facade. */
export function readShellSource() {
return [
'locationNavigation',
'cockpitCoordinator',
'navigationController',
'shareRestoration',
'visualSettings',
'panelChrome',
'aircraftDisplay',
'layerBindings',
'displayBindings',
'shellFacade',
'applicationShell',
]
.map((name) =>
@@ -27,7 +39,13 @@ export function shellMethod(name) {
ShareRestoration,
VisualSettings,
PanelChrome,
LocationNavigation,
CockpitCoordinator,
AircraftDisplay,
LayerBindings,
DisplayBindings,
StyleManager,
ShellFacade,
]) {
const method = Object.getOwnPropertyDescriptor(
owner.prototype,
+54
View File
@@ -279,11 +279,65 @@ test('Realtime service configuration selects compatible endpoint/model without f
url: '/?tier=arbitrary-model&model=other',
});
assert.equal(response.status, 200);
assert.deepEqual(response.json(), { value: 'short-lived-fixture' });
assert.equal(response.headers['x-gev-voice-model'], 'configured-model');
assert.equal(response.headers['cache-control'], 'no-store');
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
});
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const leak =
'fixture-upstream-secret req_fixture_1234 org-fixture quota exhausted';
// `data.error.message` is OpenAI's own wording — request ids, organization
// hints, quota phrasing — and was relayed verbatim whenever upstream was not ok.
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ error: { message: leak } }, { status: 429 }),
);
const summary = await request(
install(openAiRealtimeProxy()).get('/api/openai/hud-summary'),
{ method: 'POST', body: JSON.stringify({ context: {} }) },
);
assert.equal(summary.json().error, 'OpenAI HUD summary request failed');
assert.equal(summary.body.includes('req_fixture_1234'), false);
assert.equal(summary.body.includes('fixture-upstream-secret'), false);
// An HTTP error from the client-secret endpoint carries the same upstream
// detail, while a successful response must still pass the ephemeral secret.
t.mock.restoreAll();
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ error: { message: leak } }, { status: 429 }),
);
const rejectedToken = await request(
install(openAiRealtimeProxy()).get('/api/realtime/token'),
);
assert.equal(rejectedToken.status, 429);
assert.equal(
rejectedToken.headers['content-type'],
'application/json; charset=utf-8',
);
assert.deepEqual(rejectedToken.json(), {
error: 'Failed to create Realtime token',
});
assert.equal(rejectedToken.body.includes('req_fixture_1234'), false);
assert.equal(rejectedToken.body.includes('fixture-upstream-secret'), false);
// A network fault surfaced a resolver message naming the upstream host.
t.mock.restoreAll();
t.mock.method(globalThis, 'fetch', async () => {
throw Error(`getaddrinfo ENOTFOUND api.openai.com ${leak}`);
});
const token = await request(
install(openAiRealtimeProxy()).get('/api/realtime/token'),
);
assert.equal(token.status, 502);
assert.deepEqual(token.json(), { error: 'Failed to create Realtime token' });
assert.equal(token.body.includes('api.openai.com'), false);
assert.equal(token.body.includes('fixture-upstream-secret'), false);
});
test('the debug-log sink stays bounded, rate limited, and quiet about failures', async (t) => {
const sourceRoot = root(t);
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
+42
View File
@@ -149,3 +149,45 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
assert.equal(cancelled, true);
});
}
test('a failed CCTV media fetch reports a fixed health message, not the error text', async (t) => {
isolate(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-cctv-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(path.join(root, 'config'));
writeFileSync(
path.join(root, 'config/cctv_sources.austin.json'),
JSON.stringify([
{
id: 'leaky',
name: 'Leaky camera',
lat: 30.27,
lon: -97.74,
feedType: 'video',
url: 'https://cams.fixture.invalid/leaky.m3u8',
},
]),
);
const leak =
'connect ETIMEDOUT 203.0.113.9:443 C:\fixture\secret-path\cams.json';
t.mock.restoreAll();
t.mock.method(globalThis, 'fetch', () => {
throw Error(leak);
});
const call = install(cctvProxy({ sourceRoot: root }));
const media = await call('/media/leaky');
assert.equal(media.status, 502);
assert.equal(media.body.includes('203.0.113.9'), false);
// GET /health is the second door: src/layers/cctv/frames.js renders each
// entry's `message` as the camera's status label, so a raw errno stored here
// reaches the screen even though the response above is sanitized.
const camera = JSON.parse((await call('/health')).body).cameras.find(
(entry) => entry.id === 'leaky',
);
assert.equal(camera.status, 'degraded');
assert.equal(camera.message, 'Media fetch failed');
assert.equal(camera.message.includes('ETIMEDOUT'), false);
assert.equal(camera.message.includes('secret-path'), false);
});
+59
View File
@@ -328,6 +328,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
globalThis.Image = previousImage;
});
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const readRows = [];
let listener,
reads = 0,
render = true;
@@ -336,6 +337,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
UNSIGNED_BYTE: 2,
readPixels(x, y, w, h, format, type, buffer) {
reads++;
readRows.push(y);
buffer.set([255, 255, 255, 255]);
},
};
@@ -408,13 +410,70 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
},
};
const page = { evaluate: async (fn, ...args) => fn(...args) };
const sampledLayer =
window.__godsEyeView.dataManager.layers.get('transit').module;
const originalState = sampledLayer._transitStateForTest;
sampledLayer._transitStateForTest = () => {
const state = originalState();
state._vehicles.get('bus').marker.position.y = 0.0025; // Screen y = 199.5.
return state;
};
const pixels = await sampleTransitPixels(page, {});
sampledLayer._transitStateForTest = originalState;
assert.deepEqual(
readRows.slice(0, 9),
[201, 200, 199, 201, 200, 199, 201, 200, 199],
'fractional CSS coordinates must invert the floored framebuffer row',
);
assert.ok(
reads >= 13,
'centre, rings and background come from WebGL readPixels',
);
assert.ok(Math.abs(pixels[0].centre - 1) < 1e-6);
assert.equal(listener, null);
// An opaque window/panel is not a sensor core. Choose another solid patch,
// and reject a raster with no white interior even though alpha/picking pass.
for (let i = 0; i < raster.length; i += 4) raster.fill(0, i, i + 3);
assert.equal((await sampleTransitPixels(page, {})).length, 0);
raster.fill(255);
for (let y = 6; y <= 9; y++)
for (let x = 0; x < 16; x++) {
const i = (y * 16 + x) * 4;
raster.fill(0, i, i + 3);
}
assert.equal(
(await sampleTransitPixels(page, {})).length,
1,
'a body patch away from the central panel remains eligible',
);
// A narrow centre can be opaque yet put the framebuffer sample on its
// antialiased side. Prefer the wider solid band above it, before reading luma.
raster.fill(0);
for (let y = 0; y < 16; y++)
for (let x = 0; x < 16; x++) {
const wide = y >= 3 && y <= 5;
if ((wide && x >= 3 && x <= 12) || (x >= 7 && x <= 9))
raster.fill(255, (y * 16 + x) * 4, (y * 16 + x + 1) * 4);
}
const readStart = readRows.length;
assert.equal((await sampleTransitPixels(page, {})).length, 1);
assert.ok(
readRows[readStart] > 204,
'centre samples use the widest solid band',
);
raster.fill(255);
sampledLayer._transitStateForTest = () => {
const state = originalState();
state._vehicles.get('bus').mode = 'tram';
return state;
};
const tramStart = readRows.length;
assert.equal((await sampleTransitPixels(page, {})).length, 1);
assert.ok(
readRows[tramStart] > 204,
'equal-width tram bands prefer the upper solid body',
);
sampledLayer._transitStateForTest = originalState;
scene.pick = () => ({ id: 'overlapping-label' });
assert.equal(
(await sampleTransitPixels(page, {})).length,
+83
View File
@@ -0,0 +1,83 @@
/** Own the shared aircraft display preference and its controls. */
export class AircraftDisplay {
constructor({ elements, readDataManager, layout }) {
Object.assign(this, elements, {
readDataManager,
_layoutRightPanels: layout,
});
// Both aircraft layers default to models enabled in proximity mode.
this._models3dEnabled = true;
this._models3dMode = 'proximity';
this._models3dModeBtns = [
document.getElementById('models3d-mode-proximity'),
document.getElementById('models3d-mode-all'),
];
}
get _dataManager() {
return this.readDataManager();
}
_setModels3dParams(params, { origin = 'user' } = {}) {
this._dataManager?.setLayerParams('flights', params, { origin });
this._dataManager?.setLayerParams('military', params, { origin });
}
_syncModels3dFromLayerState(state) {
const options = state?.options?.flights;
if (!options) return;
this._models3dEnabled = options.models3d === true;
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
this._syncModels3dButtonState();
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
for (const button of this._models3dModeBtns || []) {
if (!button) continue;
const active = button.dataset.mode === this._models3dMode;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
this._layoutRightPanels();
}
_syncModels3dModeRow() {
if (this._models3dModeRow)
this._models3dModeRow.classList.toggle('visible', this._models3dEnabled);
this._layoutRightPanels();
}
_initModels3dToggle() {
if (!this._models3dBtn) return;
this._syncModels3dButtonState();
this._syncModels3dModeRow();
}
_setModels3dEnabled(enabled) {
this._models3dEnabled = !!enabled;
this._setModels3dParams({ models3d: this._models3dEnabled });
this._syncModels3dButtonState();
}
_setModels3dMode(mode) {
const normalized = mode === 'all' ? 'all' : 'proximity';
this._models3dMode = normalized;
this._setModels3dParams({ models3dMode: normalized });
for (const button of this._models3dModeBtns) {
if (!button) continue;
const active = button.dataset.mode === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
this._syncModels3dButtonState();
}
_syncModels3dButtonState() {
this._models3dBtn?.classList.toggle('active', this._models3dEnabled);
// The lit/dark state is a colour to a sighted operator and nothing at all to
// a screen reader without this. It matters more now that the button ships
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
// tech reported was an unpressed-looking control over an armed layer.
// Mirrors #scope-toggle, which has always carried aria-pressed.
this._models3dBtn?.setAttribute(
'aria-pressed',
String(this._models3dEnabled),
);
}
}
+224 -2466
View File
File diff suppressed because it is too large. Load diff
+408
View File
@@ -0,0 +1,408 @@
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
/** Own Cockpit entry/rollback, readouts and the single Display portal. */
export class CockpitCoordinator {
constructor({
viewer,
services,
elements,
operations,
readDataManager,
readContext,
readActiveStyle,
enterPanels,
exitPanels,
}) {
Object.assign(
this,
{
viewer,
services,
readDataManager,
readContext,
readActiveStyle,
enterPanels,
exitPanels,
},
elements,
operations,
);
const {
flightsLayer,
militaryFlightsLayer,
isTr3b,
toggleTr3b,
militaryAwarenessLayer,
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
sampleMeshFloorCells,
holdContinuousRender,
releaseContinuousRender,
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
} = services;
this._cockpitDisplayPortal = null;
this.cockpitView = new CockpitViewController(viewer, {
services: {
flightsLayer,
militaryFlightsLayer,
isTr3b,
toggleTr3b,
militaryAwarenessLayer,
formatAwarenessLabel,
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
sampleMeshFloorCells,
holdContinuousRender,
releaseContinuousRender,
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
},
onVisionChange: (mode, active, options) =>
this._setCockpitVision(mode, active, options),
onCameraTakeover: () =>
this._stampNavigation({ cancelPendingSelection: false }),
getInheritedVisionLabel: () =>
STYLE_STATUS_LABELS[this.activeStyle] ||
String(this.activeStyle || 'normal').toUpperCase(),
isEntryAllowed: () =>
cockpitEntryAllowed({
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
restoreTrackingFrame: (entity) => {
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
':',
);
const trackedId = idParts.join(':');
if (!trackedId) return false;
if (layerId === 'flights')
return flightsLayer.refocusTrackedById?.(trackedId) === true;
if (layerId === 'military')
return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true;
return false;
},
});
}
get _dataManager() {
return this.readDataManager();
}
get _contextMode() {
return this.readContext()?.mode ?? null;
}
get _contextModeChanging() {
return !!this.readContext()?.changing;
}
get activeStyle() {
return this.readActiveStyle();
}
getCockpitState() {
const { militaryAwarenessLayer } = this.services;
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
const info = this.cockpitView?.readAircraftInfo?.();
const active = Boolean(this.cockpitView?.active);
const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.());
// "Could Cockpit be ENTERED right now" — so it is false while already
// inside, unconditionally. Cockpit takes the entity off
// `viewer.trackedEntity` on entry and NEXT puts one back, which made this
// flip true/false between calls while `active` stayed true; readers
// (including the voice model) read that as a broken half-entered state.
const entryAllowed =
!active &&
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
return {
active,
entryAllowed,
// Why entry is unavailable, so a refusal can be explained rather than
// guessed at.
entryBlockedReason:
entryAllowed || active
? null
: !gateOpen
? this._contextModeChanging
? 'contacts-starting'
: 'contacts-inactive'
: 'no-tracked-aircraft',
visionMode: this.cockpitView?.visionMode || null,
subject: info
? {
id: info.icao24 || info.id || null,
layerId: info.layerId || null,
callsign: info.callsign || null,
}
: null,
navigation: snapshot
? {
canPrevious: Boolean(snapshot.navigation?.canPrevious),
canNext: Boolean(snapshot.navigation?.canNext),
canFocus: Boolean(snapshot.navigation?.canFocus),
}
: null,
awareness: snapshot
? {
radiusM: Number.isFinite(snapshot.radiusM)
? snapshot.radiusM
: null,
subject: snapshot.subject
? {
id: snapshot.subject.id || null,
layerId: snapshot.subject.layerId || null,
}
: null,
cohorts: Array.isArray(snapshot.cohorts)
? snapshot.cohorts.map((cohort) => ({
id: cohort?.id || null,
source: cohort?.source || null,
count: Number.isFinite(cohort?.count) ? cohort.count : null,
relationship: cohort?.relationship || null,
reason: cohort?.reason || null,
coverage: cohort?.coverage || null,
}))
: [],
navigation: snapshot.navigation
? {
canPrevious: Boolean(snapshot.navigation.canPrevious),
canNext: Boolean(snapshot.navigation.canNext),
canFocus: Boolean(snapshot.navigation.canFocus),
}
: null,
}
: null,
activeTracked: this.cockpitView?.active
? Boolean(this.cockpitView?.trackedEntity)
: false,
activeMapView: !this.cockpitView?.active && entryAllowed,
};
}
_retargetCockpitEntryLayer({
targetLayer,
aircraftClass,
currentTarget,
selectedTarget,
}) {
const { militaryAwarenessLayer } = this.services;
if (!['flights', 'military'].includes(targetLayer)) {
return {
ok: false,
error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`,
};
}
const activeLayer =
selectedTarget?.layerId || currentTarget?.layerId || null;
const alreadyOnLayer = activeLayer === targetLayer;
if (alreadyOnLayer && !aircraftClass)
return { ok: true, retargeted: false };
const moved = militaryAwarenessLayer?.navigateNext
? !!militaryAwarenessLayer.navigateNext({
targetLayer,
aircraftClass,
origin: 'voice',
})
: false;
if (moved) return { ok: true, retargeted: true };
// A filter that matched nothing still enters, as long as the layer is
// already right — the operator asked for that layer and is on it.
if (alreadyOnLayer) return { ok: true, retargeted: false };
const label = targetLayer === 'military' ? 'military' : 'civilian';
const filtered = aircraftClass ? `${aircraftClass} ` : '';
return {
ok: false,
error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`,
};
}
controlCockpit(
action,
{
notificationToken = null,
targetLayer = null,
aircraftClass = null,
selectedTarget = null,
rollbackTarget = undefined,
} = {},
) {
const { flightsLayer, militaryFlightsLayer } = this.services;
const normalized = String(action || '').toLowerCase();
if (!this.cockpitView) {
return {
ok: false,
action: 'control_cockpit',
error: 'Cockpit controller unavailable',
state: this.getCockpitState(),
};
}
if (normalized === 'status') {
return {
ok: true,
action: 'control_cockpit',
state: this.getCockpitState(),
notificationToken: notificationToken || null,
};
}
if (normalized === 'enter') {
// Entry is gated exactly as the manual entry chip is. Attempting it while
// the gate is shut produced the half-entered look the operator reported
// (a plane anchored under the camera with no cockpit around it), so
// refuse with the reason instead of trying.
if (!this.cockpitView.isEntryAllowed?.()) {
return {
ok: false,
action: 'control_cockpit',
error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
state: this.getCockpitState(),
};
}
let currentTarget = this.getAircraftTrackingTarget();
const layerForTarget = (target) =>
target?.layerId === 'military'
? militaryFlightsLayer
: target?.layerId === 'flights'
? flightsLayer
: null;
// A requested layer retargets BEFORE entry, through the same filtered
// navigation NEXT uses. Ignoring it entered on whatever was already
// tracked and reported success, so "cockpit in that military helicopter"
// silently put the operator in an airliner.
if (targetLayer) {
const requested = this._retargetCockpitEntryLayer({
targetLayer,
aircraftClass,
currentTarget,
selectedTarget,
});
if (!requested.ok) {
return {
ok: false,
action: 'control_cockpit',
error: requested.error,
state: this.getCockpitState(),
};
}
if (requested.retargeted) {
// The retarget is now the authority; a selection sampled before it
// would drag entry back to the wrong layer.
selectedTarget = null;
rollbackTarget =
rollbackTarget === undefined ? currentTarget : rollbackTarget;
currentTarget = this.getAircraftTrackingTarget();
}
}
const selectedLayer =
selectedTarget?.layerId === 'military'
? militaryFlightsLayer
: selectedTarget?.layerId === 'flights'
? flightsLayer
: null;
const entry = enterCockpitWithTracking({
cockpitView: this.cockpitView,
selectedLayer,
selectedTarget,
currentLayer: layerForTarget(currentTarget),
rollbackLayer: layerForTarget(
rollbackTarget === undefined ? currentTarget : rollbackTarget,
),
rollbackTarget,
selectionOrigin: 'voice',
});
return {
ok: entry.entered,
action: 'control_cockpit',
state: this.getCockpitState(),
error: entry.error,
};
}
if (normalized === 'exit') {
const exited = !!this.cockpitView.exit();
return {
ok: exited,
action: 'control_cockpit',
state: this.getCockpitState(),
error: exited ? null : 'Cockpit was already inactive',
};
}
if (normalized === 'next' || normalized === 'previous') {
const changed = this.cockpitView.navigateContext(
normalized === 'next' ? 1 : -1,
{
targetLayer,
aircraftClass,
origin: 'voice',
},
);
return {
ok: changed,
action: 'control_cockpit',
state: this.getCockpitState(),
error: changed ? null : 'No further context target was available',
};
}
return {
ok: false,
action: 'control_cockpit',
error: `Unknown cockpit action: ${action}`,
state: this.getCockpitState(),
};
}
_initCockpitDisplayPortal() {
this._cockpitDisplayPortal?.destroy();
this._cockpitDisplayPortal = new CockpitDisplayPortal({
standardPanel: this._ppToggles,
cockpitPanel: this._cockpitDisplayPanel,
groups: [
['hud', this._hudBtn?.closest('.pp-toggle-group')],
['detection', this._detectionBtn?.closest('.pp-toggle-group')],
['parameters', this._sliderPanel],
['models3d', this._models3dBtn?.closest('.pp-toggle-group')],
],
layout: () => {
this._layoutRightPanels();
this.cockpitView?.scheduleContextLayout();
},
});
}
_setCockpitDisplayPortalActive(active) {
this._cockpitDisplayPortal?.setActive(active);
}
get _cockpitDisplayPortalActive() {
return this._cockpitDisplayPortal?.active ?? false;
}
get _displayPortalScrollRestoreOwner() {
return this._cockpitDisplayPortal?.restoreOwner ?? null;
}
get _standardDisplayScrollTop() {
return this._cockpitDisplayPortal?.standardScrollTop ?? 0;
}
stop() {
this.cockpitView?.stop();
this._cockpitDisplayPortal?.stop();
}
destroy() {
this.cockpitView?.dispose();
this._cockpitDisplayPortal?.destroy();
this._cockpitDisplayPortal = null;
}
}
+216
View File
@@ -0,0 +1,216 @@
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls } from './displayControls.js';
import { canonicalizeDensity } from '../data/detectionPolicy.js';
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
export class DisplayBindings {
constructor({
viewer,
services,
elements,
operations,
readState,
claimDetection,
}) {
Object.assign(
this,
{ viewer, services, readState, claimDetection },
elements,
operations,
);
}
get shareLinkManager() {
return this.readState().shareLinkManager;
}
get hud() {
return this.readState().hud;
}
get bloomEnabled() {
return this.readState().bloomEnabled;
}
get sharpenEnabled() {
return this.readState().sharpenEnabled;
}
get celestialRing() {
return this.readState().celestialRing;
}
get celestialRingEnabled() {
return this.readState().celestialRingEnabled;
}
get _models3dEnabled() {
return this.readState()._models3dEnabled;
}
get _models3dModeBtns() {
return this.readState()._models3dModeBtns;
}
get _detectionAllocationBtns() {
return this.readState()._detectionAllocationBtns;
}
_initUI() {
const {
cycleDetectionMode,
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
} = this.services;
this._applicationShortcuts?.destroy();
this._frameRateMonitor?.destroy();
this._frameRateMonitor = createFrameRateMonitor({
viewer: this.viewer,
documentRef: document,
});
this._applicationShortcuts = bindApplicationShortcuts({
documentRef: document,
searchInput: this._locationSearch,
actions: {
setStyle: (style) => this.setStyle(style),
dismissSearch: () => {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
},
toggleHud: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
this._updateHudButtonState();
this._syncShareState();
},
toggleOrbit: () => this._toggleOrbit(),
toggleCleanView: () => this.toggleCleanView(),
toggleLayers: () =>
document.getElementById('data-panel').classList.toggle('active'),
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
cycleDetectionMode();
this._syncShareState();
},
toggleCctv: () => this._toggleCctvEnabled(),
},
});
this._displayControls?.destroy();
this._displayControls = bindDisplayControls({
elements: {
styleButtons: document.querySelectorAll('.style-btn'),
bloomButton: this._bloomBtn,
bloomSlider: this._bloomSlider,
sharpenButton: this._sharpenBtn,
sharpenSlider: this._sharpenSlider,
scopeButton: this._scopeBtn,
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
cleanViewButton: this._cleanViewBtn,
cleanViewExitButton: this._cleanViewExitBtn,
densitySlider: this._detectionDensitySlider,
detectionButton: this._detectionBtn,
allocationButtons: this._detectionAllocationBtns,
fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider],
celestialButton: this._celestialBtn,
modelsButton: this._models3dBtn,
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
},
actions: {
setStyle: (style) => this.setStyle(style),
toggleBloom: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setBloomEnabled(!this.bloomEnabled);
},
setBloomIntensity: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setBloomIntensity(value);
},
toggleSharpen: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setSharpenEnabled(!this.sharpenEnabled);
},
toggleScope: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const next = !isScopeMaskEnabled();
setScopeMaskEnabled(next);
this._scopeBtn.classList.toggle('active', next);
this._scopeBtn.setAttribute('aria-pressed', String(next));
this._syncShareState();
},
setScopeFeather: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const pct = Math.max(0, Math.min(100, value || 0));
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
setScopeMaskFeather(pct / 100);
this._syncShareState();
},
setSharpenIntensity: (pct) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
if (this._sharpenSliderValue)
this._sharpenSliderValue.textContent = `${pct}%`;
this._applySharpenIntensity(pct / 100);
this._syncShareState();
},
setHudLayout: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(value);
},
toggleCleanView: () => this.toggleCleanView(),
exitCleanView: () => this.toggleCleanView(false),
setDensity: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
const pct = canonicalizeDensity(value);
this._detectionDensitySlider.value = String(pct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${pct}%`;
this._applyDetectionDensityFromUi();
this._syncShareState();
},
setAllocation: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
this._setDetectionAllocation(value);
},
setFade: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._applyDetectionFadeFromUi();
this._syncShareState();
},
toggleCelestial: () => {
const ringIsVisible = !!this.celestialRing?.visible;
if (!this.celestialRingEnabled || !ringIsVisible) {
this.setCelestialRingEnabled(true, { focus: true });
} else {
this.setCelestialRingEnabled(false);
}
},
toggleHud: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
this._updateHudButtonState();
this._syncShareState();
},
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
cycleDetectionMode();
this._syncShareState();
},
toggleModels: () => {
this._setModels3dEnabled(!this._models3dEnabled);
this._syncModels3dModeRow();
},
setModelsMode: (mode) => this._setModels3dMode(mode),
},
});
}
destroy() {
this._applicationShortcuts?.destroy();
this._applicationShortcuts = null;
this._frameRateMonitor?.destroy();
this._frameRateMonitor = null;
this._displayControls?.destroy();
this._displayControls = null;
}
}
+267
View File
@@ -0,0 +1,267 @@
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import {
registerCctvFocusRequestListener,
routeCctvFocusRequest,
} from '../cctvFocusRequest.js';
import {
flyToWorldTarget,
registerWorldFocusRequestListener,
routeWorldFocusRequest,
} from '../worldFocus.js';
import { registerNavigationAuthorityListener } from '../navigationPolicy.js';
/** Own manager subscriptions and the camera-entry events that outlive controls. */
export class LayerBindings {
constructor({
viewer,
services,
readControls,
operations,
feedback,
shareRestoration,
}) {
Object.assign(
this,
{
viewer,
services,
readControls,
_feedback: feedback,
_shareRestoration: shareRestoration,
},
operations,
);
this._disposed = false;
this._dataManager = null;
this._directionsShellModule = null;
this._cctvRequestFocusHandler = null;
this._removeCctvRequestFocusListener = null;
this._worldRequestFocusHandler = null;
this._removeWorldRequestFocusListener = null;
this._removeNavigationAuthorityListener = null;
this._navigationOwnerChangedRemover = null;
this._awarenessSelectedHandler = null;
this._awarenessClearedHandler = null;
}
get hud() {
return this.readControls().hud;
}
get _contextControls() {
return this.readControls()._contextControls;
}
get _cctvControls() {
return this.readControls()._cctvControls;
}
get _radioControls() {
return this.readControls()._radioControls;
}
observeCamera() {
this._cctvRequestFocusHandler = (event) =>
routeCctvFocusRequest(
event,
(activate, focus) => this._runExplicitCctvFocus(activate, focus),
(cameraId, durationSec) =>
this.services.cctvLayer.focusCamera(cameraId, durationSec),
);
this._removeCctvRequestFocusListener = registerCctvFocusRequestListener(
window,
this._cctvRequestFocusHandler,
);
this._worldRequestFocusHandler = (event) =>
routeWorldFocusRequest(
event,
(detail, fly) => this._runExplicitWorldFocus(detail, fly),
(detail) => flyToWorldTarget(this.viewer, detail),
);
this._removeWorldRequestFocusListener = registerWorldFocusRequestListener(
window,
this._worldRequestFocusHandler,
);
this._navigationOwnerChangedRemover =
this.viewer.trackedEntityChanged.addEventListener((entity) => {
if (entity && !this._disposed)
this._stampNavigation({ cancelPendingSelection: false });
});
// Vessel/installation focus flies without ever assigning a tracked entity,
// so it cannot reach the listener above. It announces instead.
this._removeNavigationAuthorityListener =
registerNavigationAuthorityListener(window, (event) => {
if (this._disposed) return;
this._stampNavigation({
cancelPendingSelection:
event?.detail?.cancelPendingSelection !== false,
});
});
}
_connectDirectionsCamera() {
if (!this._dataManager) {
// Detaching: the layer outlives this shell, so it must not keep calling
// a facade whose viewer is going away.
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
return;
}
const directions = this._dataManager.layers?.get('directions')?.module;
if (this._directionsShellModule !== directions) {
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
}
if (typeof directions?.attachShellServices !== 'function') return;
this._directionsShellModule = directions;
directions.attachShellServices({
runNavigation: (navigate) =>
this.runImmediateNavigation('route', navigate),
floorFn: (lat, lon) => this.services.cachedGroundFloor(lat, lon),
warmFn: (cells) => this.services.warmGroundFloor(cells),
showToast: (message) => this._showToast(message),
});
}
_persistAwarenessSelection(event, cleared = false) {
if (!this._dataManager) return;
const origin = String(event?.detail?.origin || 'programmatic');
if (!isExplicitLayerStateOrigin(origin)) return;
const layerId = String(event?.detail?.layerId || '');
const config = {
flights: {
key: 'selectedFlightsTrackingId',
normalize: (value) =>
String(value ?? '')
.trim()
.toLowerCase() || null,
},
military: {
key: 'selectedMilitaryTrackingId',
normalize: (value) =>
String(value ?? '')
.trim()
.toLowerCase() || null,
},
satellites: {
key: 'selectedSatTrackingId',
normalize: (value) => {
const candidate = Number(value);
return Number.isFinite(candidate) && candidate > 0
? Math.trunc(candidate)
: null;
},
},
}[layerId];
if (!config) return;
const selectedValue = cleared ? null : config.normalize(event?.detail?.id);
if (cleared || selectedValue === null) {
this._dataManager.adoptLayerParams?.(
layerId,
{
[config.key]: selectedValue,
},
{ origin },
);
return;
}
// A direct selection promotes a Context-owned tracker dependency into
// durable visibility before its selected ID is normalized. Context exit
// also keeps this adopted layer instead of tearing down the user's track.
const visibilityAdopted = this._dataManager.adoptLayerVisibility?.(
layerId,
true,
{ origin, adoptedFromSelection: true },
);
if (visibilityAdopted === false) return;
// Clear the prior family before publishing the replacement. Otherwise the
// coordinator briefly sees two IDs and correctly treats them as an
// ambiguous incoming state, which would discard the new durable target.
for (const [otherLayerId, otherKey] of [
['flights', 'selectedFlightsTrackingId'],
['military', 'selectedMilitaryTrackingId'],
['satellites', 'selectedSatTrackingId'],
]) {
if (otherLayerId === layerId) continue;
this._dataManager.setLayerParams(
otherLayerId,
{ [otherKey]: null },
{ origin },
);
}
this._dataManager.adoptLayerParams?.(
layerId,
{
[config.key]: selectedValue,
},
{ origin },
);
}
attachDataManager(dataManager) {
if (this._disposed) return;
this._dataManager = dataManager || null;
this.hud.attachDataManager(this._dataManager);
this._updateTrafficSyncChip();
if (this._dataManagerUnsubscribe) {
this._dataManagerUnsubscribe();
this._dataManagerUnsubscribe = null;
}
this._contextControls.connect(this._dataManager);
if (typeof this._dataManager?.subscribe === 'function') {
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
this._feedback._loadingFeedbackEvent = change;
this._updateGlobalLoadingFeedback(performance.now());
});
}
this._updateGlobalLoadingFeedback(performance.now());
this._syncContextModeButtons();
this._cctvControls.connect();
this._radioControls.connect();
this._connectDirectionsCamera();
if (!this._awarenessSelectedHandler) {
this._awarenessSelectedHandler = (event) =>
this._persistAwarenessSelection(event, false);
this._awarenessClearedHandler = (event) =>
this._persistAwarenessSelection(event, true);
window.addEventListener(
'gev:awareness-subject-selected',
this._awarenessSelectedHandler,
);
window.addEventListener(
'gev:awareness-subject-cleared',
this._awarenessClearedHandler,
);
}
this._shareRestoration.connect(this._dataManager);
}
stop() {
if (this._disposed) return;
this._disposed = true;
if (this._awarenessSelectedHandler) {
window.removeEventListener(
'gev:awareness-subject-selected',
this._awarenessSelectedHandler,
);
this._awarenessSelectedHandler = null;
}
if (this._awarenessClearedHandler) {
window.removeEventListener(
'gev:awareness-subject-cleared',
this._awarenessClearedHandler,
);
this._awarenessClearedHandler = null;
}
this._removeCctvRequestFocusListener?.();
this._removeCctvRequestFocusListener = null;
this._cctvRequestFocusHandler = null;
this._removeWorldRequestFocusListener?.();
this._removeWorldRequestFocusListener = null;
this._worldRequestFocusHandler = null;
this._navigationOwnerChangedRemover?.();
this._navigationOwnerChangedRemover = null;
this._removeNavigationAuthorityListener?.();
this._removeNavigationAuthorityListener = null;
}
disconnect() {
this._dataManagerUnsubscribe?.();
this._dataManagerUnsubscribe = null;
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
this._dataManager = null;
}
}
+13
View File
@@ -7,6 +7,7 @@ const FEED_STATE_LABELS = Object.freeze({
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
partial: 'PARTIAL',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
@@ -21,6 +22,7 @@ const PANEL_GROUPS = [
'military',
'ais-live-vessels',
'traffic',
'transit',
'bikeshare',
],
},
@@ -504,6 +506,17 @@ export class LayerPanel {
: stats.coverage || ago;
return `${stateLabel} · ${source} · ${detail}`;
}
if (feedState === 'partial') {
const { acceptedRowCount, rawRowCount } = stats;
const detail =
Number.isInteger(acceptedRowCount) &&
Number.isInteger(rawRowCount) &&
acceptedRowCount >= 0 &&
rawRowCount > acceptedRowCount
? `${acceptedRowCount} of ${rawRowCount} records accepted`
: 'incomplete snapshot';
return `${stateLabel} · ${source} · ${detail} · ${ago}`;
}
if (feedState === 'stale') {
const retry =
typeof stats.retryInSec === 'number' && stats.retryInSec > 0
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
test('panel presentation places Transit between Street Traffic and Bike Share in Movement', () => {
const source = readFileSync(
new URL('./layerPanel.js', import.meta.url),
'utf8',
);
const declarations = source.slice(
source.indexOf('const PANEL_GROUPS ='),
source.indexOf('const PANEL_POSITIONS ='),
);
const order = JSON.parse(
runInNewContext(`${declarations}\nJSON.stringify(PANEL_ORDER)`),
);
assert.deepEqual(
order.filter(({ label }) => label === 'Movement').map(({ id }) => id),
[
'satellites',
'flights',
'military',
'ais-live-vessels',
'traffic',
'transit',
'bikeshare',
],
);
assert.equal(order.filter(({ id }) => id === 'transit').length, 1);
});
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
const { LayerPanel, layerFeedState } = await import('./layerPanel.js');
const classes = new Map();
const attrs = new Map();
const button = {
classList: { toggle: (key, value) => classes.set(key, value) },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
const layer = {
id: 'ais-live-vessels',
name: 'Live Vessels',
source: 'AISStream',
enabled: true,
stats: {
partial: true,
stale: false,
count: 2,
acceptedRowCount: 2,
rawRowCount: 3,
lastUpdate: Date.now(),
},
};
const panel = LayerPanel.prototype;
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'PARTIAL');
assert.equal(button.dataset.feedState, 'partial');
assert.equal(classes.get('feed-partial'), true);
assert.equal(classes.get('feed-stale'), false);
assert.match(attrs.get('aria-label'), /PARTIAL/);
assert.match(
panel._buildMetaText(layer),
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
);
assert.match(
panel._buildMetaText({
...layer,
stats: { ...layer.stats, rawRowCount: 2 },
}),
/incomplete snapshot/,
);
assert.equal(layerFeedState({ ...layer.stats, stale: true }), 'stale');
assert.equal(
layerFeedState({ ...layer.stats, error: 'Connection lost' }),
'degraded',
);
assert.equal(
layerFeedState({ ...layer.stats, status: 'unavailable' }),
'unavailable',
);
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
layer.stats = { ...layer.stats, partial: false };
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'ON');
assert.equal(classes.get('feed-partial'), false);
layer.enabled = false;
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'OFF');
});
+428
View File
@@ -0,0 +1,428 @@
import * as Cesium from 'cesium';
import { createStateChannel } from '../app/stateChannel.js';
import { LocationControls } from './location.js';
/** Own destination selection, lookup, orbit and world-jump lifetime. */
export class LocationNavigation {
constructor({
viewer,
placeSearch,
services,
elements,
navigation,
readCockpit,
operations,
}) {
Object.assign(
this,
{ viewer, placeSearch, services, navigation, readCockpit },
elements,
operations,
);
this._disposed = false;
this._activeLocationId = null;
this._expandedCityId = null;
this._activePoiIndex = null;
this._currentTarget = null;
this._currentPoi = null;
this._searchedLocationLabel = null;
this._trafficTransitionTimer = null;
this._globeResetPromise = null;
this._cancelGlobeReset = null;
this._worldJumpActive = false;
this.orbitController = new services.OrbitController(viewer);
this._orbitIndicator = null;
this._locationState = createStateChannel(
() => this._locationLookup?.getState() || null,
);
this._locationState.subscribe(
({ state, change }) => this._handleLocationSearchState(state, change),
{ emitCurrent: false },
);
}
get cockpitView() {
return this.readCockpit();
}
get _navigationGeneration() {
return this.navigation._navigationGeneration;
}
get _activeLocationSearchGeneration() {
return this.navigation._activeLocationSearchGeneration;
}
set _activeLocationSearchGeneration(value) {
this.navigation._activeLocationSearchGeneration = value;
}
setOrbit(enabled) {
const active = !!this.orbitController?.active;
if (typeof enabled === 'boolean' && enabled === active) {
return { ok: true, orbiting: active };
}
if (enabled === false) {
this._stopOrbit();
return { ok: true, orbiting: false };
}
if (!this._currentTarget) {
return {
ok: false,
orbiting: false,
error: 'No active landmark to orbit — fly to a landmark first',
};
}
this._toggleOrbit();
return { ok: true, orbiting: !!this.orbitController?.active };
}
subscribeLocationSearch(listener, options) {
return this._locationState.subscribe(listener, options);
}
_handleLocationSearchState(state, change) {
if (this._disposed || !change) return;
if (change.type === 'started')
this._activeLocationSearchGeneration = change.generation;
else if (change.type === 'found') {
this._searchedLocationLabel = state.destination.label || state.query;
this._setActiveLocation(null);
this._currentPoi = null;
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else if (change.type === 'missing') this._showToast('Location not found');
else if (change.type === 'failed') this._showToast('Search failed');
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
else if (
change.type === 'reset' &&
this._activeLocationSearchGeneration !== null
) {
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
}
}
_initLocationBar() {
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
this._locationControls?.destroy();
this._locationLookupUnsubscribe?.();
this._locationLookup?.destroy();
this._locationLookup = new LocationSearch({
input: this._locationSearch,
begin: () => this._beginDeferredNavigation('location'),
isCurrent: (generation) =>
!this._disposed && generation === this._navigationGeneration,
beforeFly: (generation) => this._reassertNavigationHandoff(generation),
search: (query, options) =>
searchAndFlyTo(this.viewer, query, {
placeSearch: this.placeSearch,
...options,
}),
onError: (error) => console.error('[Search] Geocoding failed:', error),
});
this._locationLookupUnsubscribe = this._locationLookup.subscribe(
({ initial, change }) => {
this._locationState.publish(initial ? { type: 'reset' } : change);
},
);
this._locationControls = new LocationControls({
elements: {
pills: this._locationPills,
poiRow: this._poiRow,
divider: this._locationBarDivider,
search: this._locationSearch,
searchToggle: this._searchToggle,
resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
statusCity: this._locationMiniCity,
statusPoi: this._locationMiniPoi,
},
cities: CITY_POIS,
getExpandedCity: () => this._expandedCityId,
onCity: (id) => this._onCityPillClick(id),
onPoi: (id, index) => this._onPoiClick(id, index),
onSearch: (query) => this._locationLookup.run(query),
onReset: () => this.resetToGlobeView(),
});
}
_beginWorldJumpTransition() {
const { suspendDetection, trafficLayer } = this.services;
clearTimeout(this._trafficTransitionTimer);
this._worldJumpActive = true;
trafficLayer.beginWorldJump?.();
suspendDetection('intercity');
}
_endWorldJumpTransition() {
const { resumeDetection, trafficLayer } = this.services;
clearTimeout(this._trafficTransitionTimer);
this._worldJumpActive = false;
this._trafficTransitionTimer = null;
trafficLayer.endWorldJump?.();
resumeDetection();
this._updateTrafficSyncChip(true);
}
_flyWithTransition(cityChanged, flyAction) {
return this._runExplicitNavigation('location', () => {
if (!cityChanged) return flyAction({});
let completed = false;
const finalize = () => {
if (completed || this._disposed) return;
completed = true;
this._endWorldJumpTransition();
};
const result = flyAction({
onStart: () => this._beginWorldJumpTransition(),
onComplete: finalize,
});
this._trafficTransitionTimer = window.setTimeout(finalize, 5200);
return result;
});
}
_onCityPillClick(cityId) {
const { CITY_POIS, flyToPresetLocation } = this.services;
if (this._expandedCityId === cityId) {
// Same city clicked again — toggle collapse
this._collapsePOIRow();
return;
}
const isCityChanged =
this._activeLocationId && this._activeLocationId !== cityId;
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
flyToPresetLocation(this.viewer, cityId, hooks),
);
if (result === false) return;
this._expandPOIRow(cityId);
this._setActiveLocation(cityId);
this._activePoiIndex = 0;
this._updatePoiHighlight();
// Track current target + POI for orbit
if (result) {
this._currentTarget = result.targetPosition;
this._currentPoi = CITY_POIS[cityId].pois[0];
}
this._updateLocationMiniStatus();
}
_onPoiClick(cityId, poiIndex) {
const { CITY_POIS, flyToPOI } = this.services;
const isCityChanged =
this._activeLocationId && this._activeLocationId !== cityId;
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
flyToPOI(this.viewer, cityId, poiIndex, hooks),
);
if (result === false) return;
this._setActiveLocation(cityId);
this._activePoiIndex = poiIndex;
this._updatePoiHighlight();
// Track current target + POI for orbit
if (result) {
this._currentTarget = result.targetPosition;
this._currentPoi = CITY_POIS[cityId].pois[poiIndex];
}
this._updateLocationMiniStatus();
}
_expandPOIRow(cityId) {
const { CITY_POIS } = this.services;
if (!CITY_POIS[cityId]) return;
this._expandedCityId = cityId;
this._locationControls.showPois(cityId);
}
_collapsePOIRow() {
this._expandedCityId = null;
this._activePoiIndex = null;
this._locationControls.hidePois();
}
_updatePoiHighlight() {
this._locationControls.highlightPoi(this._activePoiIndex);
}
clearSearchedLocation() {
if (this._searchedLocationLabel === null) return;
this._searchedLocationLabel = null;
this._updateLocationMiniStatus();
}
_setActiveLocation(locationId) {
this._activeLocationId = locationId;
// A preset city is now what the camera is framed on, so any earlier
// free-text destination has been superseded. Clearing only on a real id
// leaves the search path's own _setActiveLocation(null) untouched.
if (locationId) this._searchedLocationLabel = null;
this._locationControls?.highlightCity(locationId);
this._updateLocationMiniStatus();
}
_updateLocationMiniStatus() {
const { CITY_POIS } = this.services;
this._locationControls?.renderStatus({
city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null,
currentPoi: this._currentPoi,
searchedLabel: this._searchedLocationLabel,
});
}
_initOrbit() {
this._orbitIndicator = this._locationControls.createOrbitIndicator();
}
_toggleOrbit() {
if (!this._currentTarget) {
this._showToast('Fly to a POI first');
return;
}
const isActive = this.orbitController.toggle(this._currentTarget, {
radius: this._currentPoi?.alt || 500,
pitch: this._currentPoi?.pitch || -30,
});
this._orbitIndicator.classList.toggle('active', isActive);
}
_stopOrbit() {
if (this.orbitController.active) {
this.orbitController.stop();
this._orbitIndicator.classList.remove('active');
}
}
resetToGlobeView() {
const {
GLOBE_VIEW,
flyToGlobeView,
interruptCameraMotion,
flightsLayer,
militaryFlightsLayer,
satellitesLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
rocketLaunchesLayer,
} = this.services;
if (this._disposed)
return Promise.resolve({
ok: false,
action: 'zoom_to_globe',
cancelled: true,
});
if (this._globeResetPromise) return this._globeResetPromise;
this._stampNavigation();
interruptCameraMotion('reset-globe');
this._stopOrbit();
this.cockpitView?.exit({ restoreTracking: false });
try {
militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' });
} catch {
// Keep reset available if Context has not initialized completely.
try {
flightsLayer.stopTracking?.({ origin: 'tool' });
} catch {
/* best-effort release */
}
try {
militaryFlightsLayer.stopTracking?.({ origin: 'tool' });
} catch {
/* best-effort release */
}
try {
aisLiveVesselsLayer.clearSelection?.();
} catch {
/* best-effort release */
}
}
try {
satellitesLayer.stopTracking?.({ origin: 'tool' });
} catch {
/* best-effort release */
}
try {
rocketLaunchesLayer.releaseCameraOwnership?.();
} catch {
/* best-effort release */
}
this.viewer.trackedEntity = undefined;
this.viewer.camera.cancelFlight();
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
this._beginWorldJumpTransition();
let resolveReset;
const resetPromise = new Promise((resolve) => {
resolveReset = resolve;
});
this._globeResetPromise = resetPromise;
let settled = false;
let timer = null;
const finish = (cancelled = false) => {
if (settled) return;
settled = true;
clearTimeout(timer);
this._endWorldJumpTransition();
const carto = this.viewer.camera.positionCartographic;
const result = {
ok: !cancelled,
action: 'zoom_to_globe',
cancelled,
heightKm: Math.round(GLOBE_VIEW.heightM / 1000),
centeredOn: {
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(2)),
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(2)),
},
};
this._resetGlobeBtn?.setAttribute(
'aria-label',
'Reset to full globe view',
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
'Reset cockpit to full globe view',
);
this._globeResetPromise = null;
this._cancelGlobeReset = null;
resolveReset(result);
};
this._cancelGlobeReset = () => finish(true);
timer = window.setTimeout(() => {
const height = this.viewer.camera.positionCartographic?.height;
finish(
!Number.isFinite(height) ||
Math.abs(height - GLOBE_VIEW.heightM) > 1000,
);
}, 4200);
this._resetGlobeBtn?.setAttribute(
'aria-label',
'Resetting to full globe view',
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
'Resetting cockpit to full globe view',
);
const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false),
onCancel: () => finish(true),
});
if (!target) finish(true);
return resetPromise;
}
/** Revoke callbacks and settle owned camera work before the viewer is released. */
destroy() {
if (this._disposed) return;
this._disposed = true;
this._locationState.destroy();
this._locationLookupUnsubscribe?.();
this._locationLookupUnsubscribe = null;
this._locationLookup?.destroy();
this._locationControls?.destroy();
this._cancelGlobeReset?.();
this._cancelGlobeReset = null;
if (this._worldJumpActive) this._endWorldJumpTransition();
clearTimeout(this._trafficTransitionTimer);
this._trafficTransitionTimer = null;
this.orbitController?.stop();
}
}
+11
View File
@@ -1,3 +1,4 @@
import { createStateChannel } from '../app/stateChannel.js';
import * as Cesium from 'cesium';
import {
beginDeferredNavigation,
@@ -34,6 +35,9 @@ export class NavigationController {
cancelOrientation,
});
this._navigationGeneration = 0;
this._cameraHandoffs = createStateChannel(() => ({
generation: this._navigationGeneration,
}));
this._activeLocationSearchGeneration = null;
this._disposed = false;
}
@@ -45,6 +49,7 @@ export class NavigationController {
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
this.tracking;
this._navigationGeneration += 1;
this._cameraHandoffs?.publish();
// A newer destination owns the camera, so the last free-text search is no
// longer where we are. DEFERRED navigation opts out here and clears at the
// reassert seam instead: a geocode that never resolves moves no camera, and
@@ -247,8 +252,14 @@ export class NavigationController {
},
});
}
/** Subscribe to ownership changes without claiming the camera or exposing mutable state. */
subscribeCameraHandoff(listener) {
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
}
stop() {
this._disposed = true;
this._cameraHandoffs?.publish();
this._cameraHandoffs?.destroy();
}
destroy() {
this.stop();
+4 -1
View File
@@ -50,7 +50,10 @@ export class SceneControls {
this.listen(elements.file, 'change', async () => {
const file = elements.file?.files?.[0];
if (!file) return;
await this.run('import', file);
await this.run(
this.actions.reviewImport ? 'reviewImport' : 'import',
file,
);
if (!this.destroyed) elements.file.value = '';
});
if (!subscribe) {
+10
View File
@@ -24,6 +24,16 @@ function fixture() {
}
appendChild(element) {
this.children.push(element);
element.parentNode = this;
}
append(...nodes) {
for (const node of nodes) this.appendChild(node);
}
remove() {
if (this.parentNode)
this.parentNode.children = this.parentNode.children.filter(
(node) => node !== this,
);
}
setAttribute(name, value) {
this[name] = value;
+137
View File
@@ -0,0 +1,137 @@
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
const dialog = document.createElement('dialog');
dialog.dataset.directorDialog = '';
dialog.className = 'director-sharing-dialog';
dialog.setAttribute('aria-label', title);
Object.assign(dialog.style, {
background: '#08141e',
color: '#e3faff',
border: '1px solid #397080',
borderRadius: '6px',
padding: '20px',
width: 'min(720px,calc(100vw - 32px))',
boxSizing: 'border-box',
position: 'fixed',
inset: '0',
margin: 'auto',
maxHeight: 'calc(100vh - 32px)',
overflow: 'auto',
font: '14px/1.5 sans-serif',
});
const heading = document.createElement('h2');
heading.textContent = title;
heading.style.marginBottom = '12px';
dialog.append(heading);
const body = document.createElement('div'),
footer = document.createElement('div'),
status = document.createElement('p');
status.setAttribute('role', 'status');
dialog.append(body, status, footer);
const listeners = [];
function listen(node, type, fn) {
node.addEventListener(type, fn);
listeners.push(() => node.removeEventListener(type, fn));
}
function text(value, parent = body) {
const p = document.createElement('p');
p.textContent = value;
p.style.margin = '8px 0';
parent.append(p);
return p;
}
function button(label, fn, parent = footer) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
Object.assign(b.style, {
background: '#10242e',
color: '#e3faff',
border: '1px solid #397080',
borderRadius: '3px',
padding: '7px 11px',
margin: '6px 6px 6px 0',
cursor: 'pointer',
font: 'inherit',
});
listen(b, 'click', fn);
parent.append(b);
return b;
}
function input(label, value, { type = 'text', multiline = false } = {}) {
const wrapper = document.createElement('label');
wrapper.style.display = 'block';
wrapper.textContent = label;
const node = document.createElement(multiline ? 'textarea' : 'input');
if (!multiline) node.type = type;
node.value = value;
node.setAttribute('aria-label', label);
Object.assign(node.style, {
display: 'block',
boxSizing: 'border-box',
width: '100%',
padding: '7px',
margin: '6px 0 14px',
color: '#e3faff',
background: '#10242e',
border: '1px solid #397080',
font: multiline ? '12px monospace' : 'inherit',
});
if (multiline) node.rows = 9;
wrapper.append(node);
body.append(wrapper);
return node;
}
button('Cancel', onClose);
listen(dialog, 'cancel', (event) => {
event.preventDefault();
onClose();
});
document.body.append(dialog);
dialog.showModal();
return {
body,
footer,
status,
text,
button,
input,
listen,
element: dialog,
dispose() {
for (const remove of listeners.splice(0)) remove();
dialog.close();
dialog.remove();
},
};
}
/** Add controls only when their owner is present; dispose removes every listener and element. */
export function mountSceneSharing(
{ edit, share },
panel = document.getElementById('scene-panel'),
) {
if (!panel) return () => {};
const bar = document.createElement('div');
bar.dataset.directorAuthoring = '';
bar.className = 'scene-controls';
const entries = [
['EDIT DETAILS', edit],
['SHARE SCENE', share],
];
const removers = [];
for (const [text, fn] of entries) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.className = 'scene-btn';
b.addEventListener('click', fn);
removers.push(() => b.removeEventListener('click', fn));
bar.append(b);
}
(panel.querySelector?.('.scene-panel-inner') || panel).appendChild(bar);
return () => {
for (const remove of removers) remove();
bar.remove();
};
}
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more