Move the engine switch protocol from the layers into the scene binding

attachRenderer takes one context { engine, signal, viewRestored, ... } and
bindCatalog no longer takes a viewer; no layer module receives Cesium's
viewer any more, and a rehearsal check fails if one reads it.

The handoff between engines is one typed object (layers, camera owners,
selection, director) created when the old scene leaves, replacing the
session's handoffLayers, restoringView and selectionContext. viewRestored
resolves where the camera is restored, and the nine layers that kept
their own restoring-view flag wait on it instead. After attach the
binding applies a disabled layer's visibility, registers the picks each
module declares, and resumes the camera owners, so engineSwitch.js no
longer names directions or satellites.
This commit is contained in:
German Khokhlov committed 2026-11-10 16:33:29 +04:00
1 parent a90407d547
commit a2905eb700
104 files changed
+1029 -537

No files matched your search

+5 -5
View File
@@ -1,16 +1,16 @@
{ {
"metrics": { "metrics": {
"attachRendererWithViewer": 33, "attachRendererWithViewer": 0,
"restoreFlagCopies": 9, "restoreFlagCopies": 0,
"surfaceCallsOutsideGlobe": 41, "surfaceCallsOutsideGlobe": 41,
"engineSwitchLayerIds": 3, "engineSwitchLayerIds": 0,
"cesiumLayerRendererLines": 0, "cesiumLayerRendererLines": 0,
"navaraLayerRendererLines": 0, "navaraLayerRendererLines": 0,
"cesiumImportFiles": 3, "cesiumImportFiles": 3,
"viewerLines": 784, "viewerLines": 735,
"navaraKindChecks": 1, "navaraKindChecks": 1,
"engineSniffCalls": 0, "engineSniffCalls": 0,
"engineExtensionReads": 3, "engineExtensionReads": 4,
"groundEffectPasses": 0, "groundEffectPasses": 0,
"cesiumSdkLoads": 3, "cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 2, "screenSpaceHandlerFiles": 2,
+4 -6
View File
@@ -91,12 +91,10 @@ export function createApplicationData({
current = nextCatalog; current = nextCatalog;
for (const layer of nextCatalog.layers) for (const layer of nextCatalog.layers)
layer.attachMapStackController?.(next.mapStackController); layer.attachMapStackController?.(next.mapStackController);
const binding = dataManager.bindCatalog( dataManager.viewer = next.viewer;
next.viewer, const binding = dataManager.bindCatalog(next.engine, nextCatalog, {
next.engine, viewRestored: layerSession?.viewRestored,
nextCatalog, });
{ waitForCameraRestore: layerSession?.restoringView === true },
);
for (const layer of nextCatalog.layers) for (const layer of nextCatalog.layers)
layer.attachDataManager?.(dataManager); layer.attachDataManager?.(dataManager);
presentation.mount( presentation.mount(
+5 -7
View File
@@ -15,10 +15,9 @@
* @property {{draw?: ReturnType<typeof import('../annotations/drawTool.js').createSessionDrawTool>, geoImport?: ReturnType<typeof import('../ui/geoFileImport.js').createGeoFileImport>}} tools Session tools. * @property {{draw?: ReturnType<typeof import('../annotations/drawTool.js').createSessionDrawTool>, geoImport?: ReturnType<typeof import('../ui/geoFileImport.js').createGeoFileImport>}} tools Session tools.
* @property {ReturnType<typeof import('../voice/gevRealtime.js').createSceneVoice> | null} voice Session voice. * @property {ReturnType<typeof import('../voice/gevRealtime.js').createSceneVoice> | null} voice Session voice.
* @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory. * @property {{locale?: () => void, placeSearch?: object, sceneChrome?: boolean}} chrome Standalone startup and scene factory.
* @property {Map<string, {entry: object | null, enabled: boolean, durable: boolean | undefined, visit: boolean}> | null} handoffLayers Switch coordinator: outgoing layer intentions; visit restores the incoming address's options instead of keeping the live options. * @property {import('./sessionHandoff.js').SessionHandoff | null} handoff Switch coordinator: retained intentions and scene owners.
* @property {boolean} restoringView Switch coordinator: incoming address restoration. * @property {Promise<void>} viewRestored Switch coordinator: incoming camera restoration.
* @property {() => boolean} switching Switch coordinator: voice waits while busy. * @property {() => boolean} switching Switch coordinator: voice waits while busy.
* @property {object | null} selectionContext Scene factory: selected record carried to the next attachment.
* @property {object | null} contextScene Shell: retained Context state. * @property {object | null} contextScene Shell: retained Context state.
*/ */
@@ -37,15 +36,14 @@ export function createLayerSession() {
tools: {}, tools: {},
voice: null, voice: null,
chrome: {}, chrome: {},
handoffLayers: null, handoff: null,
restoringView: false, viewRestored: Promise.resolve(),
switching: () => false, switching: () => false,
contextScene: null, contextScene: null,
selectionContext: null,
}; };
} }
/** Whether teardown preserves the session for another globe. */ /** Whether teardown preserves the session for another globe. */
export function retainsLayerSession(session) { export function retainsLayerSession(session) {
return Boolean(session?.handoffLayers && !session.lifetime.signal.aborted); return Boolean(session?.handoff && !session.lifetime.signal.aborted);
} }
+9 -5
View File
@@ -1,9 +1,10 @@
import { createSessionHandoff } from './sessionHandoff.js';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createLayerSession, retainsLayerSession } from './layerSession.js'; import { createLayerSession, retainsLayerSession } from './layerSession.js';
import { handOffSession, resumeSession } from '../standalone/engineSwitch.js'; import { handOffSession, resumeSession } from '../standalone/engineSwitch.js';
test('a session retains layer intentions through handoff but never after page teardown', () => { test('a session retains layer intentions through handoff but never after page teardown', async () => {
const session = createLayerSession(); const session = createLayerSession();
const other = createLayerSession(); const other = createLayerSession();
const calls = []; const calls = [];
@@ -17,21 +18,24 @@ test('a session retains layer intentions through handoff but never after page te
session.manager = { session.manager = {
layers: new Map([['flights', entry]]), layers: new Map([['flights', entry]]),
isEffectivelyEnabled: () => true, isEffectivelyEnabled: () => true,
resumeScene: () => calls.push('viewport'),
}; };
session.director = { session.director = {
suspendScene: () => calls.push('suspend'), suspendScene: () => calls.push('suspend'),
resumeScene: () => calls.push('resume'), resumeScene: () => calls.push('resume'),
}; };
assert.equal(retainsLayerSession(session), false); assert.equal(retainsLayerSession(session), false);
handOffSession(session, { addressLayerIds: ['flights'] }); session.handoff = createSessionHandoff(session, {
addressLayerIds: ['flights'],
});
handOffSession(session);
assert.equal(retainsLayerSession(session), true); assert.equal(retainsLayerSession(session), true);
assert.deepEqual(session.handoffLayers.get('flights'), { assert.deepEqual(session.handoff.layers.get('flights'), {
entry,
enabled: true, enabled: true,
durable: true, durable: true,
visit: false, visit: false,
}); });
resumeSession(session); await resumeSession(session);
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']); assert.deepEqual(calls, ['suspend', 'viewport', 'resume']);
assert.equal(session.models.get('flights'), entry.module); assert.equal(session.models.get('flights'), entry.module);
assert.equal(other.models.size, 0); assert.equal(other.models.size, 0);
+1 -1
View File
@@ -256,7 +256,7 @@ const cases = {
return make(value); return make(value);
}, },
}); });
layer.attachRenderer(null, { engine }); layer.attachRenderer({ engine });
const renderer = context.parts.renderer; const renderer = context.parts.renderer;
const station = { const station = {
id: '00000000-0000-4000-8000-000000000000', id: '00000000-0000-4000-8000-000000000000',
+32 -43
View File
@@ -1,3 +1,4 @@
import { LayerLifecycle } from '../../data/lifecycle.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createApplicationCatalog } from '../constructCatalog.js'; import { createApplicationCatalog } from '../constructCatalog.js';
@@ -30,11 +31,6 @@ const retainedCredits = {
}; };
const primitives = ['movers', 'lines', 'ground', 'imagery', 'meshes']; const primitives = ['movers', 'lines', 'ground', 'imagery', 'meshes'];
const expectedReaders = {
'bhote-koshi-2026': ['scene'], // The imagery split control still reads the native scene.
'ais-live-vessels': ['isDestroyed'], // Sprite ordering still accepts the native viewer.
};
function sourceFixtures(calls) { function sourceFixtures(calls) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(CATALOG_SOURCE_CONTRACTS).map(([id, { methods }]) => [ Object.entries(CATALOG_SOURCE_CONTRACTS).map(([id, { methods }]) => [
@@ -274,7 +270,6 @@ test('every catalog layer releases its scene and attaches to another engine', as
const discoveryLifetime = new AbortController(); const discoveryLifetime = new AbortController();
const discovery = buildCatalog(discoveryLifetime, [], new Map()); const discovery = buildCatalog(discoveryLifetime, [], new Map());
t.after(() => discoveryLifetime.abort()); t.after(() => discoveryLifetime.abort());
const seenReaders = [];
const seenCredits = new Set(); const seenCredits = new Set();
for (const { id } of discovery.layers) { for (const { id } of discovery.layers) {
await t.test(id, async (t) => { await t.test(id, async (t) => {
@@ -284,20 +279,23 @@ test('every catalog layer releases its scene and attaches to another engine', as
const visibility = new Map(); const visibility = new Map();
const catalog = buildCatalog(lifetime, calls, visibility); const catalog = buildCatalog(lifetime, calls, visibility);
const layer = catalog.get(id); const layer = catalog.get(id);
assert.doesNotMatch(
layer.attachRenderer.toString(),
/attachRenderer\s*\(\s*(?:viewer|_viewer|nextViewer|_scene|scene)\b/,
'attachRenderer accepts one engine context',
);
const manager = new LayerLifecycle(null);
manager.register(layer);
const entry = manager.layers.get(id);
entry.initialized = true;
const attachedCatalog = {
layers: [layer],
metadata: catalog.metadata.filter((item) => item.id === id),
};
const a = strictEngine(); const a = strictEngine();
const b = strictEngine(); const b = strictEngine();
const beforeA = a.resources(); const beforeA = a.resources();
const beforeB = b.resources(); const beforeB = b.resources();
const reads = new Set();
const poisoned = new Proxy(
{},
{
get(_target, property) {
reads.add(String(property));
throw new Error(`viewer read: ${String(property)}`);
},
},
);
const drive = async (scene) => { const drive = async (scene) => {
scene.engine.fake.emit('camera.moveStart'); scene.engine.fake.emit('camera.moveStart');
scene.engine.fake.emit('camera.changed'); scene.engine.fake.emit('camera.changed');
@@ -320,19 +318,16 @@ test('every catalog layer releases its scene and attaches to another engine', as
].includes(layer.id) ].includes(layer.id)
) )
await layer.init(null); await layer.init(null);
const attach = async (engine, options = {}) => { const attachRenderer = layer.attachRenderer;
try { layer.attachRenderer = function (context) {
await layer.attachRenderer(poisoned, { engine, ...options }); return attachRenderer.call(this, new Proxy(context, {
} catch (error) { get(target, property) {
if ( assert.notEqual(property, 'viewer', 'attachment context must not read a viewer');
!expectedReaders[layer.id]?.includes([...reads].at(-1)) || return Reflect.get(target, property);
!error.message.startsWith('viewer read:') },
) }));
throw error;
await layer.detachRenderer();
await layer.attachRenderer(null, { engine, ...options });
}
}; };
const attach = (engine, options = {}) => manager.bindCatalog(engine, attachedCatalog, { services: sceneServicesFor(engine), ...options });
await attach(a.engine); await attach(a.engine);
await flushLifecycleTasks(t); await flushLifecycleTasks(t);
if (layer.id === 'cctv') if (layer.id === 'cctv')
@@ -342,9 +337,10 @@ test('every catalog layer releases its scene and attaches to another engine', as
'disabled CCTV markers must be hidden after attach', 'disabled CCTV markers must be hidden after attach',
); );
assertHidden(a.engine); assertHidden(a.engine);
entry.enabled = true;
await layer.enable(); await layer.enable();
await drive(a); await drive(a);
await layer.detachRenderer(); await manager.detachScene();
if (retainedCredits[id]) { if (retainedCredits[id]) {
beforeA.credits = [ beforeA.credits = [
{ html: retainedCredits[id].html, onScreen: false }, { html: retainedCredits[id].html, onScreen: false },
@@ -363,7 +359,9 @@ test('every catalog layer releases its scene and attaches to another engine', as
const destroyed = a.snapshot(); const destroyed = a.snapshot();
const sourceCount = calls.length; const sourceCount = calls.length;
const networkCount = environment.network.length; const networkCount = environment.network.length;
await attach(b.engine, { waitForCameraRestore: true }); let restoreView;
const viewRestored = new Promise((resolve) => { restoreView = resolve; });
await attach(b.engine, { viewRestored });
await drive(b); await drive(b);
if (layer.resumeViewport) { if (layer.resumeViewport) {
assert.deepEqual( assert.deepEqual(
@@ -377,17 +375,19 @@ test('every catalog layer releases its scene and attaches to another engine', as
'no transport requests before resumeViewport', 'no transport requests before resumeViewport',
); );
} }
await layer.resumeViewport?.(); restoreView();
await manager.resumeScene();
await drive(b); await drive(b);
if (id === 'local-firms') if (id === 'local-firms')
assert.ok( assert.ok(
calls.slice(sourceCount).some((call) => call.id === 'firms'), calls.slice(sourceCount).some((call) => call.id === 'firms'),
'empty FIRMS cache is fetched after camera restoration', 'empty FIRMS cache is fetched after camera restoration',
); );
entry.enabled = false;
await layer.disable(); await layer.disable();
await drive(b); await drive(b);
assertHidden(b.engine); assertHidden(b.engine);
await layer.detachRenderer(); await manager.detachScene();
if (retainedCredits[id]) if (retainedCredits[id])
beforeB.credits = [ beforeB.credits = [
{ html: retainedCredits[id].html, onScreen: false }, { html: retainedCredits[id].html, onScreen: false },
@@ -404,12 +404,6 @@ test('every catalog layer releases its scene and attaches to another engine', as
); );
assert.deepEqual(a.violations, []); assert.deepEqual(a.violations, []);
assert.deepEqual(b.violations, []); assert.deepEqual(b.violations, []);
assert.deepEqual(
[...reads].sort(),
expectedReaders[layer.id] ?? [],
'viewer reader table must only shrink',
);
if (reads.size) seenReaders.push(id);
} finally { } finally {
lifetime.abort(); lifetime.abort();
for (const entry of catalog.layers) await entry.destroy?.(); for (const entry of catalog.layers) await entry.destroy?.();
@@ -420,11 +414,6 @@ test('every catalog layer releases its scene and attaches to another engine', as
} }
}); });
} }
assert.deepEqual(
seenReaders.sort(),
Object.keys(expectedReaders).sort(),
'remove stale viewer reader entries',
);
assert.deepEqual( assert.deepEqual(
[...seenCredits].sort(), [...seenCredits].sort(),
Object.keys(retainedCredits).sort(), Object.keys(retainedCredits).sort(),
+3 -3
View File
@@ -87,14 +87,14 @@ export async function createApplicationScene({
); );
defer(worldClock.subscribe(() => engine.render.request('world-time'))); defer(worldClock.subscribe(() => engine.render.request('world-time')));
const services = createSceneServices(engine, { const services = createSceneServices(engine, {
retainedSelection: layerSession?.selectionContext, retainedSelection: layerSession?.handoff?.selection,
}); });
if (layerSession) layerSession.selectionContext = null; if (layerSession?.handoff) layerSession.handoff.selection = null;
defer(() => { defer(() => {
const retained = services.destroy({ const retained = services.destroy({
retainSelection: retainsLayerSession(layerSession), retainSelection: retainsLayerSession(layerSession),
}); });
if (layerSession) layerSession.selectionContext = retained; if (layerSession?.handoff) layerSession.handoff.selection = retained;
}); });
if (layerSession) { if (layerSession) {
// DEM cells belong to the session. Mesh samples came from the outgoing // DEM cells belong to the session. Mesh samples came from the outgoing
+34
View File
@@ -0,0 +1,34 @@
/**
* @typedef {object} SessionHandoff
* @property {Map<string, {enabled: boolean, durable: boolean | undefined, visit: boolean}>} layers
* @property {string[]} cameraOwners
* @property {object | null} selection
* @property {import('../scenes/director.js').SceneDirector | null} director
*/
/** Capture session intentions before any outgoing scene owner is released.
* @returns {SessionHandoff}
*/
export function createSessionHandoff(
session,
{ addressLayerIds = null, visit = false } = {},
) {
const listed = addressLayerIds ? new Set(addressLayerIds) : null;
return {
layers: new Map(
[...session.manager.layers].map(([id]) => [
id,
{
enabled: session.manager.isEffectivelyEnabled(id),
durable: listed ? listed.has(id) : undefined,
visit,
},
]),
),
cameraOwners: [...session.manager.layers]
.filter(([, entry]) => entry.module.cameraOwner)
.map(([id]) => id),
selection: null,
director: session.director,
};
}
+14 -4
View File
@@ -1010,6 +1010,7 @@ export function createBhoteKoshiEventLayer({
panelStore = useBhoteKoshiPanelStore(applicationPinia()), panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) { } = {}) {
let _viewer = null; let _viewer = null;
let _attachmentContext = null;
overlayHost = bindOverlayHost(overlayHost, () => _engine); overlayHost = bindOverlayHost(overlayHost, () => _engine);
let _engine = null; let _engine = null;
let _native = null; let _native = null;
@@ -2690,6 +2691,7 @@ export function createBhoteKoshiEventLayer({
function applyCinematicCamera() { function applyCinematicCamera() {
if ( if (
_attachmentContext?.viewReady === false ||
!_cinematicActive || !_cinematicActive ||
!cameraForScene() || !cameraForScene() ||
_cinematicKeyframes.length === 0 _cinematicKeyframes.length === 0
@@ -3538,16 +3540,18 @@ export function createBhoteKoshiEventLayer({
}); });
} }
async function attachRenderer(viewer, { engine = null, signal = null } = {}) { async function attachRenderer(context = {}) {
const { engine = null, signal = null } = context;
_attachmentContext = context;
const generation = ++_attachGeneration; const generation = ++_attachGeneration;
const current = () => const current = () =>
generation === _attachGeneration && signal?.aborted !== true; generation === _attachGeneration && signal?.aborted !== true;
const stack = _mapStackController; const stack = _mapStackController;
_viewer = viewer; _viewer = null;
_engine = engine; _engine = engine;
// The engine's display (src/layers/bhoteKoshi/render/display.js). // The engine's display (src/layers/bhoteKoshi/render/display.js).
_native = createNativeRenderer(engine); _native = createNativeRenderer(engine);
_embeddedMedia?.attachScene?.(viewer, engine); _embeddedMedia?.attachScene?.(null, engine);
if (!_enabled) return; if (!_enabled) return;
// Its enable has loaded the SDK; a restored attachment waits for it too. // Its enable has loaded the SDK; a restored attachment waits for it too.
if (!cesiumSdkReady()) { if (!cesiumSdkReady()) {
@@ -3622,7 +3626,7 @@ export function createBhoteKoshiEventLayer({
} }
async function init(viewer, options) { async function init(viewer, options) {
await attachRenderer(viewer, options); await attachRenderer(options);
_embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine }); _embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine });
await loadEvent(); await loadEvent();
console.log('[Data:BhoteKoshi] Event pack ready'); console.log('[Data:BhoteKoshi] Event pack ready');
@@ -4020,6 +4024,12 @@ export function createBhoteKoshiEventLayer({
updateInterval: 0, updateInterval: 0,
init, init,
attachRenderer, attachRenderer,
cameraOwner: {
suspend() {
_lastCinematicProgress = null;
},
resume: applyCinematicCamera,
},
detachRenderer, detachRenderer,
enable, enable,
disable, disable,
+3 -3
View File
@@ -3070,7 +3070,7 @@ for (const stop of ['detach', 'scene-abort']) {
const gate = new Promise((resolve) => { openGate = resolve; }); const gate = new Promise((resolve) => { openGate = resolve; });
layer.attachMapStackController(stack('photoreal', gate)); layer.attachMapStackController(stack('photoreal', gate));
const scene = new AbortController(); const scene = new AbortController();
const attaching = layer.attachRenderer(second, { signal: scene.signal }); const attaching = layer.attachRenderer({ signal: scene.signal });
let detaching = null; let detaching = null;
if (stop === 'detach') detaching = layer.detachRenderer(); if (stop === 'detach') detaching = layer.detachRenderer();
else scene.abort(); else scene.abort();
@@ -3121,7 +3121,7 @@ test('Bhote Koshi stops waiting for a stalled map switch when its scene is relea
setStack: (id) => (id === 'esri-imagery' ? new Promise(() => {}) : Promise.resolve()), setStack: (id) => (id === 'esri-imagery' ? new Promise(() => {}) : Promise.resolve()),
}); });
const scene = new AbortController(); const scene = new AbortController();
const attaching = layer.attachRenderer(second, { signal: scene.signal }); const attaching = layer.attachRenderer({ signal: scene.signal });
scene.abort(); scene.abort();
await attaching; await attaching;
await layer.detachRenderer(); await layer.detachRenderer();
@@ -3196,7 +3196,7 @@ test('Bhote Koshi republishes its evidence cards to the incoming engine overlay'
'first engine got the cards', 'first engine got the cards',
); );
await layer.detachRenderer(); await layer.detachRenderer();
await layer.attachRenderer(second, { engine: engineB }); await layer.attachRenderer({ engine: engineB });
const b = hostFor(engineB); const b = hostFor(engineB);
assert.ok( assert.ok(
[...b.entries.values()].some((e) => e.length > 0), [...b.entries.values()].some((e) => e.length > 0),
@@ -65,7 +65,7 @@ function locator(engine) {
cancelDelay() {}, cancelDelay() {},
}); });
const scene = engine === 'cesium' ? viewer : null; const scene = engine === 'cesium' ? viewer : null;
layer.attachRenderer(scene, { layer.attachRenderer({
engine: engine === 'navara' ? navara : null, engine: engine === 'navara' ? navara : null,
}); });
return { return {
@@ -175,7 +175,7 @@ test('edge:bhote-koshi-locator: an engine swap while the boundary resolves lands
layer.attachRenderer(viewer); layer.attachRenderer(viewer);
const enabling = layer.enable(viewer); const enabling = layer.enable(viewer);
layer.detachRenderer(); layer.detachRenderer();
layer.attachRenderer(null, { layer.attachRenderer({
engine: { engine: {
kind: 'navara', kind: 'navara',
render: { request() {} }, render: { request() {} },
+15 -5
View File
@@ -1043,22 +1043,27 @@ export function createBhoteKoshiLocatorLayer({
showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
} }
function attachRenderer(viewer, { engine = null } = {}) { function attachRenderer({ engine = null, viewRestored } = {}) {
_viewer = viewer; _viewer = null;
_engine = engine; _engine = engine;
if (!_enabled) return true; if (!_enabled) return true;
connectDisplay(); connectDisplay();
publishOverlayEntries({ moving: _placeRevealMoving }); publishOverlayEntries({ moving: _placeRevealMoving });
if (!viewRestored) resumeCamera();
return true;
}
function resumeCamera() {
if (!_enabled || !_engine) return;
if (_pausedApproach) { if (_pausedApproach) {
const { phase, remaining } = _pausedApproach; const { phase, remaining } = _pausedApproach;
_pausedApproach = null; _pausedApproach = null;
if (phase === 'armed') armTriggerApproach(); if (phase === 'armed') armTriggerApproach();
else flyTriggerStage((_triggerApproachAttempt = {}), phase, remaining); else flyTriggerStage((_triggerApproachAttempt = {}), phase, remaining);
} }
return true;
} }
function detachRenderer() { function suspendCamera() {
const phase = _triggerApproachAttempt?.phase; const phase = _triggerApproachAttempt?.phase;
if (phase && phase !== 'finished') { if (phase && phase !== 'finished') {
_pausedApproach = { _pausedApproach = {
@@ -1067,6 +1072,10 @@ export function createBhoteKoshiLocatorLayer({
}; };
clearTriggerApproach({ preserveCallout: true }); clearTriggerApproach({ preserveCallout: true });
} }
}
function detachRenderer() {
suspendCamera();
_native?.destroy(); _native?.destroy();
_native = null; _native = null;
hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
@@ -1151,8 +1160,9 @@ export function createBhoteKoshiLocatorLayer({
icon: '◎', icon: '◎',
source: 'OpenStreetMap + GeoPera', source: 'OpenStreetMap + GeoPera',
updateInterval: 0, updateInterval: 0,
init: attachRenderer, init: (_viewer, context) => attachRenderer(context),
attachRenderer, attachRenderer,
cameraOwner: { suspend: suspendCamera, resume: resumeCamera },
detachRenderer, detachRenderer,
enable, enable,
disable, disable,
+59 -8
View File
@@ -2,6 +2,21 @@
// A late attachment sees its scene signal aborted and releases its own work. // A late attachment sees its scene signal aborted and releases its own work.
export const ATTACH_SETTLE_TIMEOUT_MS = 5000; export const ATTACH_SETTLE_TIMEOUT_MS = 5000;
/** One readiness gate shared by every layer attached to this scene. */
export function createLayerSceneContext(options = {}) {
let viewReady = !options.viewRestored;
const context = {
...options,
get viewReady() {
return viewReady && options.signal?.aborted !== true;
},
viewRestored: Promise.resolve(options.viewRestored).then(() => {
viewReady = true;
}),
};
return context;
}
/** Scene attachment and parked work for the layer lifecycle. */ /** Scene attachment and parked work for the layer lifecycle. */
export class LayerSceneBinding { export class LayerSceneBinding {
constructor(attachSettleTimeoutMs) { constructor(attachSettleTimeoutMs) {
@@ -12,6 +27,7 @@ export class LayerSceneBinding {
this._sceneController = null; this._sceneController = null;
this._sceneDetached = false; this._sceneDetached = false;
this._pendingAttachments = new Set(); this._pendingAttachments = new Set();
this._viewResumptions = [];
this._attachSettleTimeoutMs = attachSettleTimeoutMs; this._attachSettleTimeoutMs = attachSettleTimeoutMs;
// Lifecycle work and explicit refreshes that wait for the next scene. // Lifecycle work and explicit refreshes that wait for the next scene.
// detachScene() does not wait for parked work, which waits for it. // detachScene() does not wait for parked work, which waits for it.
@@ -92,16 +108,19 @@ export class LayerSceneBinding {
} }
/** Bind the next catalog only after the preceding scene released its entries. */ /** Bind the next catalog only after the preceding scene released its entries. */
bindCatalog(viewer, engine, catalog, options = {}) { bindCatalog(engine, catalog, options = {}) {
if (this._registrationsFinalized) throw new Error('Scene already attached'); if (this._registrationsFinalized) throw new Error('Scene already attached');
this.viewer = viewer;
this.engine = engine ?? null; this.engine = engine ?? null;
this._catalogSourceAvailability = catalog.getSourceAvailability; this._catalogSourceAvailability = catalog.getSourceAvailability;
const scene = new AbortController(); const scene = new AbortController();
this._sceneController = scene; this._sceneController = scene;
// An attachment that is still running when the scene detaches sees this // An attachment that is still running when the scene detaches sees this
// signal aborted and must stop touching the scene. // signal aborted and must stop touching the scene.
const context = { ...this._moduleContext(scene.signal), ...options }; const context = createLayerSceneContext({
...this._moduleContext(scene.signal),
...options,
});
this._viewResumptions = [];
const attachments = []; const attachments = [];
for (const layer of catalog.layers) { for (const layer of catalog.layers) {
const entry = this.layers.get(layer.id); const entry = this.layers.get(layer.id);
@@ -110,7 +129,7 @@ export class LayerSceneBinding {
if (entry.module !== layer) if (entry.module !== layer)
throw new Error(`Layer owner changed: ${layer.id}`); throw new Error(`Layer owner changed: ${layer.id}`);
if (entry.initialized) { if (entry.initialized) {
attachments.push(this._attachEntryRenderer(entry, viewer, context)); attachments.push(this._attachEntryRenderer(entry, context));
} }
} }
} }
@@ -221,7 +240,7 @@ export class LayerSceneBinding {
* the previous scene is retried first. Asynchronous attachments are tracked * the previous scene is retried first. Asynchronous attachments are tracked
* so detachScene() releases them only after they stopped. * so detachScene() releases them only after they stopped.
*/ */
_attachEntryRenderer(entry, viewer, context) { _attachEntryRenderer(entry, context) {
let ready; let ready;
try { try {
const pending = this._failedReleases.get(entry); const pending = this._failedReleases.get(entry);
@@ -232,13 +251,40 @@ export class LayerSceneBinding {
.then(() => { .then(() => {
this._failedReleases.delete(entry); this._failedReleases.delete(entry);
if (context.signal.aborted) return undefined; if (context.signal.aborted) return undefined;
return entry.module.attachRenderer(viewer, context); return entry.module.attachRenderer(context);
}); });
} else ready = entry.module.attachRenderer(viewer, context); } else ready = entry.module.attachRenderer(context);
} catch (error) { } catch (error) {
ready = Promise.reject(error); ready = Promise.reject(error);
} }
if (!ready?.then) return ready; const attached = () => {
if (context.signal.aborted) return;
entry.module.show?.(entry.enabled);
const picks = entry.module.picks;
if (picks) {
const picking = context.services?.picking;
picking?.registerPickOwner(
picks.owner,
(id) => entry.enabled && picks.predicate(id),
);
context.signal.addEventListener(
'abort',
() => picking?.unregisterPickOwner(picks.owner),
{ once: true },
);
}
const resumed = context.viewRestored.then(() => {
if (context.signal.aborted || entry.sceneDetached) return;
if (entry.module.cameraOwner) return entry.module.cameraOwner.resume();
return entry.module.resumeViewport?.();
});
// Resume failures belong to the switch transaction, including when the
// camera settles before the switcher starts awaiting its owners.
resumed.catch(() => {});
this._viewResumptions.push(resumed);
};
if (!ready?.then) return attached();
ready = Promise.resolve(ready).then(attached);
const tracked = Promise.resolve(ready); const tracked = Promise.resolve(ready);
this._pendingAttachments.add(tracked); this._pendingAttachments.add(tracked);
const untrack = () => this._pendingAttachments.delete(tracked); const untrack = () => this._pendingAttachments.delete(tracked);
@@ -246,6 +292,11 @@ export class LayerSceneBinding {
return tracked; return tracked;
} }
/** Await camera owners after the switcher resolves the incoming view. */
resumeScene() {
return Promise.all(this._viewResumptions);
}
/** Run the periodic refreshes that came due while no scene was bound. */ /** Run the periodic refreshes that came due while no scene was bound. */
_runDeferredRefreshes() { _runDeferredRefreshes() {
for (const [layerId, entry] of this.layers) { for (const [layerId, entry] of this.layers) {
+10 -8
View File
@@ -120,7 +120,7 @@ export class LayerStateCoordinator {
start({ start({
shareLayerState = null, shareLayerState = null,
allowLocalState = true, allowLocalState = true,
handoffLayers = null, handoff = null,
shareCreatedAtMs = null, shareCreatedAtMs = null,
} = {}) { } = {}) {
if (this._destroyed) if (this._destroyed)
@@ -156,12 +156,12 @@ export class LayerStateCoordinator {
); );
this.shareLinkManager?.onLayerStateChange?.(); this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState(); this._notifyDurableState();
if (!selected && !handoffLayers) return this.restorePromise; if (!selected && !handoff) return this.restorePromise;
this.restorePromise = this._restoreSelectedState( this.restorePromise = this._restoreSelectedState(
this._source === 'share' this._source === 'share'
? LAYER_RESTORE_ORIGINS.share ? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local, : LAYER_RESTORE_ORIGINS.local,
handoffLayers, handoff,
); );
return this.restorePromise; return this.restorePromise;
} }
@@ -400,7 +400,7 @@ export class LayerStateCoordinator {
: this.restoreGate); : this.restoreGate);
} }
async _restoreSelectedState(origin, handoffLayers = null) { async _restoreSelectedState(origin, handoff = null) {
// One controller set per run: a superseded run must not clear the newer // One controller set per run: a superseded run must not clear the newer
// run's controllers when it settles. // run's controllers when it settles.
const controllers = new Map(); const controllers = new Map();
@@ -414,7 +414,8 @@ export class LayerStateCoordinator {
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => { LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = controllers.get(entry.id); const controller = controllers.get(entry.id);
const live = handoffLayers?.get(entry.id); const live = handoff?.layers.get(entry.id);
const retained = this.dataManager.layers.get(entry.id);
const targetEnabled = handoffTargetEnabled( const targetEnabled = handoffTargetEnabled(
live, live,
enabled.has(entry.id), enabled.has(entry.id),
@@ -447,9 +448,10 @@ export class LayerStateCoordinator {
// apply; a pick keeps the live layer as it is. // apply; a pick keeps the live layer as it is.
if ( if (
live && live &&
this.dataManager.layers.get(entry.id) === live.entry && retained?.module.attachRenderer &&
!live.entry.lifecycleUncertain && retained.initialized &&
live.entry.enabled === targetEnabled !retained.lifecycleUncertain &&
retained.enabled === targetEnabled
) { ) {
const current = this.dataManager.getLayerParams(entry.id); const current = this.dataManager.getLayerParams(entry.id);
const changed = const changed =
+7 -21
View File
@@ -1,3 +1,4 @@
import { createSessionHandoff } from '../app/sessionHandoff.js';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { DataLayerManager } from './manager.js'; import { DataLayerManager } from './manager.js';
@@ -68,31 +69,16 @@ test('same-engine Back to a gfs entry restores gfs (reference)', async () => {
assert.equal(m.getLayerParams('wind').model, 'gfs'); assert.equal(m.getLayerParams('wind').model, 'gfs');
}); });
const handoffOf = (m) => const handoffOf = (manager, visit = false) =>
new Map( createSessionHandoff({ manager, director: null }, { addressLayerIds: ['wind'], visit });
[...m.layers].map(([id, entry]) => [
id,
{ entry, enabled: m.isEffectivelyEnabled(id), durable: id === 'wind' },
]),
);
test('cross-engine Back to a gfs entry restores gfs', async () => { test('cross-engine Back to a gfs entry restores gfs', async () => {
const m = manager(); const m = manager();
await m.setEnabled('wind', true, { origin: 'user' }); await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const handoffLayers = new Map( const handoff = handoffOf(m, true);
[...m.layers].map(([id, entry]) => [
id,
{
entry,
enabled: m.isEffectivelyEnabled(id),
durable: id === 'wind',
visit: true,
},
]),
);
const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await c.start({ shareLayerState: cesiumEntry, handoffLayers }); await c.start({ shareLayerState: cesiumEntry, handoff });
assert.equal(c.getDurableState().options.wind.model, 'gfs'); assert.equal(c.getDurableState().options.wind.model, 'gfs');
assert.equal( assert.equal(
m.getLayerParams('wind').model, m.getLayerParams('wind').model,
@@ -107,7 +93,7 @@ test('a picked engine keeps the live layer options, not the address', async () =
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' }); m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const writes = windParamWrites; const writes = windParamWrites;
const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
await c.start({ shareLayerState: cesiumEntry, handoffLayers: handoffOf(m) }); await c.start({ shareLayerState: cesiumEntry, handoff: handoffOf(m) });
assert.equal(windParamWrites, writes, 'a pick writes no params'); assert.equal(windParamWrites, writes, 'a pick writes no params');
assert.equal(m.getLayerParams('wind').model, 'ifs'); assert.equal(m.getLayerParams('wind').model, 'ifs');
}); });
@@ -120,7 +106,7 @@ test('an engine switch leaves a retained layer whose options match alone', async
const c = new LayerStateCoordinator(m, sink(), { storage: storage() }); const c = new LayerStateCoordinator(m, sink(), { storage: storage() });
const results = await c.start({ const results = await c.start({
shareLayerState: cesiumEntry, shareLayerState: cesiumEntry,
handoffLayers: handoffOf(m), handoff: handoffOf(m),
}); });
assert.equal(windParamWrites, writes, 'no params write for equal options'); assert.equal(windParamWrites, writes, 'no params write for equal options');
const wind = results.find((result) => result.layerId === 'wind'); const wind = results.find((result) => result.layerId === 'wind');
+69 -33
View File
@@ -13,12 +13,12 @@ function sceneLayer(id, hooks = {}) {
source: 'test', source: 'test',
init(viewer, context) { init(viewer, context) {
calls.push('init'); calls.push('init');
module.attachRenderer(viewer, context); module.attachRenderer(context);
return true; return true;
}, },
attachRenderer(viewer, context) { attachRenderer(context) {
calls.push(['attach', viewer]); calls.push(['attach', context.engine]);
return hooks.attach?.(viewer, context); return hooks.attach?.(context.engine?.name === 'second' ? secondViewer : firstViewer, context);
}, },
detachRenderer(viewer) { detachRenderer(viewer) {
calls.push(['detach', viewer]); calls.push(['detach', viewer]);
@@ -50,6 +50,11 @@ const engine = (name) => ({ kind: 'test', name });
const firstViewer = { name: 'first viewer' }; const firstViewer = { name: 'first viewer' };
const secondViewer = { name: 'second viewer' }; const secondViewer = { name: 'second viewer' };
function bindCatalog(manager, engine, catalog, options) {
manager.viewer = engine?.name === 'second' ? secondViewer : firstViewer;
return manager.bindCatalog(engine, catalog, options);
}
async function boundManager(...layers) { async function boundManager(...layers) {
const options = layers.at(-1)?.module ? {} : layers.pop(); const options = layers.at(-1)?.module ? {} : layers.pop();
const manager = new LayerLifecycle(firstViewer, { const manager = new LayerLifecycle(firstViewer, {
@@ -57,7 +62,7 @@ async function boundManager(...layers) {
...options, ...options,
}); });
const modules = layers.map(({ module }) => module); const modules = layers.map(({ module }) => module);
await manager.bindCatalog(firstViewer, engine('first'), catalog(...modules)); await bindCatalog(manager, engine('first'), catalog(...modules));
for (const { module } of layers) for (const { module } of layers)
assert.equal( assert.equal(
await manager.setEnabled(module.id, true, { origin: 'user' }), await manager.setEnabled(module.id, true, { origin: 'user' }),
@@ -115,14 +120,14 @@ test('one failing detachRenderer leaves the others released and the manager unbo
assert.equal(detaches(after.calls).length, 1); assert.equal(detaches(after.calls).length, 1);
// The next scene binds and both retained layers attach to it. // The next scene binds and both retained layers attach to it.
await manager.bindCatalog( await bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
assert.equal(manager.viewer, secondViewer); assert.equal(manager.viewer, secondViewer);
assert.deepEqual(attaches(broken.calls).at(-1), ['attach', secondViewer]); assert.deepEqual(attaches(broken.calls).at(-1), ['attach', engine('second')]);
assert.deepEqual(attaches(after.calls).at(-1), ['attach', secondViewer]); assert.deepEqual(attaches(after.calls).at(-1), ['attach', engine('second')]);
assert.equal(manager.isEnabled('broken'), true); assert.equal(manager.isEnabled('broken'), true);
}); });
@@ -139,15 +144,15 @@ test('bindCatalog after a failed detach retries the release before attaching', a
AggregateError, AggregateError,
); );
await manager.bindCatalog( await bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
assert.deepEqual(broken.calls.slice(-3), [ assert.deepEqual(broken.calls.slice(-3), [
['detach', firstViewer], ['detach', firstViewer],
['detach', firstViewer], ['detach', firstViewer],
['attach', secondViewer], ['attach', engine('second')],
]); ]);
// The scene is settled: the next detach releases the second viewer once. // The scene is settled: the next detach releases the second viewer once.
await manager.detachScene(); await manager.detachScene();
@@ -180,8 +185,8 @@ test('an asynchronous attach detached mid-flight stops before the release', asyn
const { manager, modules } = await boundManager(slow); const { manager, modules } = await boundManager(slow);
await manager.detachScene(); await manager.detachScene();
const bound = manager.bindCatalog( const bound = bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
@@ -218,8 +223,8 @@ test('periodic refreshes due while no scene is bound run after the next bind', a
assert.equal(updates(), before, 'no refresh against a detached scene'); assert.equal(updates(), before, 'no refresh against a detached scene');
assert.equal(entry.refreshDeferred, true); assert.equal(entry.refreshDeferred, true);
await manager.bindCatalog( await bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
@@ -245,8 +250,8 @@ test('a legacy layer that cannot be destroyed is dropped so the next catalog bin
}; };
const retained = sceneLayer('retained'); const retained = sceneLayer('retained');
const manager = new LayerLifecycle(firstViewer, { engine: engine('first') }); const manager = new LayerLifecycle(firstViewer, { engine: engine('first') });
await manager.bindCatalog( await bindCatalog(
firstViewer, manager,
engine('first'), engine('first'),
catalog(legacy, retained.module), catalog(legacy, retained.module),
); );
@@ -263,8 +268,8 @@ test('a legacy layer that cannot be destroyed is dropped so the next catalog bin
assert.deepEqual(detaches(retained.calls), [['detach', firstViewer]]); assert.deepEqual(detaches(retained.calls), [['detach', firstViewer]]);
const fresh = { ...legacy, disable: () => true }; const fresh = { ...legacy, disable: () => true };
await manager.bindCatalog( await bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(fresh, retained.module), catalog(fresh, retained.module),
); );
@@ -277,8 +282,8 @@ test('QA layers are reported when a scene switch drops them', async () => {
engine: engine('first'), engine: engine('first'),
allowQaRegistration: true, allowQaRegistration: true,
}); });
await manager.bindCatalog( await bindCatalog(
firstViewer, manager,
engine('first'), engine('first'),
catalog(retained.module), catalog(retained.module),
); );
@@ -322,7 +327,7 @@ test('a detach does not wait forever for an attachment that never settles', asyn
}); });
await manager.detachScene(); await manager.detachScene();
const bound = quietly(() => const bound = quietly(() =>
manager.bindCatalog(secondViewer, engine('second'), catalog(...modules)), bindCatalog(manager, engine('second'), catalog(...modules)),
); );
await bound; await bound;
assert.equal(manager.viewer, secondViewer, 'the bind went on without it'); assert.equal(manager.viewer, secondViewer, 'the bind went on without it');
@@ -345,8 +350,8 @@ test('a refresh due while attachments run is deferred once, not run twice', asyn
const entry = manager.layers.get('feed'); const entry = manager.layers.get('feed');
const before = updatesOf(feed.calls).length; const before = updatesOf(feed.calls).length;
await manager.detachScene(); await manager.detachScene();
const bound = manager.bindCatalog( const bound = bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
@@ -372,8 +377,8 @@ test('a normal refresh clears a deferred one', async () => {
assert.equal(entry.refreshDeferred, false); assert.equal(entry.refreshDeferred, false);
manager._sceneDetached = true; manager._sceneDetached = true;
const before = updatesOf(feed.calls).length; const before = updatesOf(feed.calls).length;
await manager.bindCatalog( await bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
@@ -391,8 +396,8 @@ test('an explicit refresh between scenes waits for the next one and runs once',
const refreshed = manager.refreshLayer('feed'); const refreshed = manager.refreshLayer('feed');
await tick(); await tick();
assert.equal(updatesOf(feed.calls).length, before); assert.equal(updatesOf(feed.calls).length, before);
await manager.bindCatalog( await bindCatalog(
secondViewer, manager,
engine('second'), engine('second'),
catalog(...modules), catalog(...modules),
); );
@@ -424,9 +429,9 @@ test('a layer enabled between scenes initializes on the next scene', async () =>
const enabled = manager.setEnabled('late', true, { origin: 'user' }); const enabled = manager.setEnabled('late', true, { origin: 'user' });
await tick(); await tick();
assert.deepEqual(late.calls, [], 'no init against a missing scene'); assert.deepEqual(late.calls, [], 'no init against a missing scene');
await manager.bindCatalog(secondViewer, engine('second'), catalog(...all)); await bindCatalog(manager, engine('second'), catalog(...all));
assert.equal(await enabled, true); assert.equal(await enabled, true);
assert.deepEqual(late.calls.slice(0, 2), ['init', ['attach', secondViewer]]); assert.deepEqual(late.calls.slice(0, 2), ['init', ['attach', engine('second')]]);
}); });
test('a detach does not deadlock on an enable parked for the next scene', async () => { test('a detach does not deadlock on an enable parked for the next scene', async () => {
@@ -442,10 +447,10 @@ test('a detach does not deadlock on an enable parked for the next scene', async
const other = sceneLayer('other'); const other = sceneLayer('other');
const manager = new LayerLifecycle(firstViewer, { engine: engine('first') }); const manager = new LayerLifecycle(firstViewer, { engine: engine('first') });
const modules = [slow.module, other.module]; const modules = [slow.module, other.module];
await manager.bindCatalog(firstViewer, engine('first'), catalog(...modules)); await bindCatalog(manager, engine('first'), catalog(...modules));
await manager.setEnabled('slow', true, { origin: 'user' }); await manager.setEnabled('slow', true, { origin: 'user' });
await manager.detachScene(); await manager.detachScene();
void manager.bindCatalog(secondViewer, engine('second'), catalog(...modules)); void bindCatalog(manager, engine('second'), catalog(...modules));
// Enabled while the second scene is still attaching: parked. // Enabled while the second scene is still attaching: parked.
const enabled = manager.setEnabled('other', true, { origin: 'user' }); const enabled = manager.setEnabled('other', true, { origin: 'user' });
await tick(); await tick();
@@ -457,3 +462,34 @@ test('a detach does not deadlock on an enable parked for the next scene', async
assert.equal(await manager.destroyLayer('other'), true); assert.equal(await manager.destroyLayer('other'), true);
assert.equal(await enabled, false); assert.equal(await enabled, false);
}); });
test('the binding applies visibility and pick declarations and cancels a detached resume', async () => {
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
const { sceneServicesFor } = await import('./sceneServices.js');
const globe = createFakeEngine();
const services = sceneServicesFor(globe);
const calls = [];
let restore;
const viewRestored = new Promise((resolve) => { restore = resolve; });
const layer = sceneLayer('declared').module;
layer.show = (enabled) => calls.push(['show', enabled]);
layer.picks = { owner: 'declared', predicate: (id) => id === 'marker' };
layer.cameraOwner = { suspend() {}, resume: () => calls.push('resume') };
const manager = new LayerLifecycle(null);
manager.register(layer);
const entry = manager.layers.get(layer.id);
entry.initialized = true;
await manager.bindCatalog(globe, catalog(layer), { viewRestored, services });
assert.deepEqual(calls, [['show', false]]);
assert.equal(services.picking.findPickOwner('marker'), null);
entry.enabled = true;
assert.equal(services.picking.findPickOwner('marker'), 'declared');
await manager.detachScene();
restore();
await manager.resumeScene();
assert.deepEqual(calls, [['show', false]]);
assert.equal(services.picking.owners().includes('declared'), false);
await manager.destroyAll();
services.destroy();
globe.destroy();
});
+2 -2
View File
@@ -125,10 +125,10 @@ export function createOsmOverlayLayer(overlay) {
source: overlay.source, source: overlay.source,
updateInterval: 0, updateInterval: 0,
init(viewer, options) { init(viewer, options) {
this.attachRenderer(viewer, options); this.attachRenderer(options);
}, },
/** Drape on a globe engine's imagery. */ /** Drape on a globe engine's imagery. */
attachRenderer(_viewer, { engine: nextEngine = null } = {}) { attachRenderer({ engine: nextEngine = null } = {}) {
release(); release();
engine = nextEngine; engine = nextEngine;
sync(); sync();
+2 -2
View File
@@ -108,7 +108,7 @@ test('destroy and a renderer detach remove the owned imagery', () => {
layer.enable(); layer.enable();
layer.detachRenderer(); layer.detachRenderer();
assert.equal(layers().length, 0); assert.equal(layers().length, 0);
layer.attachRenderer(null, { engine }); layer.attachRenderer({ engine });
assert.equal( assert.equal(
layers().length, layers().length,
1, 1,
@@ -140,7 +140,7 @@ test('on Navara an overlay drapes through engine.maps and follows the globe', ()
// An engine switch: the drape leaves with the old globe, comes back on the next. // An engine switch: the drape leaves with the old globe, comes back on the next.
seaMarks.detachRenderer(); seaMarks.detachRenderer();
assert.equal(added[0].removed, true); assert.equal(added[0].removed, true);
seaMarks.attachRenderer(null, { engine: engineWith() }); seaMarks.attachRenderer({ engine: engineWith() });
assert.equal(added.length, 2); assert.equal(added.length, 2);
assert.equal(added[1].removed, false); assert.equal(added[1].removed, false);
seaMarks.disable(); seaMarks.disable();
+2 -2
View File
@@ -45,7 +45,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(b.picking.findPickOwner('camera'), null); assert.equal(b.picking.findPickOwner('camera'), null);
layer.detachRenderer(); layer.detachRenderer();
assert.equal(a.picking.findPickOwner('camera'), null); assert.equal(a.picking.findPickOwner('camera'), null);
layer.attachRenderer(null, { engine: second.engine }); layer.attachRenderer({ engine: second.engine });
assert.equal(b.picking.findPickOwner('camera'), 'alpr-cameras'); assert.equal(b.picking.findPickOwner('camera'), 'alpr-cameras');
layer.disable(); layer.disable();
assert.equal(b.picking.findPickOwner('camera'), null); assert.equal(b.picking.findPickOwner('camera'), null);
@@ -185,7 +185,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(b.overlay.getDiagnostics().entryCount, 0); assert.equal(b.overlay.getDiagnostics().entryCount, 0);
layer.detachRenderer(); layer.detachRenderer();
assert.equal(a.overlay.getDiagnostics().entryCount, 0); assert.equal(a.overlay.getDiagnostics().entryCount, 0);
layer.attachRenderer(null, { engine: second.engine }); layer.attachRenderer({ engine: second.engine });
assert.equal(requests, 1); assert.equal(requests, 1);
assert.equal(b.overlay.getDiagnostics().entryCount, 1); assert.equal(b.overlay.getDiagnostics().entryCount, 1);
a.destroy(); a.destroy();
+25 -8
View File
@@ -1,3 +1,4 @@
import { createLayerSceneContext } from './layerSceneBinding.js';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { createApplicationScene } from '../app/scene.js'; import { createApplicationScene } from '../app/scene.js';
import { createLayerSession } from '../app/layerSession.js'; import { createLayerSession } from '../app/layerSession.js';
@@ -275,7 +276,12 @@ for (const makeHarness of GROUND_HARNESSES) {
}, },
}, },
}; };
session.handoffLayers = new Map(); session.handoff = {
layers: new Map(),
cameraOwners: [],
selection: null,
director: null,
};
const cleanups = []; const cleanups = [];
const open = (engine) => const open = (engine) =>
createApplicationScene({ createApplicationScene({
@@ -297,10 +303,10 @@ for (const makeHarness of GROUND_HARNESSES) {
const outgoing = await open(first.engine); const outgoing = await open(first.engine);
const record = select(outgoing.services.context, subject()); const record = select(outgoing.services.context, subject());
cleanups.pop()(); cleanups.pop()();
assert.equal(session.selectionContext, record); assert.equal(session.handoff.selection, record);
assert.equal(getSelectedEntityContext(), record); assert.equal(getSelectedEntityContext(), record);
const incoming = await open(second.engine); const incoming = await open(second.engine);
assert.equal(session.selectionContext, null); assert.equal(session.handoff.selection, null);
assert.equal(incoming.services.context.getSelectedEntityContext(), record); assert.equal(incoming.services.context.getSelectedEntityContext(), record);
assert.equal(getSelectedEntityContext(), record); assert.equal(getSelectedEntityContext(), record);
session.lifetime.abort(); session.lifetime.abort();
@@ -451,7 +457,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(hosts[0].last().title, 'LOCAL2'); assert.equal(hosts[0].last().title, 'LOCAL2');
layer.detachRenderer(); layer.detachRenderer();
assert.equal(a.trackedReadout.activeId(), null); assert.equal(a.trackedReadout.activeId(), null);
await layer.attachRenderer(null, { engine: second.engine }); await layer.attachRenderer({ engine: second.engine });
assert.equal(layer.selectById('abc123'), true); assert.equal(layer.selectById('abc123'), true);
assert.equal(b.trackedReadout.activeId(), 'local-adsb:abc123'); assert.equal(b.trackedReadout.activeId(), 'local-adsb:abc123');
assert.equal(a.trackedReadout.activeId(), null); assert.equal(a.trackedReadout.activeId(), null);
@@ -490,7 +496,12 @@ for (const makeHarness of GROUND_HARNESSES) {
}, },
}, },
}; };
session.handoffLayers = new Map(); session.handoff = {
layers: new Map(),
cameraOwners: [],
selection: null,
director: null,
};
const cleanups = []; const cleanups = [];
const open = (engine) => const open = (engine) =>
createApplicationScene({ createApplicationScene({
@@ -564,12 +575,16 @@ for (const makeHarness of GROUND_HARNESSES) {
const selectedAt = window.__gevContextStore.selectedAt; const selectedAt = window.__gevContextStore.selectedAt;
layer.detachRenderer(); layer.detachRenderer();
cleanups.pop()(); cleanups.pop()();
assert.equal(session.selectionContext.id, id); assert.equal(session.handoff.selection.id, id);
const incoming = await open(second.engine); const incoming = await open(second.engine);
layer.attachRenderer(null, { let restoreView;
const attachmentContext = createLayerSceneContext({
engine: second.engine, engine: second.engine,
waitForCameraRestore: true, viewRestored: new Promise((resolve) => {
restoreView = resolve;
}),
}); });
layer.attachRenderer(attachmentContext);
// The shell initializes readouts before restoring the camera and resuming layers. // The shell initializes readouts before restoring the camera and resuming layers.
incoming.services.trackedReadout.init(); incoming.services.trackedReadout.init();
assert.equal(window.__gevContextStore.selectedEntityId, id); assert.equal(window.__gevContextStore.selectedEntityId, id);
@@ -577,6 +592,8 @@ for (const makeHarness of GROUND_HARNESSES) {
second.engine.camera.setView({ second.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(fire.lon, fire.lat, 100_000), destination: geo.cartesian3.fromDegrees(fire.lon, fire.lat, 100_000),
}); });
restoreView();
await attachmentContext.viewRestored;
layer.resumeViewport(); layer.resumeViewport();
assert.equal(layer.getSelectedInfo().id, id); assert.equal(layer.getSelectedInfo().id, id);
assert.equal(incoming.services.context.getSelectedEntityContext().id, id); assert.equal(incoming.services.context.getSelectedEntityContext().id, id);
+7 -3
View File
@@ -72,19 +72,23 @@ export function createAirQualityLayer({
throw new Error('Air quality layer is already initialized'); throw new Error('Air quality layer is already initialized');
initialized = true; initialized = true;
try { try {
if (engine) this.attachRenderer(viewer, { engine }); if (engine) this.attachRenderer({ engine });
} catch (error) { } catch (error) {
this.destroy(); this.destroy();
throw error; throw error;
} }
}, },
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
renderer?.setVisible(enabled);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer) if (!initialized || renderer)
throw new Error('Invalid air quality scene attachment'); throw new Error('Invalid air quality scene attachment');
sceneEngine = engine; sceneEngine = engine;
renderer = rendererFactory({ engine }); renderer = rendererFactory({ engine });
renderer.setVisible(_enabled); this.show(_enabled);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled); overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled);
if (_readings.length) this._render(); if (_readings.length) this._render();
}, },
+1 -1
View File
@@ -280,7 +280,7 @@ test('station data stays queryable and attaches to a fresh scene without polling
assert.equal(await h.layer.update(), true); assert.equal(await h.layer.update(), true);
assert.equal(h.events.length, 0); assert.equal(h.events.length, 0);
assert.equal(h.layer.getAnalystRecords()[0].value, 7); assert.equal(h.layer.getAnalystRecords()[0].value, 7);
h.layer.attachRenderer(null, { engine: h.engine }); h.layer.attachRenderer({ engine: h.engine });
assert.equal(h.events.at(-1)[0], 'air-quality'); assert.equal(h.events.at(-1)[0], 'air-quality');
assert.equal(h.ground.set('air-quality').items.length, 1); assert.equal(h.ground.set('air-quality').items.length, 1);
assert.equal(polls, 1); assert.equal(polls, 1);
+23 -12
View File
@@ -29,16 +29,10 @@ export function createAircraftSceneBinding({
} }
const methods = { const methods = {
attachRenderer(viewer, { engine = null } = {}) { cameraOwner: {
if (!flightState.initialized) suspend() {},
throw new Error('Initialize the flight layer before attaching'); resume() {
if (flightState._renderer) if (!flightState._renderer) return;
throw new Error('Flight renderer already attached');
const renderer = services.renderer.create({ viewer, engine });
flightState._renderer = renderer;
try {
renderer.fleet.attach();
parts.snapshots.presentRecords();
if (flightState._trackedIcao) { if (flightState._trackedIcao) {
parts.tracking._trackFlight(flightState._trackedIcao, { parts.tracking._trackFlight(flightState._trackedIcao, {
origin: resumeOrigin, origin: resumeOrigin,
@@ -46,15 +40,32 @@ export function createAircraftSceneBinding({
follow: resumeFollow, follow: resumeFollow,
}); });
} }
if (flightState.enabled) parts.tracking._applyPendingTrackingRestore();
},
},
show(enabled) {
flightState._renderer?.fleet.setVisible(enabled);
flightState._renderer?.models.setVisible(enabled);
},
attachRenderer({ engine = null, viewRestored } = {}) {
if (!flightState.initialized)
throw new Error('Initialize the flight layer before attaching');
if (flightState._renderer)
throw new Error('Flight renderer already attached');
const renderer = services.renderer.create({ engine });
flightState._renderer = renderer;
try {
renderer.fleet.attach();
parts.snapshots.presentRecords();
renderer.fleet.setVisible(flightState.enabled); renderer.fleet.setVisible(flightState.enabled);
renderer.models.setVisible(flightState.enabled); renderer.models.setVisible(flightState.enabled);
renderer.fleet.restoreOrder(); renderer.fleet.restoreOrder();
if (!viewRestored) methods.cameraOwner.resume();
if (flightState.enabled) { if (flightState.enabled) {
enable(); enable();
parts.tracking._applyPendingTrackingRestore();
} }
} catch (error) { } catch (error) {
methods.detachRenderer(viewer); methods.detachRenderer();
throw error; throw error;
} }
}, },
+22 -22
View File
@@ -69,7 +69,7 @@ export function createAlprCamerasLayer({
}; };
let renderer = null; let renderer = null;
let sceneEngine = null; let sceneEngine = null;
let restoringView = false; let sceneContext = null;
function setAlprStatus(status, error = null) { function setAlprStatus(status, error = null) {
if (state.status === status && state.error === error) return; if (state.status === status && state.error === error) return;
@@ -114,7 +114,8 @@ export function createAlprCamerasLayer({
} }
function scheduleLoad() { function scheduleLoad() {
if (!state.enabled || !renderer || restoringView) return; if (!state.enabled || !renderer || sceneContext?.viewReady === false)
return;
clearUnavailableRetry({ resetBackoff: false }); clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.debounceTimer); clearTimeout(state.debounceTimer);
// A view the accepted tiles already cover is a local filter: show it now. // A view the accepted tiles already cover is a local filter: show it now.
@@ -132,7 +133,8 @@ export function createAlprCamerasLayer({
} }
async function loadCameras() { async function loadCameras() {
if (!state.enabled || !renderer?.ready || restoringView) return; if (!state.enabled || !renderer?.ready || sceneContext?.viewReady === false)
return;
clearTimeout(state.debounceTimer); clearTimeout(state.debounceTimer);
state.debounceTimer = null; state.debounceTimer = null;
clearUnavailableRetry({ resetBackoff: false }); clearUnavailableRetry({ resetBackoff: false });
@@ -199,7 +201,7 @@ export function createAlprCamerasLayer({
state.lastQueryBox = null; state.lastQueryBox = null;
state.lastQueryZoom = null; state.lastQueryZoom = null;
clearUnavailableRetry(); clearUnavailableRetry();
if (!restoringView) renderer?.renderRecords(); if (sceneContext?.viewReady !== false) renderer?.renderRecords();
setAlprStatus('zoom-in'); setAlprStatus('zoom-in');
return; return;
} }
@@ -232,7 +234,7 @@ export function createAlprCamerasLayer({
clearUnavailableRetry(); clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also // Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness. // hit the cap must still warn about coverage, not just about freshness.
if (!restoringView) renderer?.renderRecords(); if (sceneContext?.viewReady !== false) renderer?.renderRecords();
setAlprStatus( setAlprStatus(
(renderer?.count() ?? records.length) (renderer?.count() ?? records.length)
? stale ? stale
@@ -265,6 +267,10 @@ export function createAlprCamerasLayer({
} }
} }
const picks = {
owner: LAYER_ID,
predicate: (id) => state.recordById.has(id),
};
const alprCamerasLayer = { const alprCamerasLayer = {
id: LAYER_ID, id: LAYER_ID,
name: 'ALPR Cameras', name: 'ALPR Cameras',
@@ -274,10 +280,14 @@ export function createAlprCamerasLayer({
updateInterval: 0, updateInterval: 0,
statsRefreshInterval: 1000, statsRefreshInterval: 1000,
init(viewer, options) { init(viewer, options) {
this.attachRenderer(viewer, options); this.attachRenderer(options);
}, },
attachRenderer(viewer, options = {}) { show(enabled) {
restoringView = Boolean(options.waitForCameraRestore); renderer?.setVisible(enabled);
},
picks,
attachRenderer(options = {}) {
sceneContext = options;
if (renderer?.ready) throw new Error('ALPR layer is already initialized'); if (renderer?.ready) throw new Error('ALPR layer is already initialized');
if (typeof rendererFactory !== 'function') if (typeof rendererFactory !== 'function')
throw new TypeError('An ALPR renderer factory is required'); throw new TypeError('An ALPR renderer factory is required');
@@ -290,26 +300,20 @@ export function createAlprCamerasLayer({
}); });
renderer.init({ onMove: scheduleLoad }); renderer.init({ onMove: scheduleLoad });
if (state.enabled) { if (state.enabled) {
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, sceneEngine);
LAYER_ID,
(id) => state.recordById.has(id),
sceneEngine,
);
renderer.setVisible(true); renderer.setVisible(true);
if (!restoringView) renderer.renderRecords(); if (sceneContext?.viewReady !== false) renderer.renderRecords();
} }
}, },
detachRenderer() { detachRenderer() {
unregisterPickOwner(LAYER_ID, sceneEngine); unregisterPickOwner(LAYER_ID, sceneEngine);
restoringView = true; sceneContext = null;
clearTimeout(state.debounceTimer); clearTimeout(state.debounceTimer);
renderer?.destroy({ retainContext: true }); renderer?.destroy({ retainContext: true });
renderer = null; renderer = null;
sceneEngine = null; sceneEngine = null;
}, },
resumeViewport() { resumeViewport() {
if (!restoringView) return;
restoringView = false;
if (state.enabled && renderer) { if (state.enabled && renderer) {
renderer.renderRecords(); renderer.renderRecords();
if (!state.loading) scheduleLoad(); if (!state.loading) scheduleLoad();
@@ -318,11 +322,7 @@ export function createAlprCamerasLayer({
enable() { enable() {
if (state.enabled) return; if (state.enabled) return;
state.enabled = true; state.enabled = true;
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, sceneEngine);
LAYER_ID,
(id) => state.recordById.has(id),
sceneEngine,
);
renderer.setVisible(true); renderer.setVisible(true);
// DataLayerManager calls update() right after enable(); it owns the first fetch. // DataLayerManager calls update() right after enable(); it owns the first fetch.
}, },
+2 -2
View File
@@ -278,10 +278,10 @@ export function createLifecycle({
parts.subject.clearAwarenessSubject(); parts.subject.clearAwarenessSubject();
} }
}; };
if (viewer || engine) this.attachRenderer(viewer, { engine }); if (viewer || engine) this.attachRenderer({ engine });
}, },
attachRenderer(_scene, { engine = null } = {}) { attachRenderer({ engine = null } = {}) {
detachRuntimeListeners(); detachRuntimeListeners();
layerState.display?.destroy?.(); layerState.display?.destroy?.();
// The engine's display from the registry (render/display.js). // The engine's display from the registry (render/display.js).
+1 -1
View File
@@ -81,7 +81,7 @@ function fixture() {
switchEngine() { switchEngine() {
layer.detachRenderer(); layer.detachRenderer();
engine = makeEngine(); engine = makeEngine();
layer.attachRenderer(null, { engine }); layer.attachRenderer({ engine });
}, },
card: () => entries.get('bikeshare-selected')?.[0] ?? null, card: () => entries.get('bikeshare-selected')?.[0] ?? null,
click(key) { click(key) {
+8 -6
View File
@@ -24,9 +24,13 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
let initialized = false; let initialized = false;
const lifecycle = { const lifecycle = {
init(viewer, options) { init(viewer, options) {
return this.attachRenderer(viewer, options); return this.attachRenderer(options);
}, },
attachRenderer(viewer, options = {}) { picks: {
owner: 'bikeshare',
predicate: (id) => state._stationRenderMap.has(id),
},
attachRenderer(options = {}) {
if (initialized) if (initialized)
throw new Error('Bikeshare layer is already initialized'); throw new Error('Bikeshare layer is already initialized');
renderer ??= rendererFactory({ renderer ??= rendererFactory({
@@ -37,7 +41,7 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
parts.display = renderer; parts.display = renderer;
initialized = true; initialized = true;
try { try {
state._restoringViewport = Boolean(options.waitForCameraRestore); state._sceneContext = options;
renderer.init(); renderer.init();
if (state._enabled) { if (state._enabled) {
const selected = state._selectedKey; const selected = state._selectedKey;
@@ -57,13 +61,11 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
} }
}, },
resumeViewport() { resumeViewport() {
if (!state._restoringViewport) return;
state._restoringViewport = false;
if (state._enabled) parts.display?.onCameraChanged(); if (state._enabled) parts.display?.onCameraChanged();
}, },
detachRenderer() { detachRenderer() {
if (!renderer) return; if (!renderer) return;
state._restoringViewport = true; state._sceneContext = null;
clearTimeout(state._cameraDebounceTimer); clearTimeout(state._cameraDebounceTimer);
const selected = state._selectedKey; const selected = state._selectedKey;
renderer.detach(); renderer.detach();
+4 -2
View File
@@ -165,7 +165,8 @@ export function createViewport({ state: layerState, services, parts, camera }) {
*/ */
async function runProximityCheck() { async function runProximityCheck() {
if (!layerState._enabled || layerState._restoringViewport) return; if (!layerState._enabled || layerState._sceneContext?.viewReady === false)
return;
const generation = ++layerState._proximityGeneration; const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude(); const altitude = getCameraAltitude();
@@ -236,7 +237,8 @@ export function createViewport({ state: layerState, services, parts, camera }) {
/** Camera change event handler — triggers a debounced proximity check. */ /** Camera change event handler — triggers a debounced proximity check. */
function onCameraChanged() { function onCameraChanged() {
if (!layerState._enabled || layerState._restoringViewport) return; if (!layerState._enabled || layerState._sceneContext?.viewReady === false)
return;
scheduleProximityCheck(); scheduleProximityCheck();
} }
return { return {
+1 -1
View File
@@ -37,7 +37,7 @@ test('disabled CCTV markers stay hidden after an engine switch', () => {
}); });
try { try {
cctvLayer.detachRenderer(); cctvLayer.detachRenderer();
cctvLayer.attachRenderer(null, { engine: second }); cctvLayer.attachRenderer({ engine: second });
second.fake.frames(2); second.fake.frames(2);
const set = second.fake.moverSet('cctv'); const set = second.fake.moverSet('cctv');
assert.ok(set, 'marker set exists'); assert.ok(set, 'marker set exists');
+31 -27
View File
@@ -32,7 +32,8 @@ export function createLifecycle({
* set changed since they were last told, including by motion passes. * set changed since they were last told, including by motion passes.
*/ */
function settleView() { function settleView() {
if (layerState._restoringViewport || !parts.renderer?.ready) return; if (layerState._sceneContext?.viewReady === false || !parts.renderer?.ready)
return;
const changed = parts.rendering.refreshHorizonCulling(); const changed = parts.rendering.refreshHorizonCulling();
noteViewPose(); noteViewPose();
parts.geometryQueue.enqueueViewGeometry(); parts.geometryQueue.enqueueViewGeometry();
@@ -242,7 +243,24 @@ export function createLifecycle({
throw error; throw error;
} }
} }
const picks = {
owner: 'cctv',
predicate: (pickedId) => {
if (layerState._recordById.has(pickedId)) return true;
if (typeof pickedId === 'string' && pickedId.startsWith(GIZMO_ID_PREFIX))
return true;
const coverage =
/^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec(
pickedId,
);
return Boolean(coverage && layerState._recordById.has(coverage[1]));
},
};
const methods = { const methods = {
cameraOwner: {
suspend: () => {},
resume: () => methods.resumeViewport(),
},
/** /**
* Initializes the CCTV layer: loads camera sources, builds the catalog, * Initializes the CCTV layer: loads camera sources, builds the catalog,
* restores calibration from localStorage, creates billboards, sets up click * restores calibration from localStorage, creates billboards, sets up click
@@ -421,9 +439,13 @@ export function createLifecycle({
} }
}, },
attachRenderer(viewer, options = {}) { show(enabled) {
const renderer = createRenderer(viewer, options); parts.renderer?.setMarkersVisible(enabled);
layerState._restoringViewport = Boolean(options.waitForCameraRestore); },
picks,
attachRenderer(options = {}) {
const renderer = createRenderer(null, options);
layerState._sceneContext = options;
renderer.createMarkers(); renderer.createMarkers();
const active = parts.selection.getActiveRecord(); const active = parts.selection.getActiveRecord();
for (const record of layerState._records) { for (const record of layerState._records) {
@@ -440,15 +462,13 @@ export function createLifecycle({
record === active && record.activationDone === true; record === active && record.activationDone === true;
record.activationDone = false; record.activationDone = false;
} }
renderer.setMarkersVisible(layerState._enabled); this.show(layerState._enabled);
bindScene(viewer, options); bindScene(null, options);
if (!layerState._restoringViewport && layerState._enabled) if (layerState._sceneContext?.viewReady !== false && layerState._enabled)
this.resumeDisplay(); this.resumeDisplay();
}, },
resumeViewport() { resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && parts.renderer) this.resumeDisplay(); if (layerState._enabled && parts.renderer) this.resumeDisplay();
}, },
@@ -467,7 +487,7 @@ export function createLifecycle({
detachRenderer() { detachRenderer() {
if (!parts.renderer) return; if (!parts.renderer) return;
layerState._restoringViewport = true; layerState._sceneContext = null;
parts.renderer.hideCredit(); parts.renderer.hideCredit();
unregisterPickOwner('cctv', parts.renderer?.engine); unregisterPickOwner('cctv', parts.renderer?.engine);
if (layerState._mapStackListener) { if (layerState._mapStackListener) {
@@ -536,23 +556,7 @@ export function createLifecycle({
parts.renderer?.showCredit(); parts.renderer?.showCredit();
// Pick-ownership (H2): camera billboards use the camera id directly; // Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids. // coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, parts.renderer?.engine);
'cctv',
(pickedId) => {
if (layerState._recordById.has(pickedId)) return true;
if (
typeof pickedId === 'string' &&
pickedId.startsWith(GIZMO_ID_PREFIX)
)
return true;
const coverage =
/^cctv-(.+)-(?:ray-tl|ray-tr|ray-br|ray-bl|cap|plane|plane-label)$/.exec(
pickedId,
);
return Boolean(coverage && layerState._recordById.has(coverage[1]));
},
parts.renderer?.engine,
);
const activeRecord = parts.selection.getActiveRecord(); const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) { if (activeRecord) {
parts.projection.ensureProjectionRuntime(activeRecord); parts.projection.ensureProjectionRuntime(activeRecord);
+8 -7
View File
@@ -249,12 +249,12 @@ export function createCyclonesLayer({
owner?.destroy(); owner?.destroy();
} }
function registerOwnership() { function registerOwnership() {
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, engine);
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
engine,
);
} }
const picks = {
owner: 'weather-cyclones',
predicate: (id) => enabled && rendering?.ownsPickId?.(id) === true,
};
const layer = { const layer = {
id: 'weather-cyclones', id: 'weather-cyclones',
name: 'Cyclone advisories', name: 'Cyclone advisories',
@@ -262,9 +262,10 @@ export function createCyclonesLayer({
source: 'NOAA NHC / CPHC', source: 'NOAA NHC / CPHC',
updateInterval: 300_000, updateInterval: 300_000,
init(_viewer, options = {}) { init(_viewer, options = {}) {
if (options.engine) return layer.attachRenderer(_viewer, options); if (options.engine) return layer.attachRenderer(options);
}, },
attachRenderer(_viewer, options = {}) { picks,
attachRenderer(options = {}) {
engine = options.engine ?? null; engine = options.engine ?? null;
rendering = rendering =
engine && createRendering engine && createRendering
+1 -1
View File
@@ -935,7 +935,7 @@ test('advisories delivered between scenes keep a deliberate selection without an
pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }])); pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }]));
await refresh; await refresh;
assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
await layer.attachRenderer(null, { engine: second }); await layer.attachRenderer({ engine: second });
assert.equal( assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', second), isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', second),
true, true,
+2 -2
View File
@@ -163,7 +163,7 @@ test('edge:directions: an engine switch mid-FLY continues the same flight on the
const next = f.makeEngine(); const next = f.makeEngine();
f.layer.suspendCamera(); f.layer.suspendCamera();
f.layer.detachRenderer(); f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next }); f.layer.attachRenderer({ engine: next });
f.layer.resumeCamera(); f.layer.resumeCamera();
// The old engine's camera is not driven any more. // The old engine's camera is not driven any more.
@@ -203,7 +203,7 @@ test('edge:directions: a swap mid-FLY lands the old flight, and the answer arriv
const next = f.makeEngine(); const next = f.makeEngine();
f.layer.suspendCamera(); f.layer.suspendCamera();
f.layer.detachRenderer(); f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next }); f.layer.attachRenderer({ engine: next });
f.layer.resumeCamera(); f.layer.resumeCamera();
f.requests[1].resolve(payload(B, A)); f.requests[1].resolve(payload(B, A));
await settle(); await settle();
+18 -9
View File
@@ -1504,6 +1504,11 @@ export function createDirectionsLayer({
// Layer module // Layer module
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const picks = {
owner: 'directions',
predicate: (pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
};
const directionsLayer = { const directionsLayer = {
id: 'directions', id: 'directions',
name: 'Directions', name: 'Directions',
@@ -1517,12 +1522,17 @@ export function createDirectionsLayer({
* @param {{engine?: object|null}} [options] * @param {{engine?: object|null}} [options]
*/ */
init(_scene, options) { init(_scene, options) {
return this.attachRenderer(_scene, options); return this.attachRenderer(options);
}, },
attachRenderer(_scene, { engine = null } = {}) { show(enabled) {
_display?.show(enabled);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, enabled);
},
picks,
attachRenderer({ engine = null } = {}) {
_engine = engine; _engine = engine;
_display = engine ? rendererFactory(engine) : null; _display = engine ? rendererFactory(engine) : null;
_display?.show(_enabled); this.show(_enabled);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled);
if (!_enabled) return; if (!_enabled) return;
const step = _selectedStep; const step = _selectedStep;
@@ -1532,6 +1542,10 @@ export function createDirectionsLayer({
if (_route) drawRoute(_route); if (_route) drawRoute(_route);
if (step !== null) _selectStep(step); if (step !== null) _selectStep(step);
}, },
cameraOwner: {
suspend: () => directionsLayer.suspendCamera(),
resume: () => directionsLayer.resumeCamera(),
},
suspendCamera() { suspendCamera() {
if (!_flightId || _suspendedFlight) return; if (!_flightId || _suspendedFlight) return;
_suspendedFlight = services.camera.suspendRouteFlight?.(_flightId); _suspendedFlight = services.camera.suspendRouteFlight?.(_flightId);
@@ -1556,12 +1570,7 @@ export function createDirectionsLayer({
_display?.show(true); _display?.show(true);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
_installClickHandler(); _installClickHandler();
services.picking.registerPickOwner( services.picking.registerPickOwner(picks.owner, picks.predicate, _engine);
'directions',
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
_engine,
);
syncRenderHold(); syncRenderHold();
}, },
@@ -315,7 +315,7 @@ test('an engine switch redraws the route, dots and markers on the new engine onl
t.after(() => next.destroy()); t.after(() => next.destroy());
f.layer.suspendCamera(); f.layer.suspendCamera();
f.layer.detachRenderer(); f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next.engine }); f.layer.attachRenderer({ engine: next.engine });
f.layer.resumeCamera(); f.layer.resumeCamera();
assert.equal(f.engine.movers.list().length, 0); assert.equal(f.engine.movers.list().length, 0);
assert.equal(f.sets(), 0); assert.equal(f.sets(), 0);
+7 -3
View File
@@ -163,7 +163,7 @@ export function createDroneAirspaceLayer({
throw new Error('Drone airspace layer is already initialized'); throw new Error('Drone airspace layer is already initialized');
initialized = true; initialized = true;
try { try {
if (engine) this.attachRenderer(viewer, { engine }); if (engine) this.attachRenderer({ engine });
} catch (error) { } catch (error) {
this.destroy(); this.destroy();
throw error; throw error;
@@ -172,12 +172,16 @@ export function createDroneAirspaceLayer({
}, },
/** Draw on a globe: the rows already fetched, and the selection. */ /** Draw on a globe: the rows already fetched, and the selection. */
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
renderer?.setVisible(enabled);
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer) if (!initialized || renderer)
throw new Error('Invalid drone airspace scene attachment'); throw new Error('Invalid drone airspace scene attachment');
sceneEngine = engine; sceneEngine = engine;
renderer = rendererFactory({ engine }); renderer = rendererFactory({ engine });
renderer.setVisible(_enabled); this.show(_enabled);
if (_rows.length) renderer.replace(_rows); if (_rows.length) renderer.replace(_rows);
if (_enabled) { if (_enabled) {
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true); overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
+1 -1
View File
@@ -274,7 +274,7 @@ test('an engine switch keeps the zones and the selected card for the next globe'
renderers[0].click({ position: { x: 1, y: 1 } }); renderers[0].click({ position: { x: 1, y: 1 } });
layer.detachRenderer(); layer.detachRenderer();
assert.equal(renderers[0].destroyed, true); assert.equal(renderers[0].destroyed, true);
layer.attachRenderer(null, { engine: 'navara' }); layer.attachRenderer({ engine: 'navara' });
const [, next] = renderers; const [, next] = renderers;
assert.equal(next.engine, 'navara'); assert.equal(next.engine, 'navara');
assert.equal(next.visible, true); assert.equal(next.visible, true);
+7 -3
View File
@@ -65,7 +65,7 @@ export function createEarthquakesLayer({
throw new Error('Earthquake layer is already initialized'); throw new Error('Earthquake layer is already initialized');
initialized = true; initialized = true;
try { try {
if (engine) this.attachRenderer(viewer, { engine }); if (engine) this.attachRenderer({ engine });
} catch (error) { } catch (error) {
this.destroy(); this.destroy();
throw error; throw error;
@@ -73,13 +73,17 @@ export function createEarthquakesLayer({
console.log('[Data:Earthquakes] Initialized'); console.log('[Data:Earthquakes] Initialized');
}, },
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
_renderer?.setVisible(enabled);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || _renderer) if (!initialized || _renderer)
throw new Error('Invalid earthquake scene attachment'); throw new Error('Invalid earthquake scene attachment');
sceneEngine = engine; sceneEngine = engine;
_renderer = rendererFactory({ engine }); _renderer = rendererFactory({ engine });
if (!_renderer) throw new Error('Earthquake renderer unavailable'); if (!_renderer) throw new Error('Earthquake renderer unavailable');
_renderer.setVisible(_enabled); this.show(_enabled);
if (discs.length) _renderer.replace(discs); if (discs.length) _renderer.replace(discs);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled);
if (_enabled) publishLabels(); if (_enabled) publishLabels();
+1 -1
View File
@@ -101,7 +101,7 @@ test('an observation received between scenes is queryable and draws without a se
assert.equal(record.id, row.usgsId); assert.equal(record.id, row.usgsId);
assert.equal(record.lat, row.lat); assert.equal(record.lat, row.lat);
assert.equal(h.events.length, 0); assert.equal(h.events.length, 0);
h.layer.attachRenderer(null, { engine: h.ground.engine }); h.layer.attachRenderer({ engine: h.ground.engine });
assert.equal(h.ground.set('earthquakes').items.length, 1); assert.equal(h.ground.set('earthquakes').items.length, 1);
assert.equal(h.events.length, 1); assert.equal(h.events.length, 1);
assert.equal(polls, 1); assert.equal(polls, 1);
@@ -180,7 +180,7 @@ for (const makeHarness of GROUND_HARNESSES) {
f.layer.detachRenderer(); f.layer.detachRenderer();
assert.equal(f.sets(), 0); assert.equal(f.sets(), 0);
const next = makeHarness(); const next = makeHarness();
f.layer.attachRenderer(null, { engine: next.engine }); f.layer.attachRenderer({ engine: next.engine });
assert.equal(next.set('earthquakes').items.length, 3); assert.equal(next.set('earthquakes').items.length, 3);
assert.equal(next.set('earthquakes').shown, true); assert.equal(next.set('earthquakes').shown, true);
f.layer.destroy(); f.layer.destroy();
+19 -12
View File
@@ -26,27 +26,36 @@ export function createLifecycle({
layerState._handoffHeat = -1; layerState._handoffHeat = -1;
} }
const picks = {
owner: id,
predicate: (pickedId) => layerState._pickIndexById.has(pickedId),
};
const methods = { const methods = {
init(viewer, options) { init(viewer, options) {
this.attachRenderer(viewer, options); this.attachRenderer(options);
}, },
/** Draws on `engine` (the scene's globe engine); without one, nothing. */ /** Draws on `engine` (the scene's globe engine); without one, nothing. */
attachRenderer( show(enabled) {
_viewer, layerState._renderer?.setVisible(enabled);
{ engine = null, waitForCameraRestore = false } = {}, layerState._heatImagery?.setShow(enabled && layerState._fires.length > 0);
) { overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, enabled);
},
picks,
attachRenderer(context = {}) {
const { engine = null } = context;
layerState._sceneContext = context;
if (layerState._destroyed) return; if (layerState._destroyed) return;
if (layerState._renderer || !engine) return; if (layerState._renderer || !engine) return;
layerState._renderer = config.renderer.create({ engine, id, services }); layerState._renderer = config.renderer.create({ engine, id, services });
if (!layerState._renderer) return; if (!layerState._renderer) return;
layerState._view = layerState._renderer.view; layerState._view = layerState._renderer.view;
layerState._renderer.setVisible(false); layerState._renderer.setVisible(false);
layerState._restoringViewport = waitForCameraRestore; if (layerState._enabled && context.viewReady !== false)
if (layerState._enabled && !waitForCameraRestore) this.showRenderer(); this.showRenderer();
// Enabled before a renderer: the load enable() could not start. // Enabled before a renderer: the load enable() could not start.
if ( if (
layerState._enabled && layerState._enabled &&
!waitForCameraRestore && context.viewReady !== false &&
!layerState._fires.length && !layerState._fires.length &&
!layerState._loading !layerState._loading
) )
@@ -54,8 +63,6 @@ export function createLifecycle({
}, },
resumeViewport() { resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._renderer) { if (layerState._enabled && layerState._renderer) {
this.showRenderer(); this.showRenderer();
if (!layerState._fires.length && !layerState._loading) if (!layerState._fires.length && !layerState._loading)
@@ -98,8 +105,8 @@ export function createLifecycle({
components.viewport.installMoveEndWatcher(); components.viewport.installMoveEndWatcher();
components.selection.installClickHandler(); components.selection.installClickHandler();
registerPickOwner( registerPickOwner(
id, picks.owner,
(pickedId) => layerState._pickIndexById.has(pickedId), picks.predicate,
layerState._renderer?.engine, layerState._renderer?.engine,
); );
components.rendering.renderCurrentLod(true); components.rendering.renderCurrentLod(true);
+1 -1
View File
@@ -332,7 +332,7 @@ export function createQueries({
* renderer of `engine` (attached here when there is none yet). * renderer of `engine` (attached here when there is none yet).
*/ */
_bindInteractionForTest(engine, fires = []) { _bindInteractionForTest(engine, fires = []) {
if (!layerState._renderer) this.attachRenderer(null, { engine }); if (!layerState._renderer) this.attachRenderer({ engine });
layerState._renderer.setVisible(true); layerState._renderer.setVisible(true);
layerState._enabled = true; layerState._enabled = true;
layerState._allFires = fires; layerState._allFires = fires;
+10 -3
View File
@@ -15,8 +15,15 @@ export function createLifecycle({ flightState, services, parts, layer }) {
enable: () => methods.enable(), enable: () => methods.enable(),
}); });
const picks = {
owner: 'flights',
predicate: (pickedId) => flightState._billboards.has(pickedId),
};
const methods = { const methods = {
picks,
attachRenderer: binding.attachRenderer, attachRenderer: binding.attachRenderer,
cameraOwner: binding.cameraOwner,
show: binding.show,
detachRenderer: binding.detachRenderer, detachRenderer: binding.detachRenderer,
/** Configure the source before initialization; an active layer keeps its owner. */ /** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) { setSource(source) {
@@ -93,7 +100,7 @@ export function createLifecycle({ flightState, services, parts, layer }) {
flightState.initialized = true; flightState.initialized = true;
try { try {
if (viewer || engine) methods.attachRenderer(viewer, { engine }); if (viewer || engine) methods.attachRenderer({ engine });
} catch (error) { } catch (error) {
methods.destroy(viewer); methods.destroy(viewer);
throw error; throw error;
@@ -129,8 +136,8 @@ export function createLifecycle({ flightState, services, parts, layer }) {
if (!renderer) return; if (!renderer) return;
parts.tracking._installClickHandler(); parts.tracking._installClickHandler();
registerPickOwner( registerPickOwner(
'flights', picks.owner,
(pickedId) => flightState._billboards.has(pickedId), picks.predicate,
flightState._renderer?.engine, flightState._renderer?.engine,
); );
// Force a fresh rotation pass on the first tick after re-enable // Force a fresh rotation pass on the first tick after re-enable
+2 -2
View File
@@ -238,7 +238,7 @@ test('a live flight keeps observations, selection and freshness across renderer
assert.equal(layer.hasContact('71BE26'), true); assert.equal(layer.hasContact('71BE26'), true);
assert.equal(layer.getAnalystRecords().length, 1); assert.equal(layer.getAnalystRecords().length, 1);
assert.equal(layer.findByQuery('QA').icao24, '71be26'); assert.equal(layer.findByQuery('QA').icao24, '71be26');
layer.attachRenderer(null, { engine: first }); layer.attachRenderer({ engine: first });
assert.equal(layer.qaDrawn().length, 1); assert.equal(layer.qaDrawn().length, 1);
assert.equal(layer.trackById('71be26'), true); assert.equal(layer.trackById('71be26'), true);
layer.detachRenderer(); layer.detachRenderer();
@@ -246,7 +246,7 @@ test('a live flight keeps observations, selection and freshness across renderer
assert.equal(layer.getTrackedInfo().icao24, '71be26'); assert.equal(layer.getTrackedInfo().icao24, '71be26');
await layer.update(null); await layer.update(null);
assert.equal(layer.getStats().lastUpdate, epoch + 2000); assert.equal(layer.getStats().lastUpdate, epoch + 2000);
layer.attachRenderer(null, { engine: second }); layer.attachRenderer({ engine: second });
assert.equal(second.camera.following()?.label.id, 'flights:71be26'); assert.equal(second.camera.following()?.label.id, 'flights:71be26');
assert.equal(layer.qaDrawn().length, 1); assert.equal(layer.qaDrawn().length, 1);
assert.equal(polls, 2, 'attaching either renderer performs no source poll'); assert.equal(polls, 2, 'attaching either renderer performs no source poll');
+8 -4
View File
@@ -70,7 +70,11 @@ test('the flights logic draws, selects and follows only through its renderer', a
militaryRegistry, militaryRegistry,
renderer: { renderer: {
create: ({ viewer, engine: given }) => { create: ({ viewer, engine: given }) => {
assert.equal(viewer, null, 'no viewer on an engine without one'); assert.equal(
viewer,
undefined,
'the renderer receives only the engine',
);
renderer = recordingRenderer(given); renderer = recordingRenderer(given);
return renderer; return renderer;
}, },
@@ -270,19 +274,19 @@ test('a failed engine switch, its rollback and the retry re-follow the tracked a
// The switch: the working engine detaches; the candidate attaches the // The switch: the working engine detaches; the candidate attaches the
// layer, then fails and releases what it attached. // layer, then fails and releases what it attached.
layer.detachRenderer(working.viewer); layer.detachRenderer(working.viewer);
layer.attachRenderer(null, { engine: candidate }); layer.attachRenderer({ engine: candidate });
assert.equal(candidate.camera.following()?.label?.id, 'flights:def456'); assert.equal(candidate.camera.following()?.label?.id, 'flights:def456');
layer.detachRenderer(null); layer.detachRenderer(null);
// The rollback: the working engine attaches again and follows the same // The rollback: the working engine attaches again and follows the same
// aircraft, announced as a restore. // aircraft, announced as a restore.
layer.attachRenderer(working.viewer, { engine: working.engine }); layer.attachRenderer({ engine: working.engine });
assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); assert.equal(layer.getTrackedInfo()?.icao24, 'def456');
assert.equal(working.viewer.trackedEntity?.gevTrackedId, 'flights:def456'); assert.equal(working.viewer.trackedEntity?.gevTrackedId, 'flights:def456');
assert.deepEqual(navigations, [], 'the rollback is not new navigation'); assert.deepEqual(navigations, [], 'the rollback is not new navigation');
// The retry onto a new Cesium engine. // The retry onto a new Cesium engine.
layer.detachRenderer(working.viewer); layer.detachRenderer(working.viewer);
retried = cesium(); retried = cesium();
layer.attachRenderer(retried.viewer, { engine: retried.engine }); layer.attachRenderer({ engine: retried.engine });
assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); assert.equal(layer.getTrackedInfo()?.icao24, 'def456');
assert.equal(retried.viewer.trackedEntity?.gevTrackedId, 'flights:def456'); assert.equal(retried.viewer.trackedEntity?.gevTrackedId, 'flights:def456');
assert.equal( assert.equal(
+2 -2
View File
@@ -158,7 +158,7 @@ test('edge:s2-ghostmaps: an edition and engine change mid-parse shows only the s
await new Promise(setImmediate); await new Promise(setImmediate);
push(catalog('v3')); push(catalog('v3'));
layer.detachRenderer(); layer.detachRenderer();
await layer.attachRenderer(null, { engine: { name: 'navara' } }); await layer.attachRenderer({ engine: { name: 'navara' } });
v2.resolve(kmz('edition two')); v2.resolve(kmz('edition two'));
v3.resolve(kmz('edition three')); v3.resolve(kmz('edition three'));
// Let the queued syncs settle. // Let the queued syncs settle.
@@ -180,7 +180,7 @@ test('edge:s2-ghostmaps: a bad archive keeps the working edition on the globe',
push(catalog('v2')); push(catalog('v2'));
// The engine changes while the broken file is still on its way. // The engine changes while the broken file is still on its way.
layer.detachRenderer(); layer.detachRenderer();
await layer.attachRenderer(null, { engine: { name: 'navara' } }); await layer.attachRenderer({ engine: { name: 'navara' } });
broken.resolve(new Uint8Array([1, 2, 3, 4])); broken.resolve(new Uint8Array([1, 2, 3, 4]));
for (let i = 0; i < 20; i += 1) await new Promise(setImmediate); for (let i = 0; i < 20; i += 1) await new Promise(setImmediate);
+9 -8
View File
@@ -524,12 +524,7 @@ export function createGhostMapsLayer({
function installClickHandler() { function installClickHandler() {
if (!canSelect() || _clickHandler || !_display) return; if (!canSelect() || _clickHandler || !_display) return;
picking.registerPickOwner?.( picking.registerPickOwner?.(picks.owner, picks.predicate, sceneEngine);
GHOSTMAPS_LAYER_ID,
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
sceneEngine,
);
_clickHandler = _display.onClick((click) => { _clickHandler = _display.onClick((click) => {
if (pointer && !pointer.isPointerFree()) return; if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.( const cardHit = overlayHost.hitTest?.(
@@ -576,6 +571,11 @@ export function createGhostMapsLayer({
return count; return count;
} }
const picks = {
owner: GHOSTMAPS_LAYER_ID,
predicate: (pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
};
const layer = { const layer = {
id: GHOSTMAPS_LAYER_ID, id: GHOSTMAPS_LAYER_ID,
name: 'S2 GhostMaps', name: 'S2 GhostMaps',
@@ -586,10 +586,11 @@ export function createGhostMapsLayer({
updateInterval: 300000, updateInterval: 300000,
init(viewer, options = {}) { init(viewer, options = {}) {
if (options.engine) return this.attachRenderer(viewer, options); if (options.engine) return this.attachRenderer(options);
}, },
attachRenderer(_viewer, { engine = null, signal = null } = {}) { picks,
attachRenderer({ engine = null, signal = null } = {}) {
if (_display) throw new Error('GhostMaps display is already attached'); if (_display) throw new Error('GhostMaps display is already attached');
if (typeof createRenderer !== 'function') if (typeof createRenderer !== 'function')
throw new TypeError('GhostMaps needs a renderer factory'); throw new TypeError('GhostMaps needs a renderer factory');
+1 -1
View File
@@ -242,7 +242,7 @@ test('a KMZ delivered between planets is reused by the new scene and releases it
['Test report'], ['Test report'],
); );
assert.equal(h.mounted.size, 0); assert.equal(h.mounted.size, 0);
await layer.attachRenderer(null, { engine: h.engine }); await layer.attachRenderer({ engine: h.engine });
assert.equal(h.mounted.size, 1); assert.equal(h.mounted.size, 1);
assert.equal(h.created.length, 1); assert.equal(h.created.length, 1);
assert.equal(h.revoked.length, 0); assert.equal(h.revoked.length, 0);
+1 -1
View File
@@ -137,7 +137,7 @@ for (const action of ['expire', 'disable']) {
// Engine switch: the new drawing is still decoding its icons. // Engine switch: the new drawing is still decoding its icons.
layer.detachRenderer(); layer.detachRenderer();
const attached = layer.attachRenderer(null, { engine: { name: 'navara' } }); const attached = layer.attachRenderer({ engine: { name: 'navara' } });
await new Promise(setImmediate); await new Promise(setImmediate);
assert.equal(gates.length, 1); assert.equal(gates.length, 1);
// The event leaves its age window; the expiry pass runs meanwhile. // The event leaves its age window; the expiry pass runs meanwhile.
+6 -3
View File
@@ -161,14 +161,17 @@ export function createGnssIntegrityLayer({
throw new Error('GNSS integrity layer is already initialized'); throw new Error('GNSS integrity layer is already initialized');
initialized = true; initialized = true;
try { try {
if (engine) this.attachRenderer(viewer, { engine }); if (engine) this.attachRenderer({ engine });
} catch (error) { } catch (error) {
this.destroy(); this.destroy();
throw error; throw error;
} }
}, },
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
renderer?.setVisible(enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer) if (!initialized || renderer)
throw new Error('Invalid GNSS scene attachment'); throw new Error('Invalid GNSS scene attachment');
renderer = rendererFactory({ engine }); renderer = rendererFactory({ engine });
@@ -180,7 +183,7 @@ export function createGnssIntegrityLayer({
); );
_signature = null; _signature = null;
if (_enabled) expire(); if (_enabled) expire();
renderer.setVisible(_enabled); this.show(_enabled);
}, },
detachRenderer() { detachRenderer() {
+1 -1
View File
@@ -745,7 +745,7 @@ test('an in-flight regional response supplies the next renderer without a new re
assert.equal(await pending, true); assert.equal(await pending, true);
assert.equal(h.ground.sets(), 0); assert.equal(h.ground.sets(), 0);
assert.equal(h.layer.getStats().levels.low, 1); assert.equal(h.layer.getStats().levels.low, 1);
h.layer.attachRenderer(null, { engine: h.engine }); h.layer.attachRenderer({ engine: h.engine });
assert.equal(h.drawn(), 1); assert.equal(h.drawn(), 1);
assert.equal(calls, 1); assert.equal(calls, 1);
assert.equal(h.camera.changed.numberOfListeners, 1); assert.equal(h.camera.changed.numberOfListeners, 1);
+2 -2
View File
@@ -136,9 +136,9 @@ export function createInfrastructureSessionLayer(
updateInterval: 0, updateInterval: 0,
statsRefreshInterval: 1000, statsRefreshInterval: 1000,
init(_viewer, options = {}) { init(_viewer, options = {}) {
if (options.engine) return this.attachRenderer(_viewer, options); if (options.engine) return this.attachRenderer(options);
}, },
attachRenderer(_viewer, { engine = null, signal = null } = {}) { attachRenderer({ engine = null, signal = null } = {}) {
if (display) throw Error('Infrastructure display already attached'); if (display) throw Error('Infrastructure display already attached');
display = engine ? createDisplay(engine, config, services) : null; display = engine ? createDisplay(engine, config, services) : null;
// The dataset load belongs to the session and keeps running; only this // The dataset load belongs to the session and keeps running; only this
+4 -4
View File
@@ -57,9 +57,9 @@ test('a bundled file delivered between planets keeps multipart IDs and is read o
await enabled; await enabled;
assert.equal(layer.getAnalystRecords().length, 2); assert.equal(layer.getAnalystRecords().length, 2);
assert.equal(captures.length, 0); assert.equal(captures.length, 0);
await layer.attachRenderer(null, { engine: {} }); await layer.attachRenderer({ engine: {} });
layer.detachRenderer(); layer.detachRenderer();
await layer.attachRenderer(null, { engine: {} }); await layer.attachRenderer({ engine: {} });
assert.equal(captures[0], captures[1]); assert.equal(captures[0], captures[1]);
assert.deepEqual( assert.deepEqual(
captures[0].records.map((r) => r.id), captures[0].records.map((r) => r.id),
@@ -94,7 +94,7 @@ test('a released scene stops waiting for a stalled dataset load, which stays in
await new Promise(setImmediate); await new Promise(setImmediate);
layer.detachRenderer(); layer.detachRenderer();
const scene = new AbortController(); const scene = new AbortController();
const attaching = layer.attachRenderer(null, { const attaching = layer.attachRenderer({
engine: {}, engine: {},
signal: scene.signal, signal: scene.signal,
}); });
@@ -385,6 +385,6 @@ test('a scene switch restores the selected feature on the next display', async (
selectEntityContext(record.entity); selectEntityContext(record.entity);
env.layer.detachRenderer(); env.layer.detachRenderer();
assert.equal(getContextStore().selectedEntityId, null); assert.equal(getContextStore().selectedEntityId, null);
await env.layer.attachRenderer(null, { engine: env.engine }); await env.layer.attachRenderer({ engine: env.engine });
assert.equal(getContextStore().selectedEntityId, 'local-dams:dam'); assert.equal(getContextStore().selectedEntityId, 'local-dams:dam');
}); });
+6 -3
View File
@@ -28,10 +28,13 @@ export function createInstallationsLayer({
let renderer = null; let renderer = null;
const lifecycle = { const lifecycle = {
init(viewer, options = {}) { init(viewer, options = {}) {
if (viewer || options.engine) if (viewer || options.engine) return lifecycle.attachRenderer(options);
return lifecycle.attachRenderer(viewer, options);
}, },
attachRenderer(viewer, options = {}) { picks: {
owner: 'military-installations',
predicate: (id) => state.recordById.has(id),
},
attachRenderer(options = {}) {
if (renderer) if (renderer)
throw new Error('Installations layer is already initialized'); throw new Error('Installations layer is already initialized');
const engine = options.engine ?? null; const engine = options.engine ?? null;
+26 -17
View File
@@ -96,6 +96,23 @@ export function createLifecycle({
} }
const methods = { const methods = {
picks: {
owner: 'rocket-launches',
predicate: (id) => getRenderer()?.ownsPick(id) ?? false,
},
cameraOwner: {
suspend() {
layerState._replayCameraHeading = layerState._replayTracks.get(
layerState._replayCameraLaunchId,
)?.lastCameraHeading;
layerState._replayCameraToken++;
layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.();
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover =
null;
},
resume: () => methods.resumeViewport(),
},
// The layer draws through the engine (options.engine) only. // The layer draws through the engine (options.engine) only.
init(_scene, options) { init(_scene, options) {
layerState._count = 0; layerState._count = 0;
@@ -103,20 +120,19 @@ export function createLifecycle({
layerState._stale = false; layerState._stale = false;
layerState._lastError = null; layerState._lastError = null;
layerState._orbitMatches = 0; layerState._orbitMatches = 0;
this.attachRenderer(null, options); this.attachRenderer(options);
}, },
attachRenderer( show(enabled) {
_scene, getRenderer()?.setVisible(enabled);
{ engine = null, waitForCameraRestore = false } = {}, },
) { attachRenderer(context = {}) {
const { engine = null } = context;
layerState._sceneContext = context;
if (engine) attachDisplay(engine); if (engine) attachDisplay(engine);
layerState._restoringViewport = true; if (!context.viewRestored) this.resumeViewport();
if (!waitForCameraRestore) this.resumeViewport();
}, },
resumeViewport() { resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._replayCameraLaunchId) { if (layerState._enabled && layerState._replayCameraLaunchId) {
parts.replay.startMissionReplay(layerState._replayCameraLaunchId, { parts.replay.startMissionReplay(layerState._replayCameraLaunchId, {
resuming: true, resuming: true,
@@ -141,14 +157,7 @@ export function createLifecycle({
layerState._waitForRenderer ??= new Promise((resolve) => { layerState._waitForRenderer ??= new Promise((resolve) => {
layerState._resumeRenderer = resolve; layerState._resumeRenderer = resolve;
}); });
layerState._replayCameraHeading = layerState._replayTracks.get( methods.cameraOwner.suspend();
layerState._replayCameraLaunchId,
)?.lastCameraHeading;
layerState._replayCameraToken++;
layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.();
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover =
null;
layerState._clickHandler?.destroy(); layerState._clickHandler?.destroy();
layerState._moveHandler?.destroy(); layerState._moveHandler?.destroy();
layerState._declutterHandler?.(); layerState._declutterHandler?.();
+1
View File
@@ -376,6 +376,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
return { return {
engine, engine,
ownsPick: owns,
occluder, occluder,
/** The engine frames seen so far (preUpdate). */ /** The engine frames seen so far (preUpdate). */
frameNumber: () => frameNumber, frameNumber: () => frameNumber,
+10 -1
View File
@@ -269,7 +269,14 @@ test('a click on a mission names its element; a click elsewhere or while off nam
const engine = createFakeEngine(); const engine = createFakeEngine();
const display = createLaunchesDisplay(engine); const display = createLaunchesDisplay(engine);
display.setVisible(true); display.setVisible(true);
display.addPoint({ id: 'rocket-launch:a', position: PAD, sizePx: 5 }); const point = display.addPoint({
id: 'rocket-launch:a',
position: PAD,
sizePx: 5,
});
assert.equal(display.ownsPick('rocket-launch:a'), true);
assert.equal(display.ownsPick('rocket-launch:missing'), false);
assert.equal(display.ownsPick('other'), false);
let enabled = true; let enabled = true;
const clicks = []; const clicks = [];
const handler = display.onClick((id) => clicks.push(id), { const handler = display.onClick((id) => clicks.push(id), {
@@ -292,6 +299,8 @@ test('a click on a mission names its element; a click elsewhere or while off nam
enabled = true; enabled = true;
engine.fake.input('click', { position: { x: 1, y: 2 } }); engine.fake.input('click', { position: { x: 1, y: 2 } });
assert.equal(clicks.length, 3); assert.equal(clicks.length, 3);
point.remove();
assert.equal(display.ownsPick('rocket-launch:a'), false);
display.destroy(); display.destroy();
engine.destroy(); engine.destroy();
}); });
+1 -1
View File
@@ -167,7 +167,7 @@ test('edge:local-adsb: an unplugged receiver ends one session and a scene switch
layer.detachRenderer(); layer.detachRenderer();
clock.now += 1_000; clock.now += 1_000;
await layer.update(); await layer.update();
layer.attachRenderer(null, { engine: createFakeEngine() }); layer.attachRenderer({ engine: createFakeEngine() });
await layer.update(); await layer.update();
assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once'); assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once');
+10 -9
View File
@@ -603,6 +603,7 @@ export function createLocalAdsbLayer({
return rows; return rows;
} }
const picks = { owner: LAYER_ID, predicate: (id) => markers.has(id) };
return { return {
id: LAYER_ID, id: LAYER_ID,
name: LAYER_NAME, name: LAYER_NAME,
@@ -632,11 +633,15 @@ export function createLocalAdsbLayer({
}); });
unsubscribe = receiver.subscribe?.(scheduleSync) || null; unsubscribe = receiver.subscribe?.(scheduleSync) || null;
unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null; unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null;
return this.attachRenderer(nextViewer, options); return this.attachRenderer(options);
}, },
attachRenderer(nextViewer, { engine = null } = {}) { show(enabled) {
renderer = services.renderer.create({ viewer: nextViewer, engine }); renderer?.setVisible(enabled);
},
picks,
attachRenderer({ engine = null } = {}) {
renderer = services.renderer.create({ engine });
renderer.attach(); renderer.attach();
if (renderer.models) { if (renderer.models) {
models = createLocalAdsbModels({ models = createLocalAdsbModels({
@@ -650,7 +655,7 @@ export function createLocalAdsbLayer({
const restore = selectedId; const restore = selectedId;
selectedId = null; selectedId = null;
renderer.setVisible(true); renderer.setVisible(true);
registerPickOwner(LAYER_ID, (id) => markers.has(id), renderer?.engine); registerPickOwner(picks.owner, picks.predicate, renderer?.engine);
installInteraction(); installInteraction();
installFrame(); installFrame();
sync(); sync();
@@ -687,11 +692,7 @@ export function createLocalAdsbLayer({
async enable() { async enable() {
enabled = true; enabled = true;
renderer?.setVisible(true); renderer?.setVisible(true);
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, renderer?.engine);
LAYER_ID,
(pickedValue) => markers.has(pickedValue),
renderer?.engine,
);
installInteraction(); installInteraction();
installFrame(); installFrame();
clearInterval(tickTimer); clearInterval(tickTimer);
+1 -1
View File
@@ -1152,7 +1152,7 @@ test('globe handoff retains the receiver, feed, trail fixes and selection', asyn
}); });
await layer.update(); await layer.update();
assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123'); assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123');
layer.attachRenderer(null, { engine: fakeEngine() }); layer.attachRenderer({ engine: fakeEngine() });
assert.deepEqual(receiver.modeCalls, ['adsb']); assert.deepEqual(receiver.modeCalls, ['adsb']);
assert.deepEqual(feeds.calls, ['start']); assert.deepEqual(feeds.calls, ['start']);
assert.equal(closes, 0); assert.equal(closes, 0);
+10 -3
View File
@@ -14,8 +14,15 @@ export function createLifecycle({ flightState, services, parts, layer }) {
parts, parts,
enable: () => methods.enable(), enable: () => methods.enable(),
}); });
const picks = {
owner: 'military',
predicate: (pickedId) => flightState._billboards.has(pickedId),
};
const methods = { const methods = {
picks,
attachRenderer: binding.attachRenderer, attachRenderer: binding.attachRenderer,
cameraOwner: binding.cameraOwner,
show: binding.show,
detachRenderer: binding.detachRenderer, detachRenderer: binding.detachRenderer,
/** Configure the source before initialization; an active layer keeps its owner. */ /** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) { setSource(source) {
@@ -76,7 +83,7 @@ export function createLifecycle({ flightState, services, parts, layer }) {
flightState.initialized = true; flightState.initialized = true;
try { try {
if (viewer || engine) binding.attachRenderer(viewer, { engine }); if (viewer || engine) binding.attachRenderer({ engine });
} catch (error) { } catch (error) {
methods.destroy(viewer); methods.destroy(viewer);
throw error; throw error;
@@ -113,8 +120,8 @@ export function createLifecycle({ flightState, services, parts, layer }) {
if (!renderer) return; if (!renderer) return;
parts.tracking._installClickHandler(); parts.tracking._installClickHandler();
registerPickOwner( registerPickOwner(
'military', picks.owner,
(pickedId) => flightState._billboards.has(pickedId), picks.predicate,
flightState._renderer?.engine, flightState._renderer?.engine,
); );
// Force a fresh rotation pass on the first tick after re-enable // Force a fresh rotation pass on the first tick after re-enable
+7 -3
View File
@@ -73,7 +73,11 @@ test('the military logic draws, selects and follows only through its renderer',
militaryRegistry, militaryRegistry,
renderer: { renderer: {
create: ({ viewer, engine: given }) => { create: ({ viewer, engine: given }) => {
assert.equal(viewer, null, 'no viewer on an engine without one'); assert.equal(
viewer,
undefined,
'the renderer receives only the engine',
);
renderer = recordingAircraftRenderer(given); renderer = recordingAircraftRenderer(given);
return renderer; return renderer;
}, },
@@ -311,14 +315,14 @@ test('military classification and the selected aircraft survive an update betwee
await layer.update(null); await layer.update(null);
assert.equal(layer.hasContact('AE0001'), true); assert.equal(layer.hasContact('AE0001'), true);
assert.equal(layer.getAnalystRecords().length, 1); assert.equal(layer.getAnalystRecords().length, 1);
layer.attachRenderer(null, { engine: first }); layer.attachRenderer({ engine: first });
assert.equal(layer.trackById('ae0001'), true); assert.equal(layer.trackById('ae0001'), true);
layer.detachRenderer(); layer.detachRenderer();
assert.equal(binding.follow.current(), undefined); assert.equal(binding.follow.current(), undefined);
assert.equal(militaryRegistry.isMilitaryLayerActive(), true); assert.equal(militaryRegistry.isMilitaryLayerActive(), true);
assert.equal(layer.getTrackedInfo().icao24, 'ae0001'); assert.equal(layer.getTrackedInfo().icao24, 'ae0001');
await layer.update(null); await layer.update(null);
layer.attachRenderer(null, { engine: second }); layer.attachRenderer({ engine: second });
assert.ok(binding.follow.current()); assert.ok(binding.follow.current());
assert.equal(layer.getStats().lastUpdate, 1700000002000); assert.equal(layer.getStats().lastUpdate, 1700000002000);
assert.equal(polls, 2); assert.equal(polls, 2);
+8 -4
View File
@@ -97,16 +97,20 @@ export function createOsmPlacesLayer({
}) })
.catch(() => {}); .catch(() => {});
overlayHost.setVisible(OVERLAY_SOURCE_ID, false); overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
if (engine) this.attachRenderer(viewer, { engine }); if (engine) this.attachRenderer({ engine });
}, },
/** Draw on a globe; the results stay for the next one. */ /** Draw on a globe; the results stay for the next one. */
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
renderer?.setVisible(enabled);
overlayHost.setVisible(OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer) if (!initialized || renderer)
throw new Error('Invalid OSM Places scene attachment'); throw new Error('Invalid OSM Places scene attachment');
sceneEngine = engine; sceneEngine = engine;
renderer = createRenderer({ viewer, engine }); renderer = createRenderer({ engine });
renderer.setVisible(enabled); this.show(enabled);
if (enabled) overlayHost.setVisible(OVERLAY_SOURCE_ID, true); if (enabled) overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
render(); render();
}, },
+1 -1
View File
@@ -132,7 +132,7 @@ for (const makeHarness of HARNESSES) {
layer.detachRenderer(); layer.detachRenderer();
assert.equal(count(h.engine), undefined, 'the points go with the globe'); assert.equal(count(h.engine), undefined, 'the points go with the globe');
const next = makeHarness(); const next = makeHarness();
layer.attachRenderer(null, { engine: next.engine }); layer.attachRenderer({ engine: next.engine });
assert.equal(count(next.engine), 2); assert.equal(count(next.engine), 2);
assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed'); assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed');
layer.disable(); layer.disable();
+7 -3
View File
@@ -277,19 +277,23 @@ export function createFirePerimetersLayer({
throw new Error('Fire perimeter layer is already initialized'); throw new Error('Fire perimeter layer is already initialized');
initialized = true; initialized = true;
try { try {
if (engine) this.attachRenderer(viewer, { engine }); if (engine) this.attachRenderer({ engine });
} catch (error) { } catch (error) {
this.destroy(); this.destroy();
throw error; throw error;
} }
}, },
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
renderer?.setVisible(enabled);
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer) if (!initialized || renderer)
throw new Error('Invalid fire perimeter scene attachment'); throw new Error('Invalid fire perimeter scene attachment');
sceneEngine = engine; sceneEngine = engine;
renderer = createRenderer({ engine }); renderer = createRenderer({ engine });
renderer.setVisible(_enabled); this.show(_enabled);
if (geometry.size) renderer.replace([...geometry.values()]); if (geometry.size) renderer.replace([...geometry.values()]);
if (_enabled) { if (_enabled) {
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true); overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
+1 -1
View File
@@ -583,7 +583,7 @@ test('a selected fire retains its card and changed polygon through a scene hando
assert.equal(await h.layer.update(), true); assert.equal(await h.layer.update(), true);
const record = h.layer.getAnalystRecords()[0]; const record = h.layer.getAnalystRecords()[0];
assert.equal(record.id, 'incident:one'); assert.equal(record.id, 'incident:one');
h.layer.attachRenderer(null, { engine: h.engine }); h.layer.attachRenderer({ engine: h.engine });
assert.equal(h.zones().items.length, 2, 'the fill and its fire line'); assert.equal(h.zones().items.length, 2, 'the fill and its fire line');
assert.equal(h.zones().items[0].rings[0][0][0], ring[0][0] + 0.1); assert.equal(h.zones().items[0].rings[0][0][0], ring[0][0] + 0.1);
assert.equal( assert.equal(
+6 -2
View File
@@ -15,9 +15,13 @@ export function createLifecycle({
const { clearSource, setVisible } = services.overlays; const { clearSource, setVisible } = services.overlays;
const methods = { const methods = {
picks: {
owner: 'radio',
predicate: (id) => parts.renderer?.ownsPick(id) ?? false,
},
/** Initialize scene resources and the single audio element. */ /** Initialize scene resources and the single audio element. */
init(viewer, options = {}) { init(viewer, options = {}) {
this.attachRenderer(viewer, options); this.attachRenderer(options);
layerState._sessionGeneration += 1; layerState._sessionGeneration += 1;
parts.clustering.resetRadioClusterOverlayIdentities(); parts.clustering.resetRadioClusterOverlayIdentities();
parts.playback.installAudio(); parts.playback.installAudio();
@@ -25,7 +29,7 @@ export function createLifecycle({
setVisible(RADIO_OVERLAY_SOURCE_ID, false); setVisible(RADIO_OVERLAY_SOURCE_ID, false);
}, },
attachRenderer(viewer, options = {}) { attachRenderer(options = {}) {
if (parts.renderer?.ready) if (parts.renderer?.ready)
throw new Error('Radio layer is already initialized'); throw new Error('Radio layer is already initialized');
if (typeof rendererFactory !== 'function') if (typeof rendererFactory !== 'function')
+2 -1
View File
@@ -305,9 +305,10 @@ export function createRadioDisplay(engine, { state, services, parts }) {
drillPick(position, limit) { drillPick(position, limit) {
return engine.picker.drillPick(position, limit); return engine.picker.drillPick(position, limit);
}, },
ownsPick: (id) => String(id).startsWith(RADIO_PREFIX),
installClick(callback) { installClick(callback) {
removeClick = selectionDispatcherFor(engine).register('radio', { removeClick = selectionDispatcherFor(engine).register('radio', {
owns: (id) => String(id).startsWith(RADIO_PREFIX), owns: renderer.ownsPick,
onClick: ({ position }) => callback(position), onClick: ({ position }) => callback(position),
}); });
return removeClick; return removeClick;
+5
View File
@@ -188,6 +188,10 @@ for (const makeHarness of HARNESSES) {
); );
near(station.pixelSize, 13, 'station size'); near(station.pixelSize, 13, 'station size');
const cluster = f.instance('radio-clusters', 0); const cluster = f.instance('radio-clusters', 0);
assert.equal(f.layer.picks.owner, 'radio');
assert.equal(f.layer.picks.predicate(station.id), true);
assert.equal(f.layer.picks.predicate(cluster.id), true);
assert.equal(f.layer.picks.predicate('other'), false);
near( near(
cluster.pixelSize, cluster.pixelSize,
Math.min(26, 12 + Math.log2(3) * 1.6), Math.min(26, 12 + Math.log2(3) * 1.6),
@@ -235,6 +239,7 @@ for (const makeHarness of HARNESSES) {
const bracket = f.instance('radio-bracket', 0); const bracket = f.instance('radio-bracket', 0);
near(bracket.pixelSize, 40, 'bracket size'); near(bracket.pixelSize, 40, 'bracket size');
assert.equal(bracket.id, `radio:selected:${clusterId}`); assert.equal(bracket.id, `radio:selected:${clusterId}`);
assert.equal(f.layer.picks.predicate(bracket.id), true);
{ {
const { options } = f.engine.fake.moverSet('radio-selected'); const { options } = f.engine.fake.moverSet('radio-selected');
assert.equal(options.point.sizePx, 14); assert.equal(options.point.sizePx, 14);
+2 -2
View File
@@ -775,10 +775,10 @@ export function createRecentImageryLayer({
updateInterval: 0, updateInterval: 0,
init(_viewer, options) { init(_viewer, options) {
return this.attachRenderer(null, options); return this.attachRenderer(options);
}, },
attachRenderer(_viewer, { engine } = {}) { attachRenderer({ engine } = {}) {
_engine = engine || null; _engine = engine || null;
if (rendererFactory && !renderer) renderer = rendererFactory({ engine }); if (rendererFactory && !renderer) renderer = rendererFactory({ engine });
if (!renderer) throw new TypeError('Recent imagery requires a renderer'); if (!renderer) throw new TypeError('Recent imagery requires a renderer');
+16 -2
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createLayerSceneContext } from '../../data/layerSceneBinding.js';
import { createSatellitesDisplay } from './render/display.js'; import { createSatellitesDisplay } from './render/display.js';
import { constantRgba } from '../shared/color.js'; import { constantRgba } from '../shared/color.js';
import { createState } from './state.js'; import { createState } from './state.js';
@@ -545,10 +546,20 @@ test('an engine switch retains the held subject and dims the replacement rings',
const engine = createFakeEngine(); const engine = createFakeEngine();
t.after(() => engine.destroy()); t.after(() => engine.destroy());
f.parts.lifecycle.methods.attachRenderer(null, { let restoreView;
const context = createLayerSceneContext({
engine, engine,
waitForCameraRestore: true, viewRestored: new Promise((resolve) => {
restoreView = resolve;
}),
}); });
f.parts.lifecycle.methods.attachRenderer(context);
assert.equal(f.methods.trackById(GPS, { origin: 'share-restore' }), true);
assert.equal(
engine.camera.following(),
null,
'retained tracking waits for the view',
);
const issPath = f.state._orbitPaths.get(ISS_NORAD); const issPath = f.state._orbitPaths.get(ISS_NORAD);
assert.equal(f.ring(ISS_NORAD).show, false, 'the hidden class stays hidden'); assert.equal(f.ring(ISS_NORAD).show, false, 'the hidden class stays hidden');
assert.equal(f.ring(GPS).show, false, 'the held ring stays hidden'); assert.equal(f.ring(GPS).show, false, 'the held ring stays hidden');
@@ -557,7 +568,10 @@ test('an engine switch retains the held subject and dims the replacement rings',
ringColors({ ...f, engine }, ISS_NORAD).color[3], ringColors({ ...f, engine }, ISS_NORAD).color[3],
issPath.baseColor.alpha * COCKPIT_ORBIT_DIM_FACTOR, issPath.baseColor.alpha * COCKPIT_ORBIT_DIM_FACTOR,
); );
restoreView();
await context.viewRestored;
f.parts.lifecycle.methods.resumeViewport(); f.parts.lifecycle.methods.resumeViewport();
assert.equal(engine.camera.following().native, f.state._trackedEntity);
engine.fake.frame(); engine.fake.frame();
assert.equal(dotShown(f), false); assert.equal(dotShown(f), false);
f.methods.setCockpitSubject(null); f.methods.setCockpitSubject(null);
+4 -1
View File
@@ -456,7 +456,10 @@ export function createControls({ state: layerState, services, parts, source }) {
!layerState._points.has(id) !layerState._points.has(id)
) )
return false; return false;
if (layerState._restoringViewport && layerState._trackedNorad === id) if (
layerState._sceneContext?.viewReady === false &&
layerState._trackedNorad === id
)
return true; return true;
parts.tracking._cancelPendingTrackingRestore(); parts.tracking._cancelPendingTrackingRestore();
parts.tracking._trackSatellite(id, { origin }); parts.tracking._trackSatellite(id, { origin });
@@ -92,9 +92,9 @@ test('restored orbit rings receive their current rotation before the first frame
}, },
}, },
}); });
lifecycle.methods.attachRenderer(null, { lifecycle.methods.attachRenderer({
engine: {}, engine: {},
waitForCameraRestore: true, viewRestored: new Promise(() => {}),
}); });
assert.equal(rotated, 1); assert.equal(rotated, 1);
assert.equal(path.primitive.show, true); assert.equal(path.primitive.show, true);
+1 -1
View File
@@ -86,7 +86,7 @@ test('edge:satellites: a scene switch at a stopped epoch keeps passes and coordi
layer.detachRenderer(); layer.detachRenderer();
// Wall time passes during the switch; the stopped world clock does not. // Wall time passes during the switch; the stopped world clock does not.
t.mock.timers.tick(15 * 60_000); t.mock.timers.tick(15 * 60_000);
layer.attachRenderer(null, { engine: second }); layer.attachRenderer({ engine: second });
const after = observe(layer, second); const after = observe(layer, second);
assert.equal(reads, catalogReads, 'the switch does not refetch elements'); assert.equal(reads, catalogReads, 'the switch does not refetch elements');
+31 -16
View File
@@ -30,7 +30,18 @@ export function createLifecycle({
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
const picks = {
owner: 'satellites',
predicate: (pickedId) => {
const norad = Number(pickedId);
return Number.isFinite(norad) && layerState._points.has(norad);
},
};
const methods = { const methods = {
cameraOwner: {
suspend: () => methods.suspendCamera(),
resume: () => methods.resumeViewport(),
},
// Catalog data refresh; propagation remains preRender-owned. // Catalog data refresh; propagation remains preRender-owned.
// The display draws on `engine` (none without one: nothing is drawn). // The display draws on `engine` (none without one: nothing is drawn).
@@ -160,10 +171,23 @@ export function createLifecycle({
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
}, },
attachRenderer( show(enabled) {
_viewer, if (layerState._pointCollection)
{ engine = null, waitForCameraRestore = false } = {}, layerState._pointCollection.show =
) { enabled && layerState._params.showPoints;
for (const [id, path] of layerState._orbitPaths) {
if (path.primitive)
path.primitive.show =
enabled &&
(id === ISS_NORAD
? parts.controls._issOrbitVisible()
: layerState._params.showOrbits);
}
},
picks,
attachRenderer(context = {}) {
const { engine = null } = context;
layerState._sceneContext = context;
layerState._engine = engine; layerState._engine = engine;
const renderer = engine const renderer = engine
? (services.renderer.create({ engine }) ?? null) ? (services.renderer.create({ engine }) ?? null)
@@ -196,13 +220,11 @@ export function createLifecycle({
layerState._resumeRenderer?.(); layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null; layerState._waitForRenderer = layerState._resumeRenderer = null;
// The restored camera runs before a retained follow gets its new native target. // The restored camera runs before a retained follow gets its new native target.
layerState._restoringViewport = true; if (!context.viewRestored) this.resumeViewport();
if (!waitForCameraRestore) this.resumeViewport();
}, },
resumeViewport() { resumeViewport() {
if (!layerState._restoringViewport || !layerState._renderer) return; if (!layerState._renderer) return;
layerState._restoringViewport = false;
if (layerState._enabled) { if (layerState._enabled) {
if (layerState._trackedNorad != null && !layerState._trackedEntity) if (layerState._trackedNorad != null && !layerState._trackedEntity)
parts.tracking._trackSatellite(layerState._trackedNorad, { parts.tracking._trackSatellite(layerState._trackedNorad, {
@@ -239,14 +261,7 @@ export function createLifecycle({
if (layerState._renderer) parts.interaction._installClickHandler(); if (layerState._renderer) parts.interaction._installClickHandler();
// Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers; // Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers;
// the registry hands predicates String()-coerced ids, so match via Number(). // the registry hands predicates String()-coerced ids, so match via Number().
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, layerState._engine);
'satellites',
(pickedId) => {
const norad = Number(pickedId);
return Number.isFinite(norad) && layerState._points.has(norad);
},
layerState._engine,
);
if (!layerState._preRenderListener && layerState._renderer) { if (!layerState._preRenderListener && layerState._renderer) {
layerState._preRenderListener = getRenderer().onFrame( layerState._preRenderListener = getRenderer().onFrame(
parts.rendering._preRenderTick, parts.rendering._preRenderTick,
+24 -17
View File
@@ -78,7 +78,7 @@ export function createStreetLevelLayer({
isAvailable: (entry) => providerAvailable(entry), isAvailable: (entry) => providerAvailable(entry),
}); });
let waitingForView = false; let sceneContext = null;
let notifyQueued = false; let notifyQueued = false;
function notify() { function notify() {
if (notifyQueued) return; if (notifyQueued) return;
@@ -113,7 +113,7 @@ export function createStreetLevelLayer({
getFilter: () => resolveFilter(state.filter, streetLevelTime()), getFilter: () => resolveFilter(state.filter, streetLevelTime()),
/** The layer and this provider's switch are both on. */ /** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on, isActive: () => state.enabled && entry.on,
waitingForView: () => waitingForView, viewReady: () => Boolean(state.map) && sceneContext?.viewReady !== false,
notify, notify,
actions: { actions: {
/** /**
@@ -510,21 +510,23 @@ export function createStreetLevelLayer({
}, },
init(scene, options) { init(scene, options) {
this.attachRenderer(scene, options); this.attachRenderer(options);
for (const entry of state.providers.values()) checkStatus(entry); for (const entry of state.providers.values()) checkStatus(entry);
}, },
/** Draws on `engine` through its display; the first argument is not read. */ /** Draws on `engine` through its display. */
attachRenderer( show(enabled) {
_scene, parts.marker.setVisible(enabled);
{ engine = null, waitForCameraRestore = false } = {}, },
) { picks: { owner: STREET_LEVEL_LAYER_ID, predicate: parts.router.ownsPick },
attachRenderer(context = {}) {
const { engine = null } = context;
sceneContext = context;
const createDisplay = services.createDisplay ?? createStreetLevelDisplay; const createDisplay = services.createDisplay ?? createStreetLevelDisplay;
const map = engine ? (createDisplay(engine) ?? null) : null; const map = engine ? (createDisplay(engine) ?? null) : null;
state.map = map; state.map = map;
waitingForView = waitForCameraRestore;
if (!map) return; if (!map) return;
parts.marker.ensure(map); parts.marker.ensure(map);
parts.marker.setVisible(state.enabled); this.show(state.enabled);
for (const entry of state.providers.values()) { for (const entry of state.providers.values()) {
entry.instance.init(map); entry.instance.init(map);
if (state.enabled && entry.on) { if (state.enabled && entry.on) {
@@ -544,18 +546,23 @@ export function createStreetLevelLayer({
remeasureGround(); remeasureGround();
} }
}, },
cameraOwner: {
suspend: () => parts.framing.cancel(),
resume() {
if (state.enabled && state.map) {
for (const entry of activeEntries())
entry.instance.refreshCoverage?.();
remeasureGround();
}
},
},
resumeViewport() { resumeViewport() {
if (!waitingForView) return; this.cameraOwner.resume();
waitingForView = false;
if (state.enabled && state.map) {
for (const entry of activeEntries()) entry.instance.refreshCoverage?.();
remeasureGround();
}
}, },
detachRenderer() { detachRenderer() {
const map = state.map; const map = state.map;
if (!map) return; if (!map) return;
waitingForView = true; sceneContext = null;
removeCameraListener?.(); removeCameraListener?.();
removeCameraListener = null; removeCameraListener = null;
cancelGroundRetry(); cancelGroundRetry();
+1 -1
View File
@@ -373,7 +373,7 @@ export function createLineCoverage({
const map = state.map; const map = state.map;
if ( if (
!map || !map ||
state.context.waitingForView?.() || state.context.viewReady?.() === false ||
!state.context.isActive() || !state.context.isActive() ||
state.coverage.blocked state.coverage.blocked
) )
@@ -128,6 +128,18 @@ function setup({ zoomFor, harness = null } = {}) {
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
} }
test('coverage waits for the shared view readiness before requesting tiles', () => {
const { state, source, coverage } = setup();
let ready = false;
state.context.viewReady = () => ready;
coverage.refresh();
assert.equal(source.calls.length, 0);
ready = true;
coverage.refresh();
assert.ok(source.calls.length > 0);
coverage.clear();
});
test('a superseded tile request cannot strand lines on the globe', async () => { test('a superseded tile request cannot strand lines on the globe', async () => {
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup(); const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
const forTile = () => source.calls.filter((call) => call.key === tileKey); const forTile = () => source.calls.filter((call) => call.key === tileKey);
+1 -1
View File
@@ -120,7 +120,7 @@ export function createSequenceCones({
* reloads a selected one (a provider that loads part of a sequence moved on). * reloads a selected one (a provider that loads part of a sequence moved on).
*/ */
async function select(sequenceId, { force = false } = {}) { async function select(sequenceId, { force = false } = {}) {
if (!sequenceId || (!state.map && !state.context.waitingForView?.())) if (!sequenceId || (!state.map && state.context.viewReady?.() !== false))
return; return;
// Already on screen, or already on its way. // Already on screen, or already on its way.
if ( if (
@@ -276,7 +276,7 @@ test('without a display the cones draw nothing and do not throw', async () => {
}); });
state.map = null; state.map = null;
sequences.ensureCollections(null); sequences.ensureCollections(null);
state.context.waitingForView = () => true; state.context.viewReady = () => false;
sequences.select('A'); sequences.select('A');
source.last('A').resolve(records('A')); source.last('A').resolve(records('A'));
await settle(); await settle();
+2 -2
View File
@@ -76,9 +76,9 @@ export function createCableSessionLayer({ source, createDisplay }) {
updateInterval: 0, updateInterval: 0,
statsRefreshInterval: 500, statsRefreshInterval: 500,
init(_viewer, ctx) { init(_viewer, ctx) {
return this.attachRenderer(_viewer, ctx); return this.attachRenderer(ctx);
}, },
attachRenderer(_viewer, { engine = null, signal = null } = {}) { attachRenderer({ engine = null, signal = null } = {}) {
display = engine ? createDisplay(engine) : null; display = engine ? createDisplay(engine) : null;
// The cable data load belongs to the session and keeps running; only // The cable data load belongs to the session and keeps running; only
// this scene's wait for it ends when the scene is released. show() // this scene's wait for it ends when the scene is released. show()
+3 -3
View File
@@ -25,7 +25,7 @@ test('cables arriving during handoff belong to the incoming display', async () =
layer.init(null, { engine: 'outgoing' }); layer.init(null, { engine: 'outgoing' });
const pending = layer.enable(); const pending = layer.enable();
layer.detachRenderer(); layer.detachRenderer();
const handoff = layer.attachRenderer(null, { engine: 'incoming' }); const handoff = layer.attachRenderer({ engine: 'incoming' });
const dataset = { cables: { features: [] }, landingPoints: { features: [] } }; const dataset = { cables: { features: [] }, landingPoints: { features: [] } };
resolve(dataset); resolve(dataset);
await Promise.all([pending, handoff]); await Promise.all([pending, handoff]);
@@ -55,7 +55,7 @@ test('a released scene stops waiting for a stalled cable load, which stays in th
const pending = layer.enable(); const pending = layer.enable();
layer.detachRenderer(); layer.detachRenderer();
const scene = new AbortController(); const scene = new AbortController();
const attaching = layer.attachRenderer(null, { const attaching = layer.attachRenderer({
engine: 'released', engine: 'released',
signal: scene.signal, signal: scene.signal,
}); });
@@ -66,7 +66,7 @@ test('a released scene stops waiting for a stalled cable load, which stays in th
resolve(dataset); resolve(dataset);
await pending; await pending;
assert.deepEqual(shown, []); assert.deepEqual(shown, []);
await layer.attachRenderer(null, { engine: 'next' }); await layer.attachRenderer({ engine: 'next' });
assert.deepEqual(shown, [['next', dataset]]); assert.deepEqual(shown, [['next', dataset]]);
layer.destroy(); layer.destroy();
}); });
-1
View File
@@ -172,7 +172,6 @@ test('resuming the viewport republishes the retained roads as a pass that can st
const surfaceInput = { roads: [road(0.004, GROUND)], bounds }; const surfaceInput = { roads: [road(0.004, GROUND)], bounds };
Object.assign(state, { Object.assign(state, {
_enabled: true, _enabled: true,
_restoringViewport: true,
_fetching: false, _fetching: false,
_surfaceInput: surfaceInput, _surfaceInput: surfaceInput,
_lastBounds: bounds, _lastBounds: bounds,
+9 -10
View File
@@ -84,7 +84,7 @@ export function createLifecycle({
* @param {{ engine?: object, waitForCameraRestore?: boolean }} [options] * @param {{ engine?: object, waitForCameraRestore?: boolean }} [options]
*/ */
init(_viewer, options) { init(_viewer, options) {
this.attachRenderer(_viewer, options); this.attachRenderer(options);
layerState._dots = []; layerState._dots = [];
layerState._roads = []; layerState._roads = [];
layerState._count = 0; layerState._count = 0;
@@ -128,15 +128,16 @@ export function createLifecycle({
console.log('[Data:Traffic] Initialized'); console.log('[Data:Traffic] Initialized');
}, },
attachRenderer( show(enabled) {
_viewer, layerState._display?.show(enabled && layerState._showDots);
{ engine = null, waitForCameraRestore = false } = {}, },
) { attachRenderer(context = {}) {
layerState._restoringViewport = waitForCameraRestore; const { engine = null } = context;
layerState._sceneContext = context;
layerState._engine = engine; layerState._engine = engine;
layerState._display = engine ? services.createRenderer(engine) : null; layerState._display = engine ? services.createRenderer(engine) : null;
layerState._display?.show(layerState._enabled && layerState._showDots); this.show(layerState._enabled);
if (!waitForCameraRestore) readyForSurfaceWork(); if (context.viewReady !== false) readyForSurfaceWork();
if (layerState._enabled) { if (layerState._enabled) {
bindScene(); bindScene();
parts.animation.drawDots(); parts.animation.drawDots();
@@ -157,8 +158,6 @@ export function createLifecycle({
} }
}, },
resumeViewport() { resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
readyForSurfaceWork(); readyForSurfaceWork();
if (!layerState._enabled) return; if (!layerState._enabled) return;
if (layerState._populationPending) parts.retention.resume(); if (layerState._populationPending) parts.retention.resume();
+2 -2
View File
@@ -175,7 +175,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
if ( if (
!layerState._enabled || !layerState._enabled ||
layerState._waitForScene || layerState._waitForScene ||
layerState._restoringViewport layerState._sceneContext?.viewReady === false
) )
return; return;
clearTimeout(layerState._fetchTimeout); clearTimeout(layerState._fetchTimeout);
@@ -203,7 +203,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
if ( if (
!layerState._enabled || !layerState._enabled ||
layerState._waitForScene || layerState._waitForScene ||
layerState._restoringViewport layerState._sceneContext?.viewReady === false
) )
return; return;
const alt = getCameraAltitude(); const alt = getCameraAltitude();
+19 -12
View File
@@ -104,6 +104,10 @@ export function createLifecycle({
hidden = false; hidden = false;
} }
const picks = {
owner: 'transit',
predicate: (pickedId) => state._vehicles.has(pickedId),
};
const methods = { const methods = {
id: 'transit', id: 'transit',
name: 'Transit', name: 'Transit',
@@ -114,10 +118,19 @@ export function createLifecycle({
* Create the marker sets. Called once at bootstrap. * Create the marker sets. Called once at bootstrap.
*/ */
init(viewer, options) { init(viewer, options) {
this.attachRenderer(viewer, options); this.attachRenderer(options);
}, },
attachRenderer(viewer, options = {}) { show(enabled) {
state._restoringViewport = Boolean(options.waitForCameraRestore); if (state._markers) state._markers.show = enabled;
if (state._animatedMarkers) state._animatedMarkers.show = enabled;
state._overlayHost.setVisible(
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
enabled,
);
},
picks,
attachRenderer(options = {}) {
state._sceneContext = options;
if (parts.renderer) if (parts.renderer)
throw new Error('Transit layer is already initialized'); throw new Error('Transit layer is already initialized');
if (typeof rendererFactory !== 'function') if (typeof rendererFactory !== 'function')
@@ -151,7 +164,7 @@ export function createLifecycle({
entry.pathRevision = -1; entry.pathRevision = -1;
} }
this.bindScene(); this.bindScene();
if (!state._restoringViewport) this.repaint(); if (state._sceneContext?.viewReady !== false) this.repaint();
} }
bindStyleEvents(); bindStyleEvents();
console.log( console.log(
@@ -179,11 +192,7 @@ export function createLifecycle({
); );
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true); state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true);
parts.selection.installClickHandler(); parts.selection.installClickHandler();
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, parts.renderer?.engine);
'transit',
(pickedId) => state._vehicles.has(pickedId),
parts.renderer?.engine,
);
if (!state._cameraChangedAttached) { if (!state._cameraChangedAttached) {
state._cameraChangedRemove = parts.renderer.onCameraChanged( state._cameraChangedRemove = parts.renderer.onCameraChanged(
parts.viewport.onCameraChanged, parts.viewport.onCameraChanged,
@@ -213,13 +222,11 @@ export function createLifecycle({
); );
}, },
resumeViewport() { resumeViewport() {
if (!state._restoringViewport) return;
state._restoringViewport = false;
if (state._enabled && parts.renderer) this.repaint(); if (state._enabled && parts.renderer) this.repaint();
}, },
detachRenderer() { detachRenderer() {
if (!parts.renderer) return; if (!parts.renderer) return;
state._restoringViewport = true; state._sceneContext = null;
unbindVisibility(); unbindVisibility();
clearInterval(state._maintenanceTimer); clearInterval(state._maintenanceTimer);
clearTimeout(state._cameraDebounceTimer); clearTimeout(state._cameraDebounceTimer);
+6 -2
View File
@@ -76,7 +76,11 @@ export function createViewport({ state, services, parts }) {
globalThis.document?.visibilityState === 'hidden' globalThis.document?.visibilityState === 'hidden'
) )
return; return;
if (!state._enabled || !parts.renderer?.ready || state._restoringViewport) if (
!state._enabled ||
!parts.renderer?.ready ||
state._sceneContext?.viewReady === false
)
return; return;
refreshViewBounds(); refreshViewBounds();
const altitude = getCameraAltitude(); const altitude = getCameraAltitude();
@@ -125,7 +129,7 @@ export function createViewport({ state, services, parts }) {
} }
function onCameraChanged() { function onCameraChanged() {
if (state._restoringViewport) return; if (state._sceneContext?.viewReady === false) return;
// The bounds update immediately; the feed proximity check stays debounced. // The bounds update immediately; the feed proximity check stays debounced.
// A person dragging the globe should not wait a third of a second for the // A person dragging the globe should not wait a third of a second for the
// vehicles under the cursor to start moving again. // vehicles under the cursor to start moving again.
+17 -14
View File
@@ -10,7 +10,7 @@ export function createLifecycle({
options, options,
}) { }) {
const { state } = vesselState; const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; const { restoreSpriteOrderOnEnable } = services.sprites;
const { releaseContinuousRender } = services.render; const { releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid; const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -138,6 +138,10 @@ export function createLifecycle({
state.trailMmsi = null; state.trailMmsi = null;
state.trailBackfillToken = 0; state.trailBackfillToken = 0;
} }
const picks = {
owner: 'ais-live-vessels',
predicate: (pickedId) => state.records.byMmsi.has(pickedId),
};
const methods = { const methods = {
/** Configure the source before initialization; an active layer keeps its owner. */ /** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) { setSource(source) {
@@ -160,23 +164,26 @@ export function createLifecycle({
init(viewer, { engine = null } = {}) { init(viewer, { engine = null } = {}) {
if (typeof vesselState._source?.getSnapshot !== 'function') if (typeof vesselState._source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required'); throw new TypeError('A snapshot source is required');
if (viewer || engine) this.attachRenderer(viewer, { engine }); if (viewer || engine) this.attachRenderer({ engine });
}, },
attachRenderer(viewer, { engine = null } = {}) { show(enabled) {
state.viewer = viewer; components.rendering.setVisible(enabled);
state.renderer ??= createRenderer(viewer, engine); },
const scene = viewer || state.renderer?.view || null; picks,
attachRenderer({ engine = null } = {}) {
state.viewer = null;
state.renderer ??= createRenderer(null, engine);
const scene = state.renderer?.view || null;
components.rendering.ensureCollections(scene); components.rendering.ensureCollections(scene);
components.selection.installInteraction(scene); components.selection.installInteraction(scene);
components.rendering.installRuntime(scene); components.rendering.installRuntime(scene);
restoreSpriteOrder(viewer); this.show(state.feed.enabled);
components.rendering.setVisible(state.feed.enabled);
const horizon = components.rendering.makeOccluder(); const horizon = components.rendering.makeOccluder();
for (const record of state.records.all) for (const record of state.records.all)
components.rendering.addRecordPrimitives(record, horizon); components.rendering.addRecordPrimitives(record, horizon);
if (!state.feed.enabled) return; if (!state.feed.enabled) return;
bindVisibleScene(viewer, scene); bindVisibleScene(null, scene);
components.tracking.attachSelectedVesselTrail(); components.tracking.attachSelectedVesselTrail();
components.rendering.updateVisibility(true); components.rendering.updateVisibility(true);
if (state.selectedRecord) if (state.selectedRecord)
@@ -288,11 +295,7 @@ export function createLifecycle({
function bindVisibleScene(activeViewer, scene) { function bindVisibleScene(activeViewer, scene) {
// Only MMSI picks present in the live record map belong to this layer. // Only MMSI picks present in the live record map belong to this layer.
registerPickOwner( registerPickOwner(picks.owner, picks.predicate, state.renderer?.engine);
'ais-live-vessels',
(pickedId) => state.records.byMmsi.has(pickedId),
state.renderer?.engine,
);
restoreSpriteOrderOnEnable('ais', activeViewer); restoreSpriteOrderOnEnable('ais', activeViewer);
// Vessels are asked for by view, so ask again when the view moves away. // Vessels are asked for by view, so ask again when the view moves away.
removeViewListener(); removeViewListener();
+1 -1
View File
@@ -243,7 +243,7 @@ test('a pending AIS poll and selected trail survive replacement of the planet',
assert.equal(layer.getSelectedInfo()?.mmsi, '111'); assert.equal(layer.getSelectedInfo()?.mmsi, '111');
assert.ok(layer.findByQuery('222').position); assert.ok(layer.findByQuery('222').position);
assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 3); assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 3);
layer.attachRenderer(null, { engine: engines[1] }); layer.attachRenderer({ engine: engines[1] });
assert.equal(recorders[1].sprites.size, 2); assert.equal(recorders[1].sprites.size, 2);
const selected = [...recorders[1].sprites].find(({ id }) => id === record); const selected = [...recorders[1].sprites].find(({ id }) => id === record);
assert.equal(selected.image, vesselChevronIcon('#ffffff', true)); assert.equal(selected.image, vesselChevronIcon('#ffffff', true));
+2 -2
View File
@@ -302,9 +302,9 @@ export function createWeatherLayer({
motion = matchMedia?.('(prefers-reduced-motion: reduce)'); motion = matchMedia?.('(prefers-reduced-motion: reduce)');
motion?.addEventListener?.('change', onVisibility); motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility);
if (options.engine) layer.attachRenderer(nextViewer, options); if (options.engine) layer.attachRenderer(options);
}, },
attachRenderer(_viewer, { engine = null } = {}) { attachRenderer({ engine = null } = {}) {
sceneEngine = engine; sceneEngine = engine;
rendering = engine rendering = engine
? (createNativeRendering(engine, { onChange: notify }) ?? null) ? (createNativeRendering(engine, { onChange: notify }) ?? null)
+12 -7
View File
@@ -522,11 +522,8 @@ export function createLifecycle({ state, services, parts }) {
services.overlayHost?.setVisible?.(id, true); services.overlayHost?.setVisible?.(id, true);
parts.selection.install(); parts.selection.install();
services.picking?.registerPickOwner?.( services.picking?.registerPickOwner?.(
'wildlife-sightings', picks.owner,
(id) => picks.predicate,
typeof id === 'string' &&
id.startsWith('wildlife:') &&
state.rowById.has(id.slice(9)),
state.engine, state.engine,
); );
state.removeCamera = state.removeCamera =
@@ -572,16 +569,24 @@ export function createLifecycle({ state, services, parts }) {
return true; return true;
} }
const picks = {
owner: 'wildlife-sightings',
predicate: (id) =>
typeof id === 'string' &&
id.startsWith('wildlife:') &&
state.rowById.has(id.slice(9)),
};
const methods = { const methods = {
/** Attach this layer to the scene's globe engine. */ /** Attach this layer to the scene's globe engine. */
init(_viewer, options = {}) { init(_viewer, options = {}) {
if (state.engine) if (state.engine)
throw new Error('Wildlife sightings layer is already initialized'); throw new Error('Wildlife sightings layer is already initialized');
return methods.attachRenderer(_viewer, options); return methods.attachRenderer(options);
}, },
/** Draw on `engine` (a scene switch brings another); observations, /** Draw on `engine` (a scene switch brings another); observations,
* filters and selection carry over. */ * filters and selection carry over. */
attachRenderer(_viewer, { engine = null, signal = null } = {}) { picks,
attachRenderer({ engine = null, signal = null } = {}) {
if (state.destroyed || signal?.aborted) return false; if (state.destroyed || signal?.aborted) return false;
if (state.engine) if (state.engine)
throw new Error('Wildlife sightings display is already attached'); throw new Error('Wildlife sightings display is already attached');
@@ -2252,7 +2252,7 @@ test('an engine switch keeps the data and selection and redraws the same badges
const other = ENGINE_KINDS.find((kind) => kind !== h.engine.kind); const other = ENGINE_KINDS.find((kind) => kind !== h.engine.kind);
const next = h.globe(other); const next = h.globe(other);
const nextCells = watchCells(next); const nextCells = watchCells(next);
assert.equal(h.layer.attachRenderer(null, { engine: next }), true); assert.equal(h.layer.attachRenderer({ engine: next }), true);
h.flushIdle(); h.flushIdle();
assert.deepEqual(drawn(next), before, 'the same badges, drawn the same'); assert.deepEqual(drawn(next), before, 'the same badges, drawn the same');
assert.deepEqual(style(next), beforeStyle); assert.deepEqual(style(next), beforeStyle);
+4 -3
View File
@@ -119,10 +119,11 @@ export function createWindLayer({
source: 'GFS / ECMWF IFS · FORECAST', source: 'GFS / ECMWF IFS · FORECAST',
updateInterval: 3600_000, updateInterval: 3600_000,
init(nextViewer, options) { init(nextViewer, options) {
layer.attachRenderer(nextViewer, options); layer.attachRenderer(options);
}, },
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) { attachRenderer({ engine: nextEngine = null } = {}) {
viewer = nextViewer; // The wind backend owns its native viewer.
viewer = nextEngine?.extensions?.cesium?.viewer ?? null;
engine = nextEngine; engine = nextEngine;
if (!engine) return; if (!engine) return;
rendering = createNativeRendering(engine, { onStatusChange: notify }); rendering = createNativeRendering(engine, { onStatusChange: notify });
+3 -1
View File
@@ -366,7 +366,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
createNativeRendering: () => rendering, createNativeRendering: () => rendering,
}); });
// A Cesium page: the viewer samples, the engine projects the marker. // A Cesium page: the viewer samples, the engine projects the marker.
layer.init(viewer, { engine: createFakeEngine({ container }) }); const engine = createFakeEngine({ container });
engine.extensions = { cesium: { viewer } };
layer.init(viewer, { engine });
layer.enable(); layer.enable();
await layer.update(); await layer.update();
layer.setParams({ inspect: true }); layer.setParams({ inspect: true });
-1
View File
@@ -89,7 +89,6 @@ window.addEventListener('popstate', () => {
function createApplication(kind, engineSwitch, transition) { function createApplication(kind, engineSwitch, transition) {
session.count('applications'); session.count('applications');
layerSession.restoringView = engineSwitch;
application = createStandaloneApplication({ application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN, cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
+8 -30
View File
@@ -67,39 +67,17 @@ export function createEngineAddress({ location, history, flush = () => {} }) {
* was where the incoming address agrees (always after a pick) and follows a * was where the incoming address agrees (always after a pick) and follows a
* visited entry where it differs (handoffTargetEnabled). * visited entry where it differs (handoffTargetEnabled).
* @param {import('../app/layerSession.js').LayerSession} session * @param {import('../app/layerSession.js').LayerSession} session
* @param {object} [options]
* @param {Iterable<string> | null} [options.addressLayerIds] Layers listed by the outgoing address.
* @param {boolean} [options.visit=false] Restore a history entry's layer options; a picker change keeps the live options.
*/ */
export function handOffSession( export function handOffSession(session) {
session, for (const id of session.handoff.cameraOwners)
{ addressLayerIds = null, visit = false } = {}, session.manager.layers.get(id)?.module.cameraOwner.suspend();
) { session.handoff.director?.suspendScene();
const manager = session.manager;
const listed = addressLayerIds ? new Set(addressLayerIds) : null;
session.handoffLayers = new Map(
[...manager.layers].map(([id, entry]) => [
id,
{
entry: entry.module.attachRenderer ? entry : null,
enabled: manager.isEffectivelyEnabled(id),
durable: listed ? listed.has(id) : undefined,
visit,
},
]),
);
manager.layers.get('directions')?.module.suspendCamera?.();
manager.layers.get('satellites')?.module.suspendCamera?.();
session.director?.suspendScene();
} }
/** Resume what handOffSession parked, once the incoming view is restored. */ /** Resume the Director once the incoming view and layer cameras are restored. */
export function resumeSession(session) { export async function resumeSession(session) {
for (const { module } of session.manager.layers.values()) await session.manager.resumeScene?.();
module.resumeViewport?.(); (session.handoff?.director ?? session.director)?.resumeScene();
if (session.handoffLayers)
session.manager.layers.get('directions')?.module.resumeCamera?.();
session.director?.resumeScene();
} }
/** /**
+17 -12
View File
@@ -1,3 +1,4 @@
import { createSessionHandoff } from '../app/sessionHandoff.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
@@ -75,8 +76,10 @@ function fakeSession(log) {
'directions', 'directions',
{ {
module: module('directions', { module: module('directions', {
suspendCamera: () => log.push('suspend:directions'), cameraOwner: {
resumeCamera: () => log.push('resume:directions'), suspend: () => log.push('suspend:directions'),
resume: () => log.push('resume:directions'),
},
}), }),
}, },
], ],
@@ -84,15 +87,19 @@ function fakeSession(log) {
'satellites', 'satellites',
{ {
module: module('satellites', { module: module('satellites', {
suspendCamera: () => log.push('suspend:satellites'), cameraOwner: { suspend: () => log.push('suspend:satellites'), resume() {} },
}), }),
}, },
], ],
]); ]);
return { return {
handoffLayers: null, handoff: null,
manager: { manager: {
layers, layers,
resumeScene() {
for (const { module } of layers.values()) module.resumeViewport();
for (const { module } of layers.values()) module.cameraOwner?.resume();
},
isEffectivelyEnabled: (id) => id === 'flights', isEffectivelyEnabled: (id) => id === 'flights',
}, },
director: { director: {
@@ -127,7 +134,8 @@ function harness({
signal: lifetime.signal, signal: lifetime.signal,
async leave(from, to) { async leave(from, to) {
log.push(`leave:${from}->${to}`); log.push(`leave:${from}->${to}`);
handOffSession(session, { addressLayerIds: session.addressLayerIds }); session.handoff = createSessionHandoff(session, { addressLayerIds: session.addressLayerIds });
handOffSession(session);
parked.add(from); parked.add(from);
running = null; running = null;
}, },
@@ -138,7 +146,7 @@ function harness({
); );
await outcome?.(next, { ...attempt, warm }); await outcome?.(next, { ...attempt, warm });
running = next; running = next;
resumeSession(session); await resumeSession(session);
}, },
address: createEngineAddress({ address: createEngineAddress({
location: browser.location, location: browser.location,
@@ -146,7 +154,7 @@ function harness({
flush: () => log.push('flush'), flush: () => log.push('flush'),
}), }),
settle: () => { settle: () => {
if (running) session.handoffLayers = null; if (running) session.handoff = null;
}, },
remember: (next) => remembered.push(next), remember: (next) => remembered.push(next),
onFailure: (error, outcome) => onFailure: (error, outcome) =>
@@ -193,7 +201,7 @@ test('Back and Forward hand the session off exactly like the picker', async () =
h.session.addressLayerIds = ['flights']; h.session.addressLayerIds = ['flights'];
let captured; let captured;
h.session.director.suspendScene = () => { h.session.director.suspendScene = () => {
captured = h.session.handoffLayers; captured = h.session.handoff.layers;
h.log.push('suspend:director'); h.log.push('suspend:director');
}; };
await h.machine.select('navara'); await h.machine.select('navara');
@@ -225,10 +233,7 @@ test('Back and Forward hand the session off exactly like the picker', async () =
// The visit records the same running state and outgoing address; the // The visit records the same running state and outgoing address; the
// layer-state coordinator compares that with the visited entry. // layer-state coordinator compares that with the visited entry.
assert.deepEqual(record(captured), afterPick); assert.deepEqual(record(captured), afterPick);
assert.equal( assert.equal('entry' in captured.get('flights'), false);
captured.get('flights').entry,
h.session.manager.layers.get('flights'),
);
h.browser.go(1); h.browser.go(1);
await h.machine.reconcile(); await h.machine.reconcile();
assert.equal(h.machine.state().kind, 'navara'); assert.equal(h.machine.state().kind, 'navara');
+14 -8
View File
@@ -1,3 +1,4 @@
import { createSessionHandoff } from '../app/sessionHandoff.js';
import { import {
createEngineSwitch, createEngineSwitch,
handOffSession, handOffSession,
@@ -41,24 +42,25 @@ export function createSceneSwitcher({
cover ??= keepFrame?.(components.scene.engine); cover ??= keepFrame?.(components.scene.engine);
const style = components.controls.styleManager; const style = components.controls.styleManager;
cockpit = style.getCockpitState(); cockpit = style.getCockpitState();
handOffSession(layerSession, { layerSession.handoff = createSessionHandoff(layerSession, {
addressLayerIds: addressLayerIds:
style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds, style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds,
visit: change?.visit === true, visit: change?.visit === true,
}); });
handOffSession(layerSession);
renderers.retain(from); renderers.retain(from);
await getApplication().detachScene({ handoff: true }, transaction); await getApplication().detachScene({ handoff: true }, transaction);
} }
count('detaches'); count('detaches');
} }
async function resume(style) { async function resume(style, restoreView) {
signal.throwIfAborted(); signal.throwIfAborted();
await style.initialRestorePromise; await style.initialRestorePromise;
signal.throwIfAborted(); signal.throwIfAborted();
layerSession.restoringView = false; restoreView();
count('attachments'); count('attachments');
resumeSession(layerSession); await resumeSession(layerSession);
// A visited entry or a stale track may replace the outgoing subject. // A visited entry or a stale track may replace the outgoing subject.
const target = style.getAircraftTrackingTarget(); const target = style.getAircraftTrackingTarget();
if ( if (
@@ -73,13 +75,16 @@ export function createSceneSwitcher({
} }
async function enter(kind, { recovery = false } = {}, transaction) { async function enter(kind, { recovery = false } = {}, transaction) {
layerSession.restoringView = true; let restoreView;
layerSession.viewRestored = new Promise((resolve) => {
restoreView = resolve;
});
let app = getApplication(); let app = getApplication();
if (!app || app.getState().status !== 'ready') { if (!app || app.getState().status !== 'ready') {
app = startApplication(kind, transaction); app = startApplication(kind, transaction);
try { try {
const { controls } = await app.start(); const { controls } = await app.start();
await resume(controls.styleManager); await resume(controls.styleManager, restoreView);
} catch (error) { } catch (error) {
if (!signal.aborted) if (!signal.aborted)
await app await app
@@ -95,7 +100,8 @@ export function createSceneSwitcher({
} else { } else {
await app.attachScene(kind, { await app.attachScene(kind, {
transaction, transaction,
resume: () => resume(app.getComponents().controls.styleManager), resume: () =>
resume(app.getComponents().controls.styleManager, restoreView),
}); });
} }
if (recovery) count('rollbacks'); if (recovery) count('rollbacks');
@@ -110,7 +116,7 @@ export function createSceneSwitcher({
cover = null; cover = null;
} }
if (!components) return; if (!components) return;
layerSession.handoffLayers = null; layerSession.handoff = null;
cockpit = null; cockpit = null;
onSettled?.(); onSettled?.();
components.controls.styleManager.shareLinkManager?.resumeNavigation?.(); components.controls.styleManager.shareLinkManager?.resumeNavigation?.();
+102 -3
View File
@@ -39,6 +39,7 @@ function fixture({ failResume = false, changeTarget = false } = {}) {
session.manager = { session.manager = {
layers: new Map([['flights', entry]]), layers: new Map([['flights', entry]]),
isEffectivelyEnabled: () => true, isEffectivelyEnabled: () => true,
resumeScene: () => entry.module.resumeViewport(),
}; };
session.director = { session.director = {
suspendScene: () => log.push('suspend-director'), suspendScene: () => log.push('suspend-director'),
@@ -96,8 +97,8 @@ test('a scene switch parks once, resumes the session and cockpit, then returns n
const { switcher, session, log, current } = fixture(); const { switcher, session, log, current } = fixture();
await switcher.select('navara'); await switcher.select('navara');
assert.equal(current(), 'navara'); assert.equal(current(), 'navara');
assert.equal(session.handoffLayers, null); assert.equal(session.handoff, null);
assert.equal(session.restoringView, false); await session.viewRestored;
assert.deepEqual(log, [ assert.deepEqual(log, [
'suspend-director', 'suspend-director',
['park', 'first'], ['park', 'first'],
@@ -125,7 +126,7 @@ test('resume failure has one candidate release and one rollback on the retained
['attach', 'first'], ['attach', 'first'],
['failure', 'resume failed', true], ['failure', 'resume failed', true],
]); ]);
assert.equal(session.handoffLayers, null); assert.equal(session.handoff, null);
assert.equal(switcher.state().busy, false); assert.equal(switcher.state().busy, false);
}); });
@@ -135,3 +136,101 @@ test('a changed tracking target does not resume the outgoing cockpit', async ()
assert.equal(log.includes('cockpit'), false); assert.equal(log.includes('cockpit'), false);
assert.equal(log.includes('ir'), false); assert.equal(log.includes('ir'), false);
}); });
test('the restored camera precedes retained follow and replay', async () => {
const { LayerLifecycle } = await import('../data/lifecycle.js');
const session = createLayerSession();
const log = [];
let restoreCamera;
const restored = new Promise((resolve) => {
restoreCamera = resolve;
});
const layers = ['follow', 'replay'].map((id) => ({
id,
name: id,
icon: '',
source: 'test',
init() {},
enable() {},
disable() {},
update() {},
destroy() {},
attachRenderer() {
log.push(`attach:${id}`);
},
detachRenderer() {},
cameraOwner: {
suspend() {
log.push(`suspend:${id}`);
},
resume() {
log.push(`resume:${id}`);
},
},
}));
const catalog = {
layers,
metadata: layers.map(({ id }) => ({
id,
token: id,
disposition: 'enabled-only',
})),
};
const manager = new LayerLifecycle(null);
session.manager = manager;
await manager.bindCatalog({ kind: 'first' }, catalog);
for (const layer of layers) await manager.setEnabled(layer.id, true);
let current = 'first';
let attached;
const attachment = new Promise((resolve) => {
attached = resolve;
});
const style = {
initialRestorePromise: restored,
getCockpitState: () => null,
getAircraftTrackingTarget: () => null,
};
const app = {
getState: () => ({ status: 'ready' }),
getComponents: () => ({
scene: { engine: { kind: current } },
controls: { styleManager: style },
}),
async detachScene() {
await manager.detachScene();
},
async attachScene(kind, { resume }) {
current = kind;
await manager.bindCatalog({ kind }, catalog, {
viewRestored: session.viewRestored,
});
attached();
await resume();
},
};
const switcher = createSceneSwitcher({
kind: 'first',
requested: () => current,
layerSession: session,
getApplication: () => app,
renderers: { retain() {} },
address: { select() {}, commit() {}, restore() {} },
});
const switching = switcher.select('navara');
await attachment;
assert.deepEqual(log, [
'suspend:follow',
'suspend:replay',
'attach:follow',
'attach:replay',
]);
log.push('camera-restored');
restoreCamera();
await switching;
assert.deepEqual(log.slice(-3), [
'camera-restored',
'resume:follow',
'resume:replay',
]);
await manager.destroyAll();
});
+2 -2
View File
@@ -405,7 +405,7 @@ export class StyleManager extends ShellScene {
this._shareRestoration.start({ this._shareRestoration.start({
engineSwitch, engineSwitch,
retainScene: Boolean(this._layerSession?.handoffLayers), retainScene: Boolean(this._layerSession?.handoff),
}); });
// Keep the parameter panel from overlapping toggle controls. // Keep the parameter panel from overlapping toggle controls.
@@ -771,7 +771,7 @@ export class StyleManager extends ShellScene {
setClearBusy: (busy) => this._publishClearBusy(busy), setClearBusy: (busy) => this._publishClearBusy(busy),
}, },
}); });
if (this._layerSession?.handoffLayers) if (this._layerSession?.handoff)
this._contextControls.resumeScene(this._layerSession.contextScene); this._contextControls.resumeScene(this._layerSession.contextScene);
// The Contacts layer publishes its panel; the view calls its navigation. // The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer); contextStore.connectAwareness(militaryAwarenessLayer);
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more