mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge origin/main into fix/sanitize-openai-proxy-errors
This commit is contained in:
commit
a88904a02f
156 files changed
+19702
-6530
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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 & 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:
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 }));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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('{}'),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', {});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LocationNavigation } from './locationNavigation.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
|
||||
function replaceWindow(t, value) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else delete globalThis.window;
|
||||
});
|
||||
}
|
||||
|
||||
function location(t) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
replaceWindow(t, { setTimeout });
|
||||
const calls = [];
|
||||
let hooks;
|
||||
const owner = new LocationNavigation({
|
||||
viewer: { camera: {
|
||||
cancelFlight() {}, lookAtTransform() {},
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: 20000000 },
|
||||
} },
|
||||
services: {
|
||||
OrbitController: class { stop() { calls.push('orbit-stop'); } },
|
||||
GLOBE_VIEW: { heightM: 20000000 },
|
||||
flyToGlobeView(_viewer, callbacks) { hooks = callbacks; return {}; },
|
||||
interruptCameraMotion() {},
|
||||
militaryAwarenessLayer: {}, satellitesLayer: {}, rocketLaunchesLayer: {},
|
||||
trafficLayer: {
|
||||
beginWorldJump() { calls.push('begin'); },
|
||||
endWorldJump() { calls.push('end'); },
|
||||
},
|
||||
suspendDetection() { calls.push('suspend'); },
|
||||
resumeDetection() { calls.push('resume'); },
|
||||
},
|
||||
elements: {}, navigation: {}, readCockpit: () => null,
|
||||
operations: {
|
||||
_stampNavigation() {}, _updateTrafficSyncChip() {},
|
||||
_runExplicitNavigation(_noun, run) { return run(); },
|
||||
},
|
||||
});
|
||||
return { owner, calls, readHooks: () => hooks };
|
||||
}
|
||||
|
||||
test('shell disposal settles a pending globe reset once and revokes its timeout', async (t) => {
|
||||
const { owner, calls, readHooks } = location(t);
|
||||
const pending = owner.resetToGlobeView();
|
||||
assert.equal(owner.resetToGlobeView(), pending);
|
||||
owner.destroy();
|
||||
assert.equal((await pending).cancelled, true);
|
||||
const after = [...calls];
|
||||
readHooks().onComplete();
|
||||
t.mock.timers.tick(10000);
|
||||
owner.destroy();
|
||||
assert.deepEqual(calls, after);
|
||||
assert.equal(calls.filter(c => c === 'resume').length, 1);
|
||||
assert.equal((await owner.resetToGlobeView()).cancelled, true);
|
||||
});
|
||||
|
||||
test('world jump completion cannot revive navigation after shell disposal', (t) => {
|
||||
const { owner, calls } = location(t);
|
||||
let complete;
|
||||
owner._flyWithTransition(true, ({ onStart, onComplete }) => {
|
||||
onStart(); complete = onComplete;
|
||||
});
|
||||
owner.destroy();
|
||||
const after = [...calls];
|
||||
complete();
|
||||
t.mock.timers.tick(10000);
|
||||
assert.deepEqual(calls, after);
|
||||
assert.equal(calls.filter(c => c === 'resume').length, 1);
|
||||
});
|
||||
|
||||
function bindings(t) {
|
||||
replaceWindow(t, new EventTarget());
|
||||
const events = [];
|
||||
const controls = {
|
||||
hud: { attachDataManager() {} },
|
||||
_contextControls: { connect() {} },
|
||||
_cctvControls: { connect() {} }, _radioControls: { connect() {} },
|
||||
};
|
||||
const owner = new LayerBindings({
|
||||
viewer: {}, services: {}, readControls: () => controls,
|
||||
feedback: {}, shareRestoration: { connect() {} },
|
||||
operations: {
|
||||
_updateTrafficSyncChip() {}, _updateGlobalLoadingFeedback() {},
|
||||
_syncContextModeButtons() {},
|
||||
},
|
||||
});
|
||||
function manager(id, directions = true) {
|
||||
return {
|
||||
layers: new Map(directions ? [['directions', { module: {
|
||||
attachShellServices(value) { events.push([id, value ? 'attach' : 'detach']); },
|
||||
} }]] : []),
|
||||
subscribe() { events.push([id, 'subscribe']); return () => events.push([id, 'unsubscribe']); },
|
||||
};
|
||||
}
|
||||
return { owner, manager, events };
|
||||
}
|
||||
|
||||
test('manager replacement releases Directions even when the new manager has no route layer', (t) => {
|
||||
const { owner, manager, events } = bindings(t);
|
||||
owner.attachDataManager(manager('old'));
|
||||
owner.attachDataManager(manager('new', false));
|
||||
assert.deepEqual(events, [
|
||||
['old', 'subscribe'], ['old', 'attach'], ['old', 'unsubscribe'],
|
||||
['new', 'subscribe'], ['old', 'detach'],
|
||||
]);
|
||||
owner.stop(); owner.disconnect();
|
||||
assert.deepEqual(events.at(-1), ['new', 'unsubscribe']);
|
||||
const count = events.length;
|
||||
owner.attachDataManager(manager('late'));
|
||||
owner.stop(); owner.disconnect();
|
||||
assert.equal(events.length, count);
|
||||
});
|
||||
|
||||
test('camera-entry listeners are removed before any asynchronous layer cleanup', (t) => {
|
||||
const { owner } = bindings(t);
|
||||
let changes = 0;
|
||||
let trackedCallback;
|
||||
owner.viewer = { trackedEntityChanged: {
|
||||
addEventListener(callback) {
|
||||
trackedCallback = callback;
|
||||
return () => { changes += 1; };
|
||||
},
|
||||
} };
|
||||
owner._stampNavigation = () => { changes += 100; };
|
||||
owner.observeCamera();
|
||||
owner.stop();
|
||||
trackedCallback({});
|
||||
owner.stop();
|
||||
assert.equal(changes, 1);
|
||||
});
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user