mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'german/refactor-e8-switch-protocol' into german/navara-refactor
# Conflicts: # scripts/engine-coupling-baseline.json # src/layers/wind/index.test.mjs
This commit is contained in:
commit
59ff0b38a2
103 files changed
+1024
-537
No files matched your search
+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,
|
||||
};
|
||||
}
|
||||
@@ -999,6 +999,7 @@ export function createBhoteKoshiEventLayer({
|
||||
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
|
||||
} = {}) {
|
||||
let _viewer = null;
|
||||
let _attachmentContext = null;
|
||||
overlayHost = bindOverlayHost(overlayHost, () => _engine);
|
||||
let _engine = null;
|
||||
let _native = null;
|
||||
@@ -2681,6 +2682,7 @@ export function createBhoteKoshiEventLayer({
|
||||
|
||||
function applyCinematicCamera() {
|
||||
if (
|
||||
_attachmentContext?.viewReady === false ||
|
||||
!_cinematicActive ||
|
||||
!cameraForScene() ||
|
||||
_cinematicKeyframes.length === 0
|
||||
@@ -3528,16 +3530,18 @@ export function createBhoteKoshiEventLayer({
|
||||
});
|
||||
}
|
||||
|
||||
async function attachRenderer(viewer, { engine = null, signal = null } = {}) {
|
||||
async function attachRenderer(context = {}) {
|
||||
const { engine = null, signal = null } = context;
|
||||
_attachmentContext = context;
|
||||
const generation = ++_attachGeneration;
|
||||
const current = () =>
|
||||
generation === _attachGeneration && signal?.aborted !== true;
|
||||
const stack = _mapStackController;
|
||||
_viewer = viewer;
|
||||
_viewer = null;
|
||||
_engine = engine;
|
||||
// The engine's display (src/layers/bhoteKoshi/render/display.js).
|
||||
_native = createNativeRenderer(engine);
|
||||
_embeddedMedia?.attachScene?.(viewer, engine);
|
||||
_embeddedMedia?.attachScene?.(null, engine);
|
||||
if (!_enabled) return;
|
||||
// Its enable has loaded the SDK; a restored attachment waits for it too.
|
||||
if (!cesiumSdkReady()) {
|
||||
@@ -3612,7 +3616,7 @@ export function createBhoteKoshiEventLayer({
|
||||
}
|
||||
|
||||
async function init(viewer, options) {
|
||||
await attachRenderer(viewer, options);
|
||||
await attachRenderer(options);
|
||||
_embeddedMedia = embeddedMediaFactory({ viewer, engine: _engine });
|
||||
await loadEvent();
|
||||
console.log('[Data:BhoteKoshi] Event pack ready');
|
||||
@@ -4010,6 +4014,12 @@ export function createBhoteKoshiEventLayer({
|
||||
updateInterval: 0,
|
||||
init,
|
||||
attachRenderer,
|
||||
cameraOwner: {
|
||||
suspend() {
|
||||
_lastCinematicProgress = null;
|
||||
},
|
||||
resume: applyCinematicCamera,
|
||||
},
|
||||
detachRenderer,
|
||||
enable,
|
||||
disable,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -61,7 +61,6 @@ export function createWindLayer({
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wind requires a snapshot source');
|
||||
let viewer = null;
|
||||
let engine = null;
|
||||
let request = null;
|
||||
let enabled = false;
|
||||
@@ -112,10 +111,9 @@ export function createWindLayer({
|
||||
source: 'GFS / ECMWF IFS · FORECAST',
|
||||
updateInterval: 3600_000,
|
||||
init(nextViewer, options) {
|
||||
layer.attachRenderer(nextViewer, options);
|
||||
layer.attachRenderer(options);
|
||||
},
|
||||
attachRenderer(nextViewer, { engine: nextEngine = null } = {}) {
|
||||
viewer = nextViewer;
|
||||
attachRenderer({ engine: nextEngine = null } = {}) {
|
||||
engine = nextEngine;
|
||||
if (!engine) return;
|
||||
rendering = createNativeRendering(engine, { onStatusChange: notify });
|
||||
@@ -135,7 +133,7 @@ export function createWindLayer({
|
||||
inspectionMarker?.destroy();
|
||||
inspectionMarker = null;
|
||||
rendering?.destroy();
|
||||
rendering = viewer = engine = null;
|
||||
rendering = engine = null;
|
||||
},
|
||||
attachShellServices() {
|
||||
rendering?.rehome?.();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -121,7 +121,7 @@ export class ShareRestoration {
|
||||
constructor({
|
||||
engine,
|
||||
canvas = engine?.host.canvas,
|
||||
handoffLayers = null,
|
||||
handoff = null,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
@@ -133,7 +133,7 @@ export class ShareRestoration {
|
||||
Object.assign(this, {
|
||||
engine,
|
||||
canvas,
|
||||
handoffLayers,
|
||||
handoff,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
@@ -396,13 +396,13 @@ export class ShareRestoration {
|
||||
);
|
||||
this._layerStateRestorePromise = this._layerStateCoordinator.start({
|
||||
shareLayerState: this._initialShareState?.layerState || null,
|
||||
handoffLayers: this.handoffLayers,
|
||||
handoff: this.handoff,
|
||||
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
|
||||
// Any valid camera/style share isolates recipient-local preferences,
|
||||
// including legacy and malformed-v2 layer payloads.
|
||||
allowLocalState: !this._initialShareState,
|
||||
});
|
||||
this.handoffLayers = null;
|
||||
this.handoff = null;
|
||||
if (this._initialShareSelectionSuperseded) {
|
||||
this._layerStateCoordinator.cancelPendingShareTracking(
|
||||
'superseded-before-layer-coordinator-start',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { layersNotOnThisEngine } from './shareRestoration.js';
|
||||
import { layersNotOnThisEngine, ShareRestoration } from './shareRestoration.js';
|
||||
import { createSessionHandoff } from '../app/sessionHandoff.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import {
|
||||
LAYER_STATE_REGISTRY,
|
||||
REGISTERED_LAYER_IDS,
|
||||
} from '../data/layerState.js';
|
||||
|
||||
test('the notice names the link layers this engine leaves out', () => {
|
||||
assert.deepEqual(
|
||||
@@ -14,3 +20,81 @@ test('the notice names the link layers this engine leaves out', () => {
|
||||
);
|
||||
assert.deepEqual(layersNotOnThisEngine([]), []);
|
||||
});
|
||||
|
||||
const viewportLayers = ['bikeshare', 'transit', 'traffic'];
|
||||
for (const scenario of [
|
||||
{
|
||||
name: 'engine choice retains operational layers',
|
||||
enabled: true,
|
||||
outgoing: [],
|
||||
incoming: [],
|
||||
visit: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'Back restores the visited disabled layers',
|
||||
enabled: true,
|
||||
outgoing: viewportLayers,
|
||||
incoming: [],
|
||||
visit: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'Forward restores the visited enabled layers',
|
||||
enabled: false,
|
||||
outgoing: [],
|
||||
incoming: viewportLayers,
|
||||
visit: true,
|
||||
expected: true,
|
||||
},
|
||||
]) {
|
||||
test(`share restoration: ${scenario.name}`, async (t) => {
|
||||
const manager = new LayerLifecycle(null);
|
||||
for (const id of REGISTERED_LAYER_IDS)
|
||||
manager.register({
|
||||
id,
|
||||
init() {},
|
||||
enable() {},
|
||||
update() {},
|
||||
disable() {},
|
||||
attachRenderer() {},
|
||||
setParams() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||
t.after(() => manager.destroyAll());
|
||||
for (const id of viewportLayers)
|
||||
await manager.setEnabled(id, scenario.enabled, { origin: 'user' });
|
||||
const models = viewportLayers.map((id) => manager.layers.get(id).module);
|
||||
const handoff = createSessionHandoff(
|
||||
{ manager, director: null },
|
||||
{ addressLayerIds: scenario.outgoing, visit: scenario.visit },
|
||||
);
|
||||
const restoration = new ShareRestoration({
|
||||
handoff,
|
||||
syncModels3d() {},
|
||||
showStatus() {},
|
||||
});
|
||||
t.after(() => restoration.destroy());
|
||||
restoration.attachLinks({
|
||||
parseInitialHash: () => ({
|
||||
layerState: { enabledLayerIds: scenario.incoming },
|
||||
}),
|
||||
});
|
||||
restoration.connect(manager);
|
||||
const results = await restoration._layerStateRestorePromise;
|
||||
assert.equal(
|
||||
restoration.handoff,
|
||||
null,
|
||||
'the coordinator consumes the handoff once',
|
||||
);
|
||||
for (const [index, id] of viewportLayers.entries()) {
|
||||
assert.equal(manager.isEffectivelyEnabled(id), scenario.expected, id);
|
||||
assert.equal(manager.layers.get(id).module, models[index]);
|
||||
const result = results.find((result) => result.layerId === id);
|
||||
assert.equal(result.succeeded, true, id);
|
||||
if (!scenario.visit) assert.equal(result.phase, 'retained', id);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user