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