style: adopt application modules into scoped formatting

This commit is contained in:
Sameh Khamis committed 2026-09-11 17:03:35 -07:00
1 parent 4d37760314
commit 8666705e12
17 files changed
+428 -191

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ listeners. Construction is also inactive until the caller invokes `start()`.
The caller supplies four constructors, in this order: The caller supplies four constructors, in this order:
| Constructor | Receives | Standalone implementation | | Constructor | Receives | Standalone implementation |
| --- | --- | --- | | ---------------- | ---------------------------------------------- | --------------------------------------------- |
| `createScene` | `signal`, `defer` | Viewer, attribution and initial map | | `createScene` | `signal`, `defer` | Viewer, attribution and initial map |
| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation | | `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation |
| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration | | `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration |
+1 -1
View File
@@ -17,7 +17,7 @@ excluded. The formatter validates every entry before writing any file.
## Current component ownership ## Current component ownership
| Surface | Owns | Receives from its caller | | Surface | Owns | Receives from its caller |
| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | | --------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- |
| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations | | `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations |
| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations | | `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations |
| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements | | `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements |
+17 -1
View File
@@ -11,5 +11,21 @@
"src/data/infrastructure.test.mjs", "src/data/infrastructure.test.mjs",
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js", "src/data/localGeojsonLod.js",
"docs/CODE-BOUNDARIES.md" "docs/CODE-BOUNDARIES.md",
"src/main.js",
"docs/APPLICATION.md",
"scripts/qa-application.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/editions/local/application.js",
"src/editions/local/controls.js",
"src/editions/local/data.js",
"src/editions/local/errors.js",
"src/editions/local/scene.js",
"src/editions/local/startupChrome.js",
"src/editions/local/startupChrome.test.mjs",
"src/editions/local/tools.js"
] ]
+6 -18
View File
@@ -10,28 +10,16 @@
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js" "src/data/localGeojsonLod.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"application": { "application": {
"exports": [ "exports": ["./application"],
"./application" "modules": ["src/app/application.js"],
],
"modules": [
"src/app/application.js"
],
"external": [] "external": []
}, },
"viewer": { "viewer": {
"exports": [ "exports": ["./application/viewer"],
"./application/viewer" "modules": ["src/app/viewer.js"],
], "external": ["cesium"]
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
} }
} }
+30 -9
View File
@@ -8,22 +8,30 @@ const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
const errors = []; const errors = [];
page.on('console', (message) => { page.on('console', (message) => {
if (message.type() === 'warn' && /^\[Data\].*(?:destroy|disable) error:/.test(message.text())) { if (
message.type() === 'warn' &&
/^\[Data\].*(?:destroy|disable) error:/.test(message.text())
) {
console.log(message.text()); console.log(message.text());
} }
}); });
page.on('pageerror', (error) => errors.push(error.stack || error.message)); page.on('pageerror', (error) => errors.push(error.stack || error.message));
await page.goto(`${url}/?welcome=1`, { waitUntil: 'domcontentloaded' }); await page.goto(`${url}/?welcome=1`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, { timeout: 60_000 }); await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
timeout: 60_000,
});
const before = await page.evaluate(async () => { const before = await page.evaluate(async () => {
const entry = [...document.scripts].find((script) => /\/src\/main\.js(?:\?|$)/.test(script.src)); const entry = [...document.scripts].find((script) =>
/\/src\/main\.js(?:\?|$)/.test(script.src),
);
const { application } = await import(entry.src); const { application } = await import(entry.src);
window.__qaApplication = application; window.__qaApplication = application;
const app = window.__godsEyeView; const app = window.__godsEyeView;
@@ -32,7 +40,12 @@ try {
await app.styleManager.initialRestorePromise; await app.styleManager.initialRestorePromise;
const enabled = await app.dataManager.setEnabled('local-datacenters', true); const enabled = await app.dataManager.setEnabled('local-datacenters', true);
const annotation = await app.annotations.annotate([ const annotation = await app.annotations.annotate([
{ type: 'pin', longitude: -97.74, latitude: 30.27, label: 'Lifecycle check' }, {
type: 'pin',
longitude: -97.74,
latitude: 30.27,
label: 'Lifecycle check',
},
]); ]);
return { return {
status: application.getState().status, status: application.getState().status,
@@ -49,7 +62,9 @@ try {
assert.equal(before.annotationCount, 1); assert.equal(before.annotationCount, 1);
assert.equal(before.viewerAlive, true); assert.equal(before.viewerAlive, true);
assert.equal(before.credits, true); assert.equal(before.credits, true);
console.log('PASS: startup, data registration, annotations and visible attribution'); console.log(
'PASS: startup, data registration, annotations and visible attribution',
);
const after = await page.evaluate(async () => { const after = await page.evaluate(async () => {
const application = window.__qaApplication; const application = window.__qaApplication;
@@ -57,7 +72,8 @@ try {
const first = application.destroy(); const first = application.destroy();
const samePromise = first === application.destroy(); const samePromise = first === application.destroy();
await first.catch((error) => { await first.catch((error) => {
const describe = (failure) => failure.errors?.map(describe).join('; ') || failure.message; const describe = (failure) =>
failure.errors?.map(describe).join('; ') || failure.message;
throw new Error(describe(error)); throw new Error(describe(error));
}); });
document.dispatchEvent(new Event('visibilitychange')); document.dispatchEvent(new Event('visibilitychange'));
@@ -71,7 +87,10 @@ try {
layers: app.dataManager.layers.size, layers: app.dataManager.layers.size,
annotations: app.annotations.count(), annotations: app.annotations.count(),
governor: app.getRenderGovernorDiagnostics(), governor: app.getRenderGovernorDiagnostics(),
handlesRemoved: !window.__godsEyeView && !window.__gevVoiceCommands && !window.__gevAnnotations, handlesRemoved:
!window.__godsEyeView &&
!window.__gevVoiceCommands &&
!window.__gevAnnotations,
creditsRemoved: !document.querySelector('#cesium-credits'), creditsRemoved: !document.querySelector('#cesium-credits'),
welcomeHidden: !document.querySelector('#first-run-launcher.visible'), welcomeHidden: !document.querySelector('#first-run-launcher.visible'),
settingsRemoved: !document.querySelector('#key-setup'), settingsRemoved: !document.querySelector('#key-setup'),
@@ -89,7 +108,9 @@ try {
assert.equal(after.welcomeHidden, true); assert.equal(after.welcomeHidden, true);
assert.equal(after.settingsRemoved, true); assert.equal(after.settingsRemoved, true);
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
console.log('PASS: terminal teardown releases runtime owners without late browser errors'); console.log(
'PASS: terminal teardown releases runtime owners without late browser errors',
);
} finally { } finally {
await browser.close(); await browser.close();
} }
+45 -12
View File
@@ -9,10 +9,21 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors * @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}} * @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/ */
export function createApplication({ createScene, createControls, createData, createTools }) { export function createApplication({
const factories = { scene: createScene, controls: createControls, data: createData, tools: createTools }; createScene,
createControls,
createData,
createTools,
}) {
const factories = {
scene: createScene,
controls: createControls,
data: createData,
tools: createTools,
};
for (const phase of START_ORDER) { for (const phase of START_ORDER) {
if (typeof factories[phase] !== 'function') throw new TypeError(`Missing ${phase} constructor`); if (typeof factories[phase] !== 'function')
throw new TypeError(`Missing ${phase} constructor`);
} }
const controller = new AbortController(); const controller = new AbortController();
const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []])); const cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []]));
@@ -26,7 +37,11 @@ export function createApplication({ createScene, createControls, createData, cre
function publish(status, phase = null) { function publish(status, phase = null) {
state = Object.freeze({ status, phase }); state = Object.freeze({ status, phase });
for (const listener of [...listeners]) { for (const listener of [...listeners]) {
try { listener(state); } catch { console.error('Application state listener failed'); } try {
listener(state);
} catch {
console.error('Application state listener failed');
}
} }
} }
@@ -35,11 +50,16 @@ export function createApplication({ createScene, createControls, createData, cre
const errors = []; const errors = [];
for (const phase of STOP_ORDER) { for (const phase of STOP_ORDER) {
while (cleanups[phase].length) { while (cleanups[phase].length) {
try { await cleanups[phase].pop()(); } catch (error) { errors.push(error); } try {
await cleanups[phase].pop()();
} catch (error) {
errors.push(error);
}
} }
delete components[phase]; delete components[phase];
} }
if (errors.length) throw new AggregateError(errors, 'Application cleanup failed'); if (errors.length)
throw new AggregateError(errors, 'Application cleanup failed');
}); });
return cleanupPromise; return cleanupPromise;
} }
@@ -57,7 +77,9 @@ export function createApplication({ createScene, createControls, createData, cre
signal: controller.signal, signal: controller.signal,
defer(dispose) { defer(dispose) {
if (!acceptingCleanup || typeof dispose !== 'function') { if (!acceptingCleanup || typeof dispose !== 'function') {
throw new TypeError('Register cleanup during component construction'); throw new TypeError(
'Register cleanup during component construction',
);
} }
cleanups[phase].push(dispose); cleanups[phase].push(dispose);
}, },
@@ -73,8 +95,13 @@ export function createApplication({ createScene, createControls, createData, cre
} catch (error) { } catch (error) {
controller.abort(); controller.abort();
let failure = error; let failure = error;
try { await cleanup(); } catch (cleanupError) { try {
failure = new AggregateError([error, cleanupError], 'Application startup and cleanup failed'); await cleanup();
} catch (cleanupError) {
failure = new AggregateError(
[error, cleanupError],
'Application startup and cleanup failed',
);
} }
if (!destroyPromise) publish('failed'); if (!destroyPromise) publish('failed');
throw failure; throw failure;
@@ -83,7 +110,8 @@ export function createApplication({ createScene, createControls, createData, cre
return Object.freeze({ return Object.freeze({
start() { start() {
if (destroyPromise) return Promise.reject(new Error('Application has been destroyed')); if (destroyPromise)
return Promise.reject(new Error('Application has been destroyed'));
// Assign before notifying subscribers, including reentrant start/destroy. // Assign before notifying subscribers, including reentrant start/destroy.
startPromise ||= Promise.resolve().then(initialize); startPromise ||= Promise.resolve().then(initialize);
return startPromise; return startPromise;
@@ -109,10 +137,15 @@ export function createApplication({ createScene, createControls, createData, cre
getState: () => state, getState: () => state,
getComponents: () => Object.freeze({ ...components }), getComponents: () => Object.freeze({ ...components }),
subscribe(listener) { subscribe(listener) {
if (typeof listener !== 'function') throw new TypeError('Expected a state listener'); if (typeof listener !== 'function')
throw new TypeError('Expected a state listener');
if (state.status === 'destroyed') return () => {}; if (state.status === 'destroyed') return () => {};
listeners.add(listener); listeners.add(listener);
try { listener(state); } catch { console.error('Application state listener failed'); } try {
listener(state);
} catch {
console.error('Application state listener failed');
}
return () => listeners.delete(listener); return () => listeners.delete(listener);
}, },
}); });
+73 -23
View File
@@ -6,31 +6,43 @@ import { createApplication } from './application.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools']; const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) { function fixture(overrides = {}) {
const events = []; const events = [];
const constructors = Object.fromEntries(phases.map((phase) => [ const constructors = Object.fromEntries(
phases.map((phase) => [
`create${phase}`, `create${phase}`,
({ defer }) => { ({ defer }) => {
events.push(`start:${phase}`); events.push(`start:${phase}`);
defer(() => events.push(`stop:${phase}`)); defer(() => events.push(`stop:${phase}`));
return { name: phase }; return { name: phase };
}, },
])); ]),
);
return { events, app: createApplication({ ...constructors, ...overrides }) }; return { events, app: createApplication({ ...constructors, ...overrides }) };
} }
function deferred() { function deferred() {
let resolve; let resolve;
const promise = new Promise((done) => { resolve = done; }); const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve }; return { promise, resolve };
} }
test('importing and constructing the package is inactive without browser globals', () => { test('importing and constructing the package is inactive without browser globals', () => {
const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` const result = spawnSync(
process.execPath,
[
'--input-type=module',
'-e',
`
globalThis.fetch = () => { throw new Error('unexpected request'); }; globalThis.fetch = () => { throw new Error('unexpected request'); };
globalThis.setTimeout = () => { throw new Error('unexpected timer'); }; globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
const { createApplication } = await import('gods-eye-view/application'); const { createApplication } = await import('gods-eye-view/application');
const fail = () => { throw new Error('unexpected construction'); }; const fail = () => { throw new Error('unexpected construction'); };
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail }); const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
if (app.getState().status !== 'created') process.exit(1); if (app.getState().status !== 'created') process.exit(1);
`], { cwd: new URL('../../', import.meta.url), encoding: 'utf8' }); `,
],
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
}); });
@@ -39,13 +51,15 @@ test('constructors are validated before allocating anything', () => {
}); });
test('startup shares one promise and teardown keeps data alive for controls', async () => { test('startup shares one promise and teardown keeps data alive for controls', async () => {
const { app, events } = fixture({ createTools({ scene, controls, data, defer }) { const { app, events } = fixture({
createTools({ scene, controls, data, defer }) {
assert.equal(scene.name, 'Scene'); assert.equal(scene.name, 'Scene');
assert.equal(controls.name, 'Controls'); assert.equal(controls.name, 'Controls');
assert.equal(data.name, 'Data'); assert.equal(data.name, 'Data');
defer(() => events.push('stop:Tools')); defer(() => events.push('stop:Tools'));
return { name: 'Tools' }; return { name: 'Tools' };
} }); },
});
const states = []; const states = [];
app.subscribe((state) => states.push(`${state.status}:${state.phase}`)); app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
const started = app.start(); const started = app.start();
@@ -57,7 +71,12 @@ test('startup shares one promise and teardown keeps data alive for controls', as
const stopped = app.destroy(); const stopped = app.destroy();
assert.equal(stopped, app.destroy()); assert.equal(stopped, app.destroy());
await stopped; await stopped;
assert.deepEqual(events.slice(-4), ['stop:Tools', 'stop:Controls', 'stop:Data', 'stop:Scene']); assert.deepEqual(events.slice(-4), [
'stop:Tools',
'stop:Controls',
'stop:Data',
'stop:Scene',
]);
assert.deepEqual(app.getComponents(), {}); assert.deepEqual(app.getComponents(), {});
assert.equal(states.at(-1), 'destroyed:null'); assert.equal(states.at(-1), 'destroyed:null');
await assert.rejects(app.start(), /destroyed/); await assert.rejects(app.start(), /destroyed/);
@@ -65,16 +84,24 @@ test('startup shares one promise and teardown keeps data alive for controls', as
for (const phase of phases) { for (const phase of phases) {
test(`failure during ${phase} releases partially acquired resources`, async () => { test(`failure during ${phase} releases partially acquired resources`, async () => {
const { app, events } = fixture({ [`create${phase}`]({ defer }) { const { app, events } = fixture({
[`create${phase}`]({ defer }) {
defer(() => events.push('partial:released')); defer(() => events.push('partial:released'));
throw new Error('broken constructor'); throw new Error('broken constructor');
} }); },
});
await assert.rejects(app.start(), /broken constructor/); await assert.rejects(app.start(), /broken constructor/);
assert.equal(events.filter((value) => value === 'partial:released').length, 1); assert.equal(
events.filter((value) => value === 'partial:released').length,
1,
);
assert.equal(app.getState().status, 'failed'); assert.equal(app.getState().status, 'failed');
assert.deepEqual(app.getComponents(), {}); assert.deepEqual(app.getComponents(), {});
await app.destroy(); await app.destroy();
assert.equal(events.filter((value) => value === 'partial:released').length, 1); assert.equal(
events.filter((value) => value === 'partial:released').length,
1,
);
}); });
} }
@@ -91,18 +118,22 @@ test('destroy aborts an in-flight constructor and cleans late acquisitions befor
const gate = deferred(); const gate = deferred();
const entered = deferred(); const entered = deferred();
let observedSignal; let observedSignal;
const { app, events } = fixture({ async createScene({ signal, defer }) { const { app, events } = fixture({
async createScene({ signal, defer }) {
observedSignal = signal; observedSignal = signal;
entered.resolve(); entered.resolve();
await gate.promise; await gate.promise;
defer(() => events.push('late:released')); defer(() => events.push('late:released'));
return {}; return {};
} }); },
});
const started = app.start(); const started = app.start();
const failed = assert.rejects(started, { name: 'AbortError' }); const failed = assert.rejects(started, { name: 'AbortError' });
await entered.promise; await entered.promise;
let settled = false; let settled = false;
const stopped = app.destroy().then(() => { settled = true; }); const stopped = app.destroy().then(() => {
settled = true;
});
assert.equal(observedSignal.aborted, true); assert.equal(observedSignal.aborted, true);
await Promise.resolve(); await Promise.resolve();
assert.equal(settled, false); assert.equal(settled, false);
@@ -113,24 +144,35 @@ test('destroy aborts an in-flight constructor and cleans late acquisitions befor
}); });
test('cleanup errors are reported after all remaining resources are attempted', async () => { test('cleanup errors are reported after all remaining resources are attempted', async () => {
const { app, events } = fixture({ createTools({ defer }) { const { app, events } = fixture({
createTools({ defer }) {
defer(() => events.push('tools:earlier')); defer(() => events.push('tools:earlier'));
defer(() => { throw new Error('dispose failed'); }); defer(() => {
throw new Error('dispose failed');
});
return {}; return {};
} }); },
});
await app.start(); await app.start();
await assert.rejects(app.destroy(), (error) => { await assert.rejects(app.destroy(), (error) => {
assert.ok(error instanceof AggregateError); assert.ok(error instanceof AggregateError);
assert.equal(error.errors[0].message, 'dispose failed'); assert.equal(error.errors[0].message, 'dispose failed');
return true; return true;
}); });
assert.deepEqual(events.slice(-4), ['tools:earlier', 'stop:Controls', 'stop:Data', 'stop:Scene']); assert.deepEqual(events.slice(-4), [
'tools:earlier',
'stop:Controls',
'stop:Data',
'stop:Scene',
]);
assert.equal(app.getState().status, 'failed'); assert.equal(app.getState().status, 'failed');
}); });
test('subscribers can destroy during startup without allowing the next constructor', async () => { test('subscribers can destroy during startup without allowing the next constructor', async () => {
const { app, events } = fixture(); const { app, events } = fixture();
app.subscribe((state) => { if (state.phase === 'controls') void app.destroy(); }); app.subscribe((state) => {
if (state.phase === 'controls') void app.destroy();
});
await assert.rejects(app.start(), { name: 'AbortError' }); await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy(); await app.destroy();
assert.deepEqual(events, ['start:Scene', 'stop:Scene']); assert.deepEqual(events, ['start:Scene', 'stop:Scene']);
@@ -138,7 +180,9 @@ test('subscribers can destroy during startup without allowing the next construct
test('destroy during ready notification rejects startup and tears everything down', async () => { test('destroy during ready notification rejects startup and tears everything down', async () => {
const { app } = fixture(); const { app } = fixture();
app.subscribe((state) => { if (state.status === 'ready') void app.destroy(); }); app.subscribe((state) => {
if (state.status === 'ready') void app.destroy();
});
await assert.rejects(app.start(), { name: 'AbortError' }); await assert.rejects(app.start(), { name: 'AbortError' });
await app.destroy(); await app.destroy();
assert.equal(app.getState().status, 'destroyed'); assert.equal(app.getState().status, 'destroyed');
@@ -146,7 +190,12 @@ test('destroy during ready notification rejects startup and tears everything dow
test('cleanup registration closes when a constructor settles', async () => { test('cleanup registration closes when a constructor settles', async () => {
let register; let register;
const { app } = fixture({ createScene({ defer }) { register = defer; return {}; } }); const { app } = fixture({
createScene({ defer }) {
register = defer;
return {};
},
});
await app.start(); await app.start();
assert.throws(() => register(() => {}), /during component construction/); assert.throws(() => register(() => {}), /during component construction/);
await app.destroy(); await app.destroy();
@@ -163,7 +212,8 @@ test('different application instances do not share lifecycle state', async () =>
}); });
test('the separate viewer export imports without constructing a browser viewer', async () => { test('the separate viewer export imports without constructing a browser viewer', async () => {
const { createApplicationViewer } = await import('gods-eye-view/application/viewer'); const { createApplicationViewer } =
await import('gods-eye-view/application/viewer');
assert.equal(typeof createApplicationViewer, 'function'); assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/); assert.throws(() => createApplicationViewer({}), /containers are required/);
}); });
+23 -6
View File
@@ -2,17 +2,28 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { MapStackController } from '../mapStackController.js'; import { MapStackController } from '../mapStackController.js';
import { import {
installRenderGovernor, uninstallRenderGovernor, installRenderGovernor,
holdContinuousRender, governorRequestRender, getRenderGovernorDiagnostics, uninstallRenderGovernor,
holdContinuousRender,
governorRequestRender,
getRenderGovernorDiagnostics,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => { test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
let resolveProvider; let resolveProvider;
let removed = 0; let removed = 0;
const changes = []; const changes = [];
const controller = new MapStackController({}, { onChange: (state) => changes.push(state.status) }); const controller = new MapStackController(
controller._getImageryProvider = () => new Promise((resolve) => { resolveProvider = resolve; }); {},
controller._removeImageryErrorListener = () => { removed++; }; { onChange: (state) => changes.push(state.status) },
);
controller._getImageryProvider = () =>
new Promise((resolve) => {
resolveProvider = resolve;
});
controller._removeImageryErrorListener = () => {
removed++;
};
const switching = controller.setStack('osm'); const switching = controller.setStack('osm');
controller.destroy(); controller.destroy();
resolveProvider({ provider: {} }); resolveProvider({ provider: {} });
@@ -26,7 +37,13 @@ test('destroy invalidates a pending imagery provider before it can touch the vie
test('uninstall only releases the current viewer and makes later render requests inactive', () => { test('uninstall only releases the current viewer and makes later render requests inactive', () => {
let renders = 0; let renders = 0;
const viewer = { scene: { requestRender: () => { renders++; } } }; const viewer = {
scene: {
requestRender: () => {
renders++;
},
},
};
installRenderGovernor(viewer); installRenderGovernor(viewer);
holdContinuousRender('fixture'); holdContinuousRender('fixture');
uninstallRenderGovernor({}); uninstallRenderGovernor({});
+9 -1
View File
@@ -8,7 +8,15 @@ test('teardown before the initial camera delay prevents a late flight', (t) => {
let cancelled = 0; let cancelled = 0;
const stop = flyToAustin({ const stop = flyToAustin({
isDestroyed: () => false, isDestroyed: () => false,
camera: { setView() {}, flyTo() { flights++; }, cancelFlight() { cancelled++; } }, camera: {
setView() {},
flyTo() {
flights++;
},
cancelFlight() {
cancelled++;
},
},
}); });
stop(); stop();
t.mock.timers.tick(1000); t.mock.timers.tick(1000);
+2 -1
View File
@@ -2,7 +2,8 @@ import * as Cesium from 'cesium';
/** Create the standard globe viewer in caller-owned, visible containers. */ /** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) { export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required'); if (!container || !creditContainer)
throw new TypeError('Viewer and credit containers are required');
const viewer = new Cesium.Viewer(container, { const viewer = new Cesium.Viewer(container, {
timeline: false, timeline: false,
animation: false, animation: false,
+13 -5
View File
@@ -8,15 +8,23 @@ import { createLocalTools } from './tools.js';
let constructed = false; let constructed = false;
/** Compose the standalone application once per page. Reload to start again. */ /** Compose the standalone application once per page. Reload to start again. */
export function createLocalApplication({ googleApiKey, cesiumToken, allowQaRegistration = false }) { export function createLocalApplication({
if (constructed) throw new Error('The standalone application already owns this page'); googleApiKey,
cesiumToken,
allowQaRegistration = false,
}) {
if (constructed)
throw new Error('The standalone application already owns this page');
constructed = true; constructed = true;
const loadingScreen = document.getElementById('loading-screen'); const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status'); const loaderStatus = loadingScreen.querySelector('.loader-status');
return createApplication({ return createApplication({
createScene: (context) => createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }), createScene: (context) =>
createControls: (context) => createLocalControls({ ...context, loaderStatus }), createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }),
createData: (context) => createLocalData({ ...context, allowQaRegistration }), createControls: (context) =>
createLocalControls({ ...context, loaderStatus }),
createData: (context) =>
createLocalData({ ...context, allowQaRegistration }),
createTools: (context) => createLocalTools({ ...context, loadingScreen }), createTools: (context) => createLocalTools({ ...context, loadingScreen }),
}); });
} }
+16 -12
View File
@@ -3,26 +3,30 @@ import { flyToAustin } from '../../camera.js';
import { initCockpitCloudEffects } from '../../cockpitCloudEffects.js'; import { initCockpitCloudEffects } from '../../cockpitCloudEffects.js';
/** Construct the existing controls and camera presentation. */ /** Construct the existing controls and camera presentation. */
export function createLocalControls({ scene: { viewer, mapStackController }, loaderStatus, defer }) { export function createLocalControls({
// Initialize the style manager (post-processing, HUD, locations, share links) scene: { viewer, mapStackController },
const styleManager = new StyleManager(viewer, { mapStackController }); loaderStatus,
defer,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new StyleManager(viewer, { mapStackController });
defer(() => styleManager.orbitController.stop()); defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy()); defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose()); defer(() => styleManager.dispose());
// The previous multi-canvas weather compositor remains disabled. Cockpit // The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches // clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode. // Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null; const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer); const cockpitCloudEffects = initCockpitCloudEffects(viewer);
defer(() => cockpitCloudEffects?.destroy()); defer(() => cockpitCloudEffects?.destroy());
// If no share link state, do default fly-to Austin // If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) { if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...'; loaderStatus.textContent = 'Flying to Austin, TX...';
defer(flyToAustin(viewer)); defer(flyToAustin(viewer));
} else { } else {
loaderStatus.textContent = 'Restoring shared view...'; loaderStatus.textContent = 'Restoring shared view...';
} }
return { styleManager, weatherEffects, cockpitCloudEffects }; return { styleManager, weatherEffects, cockpitCloudEffects };
} }
+45 -33
View File
@@ -15,52 +15,64 @@ import localDataLayers from '../../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../../data/layerState.js'; import { LAYER_STATE_REGISTRY } from '../../data/layerState.js';
/** Register the standalone layer catalog before allowing state restoration. */ /** Register the standalone layer catalog before allowing state restoration. */
export function createLocalData({ scene: { viewer }, controls: { styleManager }, allowQaRegistration, defer }) { export function createLocalData({
// Initialize data layer manager scene: { viewer },
const dataManager = new DataLayerManager(viewer, { controls: { styleManager },
allowQaRegistration, allowQaRegistration,
}); defer,
defer(async () => { }) {
// Initialize data layer manager
const dataManager = new DataLayerManager(viewer, {
allowQaRegistration,
});
defer(async () => {
await dataManager.destroyAll(); await dataManager.destroyAll();
if (dataManager.layers.size) throw new Error(`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`); if (dataManager.layers.size)
}); throw new Error(
dataManager.register(flightsLayer); `Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
dataManager.register(militaryFlightsLayer); );
dataManager.register(earthquakesLayer); });
dataManager.register(satellitesLayer); dataManager.register(flightsLayer);
dataManager.register(rocketLaunchesLayer); dataManager.register(militaryFlightsLayer);
rocketLaunchesLayer.attachDataManager(dataManager); dataManager.register(earthquakesLayer);
dataManager.register(trafficLayer); dataManager.register(satellitesLayer);
dataManager.register(cctvLayer); dataManager.register(rocketLaunchesLayer);
dataManager.register(radioLayer); rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(bikeshareLayer); dataManager.register(trafficLayer);
dataManager.register(aisLiveVesselsLayer); dataManager.register(cctvLayer);
dataManager.register(militaryInstallationsLayer); dataManager.register(radioLayer);
dataManager.register(militaryAwarenessLayer); dataManager.register(bikeshareLayer);
militaryAwarenessLayer.attachDataManager(dataManager); dataManager.register(aisLiveVesselsLayer);
for (const layer of localDataLayers) { dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer); dataManager.register(layer);
} }
// Restoration starts only after the complete production registry is sealed. // Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY); dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (allowQaRegistration) { if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => { window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch'); if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule); return dataManager.registerForQa(layerModule);
}; };
window.__gevQaUnregisterLayer = (targetManager, layerId) => { window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch'); if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId); return dataManager.unregisterForQa(layerId);
}; };
const register = window.__gevQaRegisterLayer; const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer; const unregister = window.__gevQaUnregisterLayer;
defer(() => { defer(() => {
if (window.__gevQaRegisterLayer === register) delete window.__gevQaRegisterLayer; if (window.__gevQaRegisterLayer === register)
if (window.__gevQaUnregisterLayer === unregister) delete window.__gevQaUnregisterLayer; delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister)
delete window.__gevQaUnregisterLayer;
}); });
} }
dataManager.buildTogglePanel(document.getElementById('data-toggles')); dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager); styleManager.attachDataManager(dataManager);
return { dataManager }; return { dataManager };
} }
+33 -16
View File
@@ -9,7 +9,13 @@ import { uninstallRenderGovernor } from '../../renderGovernor.js';
import { describeError } from './errors.js'; import { describeError } from './errors.js';
/** Construct the standalone globe using the caller's local configuration. */ /** Construct the standalone globe using the caller's local configuration. */
export async function createLocalScene({ googleApiKey, cesiumToken, loaderStatus, signal, defer }) { export async function createLocalScene({
googleApiKey,
cesiumToken,
loaderStatus,
signal,
defer,
}) {
defer(initLogoGaze()); defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__; const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) { if (googleApiKey) {
@@ -26,21 +32,25 @@ export async function createLocalScene({ googleApiKey, cesiumToken, loaderStatus
creditContainer.id = 'cesium-credits'; creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer); document.body.appendChild(creditContainer);
defer(() => creditContainer.remove()); defer(() => creditContainer.remove());
const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer }); const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
});
defer(() => { defer(() => {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
registerDataCredits(viewer); registerDataCredits(viewer);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
loaderStatus.textContent = googleApiKey || cesiumToken loaderStatus.textContent =
googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...' ? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...'; : 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, { const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey, googleApiKey,
cesiumToken, cesiumToken,
}); });
const tileset = photoreal.tileset; const tileset = photoreal.tileset;
// A provider can finish after cancellation; retain ownership of its result. // A provider can finish after cancellation; retain ownership of its result.
defer(() => { defer(() => {
if (tileset && !tileset.isDestroyed()) { if (tileset && !tileset.isDestroyed()) {
@@ -48,25 +58,28 @@ const tileset = photoreal.tileset;
} }
}); });
signal.throwIfAborted(); signal.throwIfAborted();
if (tileset) { if (tileset) {
viewer.scene.primitives.add(tileset); viewer.scene.primitives.add(tileset);
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom. // NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
// Google Photorealistic 3D Tiles provide their own terrain/elevation. // Google Photorealistic 3D Tiles provide their own terrain/elevation.
viewer.scene.globe.show = false; viewer.scene.globe.show = false;
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`); console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
} else { } else {
if (photoreal.errors.length) { if (photoreal.errors.length) {
const tileError = photoreal.errors.at(-1); const tileError = photoreal.errors.at(-1);
console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError); console.warn(
'[Init] Google 3D Tiles unavailable, using the keyless globe:',
tileError,
);
const tileErrorDetail = describeError(tileError); const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
} }
viewer.scene.globe.show = true; viewer.scene.globe.show = true;
} }
loaderStatus.textContent = 'Initializing systems...'; loaderStatus.textContent = 'Initializing systems...';
const mapStackController = new MapStackController(viewer, { const mapStackController = new MapStackController(viewer, {
googleTileset: tileset, googleTileset: tileset,
cesiumToken, cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery', initialStack: tileset ? 'photoreal' : 'esri-imagery',
@@ -76,12 +89,16 @@ const mapStackController = new MapStackController(viewer, {
// 'switching'/'ready'/'error'; listeners derive the surface regime from // 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless. // live scene state, so intermediate emissions are harmless.
onChange: (state) => { onChange: (state) => {
window.dispatchEvent(new CustomEvent('gev:map-stack-changed', { detail: state })); window.dispatchEvent(
new CustomEvent('gev:map-stack-changed', { detail: state }),
);
}, },
onError: (message) => console.warn('[MapStack]', message), onError: (message) => console.warn('[MapStack]', message),
}); });
defer(() => mapStackController.destroy()); defer(() => mapStackController.destroy());
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true }); await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', {
silent: true,
});
signal.throwIfAborted(); signal.throwIfAborted();
return { viewer, tileset, mapStackController }; return { viewer, tileset, mapStackController };
+18 -5
View File
@@ -2,12 +2,19 @@ import { initFirstRunExperience } from '../../firstRunExperience.js';
import { initKeySetup } from '../../keySetup.js'; import { initKeySetup } from '../../keySetup.js';
/** Reveal welcome controls only after restoration and the loading transition. */ /** Reveal welcome controls only after restoration and the loading transition. */
export function startLocalChrome({ loadingScreen, styleManager, dataManager, signal }) { export function startLocalChrome({
loadingScreen,
styleManager,
dataManager,
signal,
}) {
let disposed = false; let disposed = false;
let firstRun; let firstRun;
let revealTimer; let revealTimer;
let resolveDelay; let resolveDelay;
const minimumDelay = new Promise((resolve) => { resolveDelay = resolve; }); const minimumDelay = new Promise((resolve) => {
resolveDelay = resolve;
});
const delayTimer = setTimeout(resolveDelay, 1000); const delayTimer = setTimeout(resolveDelay, 1000);
const revealFirstRun = () => { const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return; if (disposed || signal.aborted || firstRun) return;
@@ -16,16 +23,22 @@ export function startLocalChrome({ loadingScreen, styleManager, dataManager, sig
loadingScreen.removeEventListener('transitionend', revealFirstRun); loadingScreen.removeEventListener('transitionend', revealFirstRun);
}; };
void Promise.all([styleManager.initialRestorePromise, minimumDelay]) void Promise.all([styleManager.initialRestorePromise, minimumDelay])
.catch(() => { /* Restoration reports its own outcome through the controls. */ }) .catch(() => {
/* Restoration reports its own outcome through the controls. */
})
.then(() => { .then(() => {
if (disposed || signal.aborted) return; if (disposed || signal.aborted) return;
loadingScreen.classList.add('hidden'); loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true }); loadingScreen.addEventListener('transitionend', revealFirstRun, {
once: true,
});
revealTimer = setTimeout(revealFirstRun, 900); revealTimer = setTimeout(revealFirstRun, 900);
}); });
const keySetup = initKeySetup({ signal }); const keySetup = initKeySetup({ signal });
// Own the pending initializer too; it must not reveal a dialog after abort. // Own the pending initializer too; it must not reveal a dialog after abort.
void keySetup.catch(() => console.error('Provider settings initialization failed')); void keySetup.catch(() =>
console.error('Provider settings initialization failed'),
);
return async () => { return async () => {
disposed = true; disposed = true;
clearTimeout(delayTimer); clearTimeout(delayTimer);
+32 -8
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import vm from 'node:vm'; import vm from 'node:vm';
const source = readFileSync(new URL('./startupChrome.js', import.meta.url), 'utf8') const source = readFileSync(
new URL('./startupChrome.js', import.meta.url),
'utf8',
)
.replace(/^import .*;\n/gm, '') .replace(/^import .*;\n/gm, '')
.replace('export function', 'function'); .replace('export function', 'function');
@@ -16,13 +19,21 @@ function fixture() {
const controller = new AbortController(); const controller = new AbortController();
const context = { const context = {
console, console,
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; }, setTimeout(fn, delay) {
clearTimeout(id) { timers.delete(id); }, const id = ++nextTimer;
timers.set(id, { fn, delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
initFirstRunExperience() { initFirstRunExperience() {
events.push('welcome'); events.push('welcome');
return { destroy: () => events.push('welcome:destroy') }; return { destroy: () => events.push('welcome:destroy') };
}, },
async initKeySetup() { return { destroy: () => events.push('settings:destroy') }; }, async initKeySetup() {
return { destroy: () => events.push('settings:destroy') };
},
}; };
vm.createContext(context); vm.createContext(context);
vm.runInContext(source, context); vm.runInContext(source, context);
@@ -32,21 +43,34 @@ function fixture() {
addEventListener: (type, listener) => listeners.set(type, listener), addEventListener: (type, listener) => listeners.set(type, listener),
removeEventListener: (type) => listeners.delete(type), removeEventListener: (type) => listeners.delete(type),
}, },
styleManager: { initialRestorePromise: new Promise((resolve) => { restored = resolve; }) }, styleManager: {
initialRestorePromise: new Promise((resolve) => {
restored = resolve;
}),
},
dataManager: {}, dataManager: {},
signal: controller.signal, signal: controller.signal,
}); });
return { return {
events, listeners, stop, restored, controller, events,
listeners,
stop,
restored,
controller,
fire(delay) { fire(delay) {
for (const [id, task] of timers) { for (const [id, task] of timers) {
if (task.delay === delay) { timers.delete(id); task.fn(); } if (task.delay === delay) {
timers.delete(id);
task.fn();
}
} }
}, },
timers, timers,
}; };
} }
async function flush() { for (let i = 0; i < 12; i++) await Promise.resolve(); } async function flush() {
for (let i = 0; i < 12; i++) await Promise.resolve();
}
test('welcome waits for restoration, minimum delay, and the cover transition', async () => { test('welcome waits for restoration, minimum delay, and the cover transition', async () => {
const f = fixture(); const f = fixture();
+64 -39
View File
@@ -3,13 +3,23 @@ import { initAnnotations } from '../../annotations/index.js';
import { initGevVoiceCommands } from '../../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../../scopeMask.js'; import { installScopeMask, destroyScopeMask } from '../../scopeMask.js';
import { import {
installRenderGovernor, getRenderGovernorDiagnostics, governorRequestRender, installRenderGovernor,
holdContinuousRender, releaseContinuousRender, getRenderGovernorDiagnostics,
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} from '../../renderGovernor.js'; } from '../../renderGovernor.js';
import { startLocalChrome } from './startupChrome.js'; import { startLocalChrome } from './startupChrome.js';
/** Attach scene tools, rendering listeners and the standalone debug handle. */ /** Attach scene tools, rendering listeners and the standalone debug handle. */
export function createLocalTools({ scene, controls, data, loadingScreen, signal, defer }) { export function createLocalTools({
scene,
controls,
data,
loadingScreen,
signal,
defer,
}) {
const { viewer, tileset, mapStackController } = scene; const { viewer, tileset, mapStackController } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data; const { dataManager } = data;
@@ -21,28 +31,30 @@ export function createLocalTools({ scene, controls, data, loadingScreen, signal,
annotations.destroy(); annotations.destroy();
}); });
defer(startLocalChrome({ loadingScreen, styleManager, dataManager, signal })); defer(startLocalChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever // Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had // nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2) // its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer); installRenderGovernor(viewer);
// Install the explicit scope mask used by the DISPLAY controls. // Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer); installScopeMask(viewer);
defer(() => destroyScopeMask()); defer(() => destroyScopeMask());
// The follow camera recomputes the tracked target's dead-reckon position // The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2) // every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(() => { const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity'); else releaseContinuousRender('tracked-entity');
}); },
);
// Hidden-state suspension (perf wave 2): when the window/tab is hidden, // Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for // stop the default render loop outright — a hidden canvas repaints for
// nobody, and browser rAF throttling still lets throttled frames burn // nobody, and browser rAF throttling still lets throttled frames burn
// GPU. Holder/data state is untouched, so return is seamless: restore // GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame. // the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => { const syncVisibilitySuspension = () => {
const hidden = document.hidden; const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden; viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden); cockpitCloudEffects?.setSuspended?.(hidden);
@@ -53,16 +65,21 @@ const syncVisibilitySuspension = () => {
} }
governorRequestRender('visibility-restore'); governorRequestRender('visibility-restore');
} }
}; };
document.addEventListener('visibilitychange', syncVisibilitySuspension); document.addEventListener('visibilitychange', syncVisibilitySuspension);
defer(() => document.removeEventListener('visibilitychange', syncVisibilitySuspension)); defer(() =>
defer(() => { removeTrackingListener(); releaseContinuousRender('tracked-entity'); }); document.removeEventListener('visibilitychange', syncVisibilitySuspension),
// Apply the CURRENT state too — bootstrap can complete while the tab is );
// already hidden, and waiting for the next transition would leave the defer(() => {
// loop burning behind a hidden tab. (perf wave 2 fix) removeTrackingListener();
syncVisibilitySuspension(); releaseContinuousRender('tracked-entity');
});
// Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
// loop burning behind a hidden tab. (perf wave 2 fix)
syncVisibilitySuspension();
window.__godsEyeView = { window.__godsEyeView = {
viewer, viewer,
styleManager, styleManager,
tileset, tileset,
@@ -74,15 +91,23 @@ window.__godsEyeView = {
cockpitCloudEffects, cockpitCloudEffects,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
requestRender: governorRequestRender, requestRender: governorRequestRender,
}; };
const debug = window.__godsEyeView; const debug = window.__godsEyeView;
defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; }); defer(() => {
const voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations }); if (window.__godsEyeView === debug) delete window.__godsEyeView;
defer(() => { });
const voiceCommands = initGevVoiceCommands({
viewer,
styleManager,
dataManager,
sceneDirector,
annotations,
});
defer(() => {
voiceCommands.stop({ removeUi: true }); voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands) delete window.__gevVoiceCommands; if (window.__gevVoiceCommands === voiceCommands)
}); delete window.__gevVoiceCommands;
debug.voiceCommands = voiceCommands; });
return { sceneDirector, annotations, voiceCommands }; debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
} }