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
+528 -291

No files matched your search

+6 -6
View File
@@ -6,12 +6,12 @@ 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 |
| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners | | `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners |
Each constructor returns its component object, or a promise for that object. Each constructor returns its component object, or a promise for that object.
Those objects are passed unchanged to later constructors. Configuration and Those objects are passed unchanged to later constructors. Configuration and
+7 -7
View File
@@ -16,13 +16,13 @@ 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 |
| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services | | `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services |
| `src/main.js`, `src/editions/local/` and `vite.config.js` | Standalone startup and local Node services | Local configuration | | `src/main.js`, `src/editions/local/` and `vite.config.js` | Standalone startup and local Node services | Local configuration |
The scoped package exports are browser source modules. Use their documented The scoped package exports are browser source modules. Use their documented
exports instead of importing standalone startup or reaching into internal files. exports instead of importing standalone startup or reaching into internal files.
+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);
}, },
}); });
+93 -43
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(
`create${phase}`, phases.map((phase) => [
({ defer }) => { `create${phase}`,
events.push(`start:${phase}`); ({ defer }) => {
defer(() => events.push(`stop:${phase}`)); events.push(`start:${phase}`);
return { name: phase }; defer(() => events.push(`stop:${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({
assert.equal(scene.name, 'Scene'); createTools({ scene, controls, data, defer }) {
assert.equal(controls.name, 'Controls'); assert.equal(scene.name, 'Scene');
assert.equal(data.name, 'Data'); assert.equal(controls.name, 'Controls');
defer(() => events.push('stop:Tools')); assert.equal(data.name, 'Data');
return { name: 'Tools' }; defer(() => events.push('stop: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({
defer(() => events.push('partial:released')); [`create${phase}`]({ defer }) {
throw new Error('broken constructor'); defer(() => events.push('partial:released'));
} }); 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({
observedSignal = signal; async createScene({ signal, defer }) {
entered.resolve(); observedSignal = signal;
await gate.promise; entered.resolve();
defer(() => events.push('late:released')); await gate.promise;
return {}; defer(() => events.push('late:released'));
} }); 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({
defer(() => events.push('tools:earlier')); createTools({ defer }) {
defer(() => { throw new Error('dispose failed'); }); defer(() => events.push('tools:earlier'));
return {}; defer(() => {
} }); throw new Error('dispose failed');
});
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);
+9 -8
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,
@@ -21,13 +22,13 @@ export function createApplicationViewer({ container, creditContainer }) {
contextOptions: { webgl: { preserveDrawingBuffer: true } }, contextOptions: { webgl: { preserveDrawingBuffer: true } },
}); });
try { try {
viewer.targetFrameRate = 60; viewer.targetFrameRate = 60;
viewer.scene.globe.show = false; viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true; viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18; viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
viewer.scene.skyAtmosphere.saturationShift = -0.12; viewer.scene.skyAtmosphere.saturationShift = -0.12;
viewer.scene.skyAtmosphere.brightnessShift = -0.08; viewer.scene.skyAtmosphere.brightnessShift = -0.08;
return viewer; return viewer;
} catch (error) { } catch (error) {
viewer.destroy(); viewer.destroy();
throw error; throw error;
+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 }),
}); });
} }
+19 -15
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 };
} }
+56 -44
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 () => { }) {
await dataManager.destroyAll(); // Initialize data layer manager
if (dataManager.layers.size) throw new Error(`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`); const dataManager = new DataLayerManager(viewer, {
}); allowQaRegistration,
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer;
defer(() => {
if (window.__gevQaRegisterLayer === register) delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister) delete window.__gevQaUnregisterLayer;
}); });
} defer(async () => {
dataManager.buildTogglePanel(document.getElementById('data-toggles')); await dataManager.destroyAll();
styleManager.attachDataManager(dataManager); if (dataManager.layers.size)
throw new Error(
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer;
defer(() => {
if (window.__gevQaRegisterLayer === register)
delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister)
delete window.__gevQaUnregisterLayer;
});
}
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
return { dataManager }; return { dataManager };
} }
+58 -41
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 =
? 'Loading Google 3D Tiles...' googleApiKey || cesiumToken
: 'Loading the keyless globe...'; ? 'Loading Google 3D Tiles...'
const photoreal = await loadPhotorealisticTileset(Cesium, { : 'Loading the keyless globe...';
googleApiKey, const photoreal = await loadPhotorealisticTileset(Cesium, {
cesiumToken, googleApiKey,
}); 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,40 +58,47 @@ 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(
const tileErrorDetail = describeError(tileError); '[Init] Google 3D Tiles unavailable, using the keyless globe:',
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; tileError,
);
const tileErrorDetail = describeError(tileError);
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',
// Task 5 (height-datum fix): rebroadcast stack changes as a window // Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can // CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on // react without coupling MapStackController to layer modules. Fires on
// '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), );
}); },
defer(() => mapStackController.destroy()); onError: (message) => console.warn('[MapStack]', message),
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true }); });
defer(() => mapStackController.destroy());
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();
+87 -62
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,68 +31,83 @@ 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'); () => {
else releaseContinuousRender('tracked-entity'); if (viewer.trackedEntity) holdContinuousRender('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);
if (!hidden) { if (!hidden) {
if (dataManager._panelRefreshPendingOnVisible) { if (dataManager._panelRefreshPendingOnVisible) {
dataManager._panelRefreshPendingOnVisible = false; dataManager._panelRefreshPendingOnVisible = false;
dataManager._refreshTogglePanel(); dataManager._refreshTogglePanel();
}
governorRequestRender('visibility-restore');
} }
governorRequestRender('visibility-restore'); };
} document.addEventListener('visibilitychange', syncVisibilitySuspension);
}; defer(() =>
document.addEventListener('visibilitychange', syncVisibilitySuspension); document.removeEventListener('visibilitychange', syncVisibilitySuspension),
defer(() => document.removeEventListener('visibilitychange', syncVisibilitySuspension)); );
defer(() => { removeTrackingListener(); releaseContinuousRender('tracked-entity'); }); defer(() => {
// Apply the CURRENT state too — bootstrap can complete while the tab is removeTrackingListener();
// already hidden, and waiting for the next transition would leave the releaseContinuousRender('tracked-entity');
// loop burning behind a hidden tab. (perf wave 2 fix) });
syncVisibilitySuspension(); // Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
window.__godsEyeView = { // loop burning behind a hidden tab. (perf wave 2 fix)
viewer, syncVisibilitySuspension();
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; });
const voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands) delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
window.__godsEyeView = {
viewer,
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
defer(() => {
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
const voiceCommands = initGevVoiceCommands({
viewer,
styleManager,
dataManager,
sceneDirector,
annotations,
});
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands)
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
} }