Merge branch 'german/refactor-e8-switch-protocol' into german/navara-refactor

# Conflicts:
#	scripts/engine-coupling-baseline.json
#	src/layers/wind/index.test.mjs
This commit is contained in:
German Khokhlov committed 2026-11-10 16:35:39 +04:00
commit 59ff0b38a2
103 files changed
+1024 -537

No files matched your search

+4 -4
View File
@@ -1,13 +1,13 @@
{
"metrics": {
"attachRendererWithViewer": 33,
"restoreFlagCopies": 9,
"attachRendererWithViewer": 0,
"restoreFlagCopies": 0,
"surfaceCallsOutsideGlobe": 10,
"engineSwitchLayerIds": 3,
"engineSwitchLayerIds": 0,
"cesiumLayerRendererLines": 0,
"navaraLayerRendererLines": 0,
"cesiumImportFiles": 2,
"viewerLines": 666,
"viewerLines": 613,
"navaraKindChecks": 1,
"engineSniffCalls": 0,
"engineExtensionReads": 2,
+4 -6
View File
@@ -91,12 +91,10 @@ export function createApplicationData({
current = nextCatalog;
for (const layer of nextCatalog.layers)
layer.attachMapStackController?.(next.mapStackController);
const binding = dataManager.bindCatalog(
next.viewer,
next.engine,
nextCatalog,
{ waitForCameraRestore: layerSession?.restoringView === true },
);
dataManager.viewer = next.viewer;
const binding = dataManager.bindCatalog(next.engine, nextCatalog, {
viewRestored: layerSession?.viewRestored,
});
for (const layer of nextCatalog.layers)
layer.attachDataManager?.(dataManager);
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 {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 {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 {boolean} restoringView Switch coordinator: incoming address restoration.
* @property {import('./sessionHandoff.js').SessionHandoff | null} handoff Switch coordinator: retained intentions and scene owners.
* @property {Promise<void>} viewRestored Switch coordinator: incoming camera restoration.
* @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.
*/
@@ -37,15 +36,14 @@ export function createLayerSession() {
tools: {},
voice: null,
chrome: {},
handoffLayers: null,
restoringView: false,
handoff: null,
viewRestored: Promise.resolve(),
switching: () => false,
contextScene: null,
selectionContext: null,
};
}
/** Whether teardown preserves the session for another globe. */
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 assert from 'node:assert/strict';
import { createLayerSession, retainsLayerSession } from './layerSession.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 other = createLayerSession();
const calls = [];
@@ -17,21 +18,24 @@ test('a session retains layer intentions through handoff but never after page te
session.manager = {
layers: new Map([['flights', entry]]),
isEffectivelyEnabled: () => true,
resumeScene: () => calls.push('viewport'),
};
session.director = {
suspendScene: () => calls.push('suspend'),
resumeScene: () => calls.push('resume'),
};
assert.equal(retainsLayerSession(session), false);
handOffSession(session, { addressLayerIds: ['flights'] });
session.handoff = createSessionHandoff(session, {
addressLayerIds: ['flights'],
});
handOffSession(session);
assert.equal(retainsLayerSession(session), true);
assert.deepEqual(session.handoffLayers.get('flights'), {
entry,
assert.deepEqual(session.handoff.layers.get('flights'), {
enabled: true,
durable: true,
visit: false,
});
resumeSession(session);
await resumeSession(session);
assert.deepEqual(calls, ['suspend', 'viewport', 'resume']);
assert.equal(session.models.get('flights'), entry.module);
assert.equal(other.models.size, 0);
+1 -1
View File
@@ -256,7 +256,7 @@ const cases = {
return make(value);
},
});
layer.attachRenderer(null, { engine });
layer.attachRenderer({ engine });
const renderer = context.parts.renderer;
const station = {
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 test from 'node:test';
import { createApplicationCatalog } from '../constructCatalog.js';
@@ -30,11 +31,6 @@ const retainedCredits = {
};
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) {
return Object.fromEntries(
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 discovery = buildCatalog(discoveryLifetime, [], new Map());
t.after(() => discoveryLifetime.abort());
const seenReaders = [];
const seenCredits = new Set();
for (const { id } of discovery.layers) {
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 catalog = buildCatalog(lifetime, calls, visibility);
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 b = strictEngine();
const beforeA = a.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) => {
scene.engine.fake.emit('camera.moveStart');
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)
)
await layer.init(null);
const attach = async (engine, options = {}) => {
try {
await layer.attachRenderer(poisoned, { engine, ...options });
} catch (error) {
if (
!expectedReaders[layer.id]?.includes([...reads].at(-1)) ||
!error.message.startsWith('viewer read:')
)
throw error;
await layer.detachRenderer();
await layer.attachRenderer(null, { engine, ...options });
}
const attachRenderer = layer.attachRenderer;
layer.attachRenderer = function (context) {
return attachRenderer.call(this, new Proxy(context, {
get(target, property) {
assert.notEqual(property, 'viewer', 'attachment context must not read a viewer');
return Reflect.get(target, property);
},
}));
};
const attach = (engine, options = {}) => manager.bindCatalog(engine, attachedCatalog, { services: sceneServicesFor(engine), ...options });
await attach(a.engine);
await flushLifecycleTasks(t);
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',
);
assertHidden(a.engine);
entry.enabled = true;
await layer.enable();
await drive(a);
await layer.detachRenderer();
await manager.detachScene();
if (retainedCredits[id]) {
beforeA.credits = [
{ 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 sourceCount = calls.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);
if (layer.resumeViewport) {
assert.deepEqual(
@@ -377,17 +375,19 @@ test('every catalog layer releases its scene and attaches to another engine', as
'no transport requests before resumeViewport',
);
}
await layer.resumeViewport?.();
restoreView();
await manager.resumeScene();
await drive(b);
if (id === 'local-firms')
assert.ok(
calls.slice(sourceCount).some((call) => call.id === 'firms'),
'empty FIRMS cache is fetched after camera restoration',
);
entry.enabled = false;
await layer.disable();
await drive(b);
assertHidden(b.engine);
await layer.detachRenderer();
await manager.detachScene();
if (retainedCredits[id])
beforeB.credits = [
{ 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(b.violations, []);
assert.deepEqual(
[...reads].sort(),
expectedReaders[layer.id] ?? [],
'viewer reader table must only shrink',
);
if (reads.size) seenReaders.push(id);
} finally {
lifetime.abort();
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(
[...seenCredits].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')));
const services = createSceneServices(engine, {
retainedSelection: layerSession?.selectionContext,
retainedSelection: layerSession?.handoff?.selection,
});
if (layerSession) layerSession.selectionContext = null;
if (layerSession?.handoff) layerSession.handoff.selection = null;
defer(() => {
const retained = services.destroy({
retainSelection: retainsLayerSession(layerSession),
});
if (layerSession) layerSession.selectionContext = retained;
if (layerSession?.handoff) layerSession.handoff.selection = retained;
});
if (layerSession) {
// 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
@@ -999,6 +999,7 @@ export function createBhoteKoshiEventLayer({
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) {
let _viewer = null;
let _attachmentContext = null;
overlayHost = bindOverlayHost(overlayHost, () => _engine);
let _engine = null;
let _native = null;
@@ -2681,6 +2682,7 @@ export function createBhoteKoshiEventLayer({
function applyCinematicCamera() {
if (
_attachmentContext?.viewReady === false ||
!_cinematicActive ||
!cameraForScene() ||
_cinematicKeyframes.length === 0
@@ -3528,16 +3530,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 current = () =>
generation === _attachGeneration && signal?.aborted !== true;
const stack = _mapStackController;
_viewer = viewer;
_viewer = null;
_engine = engine;
// The engine's display (src/layers/bhoteKoshi/render/display.js).
_native = createNativeRenderer(engine);
_embeddedMedia?.attachScene?.(viewer, engine);
_embeddedMedia?.attachScene?.(null, engine);
if (!_enabled) return;
// Its enable has loaded the SDK; a restored attachment waits for it too.
if (!cesiumSdkReady()) {
@@ -3612,7 +3616,7 @@ export function createBhoteKoshiEventLayer({
}
async function init(viewer, options) {
await attachRenderer(viewer, options);
await attachRenderer(options);
_embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine });
await loadEvent();
console.log('[Data:BhoteKoshi] Event pack ready');
@@ -4010,6 +4014,12 @@ export function createBhoteKoshiEventLayer({
updateInterval: 0,
init,
attachRenderer,
cameraOwner: {
suspend() {
_lastCinematicProgress = null;
},
resume: applyCinematicCamera,
},
detachRenderer,
enable,
disable,
+3 -3
View File
@@ -3070,7 +3070,7 @@ for (const stop of ['detach', 'scene-abort']) {
const gate = new Promise((resolve) => { openGate = resolve; });
layer.attachMapStackController(stack('photoreal', gate));
const scene = new AbortController();
const attaching = layer.attachRenderer(second, { signal: scene.signal });
const attaching = layer.attachRenderer({ signal: scene.signal });
let detaching = null;
if (stop === 'detach') detaching = layer.detachRenderer();
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()),
});
const scene = new AbortController();
const attaching = layer.attachRenderer(second, { signal: scene.signal });
const attaching = layer.attachRenderer({ signal: scene.signal });
scene.abort();
await attaching;
await layer.detachRenderer();
@@ -3196,7 +3196,7 @@ test('Bhote Koshi republishes its evidence cards to the incoming engine overlay'
'first engine got the cards',
);
await layer.detachRenderer();
await layer.attachRenderer(second, { engine: engineB });
await layer.attachRenderer({ engine: engineB });
const b = hostFor(engineB);
assert.ok(
[...b.entries.values()].some((e) => e.length > 0),
@@ -65,7 +65,7 @@ function locator(engine) {
cancelDelay() {},
});
const scene = engine === 'cesium' ? viewer : null;
layer.attachRenderer(scene, {
layer.attachRenderer({
engine: engine === 'navara' ? navara : null,
});
return {
@@ -175,7 +175,7 @@ test('edge:bhote-koshi-locator: an engine swap while the boundary resolves lands
layer.attachRenderer(viewer);
const enabling = layer.enable(viewer);
layer.detachRenderer();
layer.attachRenderer(null, {
layer.attachRenderer({
engine: {
kind: 'navara',
render: { request() {} },
+15 -5
View File
@@ -1043,22 +1043,27 @@ export function createBhoteKoshiLocatorLayer({
showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
}
function attachRenderer(viewer, { engine = null } = {}) {
_viewer = viewer;
function attachRenderer({ engine = null, viewRestored } = {}) {
_viewer = null;
_engine = engine;
if (!_enabled) return true;
connectDisplay();
publishOverlayEntries({ moving: _placeRevealMoving });
if (!viewRestored) resumeCamera();
return true;
}
function resumeCamera() {
if (!_enabled || !_engine) return;
if (_pausedApproach) {
const { phase, remaining } = _pausedApproach;
_pausedApproach = null;
if (phase === 'armed') armTriggerApproach();
else flyTriggerStage((_triggerApproachAttempt = {}), phase, remaining);
}
return true;
}
function detachRenderer() {
function suspendCamera() {
const phase = _triggerApproachAttempt?.phase;
if (phase && phase !== 'finished') {
_pausedApproach = {
@@ -1067,6 +1072,10 @@ export function createBhoteKoshiLocatorLayer({
};
clearTriggerApproach({ preserveCallout: true });
}
}
function detachRenderer() {
suspendCamera();
_native?.destroy();
_native = null;
hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID);
@@ -1151,8 +1160,9 @@ export function createBhoteKoshiLocatorLayer({
icon: '◎',
source: 'OpenStreetMap + GeoPera',
updateInterval: 0,
init: attachRenderer,
init: (_viewer, context) => attachRenderer(context),
attachRenderer,
cameraOwner: { suspend: suspendCamera, resume: resumeCamera },
detachRenderer,
enable,
disable,
+59 -8
View File
@@ -2,6 +2,21 @@
// A late attachment sees its scene signal aborted and releases its own work.
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. */
export class LayerSceneBinding {
constructor(attachSettleTimeoutMs) {
@@ -12,6 +27,7 @@ export class LayerSceneBinding {
this._sceneController = null;
this._sceneDetached = false;
this._pendingAttachments = new Set();
this._viewResumptions = [];
this._attachSettleTimeoutMs = attachSettleTimeoutMs;
// Lifecycle work and explicit refreshes that wait for the next scene.
// 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. */
bindCatalog(viewer, engine, catalog, options = {}) {
bindCatalog(engine, catalog, options = {}) {
if (this._registrationsFinalized) throw new Error('Scene already attached');
this.viewer = viewer;
this.engine = engine ?? null;
this._catalogSourceAvailability = catalog.getSourceAvailability;
const scene = new AbortController();
this._sceneController = scene;
// An attachment that is still running when the scene detaches sees this
// 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 = [];
for (const layer of catalog.layers) {
const entry = this.layers.get(layer.id);
@@ -110,7 +129,7 @@ export class LayerSceneBinding {
if (entry.module !== layer)
throw new Error(`Layer owner changed: ${layer.id}`);
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
* so detachScene() releases them only after they stopped.
*/
_attachEntryRenderer(entry, viewer, context) {
_attachEntryRenderer(entry, context) {
let ready;
try {
const pending = this._failedReleases.get(entry);
@@ -232,13 +251,40 @@ export class LayerSceneBinding {
.then(() => {
this._failedReleases.delete(entry);
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) {
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);
this._pendingAttachments.add(tracked);
const untrack = () => this._pendingAttachments.delete(tracked);
@@ -246,6 +292,11 @@ export class LayerSceneBinding {
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. */
_runDeferredRefreshes() {
for (const [layerId, entry] of this.layers) {
+10 -8
View File
@@ -120,7 +120,7 @@ export class LayerStateCoordinator {
start({
shareLayerState = null,
allowLocalState = true,
handoffLayers = null,
handoff = null,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
@@ -156,12 +156,12 @@ export class LayerStateCoordinator {
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected && !handoffLayers) return this.restorePromise;
if (!selected && !handoff) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
handoffLayers,
handoff,
);
return this.restorePromise;
}
@@ -400,7 +400,7 @@ export class LayerStateCoordinator {
: 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
// run's controllers when it settles.
const controllers = new Map();
@@ -414,7 +414,8 @@ export class LayerStateCoordinator {
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
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(
live,
enabled.has(entry.id),
@@ -447,9 +448,10 @@ export class LayerStateCoordinator {
// apply; a pick keeps the live layer as it is.
if (
live &&
this.dataManager.layers.get(entry.id) === live.entry &&
!live.entry.lifecycleUncertain &&
live.entry.enabled === targetEnabled
retained?.module.attachRenderer &&
retained.initialized &&
!retained.lifecycleUncertain &&
retained.enabled === targetEnabled
) {
const current = this.dataManager.getLayerParams(entry.id);
const changed =
+7 -21
View File
@@ -1,3 +1,4 @@
import { createSessionHandoff } from '../app/sessionHandoff.js';
import test from 'node:test';
import assert from 'node:assert/strict';
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');
});
const handoffOf = (m) =>
new Map(
[...m.layers].map(([id, entry]) => [
id,
{ entry, enabled: m.isEffectivelyEnabled(id), durable: id === 'wind' },
]),
);
const handoffOf = (manager, visit = false) =>
createSessionHandoff({ manager, director: null }, { addressLayerIds: ['wind'], visit });
test('cross-engine Back to a gfs entry restores gfs', async () => {
const m = manager();
await m.setEnabled('wind', true, { origin: 'user' });
m.setLayerParams('wind', { model: 'ifs' }, { origin: 'user' });
const handoffLayers = new Map(
[...m.layers].map(([id, entry]) => [
id,
{
entry,
enabled: m.isEffectivelyEnabled(id),
durable: id === 'wind',
visit: true,
},
]),
);
const handoff = handoffOf(m, true);
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(
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' });
const writes = windParamWrites;
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(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 results = await c.start({
shareLayerState: cesiumEntry,
handoffLayers: handoffOf(m),
handoff: handoffOf(m),
});
assert.equal(windParamWrites, writes, 'no params write for equal options');
const wind = results.find((result) => result.layerId === 'wind');
+69 -33
View File
@@ -13,12 +13,12 @@ function sceneLayer(id, hooks = {}) {
source: 'test',
init(viewer, context) {
calls.push('init');
module.attachRenderer(viewer, context);
module.attachRenderer(context);
return true;
},
attachRenderer(viewer, context) {
calls.push(['attach', viewer]);
return hooks.attach?.(viewer, context);
attachRenderer(context) {
calls.push(['attach', context.engine]);
return hooks.attach?.(context.engine?.name === 'second' ? secondViewer : firstViewer, context);
},
detachRenderer(viewer) {
calls.push(['detach', viewer]);
@@ -50,6 +50,11 @@ const engine = (name) => ({ kind: 'test', name });
const firstViewer = { name: 'first 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) {
const options = layers.at(-1)?.module ? {} : layers.pop();
const manager = new LayerLifecycle(firstViewer, {
@@ -57,7 +62,7 @@ async function boundManager(...layers) {
...options,
});
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)
assert.equal(
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);
// The next scene binds and both retained layers attach to it.
await manager.bindCatalog(
secondViewer,
await bindCatalog(
manager,
engine('second'),
catalog(...modules),
);
assert.equal(manager.viewer, secondViewer);
assert.deepEqual(attaches(broken.calls).at(-1), ['attach', secondViewer]);
assert.deepEqual(attaches(after.calls).at(-1), ['attach', secondViewer]);
assert.deepEqual(attaches(broken.calls).at(-1), ['attach', engine('second')]);
assert.deepEqual(attaches(after.calls).at(-1), ['attach', engine('second')]);
assert.equal(manager.isEnabled('broken'), true);
});
@@ -139,15 +144,15 @@ test('bindCatalog after a failed detach retries the release before attaching', a
AggregateError,
);
await manager.bindCatalog(
secondViewer,
await bindCatalog(
manager,
engine('second'),
catalog(...modules),
);
assert.deepEqual(broken.calls.slice(-3), [
['detach', firstViewer],
['detach', firstViewer],
['attach', secondViewer],
['attach', engine('second')],
]);
// The scene is settled: the next detach releases the second viewer once.
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);
await manager.detachScene();
const bound = manager.bindCatalog(
secondViewer,
const bound = bindCatalog(
manager,
engine('second'),
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(entry.refreshDeferred, true);
await manager.bindCatalog(
secondViewer,
await bindCatalog(
manager,
engine('second'),
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 manager = new LayerLifecycle(firstViewer, { engine: engine('first') });
await manager.bindCatalog(
firstViewer,
await bindCatalog(
manager,
engine('first'),
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]]);
const fresh = { ...legacy, disable: () => true };
await manager.bindCatalog(
secondViewer,
await bindCatalog(
manager,
engine('second'),
catalog(fresh, retained.module),
);
@@ -277,8 +282,8 @@ test('QA layers are reported when a scene switch drops them', async () => {
engine: engine('first'),
allowQaRegistration: true,
});
await manager.bindCatalog(
firstViewer,
await bindCatalog(
manager,
engine('first'),
catalog(retained.module),
);
@@ -322,7 +327,7 @@ test('a detach does not wait forever for an attachment that never settles', asyn
});
await manager.detachScene();
const bound = quietly(() =>
manager.bindCatalog(secondViewer, engine('second'), catalog(...modules)),
bindCatalog(manager, engine('second'), catalog(...modules)),
);
await bound;
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 before = updatesOf(feed.calls).length;
await manager.detachScene();
const bound = manager.bindCatalog(
secondViewer,
const bound = bindCatalog(
manager,
engine('second'),
catalog(...modules),
);
@@ -372,8 +377,8 @@ test('a normal refresh clears a deferred one', async () => {
assert.equal(entry.refreshDeferred, false);
manager._sceneDetached = true;
const before = updatesOf(feed.calls).length;
await manager.bindCatalog(
secondViewer,
await bindCatalog(
manager,
engine('second'),
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');
await tick();
assert.equal(updatesOf(feed.calls).length, before);
await manager.bindCatalog(
secondViewer,
await bindCatalog(
manager,
engine('second'),
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' });
await tick();
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.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 () => {
@@ -442,10 +447,10 @@ test('a detach does not deadlock on an enable parked for the next scene', async
const other = sceneLayer('other');
const manager = new LayerLifecycle(firstViewer, { engine: engine('first') });
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.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.
const enabled = manager.setEnabled('other', true, { origin: 'user' });
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 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,
updateInterval: 0,
init(viewer, options) {
this.attachRenderer(viewer, options);
this.attachRenderer(options);
},
/** Drape on a globe engine's imagery. */
attachRenderer(_viewer, { engine: nextEngine = null } = {}) {
attachRenderer({ engine: nextEngine = null } = {}) {
release();
engine = nextEngine;
sync();
+2 -2
View File
@@ -108,7 +108,7 @@ test('destroy and a renderer detach remove the owned imagery', () => {
layer.enable();
layer.detachRenderer();
assert.equal(layers().length, 0);
layer.attachRenderer(null, { engine });
layer.attachRenderer({ engine });
assert.equal(
layers().length,
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.
seaMarks.detachRenderer();
assert.equal(added[0].removed, true);
seaMarks.attachRenderer(null, { engine: engineWith() });
seaMarks.attachRenderer({ engine: engineWith() });
assert.equal(added.length, 2);
assert.equal(added[1].removed, false);
seaMarks.disable();
+2 -2
View File
@@ -45,7 +45,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(b.picking.findPickOwner('camera'), null);
layer.detachRenderer();
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');
layer.disable();
assert.equal(b.picking.findPickOwner('camera'), null);
@@ -185,7 +185,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(b.overlay.getDiagnostics().entryCount, 0);
layer.detachRenderer();
assert.equal(a.overlay.getDiagnostics().entryCount, 0);
layer.attachRenderer(null, { engine: second.engine });
layer.attachRenderer({ engine: second.engine });
assert.equal(requests, 1);
assert.equal(b.overlay.getDiagnostics().entryCount, 1);
a.destroy();
+25 -8
View File
@@ -1,3 +1,4 @@
import { createLayerSceneContext } from './layerSceneBinding.js';
import * as geo from '../geo/index.js';
import { createApplicationScene } from '../app/scene.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 open = (engine) =>
createApplicationScene({
@@ -297,10 +303,10 @@ for (const makeHarness of GROUND_HARNESSES) {
const outgoing = await open(first.engine);
const record = select(outgoing.services.context, subject());
cleanups.pop()();
assert.equal(session.selectionContext, record);
assert.equal(session.handoff.selection, record);
assert.equal(getSelectedEntityContext(), record);
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(getSelectedEntityContext(), record);
session.lifetime.abort();
@@ -451,7 +457,7 @@ for (const makeHarness of GROUND_HARNESSES) {
assert.equal(hosts[0].last().title, 'LOCAL2');
layer.detachRenderer();
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(b.trackedReadout.activeId(), 'local-adsb:abc123');
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 open = (engine) =>
createApplicationScene({
@@ -564,12 +575,16 @@ for (const makeHarness of GROUND_HARNESSES) {
const selectedAt = window.__gevContextStore.selectedAt;
layer.detachRenderer();
cleanups.pop()();
assert.equal(session.selectionContext.id, id);
assert.equal(session.handoff.selection.id, id);
const incoming = await open(second.engine);
layer.attachRenderer(null, {
let restoreView;
const attachmentContext = createLayerSceneContext({
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.
incoming.services.trackedReadout.init();
assert.equal(window.__gevContextStore.selectedEntityId, id);
@@ -577,6 +592,8 @@ for (const makeHarness of GROUND_HARNESSES) {
second.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(fire.lon, fire.lat, 100_000),
});
restoreView();
await attachmentContext.viewRestored;
layer.resumeViewport();
assert.equal(layer.getSelectedInfo().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');
initialized = true;
try {
if (engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer({ engine });
} catch (error) {
this.destroy();
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)
throw new Error('Invalid air quality scene attachment');
sceneEngine = engine;
renderer = rendererFactory({ engine });
renderer.setVisible(_enabled);
this.show(_enabled);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled);
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(h.events.length, 0);
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.ground.set('air-quality').items.length, 1);
assert.equal(polls, 1);
+23 -12
View File
@@ -29,16 +29,10 @@ export function createAircraftSceneBinding({
}
const methods = {
attachRenderer(viewer, { engine = null } = {}) {
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({ viewer, engine });
flightState._renderer = renderer;
try {
renderer.fleet.attach();
parts.snapshots.presentRecords();
cameraOwner: {
suspend() {},
resume() {
if (!flightState._renderer) return;
if (flightState._trackedIcao) {
parts.tracking._trackFlight(flightState._trackedIcao, {
origin: resumeOrigin,
@@ -46,15 +40,32 @@ export function createAircraftSceneBinding({
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.models.setVisible(flightState.enabled);
renderer.fleet.restoreOrder();
if (!viewRestored) methods.cameraOwner.resume();
if (flightState.enabled) {
enable();
parts.tracking._applyPendingTrackingRestore();
}
} catch (error) {
methods.detachRenderer(viewer);
methods.detachRenderer();
throw error;
}
},
+22 -22
View File
@@ -69,7 +69,7 @@ export function createAlprCamerasLayer({
};
let renderer = null;
let sceneEngine = null;
let restoringView = false;
let sceneContext = null;
function setAlprStatus(status, error = null) {
if (state.status === status && state.error === error) return;
@@ -114,7 +114,8 @@ export function createAlprCamerasLayer({
}
function scheduleLoad() {
if (!state.enabled || !renderer || restoringView) return;
if (!state.enabled || !renderer || sceneContext?.viewReady === false)
return;
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.debounceTimer);
// A view the accepted tiles already cover is a local filter: show it now.
@@ -132,7 +133,8 @@ export function createAlprCamerasLayer({
}
async function loadCameras() {
if (!state.enabled || !renderer?.ready || restoringView) return;
if (!state.enabled || !renderer?.ready || sceneContext?.viewReady === false)
return;
clearTimeout(state.debounceTimer);
state.debounceTimer = null;
clearUnavailableRetry({ resetBackoff: false });
@@ -199,7 +201,7 @@ export function createAlprCamerasLayer({
state.lastQueryBox = null;
state.lastQueryZoom = null;
clearUnavailableRetry();
if (!restoringView) renderer?.renderRecords();
if (sceneContext?.viewReady !== false) renderer?.renderRecords();
setAlprStatus('zoom-in');
return;
}
@@ -232,7 +234,7 @@ export function createAlprCamerasLayer({
clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness.
if (!restoringView) renderer?.renderRecords();
if (sceneContext?.viewReady !== false) renderer?.renderRecords();
setAlprStatus(
(renderer?.count() ?? records.length)
? stale
@@ -265,6 +267,10 @@ export function createAlprCamerasLayer({
}
}
const picks = {
owner: LAYER_ID,
predicate: (id) => state.recordById.has(id),
};
const alprCamerasLayer = {
id: LAYER_ID,
name: 'ALPR Cameras',
@@ -274,10 +280,14 @@ export function createAlprCamerasLayer({
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer, options) {
this.attachRenderer(viewer, options);
this.attachRenderer(options);
},
attachRenderer(viewer, options = {}) {
restoringView = Boolean(options.waitForCameraRestore);
show(enabled) {
renderer?.setVisible(enabled);
},
picks,
attachRenderer(options = {}) {
sceneContext = options;
if (renderer?.ready) throw new Error('ALPR layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('An ALPR renderer factory is required');
@@ -290,26 +300,20 @@ export function createAlprCamerasLayer({
});
renderer.init({ onMove: scheduleLoad });
if (state.enabled) {
registerPickOwner(
LAYER_ID,
(id) => state.recordById.has(id),
sceneEngine,
);
registerPickOwner(picks.owner, picks.predicate, sceneEngine);
renderer.setVisible(true);
if (!restoringView) renderer.renderRecords();
if (sceneContext?.viewReady !== false) renderer.renderRecords();
}
},
detachRenderer() {
unregisterPickOwner(LAYER_ID, sceneEngine);
restoringView = true;
sceneContext = null;
clearTimeout(state.debounceTimer);
renderer?.destroy({ retainContext: true });
renderer = null;
sceneEngine = null;
},
resumeViewport() {
if (!restoringView) return;
restoringView = false;
if (state.enabled && renderer) {
renderer.renderRecords();
if (!state.loading) scheduleLoad();
@@ -318,11 +322,7 @@ export function createAlprCamerasLayer({
enable() {
if (state.enabled) return;
state.enabled = true;
registerPickOwner(
LAYER_ID,
(id) => state.recordById.has(id),
sceneEngine,
);
registerPickOwner(picks.owner, picks.predicate, sceneEngine);
renderer.setVisible(true);
// 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();
}
};
if (viewer || engine) this.attachRenderer(viewer, { engine });
if (viewer || engine) this.attachRenderer({ engine });
},
attachRenderer(_scene, { engine = null } = {}) {
attachRenderer({ engine = null } = {}) {
detachRuntimeListeners();
layerState.display?.destroy?.();
// The engine's display from the registry (render/display.js).
+1 -1
View File
@@ -81,7 +81,7 @@ function fixture() {
switchEngine() {
layer.detachRenderer();
engine = makeEngine();
layer.attachRenderer(null, { engine });
layer.attachRenderer({ engine });
},
card: () => entries.get('bikeshare-selected')?.[0] ?? null,
click(key) {
+8 -6
View File
@@ -24,9 +24,13 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
let initialized = false;
const lifecycle = {
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)
throw new Error('Bikeshare layer is already initialized');
renderer ??= rendererFactory({
@@ -37,7 +41,7 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
parts.display = renderer;
initialized = true;
try {
state._restoringViewport = Boolean(options.waitForCameraRestore);
state._sceneContext = options;
renderer.init();
if (state._enabled) {
const selected = state._selectedKey;
@@ -57,13 +61,11 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
}
},
resumeViewport() {
if (!state._restoringViewport) return;
state._restoringViewport = false;
if (state._enabled) parts.display?.onCameraChanged();
},
detachRenderer() {
if (!renderer) return;
state._restoringViewport = true;
state._sceneContext = null;
clearTimeout(state._cameraDebounceTimer);
const selected = state._selectedKey;
renderer.detach();
+4 -2
View File
@@ -165,7 +165,8 @@ export function createViewport({ state: layerState, services, parts, camera }) {
*/
async function runProximityCheck() {
if (!layerState._enabled || layerState._restoringViewport) return;
if (!layerState._enabled || layerState._sceneContext?.viewReady === false)
return;
const generation = ++layerState._proximityGeneration;
const altitude = getCameraAltitude();
@@ -236,7 +237,8 @@ export function createViewport({ state: layerState, services, parts, camera }) {
/** Camera change event handler — triggers a debounced proximity check. */
function onCameraChanged() {
if (!layerState._enabled || layerState._restoringViewport) return;
if (!layerState._enabled || layerState._sceneContext?.viewReady === false)
return;
scheduleProximityCheck();
}
return {
+1 -1
View File
@@ -37,7 +37,7 @@ test('disabled CCTV markers stay hidden after an engine switch', () => {
});
try {
cctvLayer.detachRenderer();
cctvLayer.attachRenderer(null, { engine: second });
cctvLayer.attachRenderer({ engine: second });
second.fake.frames(2);
const set = second.fake.moverSet('cctv');
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.
*/
function settleView() {
if (layerState._restoringViewport || !parts.renderer?.ready) return;
if (layerState._sceneContext?.viewReady === false || !parts.renderer?.ready)
return;
const changed = parts.rendering.refreshHorizonCulling();
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
@@ -242,7 +243,24 @@ export function createLifecycle({
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 = {
cameraOwner: {
suspend: () => {},
resume: () => methods.resumeViewport(),
},
/**
* Initializes the CCTV layer: loads camera sources, builds the catalog,
* restores calibration from localStorage, creates billboards, sets up click
@@ -421,9 +439,13 @@ export function createLifecycle({
}
},
attachRenderer(viewer, options = {}) {
const renderer = createRenderer(viewer, options);
layerState._restoringViewport = Boolean(options.waitForCameraRestore);
show(enabled) {
parts.renderer?.setMarkersVisible(enabled);
},
picks,
attachRenderer(options = {}) {
const renderer = createRenderer(null, options);
layerState._sceneContext = options;
renderer.createMarkers();
const active = parts.selection.getActiveRecord();
for (const record of layerState._records) {
@@ -440,15 +462,13 @@ export function createLifecycle({
record === active && record.activationDone === true;
record.activationDone = false;
}
renderer.setMarkersVisible(layerState._enabled);
bindScene(viewer, options);
if (!layerState._restoringViewport && layerState._enabled)
this.show(layerState._enabled);
bindScene(null, options);
if (layerState._sceneContext?.viewReady !== false && layerState._enabled)
this.resumeDisplay();
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && parts.renderer) this.resumeDisplay();
},
@@ -467,7 +487,7 @@ export function createLifecycle({
detachRenderer() {
if (!parts.renderer) return;
layerState._restoringViewport = true;
layerState._sceneContext = null;
parts.renderer.hideCredit();
unregisterPickOwner('cctv', parts.renderer?.engine);
if (layerState._mapStackListener) {
@@ -536,23 +556,7 @@ export function createLifecycle({
parts.renderer?.showCredit();
// Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
registerPickOwner(
'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,
);
registerPickOwner(picks.owner, picks.predicate, parts.renderer?.engine);
const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) {
parts.projection.ensureProjectionRuntime(activeRecord);
+8 -7
View File
@@ -249,12 +249,12 @@ export function createCyclonesLayer({
owner?.destroy();
}
function registerOwnership() {
registerPickOwner(
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
engine,
);
registerPickOwner(picks.owner, picks.predicate, engine);
}
const picks = {
owner: 'weather-cyclones',
predicate: (id) => enabled && rendering?.ownsPickId?.(id) === true,
};
const layer = {
id: 'weather-cyclones',
name: 'Cyclone advisories',
@@ -262,9 +262,10 @@ export function createCyclonesLayer({
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
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;
rendering =
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' }]));
await refresh;
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
await layer.attachRenderer(null, { engine: second });
await layer.attachRenderer({ engine: second });
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep152026:center', second),
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();
f.layer.suspendCamera();
f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next });
f.layer.attachRenderer({ engine: next });
f.layer.resumeCamera();
// 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();
f.layer.suspendCamera();
f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next });
f.layer.attachRenderer({ engine: next });
f.layer.resumeCamera();
f.requests[1].resolve(payload(B, A));
await settle();
+18 -9
View File
@@ -1504,6 +1504,11 @@ export function createDirectionsLayer({
// Layer module
// ---------------------------------------------------------------------------
const picks = {
owner: 'directions',
predicate: (pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
};
const directionsLayer = {
id: 'directions',
name: 'Directions',
@@ -1517,12 +1522,17 @@ export function createDirectionsLayer({
* @param {{engine?: object|null}} [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;
_display = engine ? rendererFactory(engine) : null;
_display?.show(_enabled);
this.show(_enabled);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled);
if (!_enabled) return;
const step = _selectedStep;
@@ -1532,6 +1542,10 @@ export function createDirectionsLayer({
if (_route) drawRoute(_route);
if (step !== null) _selectStep(step);
},
cameraOwner: {
suspend: () => directionsLayer.suspendCamera(),
resume: () => directionsLayer.resumeCamera(),
},
suspendCamera() {
if (!_flightId || _suspendedFlight) return;
_suspendedFlight = services.camera.suspendRouteFlight?.(_flightId);
@@ -1556,12 +1570,7 @@ export function createDirectionsLayer({
_display?.show(true);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
_installClickHandler();
services.picking.registerPickOwner(
'directions',
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
_engine,
);
services.picking.registerPickOwner(picks.owner, picks.predicate, _engine);
syncRenderHold();
},
@@ -315,7 +315,7 @@ test('an engine switch redraws the route, dots and markers on the new engine onl
t.after(() => next.destroy());
f.layer.suspendCamera();
f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next.engine });
f.layer.attachRenderer({ engine: next.engine });
f.layer.resumeCamera();
assert.equal(f.engine.movers.list().length, 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');
initialized = true;
try {
if (engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer({ engine });
} catch (error) {
this.destroy();
throw error;
@@ -172,12 +172,16 @@ export function createDroneAirspaceLayer({
},
/** 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)
throw new Error('Invalid drone airspace scene attachment');
sceneEngine = engine;
renderer = rendererFactory({ engine });
renderer.setVisible(_enabled);
this.show(_enabled);
if (_rows.length) renderer.replace(_rows);
if (_enabled) {
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 } });
layer.detachRenderer();
assert.equal(renderers[0].destroyed, true);
layer.attachRenderer(null, { engine: 'navara' });
layer.attachRenderer({ engine: 'navara' });
const [, next] = renderers;
assert.equal(next.engine, 'navara');
assert.equal(next.visible, true);
+7 -3
View File
@@ -65,7 +65,7 @@ export function createEarthquakesLayer({
throw new Error('Earthquake layer is already initialized');
initialized = true;
try {
if (engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer({ engine });
} catch (error) {
this.destroy();
throw error;
@@ -73,13 +73,17 @@ export function createEarthquakesLayer({
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)
throw new Error('Invalid earthquake scene attachment');
sceneEngine = engine;
_renderer = rendererFactory({ engine });
if (!_renderer) throw new Error('Earthquake renderer unavailable');
_renderer.setVisible(_enabled);
this.show(_enabled);
if (discs.length) _renderer.replace(discs);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, _enabled);
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.lat, row.lat);
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.events.length, 1);
assert.equal(polls, 1);
@@ -180,7 +180,7 @@ for (const makeHarness of GROUND_HARNESSES) {
f.layer.detachRenderer();
assert.equal(f.sets(), 0);
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').shown, true);
f.layer.destroy();
+19 -12
View File
@@ -26,27 +26,36 @@ export function createLifecycle({
layerState._handoffHeat = -1;
}
const picks = {
owner: id,
predicate: (pickedId) => layerState._pickIndexById.has(pickedId),
};
const methods = {
init(viewer, options) {
this.attachRenderer(viewer, options);
this.attachRenderer(options);
},
/** Draws on `engine` (the scene's globe engine); without one, nothing. */
attachRenderer(
_viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
show(enabled) {
layerState._renderer?.setVisible(enabled);
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._renderer || !engine) return;
layerState._renderer = config.renderer.create({ engine, id, services });
if (!layerState._renderer) return;
layerState._view = layerState._renderer.view;
layerState._renderer.setVisible(false);
layerState._restoringViewport = waitForCameraRestore;
if (layerState._enabled && !waitForCameraRestore) this.showRenderer();
if (layerState._enabled && context.viewReady !== false)
this.showRenderer();
// Enabled before a renderer: the load enable() could not start.
if (
layerState._enabled &&
!waitForCameraRestore &&
context.viewReady !== false &&
!layerState._fires.length &&
!layerState._loading
)
@@ -54,8 +63,6 @@ export function createLifecycle({
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._renderer) {
this.showRenderer();
if (!layerState._fires.length && !layerState._loading)
@@ -98,8 +105,8 @@ export function createLifecycle({
components.viewport.installMoveEndWatcher();
components.selection.installClickHandler();
registerPickOwner(
id,
(pickedId) => layerState._pickIndexById.has(pickedId),
picks.owner,
picks.predicate,
layerState._renderer?.engine,
);
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).
*/
_bindInteractionForTest(engine, fires = []) {
if (!layerState._renderer) this.attachRenderer(null, { engine });
if (!layerState._renderer) this.attachRenderer({ engine });
layerState._renderer.setVisible(true);
layerState._enabled = true;
layerState._allFires = fires;
+10 -3
View File
@@ -15,8 +15,15 @@ export function createLifecycle({ flightState, services, parts, layer }) {
enable: () => methods.enable(),
});
const picks = {
owner: 'flights',
predicate: (pickedId) => flightState._billboards.has(pickedId),
};
const methods = {
picks,
attachRenderer: binding.attachRenderer,
cameraOwner: binding.cameraOwner,
show: binding.show,
detachRenderer: binding.detachRenderer,
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
@@ -93,7 +100,7 @@ export function createLifecycle({ flightState, services, parts, layer }) {
flightState.initialized = true;
try {
if (viewer || engine) methods.attachRenderer(viewer, { engine });
if (viewer || engine) methods.attachRenderer({ engine });
} catch (error) {
methods.destroy(viewer);
throw error;
@@ -129,8 +136,8 @@ export function createLifecycle({ flightState, services, parts, layer }) {
if (!renderer) return;
parts.tracking._installClickHandler();
registerPickOwner(
'flights',
(pickedId) => flightState._billboards.has(pickedId),
picks.owner,
picks.predicate,
flightState._renderer?.engine,
);
// 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.getAnalystRecords().length, 1);
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.trackById('71be26'), true);
layer.detachRenderer();
@@ -246,7 +246,7 @@ test('a live flight keeps observations, selection and freshness across renderer
assert.equal(layer.getTrackedInfo().icao24, '71be26');
await layer.update(null);
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(layer.qaDrawn().length, 1);
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,
renderer: {
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);
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
// layer, then fails and releases what it attached.
layer.detachRenderer(working.viewer);
layer.attachRenderer(null, { engine: candidate });
layer.attachRenderer({ engine: candidate });
assert.equal(candidate.camera.following()?.label?.id, 'flights:def456');
layer.detachRenderer(null);
// The rollback: the working engine attaches again and follows the same
// 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(working.viewer.trackedEntity?.gevTrackedId, 'flights:def456');
assert.deepEqual(navigations, [], 'the rollback is not new navigation');
// The retry onto a new Cesium engine.
layer.detachRenderer(working.viewer);
retried = cesium();
layer.attachRenderer(retried.viewer, { engine: retried.engine });
layer.attachRenderer({ engine: retried.engine });
assert.equal(layer.getTrackedInfo()?.icao24, 'def456');
assert.equal(retried.viewer.trackedEntity?.gevTrackedId, 'flights:def456');
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);
push(catalog('v3'));
layer.detachRenderer();
await layer.attachRenderer(null, { engine: { name: 'navara' } });
await layer.attachRenderer({ engine: { name: 'navara' } });
v2.resolve(kmz('edition two'));
v3.resolve(kmz('edition three'));
// 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'));
// The engine changes while the broken file is still on its way.
layer.detachRenderer();
await layer.attachRenderer(null, { engine: { name: 'navara' } });
await layer.attachRenderer({ engine: { name: 'navara' } });
broken.resolve(new Uint8Array([1, 2, 3, 4]));
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() {
if (!canSelect() || _clickHandler || !_display) return;
picking.registerPickOwner?.(
GHOSTMAPS_LAYER_ID,
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
sceneEngine,
);
picking.registerPickOwner?.(picks.owner, picks.predicate, sceneEngine);
_clickHandler = _display.onClick((click) => {
if (pointer && !pointer.isPointerFree()) return;
const cardHit = overlayHost.hitTest?.(
@@ -576,6 +571,11 @@ export function createGhostMapsLayer({
return count;
}
const picks = {
owner: GHOSTMAPS_LAYER_ID,
predicate: (pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX),
};
const layer = {
id: GHOSTMAPS_LAYER_ID,
name: 'S2 GhostMaps',
@@ -586,10 +586,11 @@ export function createGhostMapsLayer({
updateInterval: 300000,
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 (typeof createRenderer !== 'function')
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'],
);
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.created.length, 1);
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.
layer.detachRenderer();
const attached = layer.attachRenderer(null, { engine: { name: 'navara' } });
const attached = layer.attachRenderer({ engine: { name: 'navara' } });
await new Promise(setImmediate);
assert.equal(gates.length, 1);
// 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');
initialized = true;
try {
if (engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer({ engine });
} catch (error) {
this.destroy();
throw error;
}
},
attachRenderer(viewer, { engine = null } = {}) {
show(enabled) {
renderer?.setVisible(enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid GNSS scene attachment');
renderer = rendererFactory({ engine });
@@ -180,7 +183,7 @@ export function createGnssIntegrityLayer({
);
_signature = null;
if (_enabled) expire();
renderer.setVisible(_enabled);
this.show(_enabled);
},
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(h.ground.sets(), 0);
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(calls, 1);
assert.equal(h.camera.changed.numberOfListeners, 1);
+2 -2
View File
@@ -136,9 +136,9 @@ export function createInfrastructureSessionLayer(
updateInterval: 0,
statsRefreshInterval: 1000,
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');
display = engine ? createDisplay(engine, config, services) : null;
// 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;
assert.equal(layer.getAnalystRecords().length, 2);
assert.equal(captures.length, 0);
await layer.attachRenderer(null, { engine: {} });
await layer.attachRenderer({ engine: {} });
layer.detachRenderer();
await layer.attachRenderer(null, { engine: {} });
await layer.attachRenderer({ engine: {} });
assert.equal(captures[0], captures[1]);
assert.deepEqual(
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);
layer.detachRenderer();
const scene = new AbortController();
const attaching = layer.attachRenderer(null, {
const attaching = layer.attachRenderer({
engine: {},
signal: scene.signal,
});
@@ -385,6 +385,6 @@ test('a scene switch restores the selected feature on the next display', async (
selectEntityContext(record.entity);
env.layer.detachRenderer();
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');
});
+6 -3
View File
@@ -28,10 +28,13 @@ export function createInstallationsLayer({
let renderer = null;
const lifecycle = {
init(viewer, options = {}) {
if (viewer || options.engine)
return lifecycle.attachRenderer(viewer, options);
if (viewer || options.engine) return lifecycle.attachRenderer(options);
},
attachRenderer(viewer, options = {}) {
picks: {
owner: 'military-installations',
predicate: (id) => state.recordById.has(id),
},
attachRenderer(options = {}) {
if (renderer)
throw new Error('Installations layer is already initialized');
const engine = options.engine ?? null;
+26 -17
View File
@@ -96,6 +96,23 @@ export function createLifecycle({
}
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.
init(_scene, options) {
layerState._count = 0;
@@ -103,20 +120,19 @@ export function createLifecycle({
layerState._stale = false;
layerState._lastError = null;
layerState._orbitMatches = 0;
this.attachRenderer(null, options);
this.attachRenderer(options);
},
attachRenderer(
_scene,
{ engine = null, waitForCameraRestore = false } = {},
) {
show(enabled) {
getRenderer()?.setVisible(enabled);
},
attachRenderer(context = {}) {
const { engine = null } = context;
layerState._sceneContext = context;
if (engine) attachDisplay(engine);
layerState._restoringViewport = true;
if (!waitForCameraRestore) this.resumeViewport();
if (!context.viewRestored) this.resumeViewport();
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._replayCameraLaunchId) {
parts.replay.startMissionReplay(layerState._replayCameraLaunchId, {
resuming: true,
@@ -141,14 +157,7 @@ export function createLifecycle({
layerState._waitForRenderer ??= new Promise((resolve) => {
layerState._resumeRenderer = resolve;
});
layerState._replayCameraHeading = layerState._replayTracks.get(
layerState._replayCameraLaunchId,
)?.lastCameraHeading;
layerState._replayCameraToken++;
layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.();
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover =
null;
methods.cameraOwner.suspend();
layerState._clickHandler?.destroy();
layerState._moveHandler?.destroy();
layerState._declutterHandler?.();
+1
View File
@@ -376,6 +376,7 @@ export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
return {
engine,
ownsPick: owns,
occluder,
/** The engine frames seen so far (preUpdate). */
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 display = createLaunchesDisplay(engine);
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;
const clicks = [];
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;
engine.fake.input('click', { position: { x: 1, y: 2 } });
assert.equal(clicks.length, 3);
point.remove();
assert.equal(display.ownsPick('rocket-launch:a'), false);
display.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();
clock.now += 1_000;
await layer.update();
layer.attachRenderer(null, { engine: createFakeEngine() });
layer.attachRenderer({ engine: createFakeEngine() });
await layer.update();
assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once');
+10 -9
View File
@@ -603,6 +603,7 @@ export function createLocalAdsbLayer({
return rows;
}
const picks = { owner: LAYER_ID, predicate: (id) => markers.has(id) };
return {
id: LAYER_ID,
name: LAYER_NAME,
@@ -632,11 +633,15 @@ export function createLocalAdsbLayer({
});
unsubscribe = receiver.subscribe?.(scheduleSync) || null;
unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null;
return this.attachRenderer(nextViewer, options);
return this.attachRenderer(options);
},
attachRenderer(nextViewer, { engine = null } = {}) {
renderer = services.renderer.create({ viewer: nextViewer, engine });
show(enabled) {
renderer?.setVisible(enabled);
},
picks,
attachRenderer({ engine = null } = {}) {
renderer = services.renderer.create({ engine });
renderer.attach();
if (renderer.models) {
models = createLocalAdsbModels({
@@ -650,7 +655,7 @@ export function createLocalAdsbLayer({
const restore = selectedId;
selectedId = null;
renderer.setVisible(true);
registerPickOwner(LAYER_ID, (id) => markers.has(id), renderer?.engine);
registerPickOwner(picks.owner, picks.predicate, renderer?.engine);
installInteraction();
installFrame();
sync();
@@ -687,11 +692,7 @@ export function createLocalAdsbLayer({
async enable() {
enabled = true;
renderer?.setVisible(true);
registerPickOwner(
LAYER_ID,
(pickedValue) => markers.has(pickedValue),
renderer?.engine,
);
registerPickOwner(picks.owner, picks.predicate, renderer?.engine);
installInteraction();
installFrame();
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();
assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123');
layer.attachRenderer(null, { engine: fakeEngine() });
layer.attachRenderer({ engine: fakeEngine() });
assert.deepEqual(receiver.modeCalls, ['adsb']);
assert.deepEqual(feeds.calls, ['start']);
assert.equal(closes, 0);
+10 -3
View File
@@ -14,8 +14,15 @@ export function createLifecycle({ flightState, services, parts, layer }) {
parts,
enable: () => methods.enable(),
});
const picks = {
owner: 'military',
predicate: (pickedId) => flightState._billboards.has(pickedId),
};
const methods = {
picks,
attachRenderer: binding.attachRenderer,
cameraOwner: binding.cameraOwner,
show: binding.show,
detachRenderer: binding.detachRenderer,
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
@@ -76,7 +83,7 @@ export function createLifecycle({ flightState, services, parts, layer }) {
flightState.initialized = true;
try {
if (viewer || engine) binding.attachRenderer(viewer, { engine });
if (viewer || engine) binding.attachRenderer({ engine });
} catch (error) {
methods.destroy(viewer);
throw error;
@@ -113,8 +120,8 @@ export function createLifecycle({ flightState, services, parts, layer }) {
if (!renderer) return;
parts.tracking._installClickHandler();
registerPickOwner(
'military',
(pickedId) => flightState._billboards.has(pickedId),
picks.owner,
picks.predicate,
flightState._renderer?.engine,
);
// 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,
renderer: {
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);
return renderer;
},
@@ -311,14 +315,14 @@ test('military classification and the selected aircraft survive an update betwee
await layer.update(null);
assert.equal(layer.hasContact('AE0001'), true);
assert.equal(layer.getAnalystRecords().length, 1);
layer.attachRenderer(null, { engine: first });
layer.attachRenderer({ engine: first });
assert.equal(layer.trackById('ae0001'), true);
layer.detachRenderer();
assert.equal(binding.follow.current(), undefined);
assert.equal(militaryRegistry.isMilitaryLayerActive(), true);
assert.equal(layer.getTrackedInfo().icao24, 'ae0001');
await layer.update(null);
layer.attachRenderer(null, { engine: second });
layer.attachRenderer({ engine: second });
assert.ok(binding.follow.current());
assert.equal(layer.getStats().lastUpdate, 1700000002000);
assert.equal(polls, 2);
+8 -4
View File
@@ -97,16 +97,20 @@ export function createOsmPlacesLayer({
})
.catch(() => {});
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. */
attachRenderer(viewer, { engine = null } = {}) {
show(enabled) {
renderer?.setVisible(enabled);
overlayHost.setVisible(OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid OSM Places scene attachment');
sceneEngine = engine;
renderer = createRenderer({ viewer, engine });
renderer.setVisible(enabled);
renderer = createRenderer({ engine });
this.show(enabled);
if (enabled) overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
render();
},
+1 -1
View File
@@ -132,7 +132,7 @@ for (const makeHarness of HARNESSES) {
layer.detachRenderer();
assert.equal(count(h.engine), undefined, 'the points go with the globe');
const next = makeHarness();
layer.attachRenderer(null, { engine: next.engine });
layer.attachRenderer({ engine: next.engine });
assert.equal(count(next.engine), 2);
assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed');
layer.disable();
+7 -3
View File
@@ -277,19 +277,23 @@ export function createFirePerimetersLayer({
throw new Error('Fire perimeter layer is already initialized');
initialized = true;
try {
if (engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer({ engine });
} catch (error) {
this.destroy();
throw error;
}
},
attachRenderer(viewer, { engine = null } = {}) {
show(enabled) {
renderer?.setVisible(enabled);
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, enabled);
},
attachRenderer({ engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid fire perimeter scene attachment');
sceneEngine = engine;
renderer = createRenderer({ engine });
renderer.setVisible(_enabled);
this.show(_enabled);
if (geometry.size) renderer.replace([...geometry.values()]);
if (_enabled) {
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);
const record = h.layer.getAnalystRecords()[0];
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[0].rings[0][0][0], ring[0][0] + 0.1);
assert.equal(
+6 -2
View File
@@ -15,9 +15,13 @@ export function createLifecycle({
const { clearSource, setVisible } = services.overlays;
const methods = {
picks: {
owner: 'radio',
predicate: (id) => parts.renderer?.ownsPick(id) ?? false,
},
/** Initialize scene resources and the single audio element. */
init(viewer, options = {}) {
this.attachRenderer(viewer, options);
this.attachRenderer(options);
layerState._sessionGeneration += 1;
parts.clustering.resetRadioClusterOverlayIdentities();
parts.playback.installAudio();
@@ -25,7 +29,7 @@ export function createLifecycle({
setVisible(RADIO_OVERLAY_SOURCE_ID, false);
},
attachRenderer(viewer, options = {}) {
attachRenderer(options = {}) {
if (parts.renderer?.ready)
throw new Error('Radio layer is already initialized');
if (typeof rendererFactory !== 'function')
+2 -1
View File
@@ -305,9 +305,10 @@ export function createRadioDisplay(engine, { state, services, parts }) {
drillPick(position, limit) {
return engine.picker.drillPick(position, limit);
},
ownsPick: (id) => String(id).startsWith(RADIO_PREFIX),
installClick(callback) {
removeClick = selectionDispatcherFor(engine).register('radio', {
owns: (id) => String(id).startsWith(RADIO_PREFIX),
owns: renderer.ownsPick,
onClick: ({ position }) => callback(position),
});
return removeClick;
+5
View File
@@ -188,6 +188,10 @@ for (const makeHarness of HARNESSES) {
);
near(station.pixelSize, 13, 'station size');
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(
cluster.pixelSize,
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);
near(bracket.pixelSize, 40, 'bracket size');
assert.equal(bracket.id, `radio:selected:${clusterId}`);
assert.equal(f.layer.picks.predicate(bracket.id), true);
{
const { options } = f.engine.fake.moverSet('radio-selected');
assert.equal(options.point.sizePx, 14);
+2 -2
View File
@@ -775,10 +775,10 @@ export function createRecentImageryLayer({
updateInterval: 0,
init(_viewer, options) {
return this.attachRenderer(null, options);
return this.attachRenderer(options);
},
attachRenderer(_viewer, { engine } = {}) {
attachRenderer({ engine } = {}) {
_engine = engine || null;
if (rendererFactory && !renderer) renderer = rendererFactory({ engine });
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 * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createLayerSceneContext } from '../../data/layerSceneBinding.js';
import { createSatellitesDisplay } from './render/display.js';
import { constantRgba } from '../shared/color.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();
t.after(() => engine.destroy());
f.parts.lifecycle.methods.attachRenderer(null, {
let restoreView;
const context = createLayerSceneContext({
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);
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');
@@ -557,7 +568,10 @@ test('an engine switch retains the held subject and dims the replacement rings',
ringColors({ ...f, engine }, ISS_NORAD).color[3],
issPath.baseColor.alpha * COCKPIT_ORBIT_DIM_FACTOR,
);
restoreView();
await context.viewRestored;
f.parts.lifecycle.methods.resumeViewport();
assert.equal(engine.camera.following().native, f.state._trackedEntity);
engine.fake.frame();
assert.equal(dotShown(f), false);
f.methods.setCockpitSubject(null);
+4 -1
View File
@@ -456,7 +456,10 @@ export function createControls({ state: layerState, services, parts, source }) {
!layerState._points.has(id)
)
return false;
if (layerState._restoringViewport && layerState._trackedNorad === id)
if (
layerState._sceneContext?.viewReady === false &&
layerState._trackedNorad === id
)
return true;
parts.tracking._cancelPendingTrackingRestore();
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: {},
waitForCameraRestore: true,
viewRestored: new Promise(() => {}),
});
assert.equal(rotated, 1);
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();
// Wall time passes during the switch; the stopped world clock does not.
t.mock.timers.tick(15 * 60_000);
layer.attachRenderer(null, { engine: second });
layer.attachRenderer({ engine: second });
const after = observe(layer, second);
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 { registerPickOwner, unregisterPickOwner } = services.picking;
const picks = {
owner: 'satellites',
predicate: (pickedId) => {
const norad = Number(pickedId);
return Number.isFinite(norad) && layerState._points.has(norad);
},
};
const methods = {
cameraOwner: {
suspend: () => methods.suspendCamera(),
resume: () => methods.resumeViewport(),
},
// Catalog data refresh; propagation remains preRender-owned.
// 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);
},
attachRenderer(
_viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
show(enabled) {
if (layerState._pointCollection)
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;
const renderer = engine
? (services.renderer.create({ engine }) ?? null)
@@ -196,13 +220,11 @@ export function createLifecycle({
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
// The restored camera runs before a retained follow gets its new native target.
layerState._restoringViewport = true;
if (!waitForCameraRestore) this.resumeViewport();
if (!context.viewRestored) this.resumeViewport();
},
resumeViewport() {
if (!layerState._restoringViewport || !layerState._renderer) return;
layerState._restoringViewport = false;
if (!layerState._renderer) return;
if (layerState._enabled) {
if (layerState._trackedNorad != null && !layerState._trackedEntity)
parts.tracking._trackSatellite(layerState._trackedNorad, {
@@ -239,14 +261,7 @@ export function createLifecycle({
if (layerState._renderer) parts.interaction._installClickHandler();
// Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers;
// the registry hands predicates String()-coerced ids, so match via Number().
registerPickOwner(
'satellites',
(pickedId) => {
const norad = Number(pickedId);
return Number.isFinite(norad) && layerState._points.has(norad);
},
layerState._engine,
);
registerPickOwner(picks.owner, picks.predicate, layerState._engine);
if (!layerState._preRenderListener && layerState._renderer) {
layerState._preRenderListener = getRenderer().onFrame(
parts.rendering._preRenderTick,
+24 -17
View File
@@ -78,7 +78,7 @@ export function createStreetLevelLayer({
isAvailable: (entry) => providerAvailable(entry),
});
let waitingForView = false;
let sceneContext = null;
let notifyQueued = false;
function notify() {
if (notifyQueued) return;
@@ -113,7 +113,7 @@ export function createStreetLevelLayer({
getFilter: () => resolveFilter(state.filter, streetLevelTime()),
/** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on,
waitingForView: () => waitingForView,
viewReady: () => Boolean(state.map) && sceneContext?.viewReady !== false,
notify,
actions: {
/**
@@ -510,21 +510,23 @@ export function createStreetLevelLayer({
},
init(scene, options) {
this.attachRenderer(scene, options);
this.attachRenderer(options);
for (const entry of state.providers.values()) checkStatus(entry);
},
/** Draws on `engine` through its display; the first argument is not read. */
attachRenderer(
_scene,
{ engine = null, waitForCameraRestore = false } = {},
) {
/** Draws on `engine` through its display. */
show(enabled) {
parts.marker.setVisible(enabled);
},
picks: { owner: STREET_LEVEL_LAYER_ID, predicate: parts.router.ownsPick },
attachRenderer(context = {}) {
const { engine = null } = context;
sceneContext = context;
const createDisplay = services.createDisplay ?? createStreetLevelDisplay;
const map = engine ? (createDisplay(engine) ?? null) : null;
state.map = map;
waitingForView = waitForCameraRestore;
if (!map) return;
parts.marker.ensure(map);
parts.marker.setVisible(state.enabled);
this.show(state.enabled);
for (const entry of state.providers.values()) {
entry.instance.init(map);
if (state.enabled && entry.on) {
@@ -544,18 +546,23 @@ export function createStreetLevelLayer({
remeasureGround();
}
},
cameraOwner: {
suspend: () => parts.framing.cancel(),
resume() {
if (state.enabled && state.map) {
for (const entry of activeEntries())
entry.instance.refreshCoverage?.();
remeasureGround();
}
},
},
resumeViewport() {
if (!waitingForView) return;
waitingForView = false;
if (state.enabled && state.map) {
for (const entry of activeEntries()) entry.instance.refreshCoverage?.();
remeasureGround();
}
this.cameraOwner.resume();
},
detachRenderer() {
const map = state.map;
if (!map) return;
waitingForView = true;
sceneContext = null;
removeCameraListener?.();
removeCameraListener = null;
cancelGroundRetry();
+1 -1
View File
@@ -373,7 +373,7 @@ export function createLineCoverage({
const map = state.map;
if (
!map ||
state.context.waitingForView?.() ||
state.context.viewReady?.() === false ||
!state.context.isActive() ||
state.coverage.blocked
)
@@ -128,6 +128,18 @@ function setup({ zoomFor, harness = null } = {}) {
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 () => {
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
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).
*/
async function select(sequenceId, { force = false } = {}) {
if (!sequenceId || (!state.map && !state.context.waitingForView?.()))
if (!sequenceId || (!state.map && state.context.viewReady?.() !== false))
return;
// Already on screen, or already on its way.
if (
@@ -276,7 +276,7 @@ test('without a display the cones draw nothing and do not throw', async () => {
});
state.map = null;
sequences.ensureCollections(null);
state.context.waitingForView = () => true;
state.context.viewReady = () => false;
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
+2 -2
View File
@@ -76,9 +76,9 @@ export function createCableSessionLayer({ source, createDisplay }) {
updateInterval: 0,
statsRefreshInterval: 500,
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;
// 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()
+3 -3
View File
@@ -25,7 +25,7 @@ test('cables arriving during handoff belong to the incoming display', async () =
layer.init(null, { engine: 'outgoing' });
const pending = layer.enable();
layer.detachRenderer();
const handoff = layer.attachRenderer(null, { engine: 'incoming' });
const handoff = layer.attachRenderer({ engine: 'incoming' });
const dataset = { cables: { features: [] }, landingPoints: { features: [] } };
resolve(dataset);
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();
layer.detachRenderer();
const scene = new AbortController();
const attaching = layer.attachRenderer(null, {
const attaching = layer.attachRenderer({
engine: 'released',
signal: scene.signal,
});
@@ -66,7 +66,7 @@ test('a released scene stops waiting for a stalled cable load, which stays in th
resolve(dataset);
await pending;
assert.deepEqual(shown, []);
await layer.attachRenderer(null, { engine: 'next' });
await layer.attachRenderer({ engine: 'next' });
assert.deepEqual(shown, [['next', dataset]]);
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 };
Object.assign(state, {
_enabled: true,
_restoringViewport: true,
_fetching: false,
_surfaceInput: surfaceInput,
_lastBounds: bounds,
+9 -10
View File
@@ -84,7 +84,7 @@ export function createLifecycle({
* @param {{ engine?: object, waitForCameraRestore?: boolean }} [options]
*/
init(_viewer, options) {
this.attachRenderer(_viewer, options);
this.attachRenderer(options);
layerState._dots = [];
layerState._roads = [];
layerState._count = 0;
@@ -128,15 +128,16 @@ export function createLifecycle({
console.log('[Data:Traffic] Initialized');
},
attachRenderer(
_viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
layerState._restoringViewport = waitForCameraRestore;
show(enabled) {
layerState._display?.show(enabled && layerState._showDots);
},
attachRenderer(context = {}) {
const { engine = null } = context;
layerState._sceneContext = context;
layerState._engine = engine;
layerState._display = engine ? services.createRenderer(engine) : null;
layerState._display?.show(layerState._enabled && layerState._showDots);
if (!waitForCameraRestore) readyForSurfaceWork();
this.show(layerState._enabled);
if (context.viewReady !== false) readyForSurfaceWork();
if (layerState._enabled) {
bindScene();
parts.animation.drawDots();
@@ -157,8 +158,6 @@ export function createLifecycle({
}
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
readyForSurfaceWork();
if (!layerState._enabled) return;
if (layerState._populationPending) parts.retention.resume();
+2 -2
View File
@@ -175,7 +175,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
if (
!layerState._enabled ||
layerState._waitForScene ||
layerState._restoringViewport
layerState._sceneContext?.viewReady === false
)
return;
clearTimeout(layerState._fetchTimeout);
@@ -203,7 +203,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
if (
!layerState._enabled ||
layerState._waitForScene ||
layerState._restoringViewport
layerState._sceneContext?.viewReady === false
)
return;
const alt = getCameraAltitude();
+19 -12
View File
@@ -104,6 +104,10 @@ export function createLifecycle({
hidden = false;
}
const picks = {
owner: 'transit',
predicate: (pickedId) => state._vehicles.has(pickedId),
};
const methods = {
id: 'transit',
name: 'Transit',
@@ -114,10 +118,19 @@ export function createLifecycle({
* Create the marker sets. Called once at bootstrap.
*/
init(viewer, options) {
this.attachRenderer(viewer, options);
this.attachRenderer(options);
},
attachRenderer(viewer, options = {}) {
state._restoringViewport = Boolean(options.waitForCameraRestore);
show(enabled) {
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)
throw new Error('Transit layer is already initialized');
if (typeof rendererFactory !== 'function')
@@ -151,7 +164,7 @@ export function createLifecycle({
entry.pathRevision = -1;
}
this.bindScene();
if (!state._restoringViewport) this.repaint();
if (state._sceneContext?.viewReady !== false) this.repaint();
}
bindStyleEvents();
console.log(
@@ -179,11 +192,7 @@ export function createLifecycle({
);
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true);
parts.selection.installClickHandler();
registerPickOwner(
'transit',
(pickedId) => state._vehicles.has(pickedId),
parts.renderer?.engine,
);
registerPickOwner(picks.owner, picks.predicate, parts.renderer?.engine);
if (!state._cameraChangedAttached) {
state._cameraChangedRemove = parts.renderer.onCameraChanged(
parts.viewport.onCameraChanged,
@@ -213,13 +222,11 @@ export function createLifecycle({
);
},
resumeViewport() {
if (!state._restoringViewport) return;
state._restoringViewport = false;
if (state._enabled && parts.renderer) this.repaint();
},
detachRenderer() {
if (!parts.renderer) return;
state._restoringViewport = true;
state._sceneContext = null;
unbindVisibility();
clearInterval(state._maintenanceTimer);
clearTimeout(state._cameraDebounceTimer);
+6 -2
View File
@@ -76,7 +76,11 @@ export function createViewport({ state, services, parts }) {
globalThis.document?.visibilityState === 'hidden'
)
return;
if (!state._enabled || !parts.renderer?.ready || state._restoringViewport)
if (
!state._enabled ||
!parts.renderer?.ready ||
state._sceneContext?.viewReady === false
)
return;
refreshViewBounds();
const altitude = getCameraAltitude();
@@ -125,7 +129,7 @@ export function createViewport({ state, services, parts }) {
}
function onCameraChanged() {
if (state._restoringViewport) return;
if (state._sceneContext?.viewReady === false) return;
// 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
// vehicles under the cursor to start moving again.
+17 -14
View File
@@ -10,7 +10,7 @@ export function createLifecycle({
options,
}) {
const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
const { restoreSpriteOrderOnEnable } = services.sprites;
const { releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -138,6 +138,10 @@ export function createLifecycle({
state.trailMmsi = null;
state.trailBackfillToken = 0;
}
const picks = {
owner: 'ais-live-vessels',
predicate: (pickedId) => state.records.byMmsi.has(pickedId),
};
const methods = {
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
@@ -160,23 +164,26 @@ export function createLifecycle({
init(viewer, { engine = null } = {}) {
if (typeof vesselState._source?.getSnapshot !== 'function')
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 } = {}) {
state.viewer = viewer;
state.renderer ??= createRenderer(viewer, engine);
const scene = viewer || state.renderer?.view || null;
show(enabled) {
components.rendering.setVisible(enabled);
},
picks,
attachRenderer({ engine = null } = {}) {
state.viewer = null;
state.renderer ??= createRenderer(null, engine);
const scene = state.renderer?.view || null;
components.rendering.ensureCollections(scene);
components.selection.installInteraction(scene);
components.rendering.installRuntime(scene);
restoreSpriteOrder(viewer);
components.rendering.setVisible(state.feed.enabled);
this.show(state.feed.enabled);
const horizon = components.rendering.makeOccluder();
for (const record of state.records.all)
components.rendering.addRecordPrimitives(record, horizon);
if (!state.feed.enabled) return;
bindVisibleScene(viewer, scene);
bindVisibleScene(null, scene);
components.tracking.attachSelectedVesselTrail();
components.rendering.updateVisibility(true);
if (state.selectedRecord)
@@ -288,11 +295,7 @@ export function createLifecycle({
function bindVisibleScene(activeViewer, scene) {
// Only MMSI picks present in the live record map belong to this layer.
registerPickOwner(
'ais-live-vessels',
(pickedId) => state.records.byMmsi.has(pickedId),
state.renderer?.engine,
);
registerPickOwner(picks.owner, picks.predicate, state.renderer?.engine);
restoreSpriteOrderOnEnable('ais', activeViewer);
// Vessels are asked for by view, so ask again when the view moves away.
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.ok(layer.findByQuery('222').position);
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);
const selected = [...recorders[1].sprites].find(({ id }) => id === record);
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?.addEventListener?.('change', 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;
rendering = engine
? (createNativeRendering(engine, { onChange: notify }) ?? null)
+12 -7
View File
@@ -522,11 +522,8 @@ export function createLifecycle({ state, services, parts }) {
services.overlayHost?.setVisible?.(id, true);
parts.selection.install();
services.picking?.registerPickOwner?.(
'wildlife-sightings',
(id) =>
typeof id === 'string' &&
id.startsWith('wildlife:') &&
state.rowById.has(id.slice(9)),
picks.owner,
picks.predicate,
state.engine,
);
state.removeCamera =
@@ -572,16 +569,24 @@ export function createLifecycle({ state, services, parts }) {
return true;
}
const picks = {
owner: 'wildlife-sightings',
predicate: (id) =>
typeof id === 'string' &&
id.startsWith('wildlife:') &&
state.rowById.has(id.slice(9)),
};
const methods = {
/** Attach this layer to the scene's globe engine. */
init(_viewer, options = {}) {
if (state.engine)
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,
* 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.engine)
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 next = h.globe(other);
const nextCells = watchCells(next);
assert.equal(h.layer.attachRenderer(null, { engine: next }), true);
assert.equal(h.layer.attachRenderer({ engine: next }), true);
h.flushIdle();
assert.deepEqual(drawn(next), before, 'the same badges, drawn the same');
assert.deepEqual(style(next), beforeStyle);
+3 -5
View File
@@ -61,7 +61,6 @@ export function createWindLayer({
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Wind requires a snapshot source');
let viewer = null;
let engine = null;
let request = null;
let enabled = false;
@@ -112,10 +111,9 @@ export function createWindLayer({
source: 'GFS / ECMWF IFS · FORECAST',
updateInterval: 3600_000,
init(nextViewer, options) {
layer.attachRenderer(nextViewer, options);
layer.attachRenderer(options);
},
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
viewer = nextViewer;
attachRenderer({ engine: nextEngine = null } = {}) {
engine = nextEngine;
if (!engine) return;
rendering = createNativeRendering(engine, { onStatusChange: notify });
@@ -135,7 +133,7 @@ export function createWindLayer({
inspectionMarker?.destroy();
inspectionMarker = null;
rendering?.destroy();
rendering = viewer = engine = null;
rendering = engine = null;
},
attachShellServices() {
rendering?.rehome?.();
-1
View File
@@ -89,7 +89,6 @@ window.addEventListener('popstate', () => {
function createApplication(kind, engineSwitch, transition) {
session.count('applications');
layerSession.restoringView = engineSwitch;
application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
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
* visited entry where it differs (handoffTargetEnabled).
* @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(
session,
{ addressLayerIds = null, visit = false } = {},
) {
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();
export function handOffSession(session) {
for (const id of session.handoff.cameraOwners)
session.manager.layers.get(id)?.module.cameraOwner.suspend();
session.handoff.director?.suspendScene();
}
/** Resume what handOffSession parked, once the incoming view is restored. */
export function resumeSession(session) {
for (const { module } of session.manager.layers.values())
module.resumeViewport?.();
if (session.handoffLayers)
session.manager.layers.get('directions')?.module.resumeCamera?.();
session.director?.resumeScene();
/** Resume the Director once the incoming view and layer cameras are restored. */
export async function resumeSession(session) {
await session.manager.resumeScene?.();
(session.handoff?.director ?? session.director)?.resumeScene();
}
/**
+17 -12
View File
@@ -1,3 +1,4 @@
import { createSessionHandoff } from '../app/sessionHandoff.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -75,8 +76,10 @@ function fakeSession(log) {
'directions',
{
module: module('directions', {
suspendCamera: () => log.push('suspend:directions'),
resumeCamera: () => log.push('resume:directions'),
cameraOwner: {
suspend: () => log.push('suspend:directions'),
resume: () => log.push('resume:directions'),
},
}),
},
],
@@ -84,15 +87,19 @@ function fakeSession(log) {
'satellites',
{
module: module('satellites', {
suspendCamera: () => log.push('suspend:satellites'),
cameraOwner: { suspend: () => log.push('suspend:satellites'), resume() {} },
}),
},
],
]);
return {
handoffLayers: null,
handoff: null,
manager: {
layers,
resumeScene() {
for (const { module } of layers.values()) module.resumeViewport();
for (const { module } of layers.values()) module.cameraOwner?.resume();
},
isEffectivelyEnabled: (id) => id === 'flights',
},
director: {
@@ -127,7 +134,8 @@ function harness({
signal: lifetime.signal,
async 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);
running = null;
},
@@ -138,7 +146,7 @@ function harness({
);
await outcome?.(next, { ...attempt, warm });
running = next;
resumeSession(session);
await resumeSession(session);
},
address: createEngineAddress({
location: browser.location,
@@ -146,7 +154,7 @@ function harness({
flush: () => log.push('flush'),
}),
settle: () => {
if (running) session.handoffLayers = null;
if (running) session.handoff = null;
},
remember: (next) => remembered.push(next),
onFailure: (error, outcome) =>
@@ -193,7 +201,7 @@ test('Back and Forward hand the session off exactly like the picker', async () =
h.session.addressLayerIds = ['flights'];
let captured;
h.session.director.suspendScene = () => {
captured = h.session.handoffLayers;
captured = h.session.handoff.layers;
h.log.push('suspend:director');
};
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
// layer-state coordinator compares that with the visited entry.
assert.deepEqual(record(captured), afterPick);
assert.equal(
captured.get('flights').entry,
h.session.manager.layers.get('flights'),
);
assert.equal('entry' in captured.get('flights'), false);
h.browser.go(1);
await h.machine.reconcile();
assert.equal(h.machine.state().kind, 'navara');
+14 -8
View File
@@ -1,3 +1,4 @@
import { createSessionHandoff } from '../app/sessionHandoff.js';
import {
createEngineSwitch,
handOffSession,
@@ -41,24 +42,25 @@ export function createSceneSwitcher({
cover ??= keepFrame?.(components.scene.engine);
const style = components.controls.styleManager;
cockpit = style.getCockpitState();
handOffSession(layerSession, {
layerSession.handoff = createSessionHandoff(layerSession, {
addressLayerIds:
style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds,
visit: change?.visit === true,
});
handOffSession(layerSession);
renderers.retain(from);
await getApplication().detachScene({ handoff: true }, transaction);
}
count('detaches');
}
async function resume(style) {
async function resume(style, restoreView) {
signal.throwIfAborted();
await style.initialRestorePromise;
signal.throwIfAborted();
layerSession.restoringView = false;
restoreView();
count('attachments');
resumeSession(layerSession);
await resumeSession(layerSession);
// A visited entry or a stale track may replace the outgoing subject.
const target = style.getAircraftTrackingTarget();
if (
@@ -73,13 +75,16 @@ export function createSceneSwitcher({
}
async function enter(kind, { recovery = false } = {}, transaction) {
layerSession.restoringView = true;
let restoreView;
layerSession.viewRestored = new Promise((resolve) => {
restoreView = resolve;
});
let app = getApplication();
if (!app || app.getState().status !== 'ready') {
app = startApplication(kind, transaction);
try {
const { controls } = await app.start();
await resume(controls.styleManager);
await resume(controls.styleManager, restoreView);
} catch (error) {
if (!signal.aborted)
await app
@@ -95,7 +100,8 @@ export function createSceneSwitcher({
} else {
await app.attachScene(kind, {
transaction,
resume: () => resume(app.getComponents().controls.styleManager),
resume: () =>
resume(app.getComponents().controls.styleManager, restoreView),
});
}
if (recovery) count('rollbacks');
@@ -110,7 +116,7 @@ export function createSceneSwitcher({
cover = null;
}
if (!components) return;
layerSession.handoffLayers = null;
layerSession.handoff = null;
cockpit = null;
onSettled?.();
components.controls.styleManager.shareLinkManager?.resumeNavigation?.();
+102 -3
View File
@@ -39,6 +39,7 @@ function fixture({ failResume = false, changeTarget = false } = {}) {
session.manager = {
layers: new Map([['flights', entry]]),
isEffectivelyEnabled: () => true,
resumeScene: () => entry.module.resumeViewport(),
};
session.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();
await switcher.select('navara');
assert.equal(current(), 'navara');
assert.equal(session.handoffLayers, null);
assert.equal(session.restoringView, false);
assert.equal(session.handoff, null);
await session.viewRestored;
assert.deepEqual(log, [
'suspend-director',
['park', 'first'],
@@ -125,7 +126,7 @@ test('resume failure has one candidate release and one rollback on the retained
['attach', 'first'],
['failure', 'resume failed', true],
]);
assert.equal(session.handoffLayers, null);
assert.equal(session.handoff, null);
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('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({
engineSwitch,
retainScene: Boolean(this._layerSession?.handoffLayers),
retainScene: Boolean(this._layerSession?.handoff),
});
// Keep the parameter panel from overlapping toggle controls.
@@ -771,7 +771,7 @@ export class StyleManager extends ShellScene {
setClearBusy: (busy) => this._publishClearBusy(busy),
},
});
if (this._layerSession?.handoffLayers)
if (this._layerSession?.handoff)
this._contextControls.resumeScene(this._layerSession.contextScene);
// The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer);
+4 -4
View File
@@ -121,7 +121,7 @@ export class ShareRestoration {
constructor({
engine,
canvas = engine?.host.canvas,
handoffLayers = null,
handoff = null,
navigation,
syncShareState,
syncModels3d,
@@ -133,7 +133,7 @@ export class ShareRestoration {
Object.assign(this, {
engine,
canvas,
handoffLayers,
handoff,
navigation,
syncShareState,
syncModels3d,
@@ -396,13 +396,13 @@ export class ShareRestoration {
);
this._layerStateRestorePromise = this._layerStateCoordinator.start({
shareLayerState: this._initialShareState?.layerState || null,
handoffLayers: this.handoffLayers,
handoff: this.handoff,
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
// Any valid camera/style share isolates recipient-local preferences,
// including legacy and malformed-v2 layer payloads.
allowLocalState: !this._initialShareState,
});
this.handoffLayers = null;
this.handoff = null;
if (this._initialShareSelectionSuperseded) {
this._layerStateCoordinator.cancelPendingShareTracking(
'superseded-before-layer-coordinator-start',
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more