mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Move the engine switch protocol from the layers into the scene binding
attachRenderer takes one context { engine, signal, viewRestored, ... } and
bindCatalog no longer takes a viewer; no layer module receives Cesium's
viewer any more, and a rehearsal check fails if one reads it.
The handoff between engines is one typed object (layers, camera owners,
selection, director) created when the old scene leaves, replacing the
session's handoffLayers, restoringView and selectionContext. viewRestored
resolves where the camera is restored, and the nine layers that kept
their own restoring-view flag wait on it instead. After attach the
binding applies a disabled layer's visibility, registers the picks each
module declares, and resumes the camera owners, so engineSwitch.js no
longer names directions or satellites.
This commit is contained in:
1 parent
a90407d547
commit
a2905eb700
104 files changed
+1029
-537
No files matched your search
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"attachRendererWithViewer": 33,
|
||||
"restoreFlagCopies": 9,
|
||||
"attachRendererWithViewer": 0,
|
||||
"restoreFlagCopies": 0,
|
||||
"surfaceCallsOutsideGlobe": 41,
|
||||
"engineSwitchLayerIds": 3,
|
||||
"engineSwitchLayerIds": 0,
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 0,
|
||||
"cesiumImportFiles": 3,
|
||||
"viewerLines": 784,
|
||||
"viewerLines": 735,
|
||||
"navaraKindChecks": 1,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 3,
|
||||
"engineExtensionReads": 4,
|
||||
"groundEffectPasses": 0,
|
||||
"cesiumSdkLoads": 3,
|
||||
"screenSpaceHandlerFiles": 2,
|
||||
|
||||
+4
-6
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1010,6 +1010,7 @@ export function createBhoteKoshiEventLayer({
|
||||
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
|
||||
} = {}) {
|
||||
let _viewer = null;
|
||||
let _attachmentContext = null;
|
||||
overlayHost = bindOverlayHost(overlayHost, () => _engine);
|
||||
let _engine = null;
|
||||
let _native = null;
|
||||
@@ -2690,6 +2691,7 @@ export function createBhoteKoshiEventLayer({
|
||||
|
||||
function applyCinematicCamera() {
|
||||
if (
|
||||
_attachmentContext?.viewReady === false ||
|
||||
!_cinematicActive ||
|
||||
!cameraForScene() ||
|
||||
_cinematicKeyframes.length === 0
|
||||
@@ -3538,16 +3540,18 @@ export function createBhoteKoshiEventLayer({
|
||||
});
|
||||
}
|
||||
|
||||
async function attachRenderer(viewer, { engine = null, signal = null } = {}) {
|
||||
async function attachRenderer(context = {}) {
|
||||
const { engine = null, signal = null } = context;
|
||||
_attachmentContext = context;
|
||||
const generation = ++_attachGeneration;
|
||||
const 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()) {
|
||||
@@ -3622,7 +3626,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');
|
||||
@@ -4020,6 +4024,12 @@ export function createBhoteKoshiEventLayer({
|
||||
updateInterval: 0,
|
||||
init,
|
||||
attachRenderer,
|
||||
cameraOwner: {
|
||||
suspend() {
|
||||
_lastCinematicProgress = null;
|
||||
},
|
||||
resume: applyCinematicCamera,
|
||||
},
|
||||
detachRenderer,
|
||||
enable,
|
||||
disable,
|
||||
|
||||
@@ -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() {} },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+4
-1
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -119,10 +119,11 @@ 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 } = {}) {
|
||||
// The wind backend owns its native viewer.
|
||||
viewer = nextEngine?.extensions?.cesium?.viewer ?? null;
|
||||
engine = nextEngine;
|
||||
if (!engine) return;
|
||||
rendering = createNativeRendering(engine, { onStatusChange: notify });
|
||||
|
||||
@@ -366,7 +366,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
|
||||
createNativeRendering: () => rendering,
|
||||
});
|
||||
// A Cesium page: the viewer samples, the engine projects the marker.
|
||||
layer.init(viewer, { engine: createFakeEngine({ container }) });
|
||||
const engine = createFakeEngine({ container });
|
||||
engine.extensions = { cesium: { viewer } };
|
||||
layer.init(viewer, { engine });
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ inspect: true });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user