Merge branch 'german/refactor-e2-layer-lifecycle' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:27:12 +04:00
commit 233c4def5b
6 files changed
+621 -15

No files matched your search

+54 -13
View File
@@ -73,6 +73,8 @@ export function createApplicationCatalog({
nepalBoundaryResolver,
streetLevelProviders,
engineKind = 'cesium',
// Supply display options and scene services without replacing layer models.
createDisplay = rendererFor,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -80,6 +82,13 @@ export function createApplicationCatalog({
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
const renderer = (id) => ({
create: ({ engine, ...options }) => createDisplay(id, engine, options),
});
const rendererFactory =
(id) =>
({ engine, ...options }) =>
createDisplay(id, engine, options);
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry =
@@ -122,6 +131,7 @@ export function createApplicationCatalog({
};
const flights = sessionModel('flights', () =>
createApplicationFlights({
renderer: renderer('flights'),
surface,
source: sources.flights,
militaryRegistry,
@@ -130,6 +140,7 @@ export function createApplicationCatalog({
);
const military = sessionModel('military', () =>
createApplicationMilitary({
renderer: renderer('military'),
surface,
source: sources.military,
militaryRegistry,
@@ -138,6 +149,7 @@ export function createApplicationCatalog({
);
const vessels = sessionModel('ais-live-vessels', () =>
createApplicationVessels({
renderer: renderer('ais-live-vessels'),
source: sources.vessels,
options: vesselOptions,
}),
@@ -150,6 +162,7 @@ export function createApplicationCatalog({
);
const satellites = sessionModel('satellites', () =>
createApplicationSatellites({
renderer: renderer('satellites'),
source: sources.satellites,
}),
);
@@ -157,20 +170,21 @@ export function createApplicationCatalog({
sessionModel('bhote-koshi-2026', () =>
createBhoteKoshiEventLayer({
createNativeRenderer: (engine) =>
rendererFor('bhote-koshi-2026', engine),
createDisplay('bhote-koshi-2026', engine),
}),
),
sessionModel('bhote-koshi-locator', () =>
createBhoteKoshiLocatorLayer({
boundaryResolver: nepalBoundaryResolver,
createNativeRenderer: (engine, data) =>
rendererFor('bhote-koshi-locator', engine, { data }),
createDisplay('bhote-koshi-locator', engine, { data }),
}),
),
flights,
military,
sessionModel('local-adsb', () =>
createApplicationLocalAdsb({
renderer: renderer('local-adsb'),
surface,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds ? { feeds: sources.localAdsbFeeds } : {}),
@@ -180,18 +194,26 @@ export function createApplicationCatalog({
}),
),
sessionModel('earthquakes', () =>
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationEarthquakes({
rendererFactory: rendererFactory('earthquakes'),
source: sources.earthquakes,
}),
),
sessionModel('air-quality', () =>
createApplicationAirQuality({ source: sources['air-quality'] }),
createApplicationAirQuality({
rendererFactory: rendererFactory('air-quality'),
source: sources['air-quality'],
}),
),
sessionModel('fire-perimeters', () =>
createApplicationFirePerimeters({
createRenderer: rendererFactory('fire-perimeters'),
source: sources['fire-perimeters'],
}),
),
sessionModel('gnss-integrity', () =>
createApplicationGnssIntegrity({
rendererFactory: rendererFactory('gnss-integrity'),
source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}),
@@ -201,6 +223,7 @@ export function createApplicationCatalog({
),
sessionModel('drone-airspace', () =>
createApplicationDroneAirspace({
rendererFactory: rendererFactory('drone-airspace'),
source: sources['drone-airspace'],
}),
),
@@ -211,12 +234,16 @@ export function createApplicationCatalog({
}),
),
sessionModel('alpr-cameras', () =>
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationAlpr({
rendererFactory: rendererFactory('alpr-cameras'),
surface,
source: sources.alpr,
}),
),
sessionModel('street-level', () =>
createApplicationStreetLevel({
surface,
nativeRenderer: (engine) => rendererFor('street-level', engine),
nativeRenderer: (engine) => createDisplay('street-level', engine),
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
@@ -224,6 +251,7 @@ export function createApplicationCatalog({
satellites,
sessionModel('rocket-launches', () =>
createApplicationLaunches({
renderer: renderer('rocket-launches'),
source: sources.launches,
satellites: satellites,
}),
@@ -235,13 +263,25 @@ export function createApplicationCatalog({
}),
),
sessionModel('cctv', () =>
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationCctv({
rendererFactory: rendererFactory('cctv'),
surface,
source: sources.cctv,
}),
),
sessionModel('radio', () =>
createApplicationRadio({ surface, source: sources.radio }),
createApplicationRadio({
rendererFactory: rendererFactory('radio'),
surface,
source: sources.radio,
}),
),
sessionModel('transit', () =>
createApplicationTransit({ surface, source: sources.transit }),
createApplicationTransit({
rendererFactory: rendererFactory('transit'),
surface,
source: sources.transit,
}),
),
sessionModel('bikeshare', () =>
createApplicationBikeshare({ source: sources.bikeshare }),
@@ -273,7 +313,7 @@ export function createApplicationCatalog({
feed: sources.wind,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor('wind', engine, options),
createDisplay('wind', engine, options),
}),
),
...['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) =>
@@ -283,7 +323,7 @@ export function createApplicationCatalog({
id,
clock: weatherClock,
createNativeRendering: (engine, options) =>
rendererFor(id, engine, options),
createDisplay(id, engine, options),
}),
),
),
@@ -291,7 +331,7 @@ export function createApplicationCatalog({
createApplicationCyclones({
feed: sources.cyclones,
createNativeRendering: (engine, options) =>
rendererFor('weather-cyclones', engine, options),
createDisplay('weather-cyclones', engine, options),
}),
),
...sessionModel('osm-overlays', () => createOsmOverlayLayers()),
@@ -301,7 +341,7 @@ export function createApplicationCatalog({
config,
services,
(engine, descriptor, ops) =>
rendererFor(config.id, engine, {
createDisplay(config.id, engine, {
config: descriptor,
services: ops,
}),
@@ -313,6 +353,7 @@ export function createApplicationCatalog({
),
sessionModel('local-firms', () =>
createApplicationFirms({
renderer: renderer('local-firms'),
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
+433
View File
@@ -0,0 +1,433 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationCatalog } from '../constructCatalog.js';
import { createSurfaceServices } from '../surfaceServices.js';
import { CATALOG_SOURCE_CONTRACTS } from '../sourceComposition.js';
import { loadLayerRenderers, rendererFor } from './renderers.js';
import {
BHOTE_KOSHI_CREDIT,
BHOTE_KOSHI_LOCATOR_CREDIT,
transitFeedCredit,
} from '../../data/dataCredits.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { sceneServicesFor } from '../../data/sceneServices.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { aircraftAtlasLayout } from '../../layers/aircraft/render/display.js';
import { vesselAtlasLayout } from '../../layers/vessels/render/display.js';
import { transitTestAtlas } from '../../layers/transit/render/testAtlas.mjs';
import {
installLifecycleEnvironment,
flushLifecycleTasks,
} from '../../testSupport/lifecycleEnvironment.mjs';
const retainedCredits = {
'bhote-koshi-2026': BHOTE_KOSHI_CREDIT, // Conditional attribution is permanent in the scene's credit popover.
transit: transitFeedCredit(
TRANSIT_ENABLED_FEEDS.find((feed) => feed.id === 'capmetro-austin'),
), // Transit feed attribution is permanent in the scene's credit popover.
'bhote-koshi-locator': BHOTE_KOSHI_LOCATOR_CREDIT, // Conditional attribution is permanent in the scene's credit popover.
};
const primitives = ['movers', 'lines', 'ground', 'imagery', 'meshes'];
const expectedReaders = {
'bhote-koshi-2026': ['scene'], // The imagery split control still reads the native scene.
'ais-live-vessels': ['isDestroyed'], // Sprite ordering still accepts the native viewer.
};
function sourceFixtures(calls) {
return Object.fromEntries(
Object.entries(CATALOG_SOURCE_CONTRACTS).map(([id, { methods }]) => [
id,
{
label: id,
...Object.fromEntries(
methods.map((method) => [
method,
() => {
calls.push({ id, method });
if (method === 'getFlowSessionStats') return {};
if (method === 'getStatus')
return Promise.resolve({ hasKey: false });
if (id === 'wildlife-sightings') return Promise.resolve([]);
if (method === 'requestSnapshot')
return Promise.resolve({
ok: true,
headers: new Headers(),
json: async () => ({ vehicles: [], fetchedAt: Date.now() }),
});
if (method === 'requestRoads')
return Promise.resolve({
ok: true,
headers: new Headers(),
json: async () => ({ roads: [], roadMode: 'osm' }),
});
if (method === 'getStations')
return Promise.resolve({ data: { stations: [] } });
if (method === 'resetFlowTileCache') return;
if (method === 'fetch') return Promise.resolve([]);
if (method === 'getSnapshot')
return Promise.resolve({
records: [],
features: [],
rows: [],
unavailable: true,
});
return Promise.resolve([]);
},
]),
),
},
]),
);
}
function strictEngine() {
const engine = createFakeEngine({
kind: 'rehearsal',
longitude: (-97.74 * Math.PI) / 180,
latitude: (30.27 * Math.PI) / 180,
altitude: 5000,
container: document.createElement('div'),
canvas: document.createElement('canvas'),
});
engine.extensions = {};
engine.fake.script('sampleHeight', 0);
engine.fake.script('terrainHeight', 0);
const violations = [];
const inputs = new Set();
const suspensions = new Set();
const suspend = engine.input.suspendCameraInputs;
engine.input.suspendCameraInputs = () => {
const resume = suspend();
const token = {};
suspensions.add(token);
return () => {
suspensions.delete(token);
resume();
};
};
const on = engine.input.on;
engine.input.on = (...args) => {
const off = on(...args);
const token = {};
inputs.add(token);
return () => {
inputs.delete(token);
off();
};
};
for (const family of primitives) {
for (const [name, create] of Object.entries(engine[family])) {
if (!name.startsWith('create')) continue;
engine[family][name] = (...args) => {
if (engine.isDestroyed()) {
const message = `${family}.${name} on destroyed engine`;
violations.push(message);
throw new Error(message);
}
const handle = create(...args);
let removed = false;
const destroy = handle.destroy;
handle.destroy = () => {
removed = true;
return destroy();
};
for (const method of Object.keys(handle).filter((name) =>
/^(set|update|replace|commit|reveal|show)/.test(name),
)) {
const write = handle[method];
handle[method] = (...values) => {
if (
(removed || engine.isDestroyed()) &&
(method !== 'show' || values.length)
) {
const message = `${family} ${handle.id}.${method} after removal`;
violations.push(message);
throw new Error(message);
}
return write(...values);
};
}
return handle;
};
}
}
const services = sceneServicesFor(engine);
services.render.install();
const resources = () => ({
lists: Object.fromEntries(
primitives.map((name) => [name, engine[name].list()]),
),
listeners: [engine.camera, engine.frames, engine.clock].flatMap((part) =>
Object.values(part)
.filter((value) => typeof value?.addEventListener === 'function')
.map((event) => event.numberOfListeners),
),
inputs: inputs.size,
credits: [...engine.fake.credits],
cameraInputs: engine.input.cameraInputsEnabled(),
suspensions: suspensions.size,
continuous: engine.render.isContinuous(),
holds: services.render.getRenderGovernorDiagnostics().holds,
following: engine.camera.following(),
owners: services.picking.owners(),
});
const snapshot = () => ({
calls: [...engine.fake.calls],
renderRequests: engine.fake.renderRequests,
resources: resources(),
violations: [...violations],
});
return { engine, services, resources, snapshot, violations };
}
function assertHidden(engine) {
for (const { id } of engine.movers.list()) {
const { store } = engine.fake.moverSet(id);
assert.ok(
[...store.shows.slice(0, store.count)].every((shown) => !shown),
`disabled movers ${id} must be hidden`,
);
}
for (const family of ['lines', 'ground', 'imagery']) {
for (const item of engine[family].list()) {
if (item.count === 0) continue;
assert.equal(
item.shown,
false,
`disabled ${family} ${item.id} must be hidden`,
);
}
}
for (const id of engine.meshes.list())
assert.equal(
engine.fake.mesh(id).show,
false,
`disabled mesh ${id} must be hidden`,
);
}
test('every catalog layer releases its scene and attaches to another engine', async (t) => {
await loadLayerRenderers('rehearsal');
const buildCatalog = (lifetime, calls, visibility) =>
createApplicationCatalog({
signal: lifetime.signal,
sources: sourceFixtures(calls),
surface: createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal: lifetime.signal,
eventTarget: null,
}),
streetLevelProviders: [
createMapillaryProvider({
source: {
hasToken: () => true,
getStatus: async () => {
calls.push({ id: 'mapillary', method: 'getStatus' });
return { configured: true };
},
getTile: async () => {
calls.push({ id: 'mapillary', method: 'getTile' });
return new Uint8Array(0);
},
getSequenceImages: async () => {
calls.push({ id: 'mapillary', method: 'getSequenceImages' });
return [];
},
},
}),
],
nepalBoundaryResolver: async () => [],
createDisplay(id, engine, options = {}) {
const services = sceneServicesFor(engine);
const layout =
id === 'ais-live-vessels'
? vesselAtlasLayout()
: aircraftAtlasLayout();
const display = rendererFor(id, engine, {
...options,
services: {
...options.services,
picking: services.picking,
render: services.render,
},
drawAtlas:
id === 'transit'
? transitTestAtlas
: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
});
if (id === 'cctv') {
visibility.set(engine, true);
const setVisible = display.setMarkersVisible;
display.setMarkersVisible = (value) => {
visibility.set(engine, value);
return setVisible(value);
};
}
return display;
},
});
const discoveryLifetime = new AbortController();
const discovery = buildCatalog(discoveryLifetime, [], new Map());
t.after(() => discoveryLifetime.abort());
const seenReaders = [];
const seenCredits = new Set();
for (const { id } of discovery.layers) {
await t.test(id, async (t) => {
const environment = installLifecycleEnvironment(t);
const lifetime = new AbortController();
const calls = [];
const visibility = new Map();
const catalog = buildCatalog(lifetime, calls, visibility);
const layer = catalog.get(id);
const a = strictEngine();
const b = strictEngine();
const beforeA = a.resources();
const beforeB = b.resources();
const reads = new Set();
const poisoned = new Proxy(
{},
{
get(_target, property) {
reads.add(String(property));
throw new Error(`viewer read: ${String(property)}`);
},
},
);
const drive = async (scene) => {
scene.engine.fake.emit('camera.moveStart');
scene.engine.fake.emit('camera.changed');
scene.engine.fake.frame();
scene.engine.fake.emit('camera.moveEnd');
window.dispatchEvent(new CustomEvent('gev:map-stack-changed'));
await flushLifecycleTasks(t, 1000);
};
try {
if (
[
'flights',
'military',
'earthquakes',
'air-quality',
'fire-perimeters',
'gnss-integrity',
'drone-airspace',
'osm-places',
].includes(layer.id)
)
await layer.init(null);
const attach = async (engine, options = {}) => {
try {
await layer.attachRenderer(poisoned, { engine, ...options });
} catch (error) {
if (
!expectedReaders[layer.id]?.includes([...reads].at(-1)) ||
!error.message.startsWith('viewer read:')
)
throw error;
await layer.detachRenderer();
await layer.attachRenderer(null, { engine, ...options });
}
};
await attach(a.engine);
await flushLifecycleTasks(t);
if (layer.id === 'cctv')
assert.equal(
visibility.get(a.engine),
false,
'disabled CCTV markers must be hidden after attach',
);
assertHidden(a.engine);
await layer.enable();
await drive(a);
await layer.detachRenderer();
if (retainedCredits[id]) {
beforeA.credits = [
{ html: retainedCredits[id].html, onScreen: false },
];
seenCredits.add(id);
}
assert.deepEqual(
a.resources(),
beforeA,
'A resources released before engine destruction',
);
const detached = a.snapshot();
await flushLifecycleTasks(t, 60_000);
assert.deepEqual(a.snapshot(), detached, 'no writes to detached A');
a.engine.destroy();
const destroyed = a.snapshot();
const sourceCount = calls.length;
const networkCount = environment.network.length;
await attach(b.engine, { waitForCameraRestore: true });
await drive(b);
if (layer.resumeViewport) {
assert.deepEqual(
calls.slice(sourceCount),
[],
'no source requests before resumeViewport',
);
assert.deepEqual(
environment.network.slice(networkCount),
[],
'no transport requests before resumeViewport',
);
}
await layer.resumeViewport?.();
await drive(b);
if (id === 'local-firms')
assert.ok(
calls.slice(sourceCount).some((call) => call.id === 'firms'),
'empty FIRMS cache is fetched after camera restoration',
);
await layer.disable();
await drive(b);
assertHidden(b.engine);
await layer.detachRenderer();
if (retainedCredits[id])
beforeB.credits = [
{ html: retainedCredits[id].html, onScreen: false },
];
assert.deepEqual(b.resources(), beforeB, 'B resources released');
const detachedB = b.snapshot();
await flushLifecycleTasks(t, 60_000);
assert.deepEqual(b.snapshot(), detachedB, 'no writes to detached B');
await layer.destroy?.();
assert.deepEqual(
a.snapshot(),
destroyed,
'no writes to destroyed A while driving B',
);
assert.deepEqual(a.violations, []);
assert.deepEqual(b.violations, []);
assert.deepEqual(
[...reads].sort(),
expectedReaders[layer.id] ?? [],
'viewer reader table must only shrink',
);
if (reads.size) seenReaders.push(id);
} finally {
lifetime.abort();
for (const entry of catalog.layers) await entry.destroy?.();
a.services.destroy();
b.services.destroy();
if (!a.engine.isDestroyed()) a.engine.destroy();
b.engine.destroy();
}
});
}
assert.deepEqual(
seenReaders.sort(),
Object.keys(expectedReaders).sort(),
'remove stale viewer reader entries',
);
assert.deepEqual(
[...seenCredits].sort(),
Object.keys(retainedCredits).sort(),
'remove stale retained-credit entries',
);
});
+1
View File
@@ -150,6 +150,7 @@ export function createPickRegistry() {
return {
resolvePickId,
owners: () => [..._owners.keys()],
registerPickOwner,
unregisterPickOwner,
findPickOwner,
+6 -1
View File
@@ -46,6 +46,7 @@ export function createLifecycle({
// Enabled before a renderer: the load enable() could not start.
if (
layerState._enabled &&
!waitForCameraRestore &&
!layerState._fires.length &&
!layerState._loading
)
@@ -55,7 +56,11 @@ export function createLifecycle({
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._renderer) this.showRenderer();
if (layerState._enabled && layerState._renderer) {
this.showRenderer();
if (!layerState._fires.length && !layerState._loading)
void components.ingestion.loadHeatmap();
}
},
detachRenderer() {
+2 -1
View File
@@ -28,7 +28,8 @@ export function createWindInspectionMarker({
removeRender = null;
if (shown()) store.publish(null);
marker = null;
viewer?.scene?.requestRender?.();
if (engine) engine.render.request('wind-inspection');
else viewer?.scene?.requestRender?.();
};
return {
show(position) {
+125
View File
@@ -0,0 +1,125 @@
import { readFile } from 'node:fs/promises';
import { Window } from 'happy-dom';
export function installLifecycleEnvironment(t) {
const window = new Window({ url: 'http://localhost/' });
const network = [];
for (const name of [
'window',
'document',
'localStorage',
'CustomEvent',
'ResizeObserver',
'Image',
'Audio',
'createImageBitmap',
]) {
const previous = Object.getOwnPropertyDescriptor(globalThis, name);
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value: name === 'window' ? window : window[name],
});
t.after(() => {
if (previous) Object.defineProperty(globalThis, name, previous);
else delete globalThis[name];
});
}
t.mock.timers.enable({
apis: ['Date', 'setTimeout', 'setInterval'],
now: Date.UTC(2026, 0, 1),
});
t.mock.method(globalThis, 'fetch', async (url) => {
network.push(String(url));
const body = String(url).endsWith('/event.json')
? await readFile(
new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
),
'utf8',
)
: JSON.stringify({ type: 'FeatureCollection', features: [] });
return new Response(body, {
headers: { 'content-type': 'application/json' },
});
});
globalThis.Image = class {
width = 1;
height = 1;
set src(value) {
queueMicrotask(() => this.onload?.());
}
};
globalThis.createImageBitmap = async () => ({
width: 1,
height: 1,
close() {},
});
for (const name of [
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
])
window[name] = globalThis[name];
const raf = globalThis.requestAnimationFrame;
const caf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) =>
setTimeout(() => callback(Date.now()), 16);
globalThis.cancelAnimationFrame = clearTimeout;
t.after(() => {
if (raf) globalThis.requestAnimationFrame = raf;
else delete globalThis.requestAnimationFrame;
if (caf) globalThis.cancelAnimationFrame = caf;
else delete globalThis.cancelAnimationFrame;
window.happyDOM.abort();
});
const context = {
measureText: (text) => ({ width: String(text).length * 8 }),
createLinearGradient: () => ({ addColorStop() {} }),
createRadialGradient: () => ({ addColorStop() {} }),
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
};
for (const name of [
'clearRect',
'fillRect',
'strokeRect',
'beginPath',
'closePath',
'moveTo',
'lineTo',
'arc',
'ellipse',
'fill',
'stroke',
'save',
'restore',
'translate',
'rotate',
'scale',
'setTransform',
'drawImage',
'fillText',
'strokeText',
'roundRect',
'quadraticCurveTo',
'bezierCurveTo',
'clip',
'setLineDash',
])
context[name] = () => {};
t.mock.method(
window.HTMLCanvasElement.prototype,
'getContext',
() => context,
);
return { window, network };
}
export async function flushLifecycleTasks(t, ms = 0) {
// Drain promise chains before running timers, including atlas preparation.
for (let i = 0; i < 20; i++) await Promise.resolve();
t.mock.timers.tick(ms);
for (let i = 0; i < 20; i++) await Promise.resolve();
}