mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
style: adopt application modules into scoped formatting
This commit is contained in:
1 parent
4d37760314
commit
8666705e12
17 files changed
+528
-291
No files matched your search
+6
-6
@@ -6,12 +6,12 @@ listeners. Construction is also inactive until the caller invokes `start()`.
|
||||
|
||||
The caller supplies four constructors, in this order:
|
||||
|
||||
| Constructor | Receives | Standalone implementation |
|
||||
| --- | --- | --- |
|
||||
| `createScene` | `signal`, `defer` | Viewer, attribution and initial map |
|
||||
| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation |
|
||||
| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration |
|
||||
| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners |
|
||||
| Constructor | Receives | Standalone implementation |
|
||||
| ---------------- | ---------------------------------------------- | --------------------------------------------- |
|
||||
| `createScene` | `signal`, `defer` | Viewer, attribution and initial map |
|
||||
| `createControls` | `scene`, `signal`, `defer` | Style manager and camera presentation |
|
||||
| `createData` | `scene`, `controls`, `signal`, `defer` | Layer catalog, registration and restoration |
|
||||
| `createTools` | `scene`, `controls`, `data`, `signal`, `defer` | Scenes, annotations, voice and page listeners |
|
||||
|
||||
Each constructor returns its component object, or a promise for that object.
|
||||
Those objects are passed unchanged to later constructors. Configuration and
|
||||
|
||||
@@ -16,13 +16,13 @@ excluded. The formatter validates every entry before writing any file.
|
||||
|
||||
## Current component ownership
|
||||
|
||||
| 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/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 |
|
||||
| `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 |
|
||||
| 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/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 |
|
||||
| `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 |
|
||||
|
||||
The scoped package exports are browser source modules. Use their documented
|
||||
exports instead of importing standalone startup or reaching into internal files.
|
||||
|
||||
@@ -11,5 +11,21 @@
|
||||
"src/data/infrastructure.test.mjs",
|
||||
"src/data/localGeojsonCore.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"
|
||||
]
|
||||
@@ -10,28 +10,16 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"application": {
|
||||
"exports": [
|
||||
"./application"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/application.js"
|
||||
],
|
||||
"exports": ["./application"],
|
||||
"modules": ["src/app/application.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": [
|
||||
"./application/viewer"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/viewer.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,30 @@ const urlIndex = args.indexOf('--url');
|
||||
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
|
||||
const browser = await puppeteer.launch({
|
||||
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'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
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());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.stack || error.message));
|
||||
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 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);
|
||||
window.__qaApplication = application;
|
||||
const app = window.__godsEyeView;
|
||||
@@ -32,7 +40,12 @@ try {
|
||||
await app.styleManager.initialRestorePromise;
|
||||
const enabled = await app.dataManager.setEnabled('local-datacenters', true);
|
||||
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 {
|
||||
status: application.getState().status,
|
||||
@@ -49,7 +62,9 @@ try {
|
||||
assert.equal(before.annotationCount, 1);
|
||||
assert.equal(before.viewerAlive, 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 application = window.__qaApplication;
|
||||
@@ -57,7 +72,8 @@ try {
|
||||
const first = application.destroy();
|
||||
const samePromise = first === application.destroy();
|
||||
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));
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
@@ -71,7 +87,10 @@ try {
|
||||
layers: app.dataManager.layers.size,
|
||||
annotations: app.annotations.count(),
|
||||
governor: app.getRenderGovernorDiagnostics(),
|
||||
handlesRemoved: !window.__godsEyeView && !window.__gevVoiceCommands && !window.__gevAnnotations,
|
||||
handlesRemoved:
|
||||
!window.__godsEyeView &&
|
||||
!window.__gevVoiceCommands &&
|
||||
!window.__gevAnnotations,
|
||||
creditsRemoved: !document.querySelector('#cesium-credits'),
|
||||
welcomeHidden: !document.querySelector('#first-run-launcher.visible'),
|
||||
settingsRemoved: !document.querySelector('#key-setup'),
|
||||
@@ -89,7 +108,9 @@ try {
|
||||
assert.equal(after.welcomeHidden, true);
|
||||
assert.equal(after.settingsRemoved, true);
|
||||
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 {
|
||||
await browser.close();
|
||||
}
|
||||
+45
-12
@@ -9,10 +9,21 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
|
||||
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors
|
||||
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
|
||||
*/
|
||||
export function createApplication({ createScene, createControls, createData, createTools }) {
|
||||
const factories = { scene: createScene, controls: createControls, data: createData, tools: createTools };
|
||||
export function createApplication({
|
||||
createScene,
|
||||
createControls,
|
||||
createData,
|
||||
createTools,
|
||||
}) {
|
||||
const factories = {
|
||||
scene: createScene,
|
||||
controls: createControls,
|
||||
data: createData,
|
||||
tools: createTools,
|
||||
};
|
||||
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 cleanups = Object.fromEntries(START_ORDER.map((phase) => [phase, []]));
|
||||
@@ -26,7 +37,11 @@ export function createApplication({ createScene, createControls, createData, cre
|
||||
function publish(status, phase = null) {
|
||||
state = Object.freeze({ status, phase });
|
||||
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 = [];
|
||||
for (const phase of STOP_ORDER) {
|
||||
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];
|
||||
}
|
||||
if (errors.length) throw new AggregateError(errors, 'Application cleanup failed');
|
||||
if (errors.length)
|
||||
throw new AggregateError(errors, 'Application cleanup failed');
|
||||
});
|
||||
return cleanupPromise;
|
||||
}
|
||||
@@ -57,7 +77,9 @@ export function createApplication({ createScene, createControls, createData, cre
|
||||
signal: controller.signal,
|
||||
defer(dispose) {
|
||||
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);
|
||||
},
|
||||
@@ -73,8 +95,13 @@ export function createApplication({ createScene, createControls, createData, cre
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
let failure = error;
|
||||
try { await cleanup(); } catch (cleanupError) {
|
||||
failure = new AggregateError([error, cleanupError], 'Application startup and cleanup failed');
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (cleanupError) {
|
||||
failure = new AggregateError(
|
||||
[error, cleanupError],
|
||||
'Application startup and cleanup failed',
|
||||
);
|
||||
}
|
||||
if (!destroyPromise) publish('failed');
|
||||
throw failure;
|
||||
@@ -83,7 +110,8 @@ export function createApplication({ createScene, createControls, createData, cre
|
||||
|
||||
return Object.freeze({
|
||||
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.
|
||||
startPromise ||= Promise.resolve().then(initialize);
|
||||
return startPromise;
|
||||
@@ -109,10 +137,15 @@ export function createApplication({ createScene, createControls, createData, cre
|
||||
getState: () => state,
|
||||
getComponents: () => Object.freeze({ ...components }),
|
||||
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 () => {};
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,31 +6,43 @@ import { createApplication } from './application.js';
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
const events = [];
|
||||
const constructors = Object.fromEntries(phases.map((phase) => [
|
||||
`create${phase}`,
|
||||
({ defer }) => {
|
||||
events.push(`start:${phase}`);
|
||||
defer(() => events.push(`stop:${phase}`));
|
||||
return { name: phase };
|
||||
},
|
||||
]));
|
||||
const constructors = Object.fromEntries(
|
||||
phases.map((phase) => [
|
||||
`create${phase}`,
|
||||
({ defer }) => {
|
||||
events.push(`start:${phase}`);
|
||||
defer(() => events.push(`stop:${phase}`));
|
||||
return { name: phase };
|
||||
},
|
||||
]),
|
||||
);
|
||||
return { events, app: createApplication({ ...constructors, ...overrides }) };
|
||||
}
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
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.setTimeout = () => { throw new Error('unexpected timer'); };
|
||||
const { createApplication } = await import('gods-eye-view/application');
|
||||
const fail = () => { throw new Error('unexpected construction'); };
|
||||
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -39,13 +51,15 @@ test('constructors are validated before allocating anything', () => {
|
||||
});
|
||||
|
||||
test('startup shares one promise and teardown keeps data alive for controls', async () => {
|
||||
const { app, events } = fixture({ createTools({ scene, controls, data, defer }) {
|
||||
assert.equal(scene.name, 'Scene');
|
||||
assert.equal(controls.name, 'Controls');
|
||||
assert.equal(data.name, 'Data');
|
||||
defer(() => events.push('stop:Tools'));
|
||||
return { name: 'Tools' };
|
||||
} });
|
||||
const { app, events } = fixture({
|
||||
createTools({ scene, controls, data, defer }) {
|
||||
assert.equal(scene.name, 'Scene');
|
||||
assert.equal(controls.name, 'Controls');
|
||||
assert.equal(data.name, 'Data');
|
||||
defer(() => events.push('stop:Tools'));
|
||||
return { name: 'Tools' };
|
||||
},
|
||||
});
|
||||
const states = [];
|
||||
app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
|
||||
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();
|
||||
assert.equal(stopped, app.destroy());
|
||||
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.equal(states.at(-1), 'destroyed:null');
|
||||
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) {
|
||||
test(`failure during ${phase} releases partially acquired resources`, async () => {
|
||||
const { app, events } = fixture({ [`create${phase}`]({ defer }) {
|
||||
defer(() => events.push('partial:released'));
|
||||
throw new Error('broken constructor');
|
||||
} });
|
||||
const { app, events } = fixture({
|
||||
[`create${phase}`]({ defer }) {
|
||||
defer(() => events.push('partial:released'));
|
||||
throw new Error('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.deepEqual(app.getComponents(), {});
|
||||
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 entered = deferred();
|
||||
let observedSignal;
|
||||
const { app, events } = fixture({ async createScene({ signal, defer }) {
|
||||
observedSignal = signal;
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
defer(() => events.push('late:released'));
|
||||
return {};
|
||||
} });
|
||||
const { app, events } = fixture({
|
||||
async createScene({ signal, defer }) {
|
||||
observedSignal = signal;
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
defer(() => events.push('late:released'));
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const started = app.start();
|
||||
const failed = assert.rejects(started, { name: 'AbortError' });
|
||||
await entered.promise;
|
||||
let settled = false;
|
||||
const stopped = app.destroy().then(() => { settled = true; });
|
||||
const stopped = app.destroy().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
await Promise.resolve();
|
||||
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 () => {
|
||||
const { app, events } = fixture({ createTools({ defer }) {
|
||||
defer(() => events.push('tools:earlier'));
|
||||
defer(() => { throw new Error('dispose failed'); });
|
||||
return {};
|
||||
} });
|
||||
const { app, events } = fixture({
|
||||
createTools({ defer }) {
|
||||
defer(() => events.push('tools:earlier'));
|
||||
defer(() => {
|
||||
throw new Error('dispose failed');
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
await app.start();
|
||||
await assert.rejects(app.destroy(), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.equal(error.errors[0].message, 'dispose failed');
|
||||
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');
|
||||
});
|
||||
|
||||
test('subscribers can destroy during startup without allowing the next constructor', async () => {
|
||||
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 app.destroy();
|
||||
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 () => {
|
||||
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 app.destroy();
|
||||
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 () => {
|
||||
let register;
|
||||
const { app } = fixture({ createScene({ defer }) { register = defer; return {}; } });
|
||||
const { app } = fixture({
|
||||
createScene({ defer }) {
|
||||
register = defer;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
await app.start();
|
||||
assert.throws(() => register(() => {}), /during component construction/);
|
||||
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 () => {
|
||||
const { createApplicationViewer } = await import('gods-eye-view/application/viewer');
|
||||
const { createApplicationViewer } =
|
||||
await import('gods-eye-view/application/viewer');
|
||||
assert.equal(typeof createApplicationViewer, 'function');
|
||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||||
});
|
||||
@@ -2,17 +2,28 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MapStackController } from '../mapStackController.js';
|
||||
import {
|
||||
installRenderGovernor, uninstallRenderGovernor,
|
||||
holdContinuousRender, governorRequestRender, getRenderGovernorDiagnostics,
|
||||
installRenderGovernor,
|
||||
uninstallRenderGovernor,
|
||||
holdContinuousRender,
|
||||
governorRequestRender,
|
||||
getRenderGovernorDiagnostics,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
test('destroy invalidates a pending imagery provider before it can touch the viewer', async () => {
|
||||
let resolveProvider;
|
||||
let removed = 0;
|
||||
const changes = [];
|
||||
const controller = new MapStackController({}, { onChange: (state) => changes.push(state.status) });
|
||||
controller._getImageryProvider = () => new Promise((resolve) => { resolveProvider = resolve; });
|
||||
controller._removeImageryErrorListener = () => { removed++; };
|
||||
const controller = new MapStackController(
|
||||
{},
|
||||
{ onChange: (state) => changes.push(state.status) },
|
||||
);
|
||||
controller._getImageryProvider = () =>
|
||||
new Promise((resolve) => {
|
||||
resolveProvider = resolve;
|
||||
});
|
||||
controller._removeImageryErrorListener = () => {
|
||||
removed++;
|
||||
};
|
||||
const switching = controller.setStack('osm');
|
||||
controller.destroy();
|
||||
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', () => {
|
||||
let renders = 0;
|
||||
const viewer = { scene: { requestRender: () => { renders++; } } };
|
||||
const viewer = {
|
||||
scene: {
|
||||
requestRender: () => {
|
||||
renders++;
|
||||
},
|
||||
},
|
||||
};
|
||||
installRenderGovernor(viewer);
|
||||
holdContinuousRender('fixture');
|
||||
uninstallRenderGovernor({});
|
||||
|
||||
@@ -8,7 +8,15 @@ test('teardown before the initial camera delay prevents a late flight', (t) => {
|
||||
let cancelled = 0;
|
||||
const stop = flyToAustin({
|
||||
isDestroyed: () => false,
|
||||
camera: { setView() {}, flyTo() { flights++; }, cancelFlight() { cancelled++; } },
|
||||
camera: {
|
||||
setView() {},
|
||||
flyTo() {
|
||||
flights++;
|
||||
},
|
||||
cancelFlight() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
});
|
||||
stop();
|
||||
t.mock.timers.tick(1000);
|
||||
|
||||
+9
-8
@@ -2,7 +2,8 @@ import * as Cesium from 'cesium';
|
||||
|
||||
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||
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, {
|
||||
timeline: false,
|
||||
animation: false,
|
||||
@@ -21,13 +22,13 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
contextOptions: { webgl: { preserveDrawingBuffer: true } },
|
||||
});
|
||||
try {
|
||||
viewer.targetFrameRate = 60;
|
||||
viewer.scene.globe.show = false;
|
||||
viewer.scene.skyAtmosphere.show = true;
|
||||
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
|
||||
viewer.scene.skyAtmosphere.saturationShift = -0.12;
|
||||
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
|
||||
return viewer;
|
||||
viewer.targetFrameRate = 60;
|
||||
viewer.scene.globe.show = false;
|
||||
viewer.scene.skyAtmosphere.show = true;
|
||||
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
|
||||
viewer.scene.skyAtmosphere.saturationShift = -0.12;
|
||||
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
|
||||
return viewer;
|
||||
} catch (error) {
|
||||
viewer.destroy();
|
||||
throw error;
|
||||
|
||||
@@ -8,15 +8,23 @@ import { createLocalTools } from './tools.js';
|
||||
let constructed = false;
|
||||
|
||||
/** Compose the standalone application once per page. Reload to start again. */
|
||||
export function createLocalApplication({ googleApiKey, cesiumToken, allowQaRegistration = false }) {
|
||||
if (constructed) throw new Error('The standalone application already owns this page');
|
||||
export function createLocalApplication({
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
allowQaRegistration = false,
|
||||
}) {
|
||||
if (constructed)
|
||||
throw new Error('The standalone application already owns this page');
|
||||
constructed = true;
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
const loaderStatus = loadingScreen.querySelector('.loader-status');
|
||||
return createApplication({
|
||||
createScene: (context) => createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }),
|
||||
createControls: (context) => createLocalControls({ ...context, loaderStatus }),
|
||||
createData: (context) => createLocalData({ ...context, allowQaRegistration }),
|
||||
createScene: (context) =>
|
||||
createLocalScene({ ...context, googleApiKey, cesiumToken, loaderStatus }),
|
||||
createControls: (context) =>
|
||||
createLocalControls({ ...context, loaderStatus }),
|
||||
createData: (context) =>
|
||||
createLocalData({ ...context, allowQaRegistration }),
|
||||
createTools: (context) => createLocalTools({ ...context, loadingScreen }),
|
||||
});
|
||||
}
|
||||
Vendored
+19
-15
@@ -3,26 +3,30 @@ import { flyToAustin } from '../../camera.js';
|
||||
import { initCockpitCloudEffects } from '../../cockpitCloudEffects.js';
|
||||
|
||||
/** Construct the existing controls and camera presentation. */
|
||||
export function createLocalControls({ scene: { viewer, mapStackController }, loaderStatus, defer }) {
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new StyleManager(viewer, { mapStackController });
|
||||
export function createLocalControls({
|
||||
scene: { 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.hud.destroy());
|
||||
defer(() => styleManager.dispose());
|
||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||
// 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.
|
||||
const weatherEffects = null;
|
||||
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
|
||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||
// 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.
|
||||
const weatherEffects = null;
|
||||
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
|
||||
defer(() => cockpitCloudEffects?.destroy());
|
||||
|
||||
// If no share link state, do default fly-to Austin
|
||||
if (!styleManager.hasShareState) {
|
||||
loaderStatus.textContent = 'Flying to Austin, TX...';
|
||||
defer(flyToAustin(viewer));
|
||||
} else {
|
||||
loaderStatus.textContent = 'Restoring shared view...';
|
||||
}
|
||||
// If no share link state, do default fly-to Austin
|
||||
if (!styleManager.hasShareState) {
|
||||
loaderStatus.textContent = 'Flying to Austin, TX...';
|
||||
defer(flyToAustin(viewer));
|
||||
} else {
|
||||
loaderStatus.textContent = 'Restoring shared view...';
|
||||
}
|
||||
|
||||
return { styleManager, weatherEffects, cockpitCloudEffects };
|
||||
}
|
||||
+56
-44
@@ -15,52 +15,64 @@ import localDataLayers from '../../data/localLayers.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../../data/layerState.js';
|
||||
|
||||
/** Register the standalone layer catalog before allowing state restoration. */
|
||||
export function createLocalData({ scene: { viewer }, controls: { styleManager }, allowQaRegistration, defer }) {
|
||||
// Initialize data layer manager
|
||||
const dataManager = new DataLayerManager(viewer, {
|
||||
export function createLocalData({
|
||||
scene: { viewer },
|
||||
controls: { styleManager },
|
||||
allowQaRegistration,
|
||||
});
|
||||
defer(async () => {
|
||||
await dataManager.destroyAll();
|
||||
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;
|
||||
defer,
|
||||
}) {
|
||||
// Initialize data layer manager
|
||||
const dataManager = new DataLayerManager(viewer, {
|
||||
allowQaRegistration,
|
||||
});
|
||||
}
|
||||
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
|
||||
styleManager.attachDataManager(dataManager);
|
||||
defer(async () => {
|
||||
await dataManager.destroyAll();
|
||||
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 };
|
||||
}
|
||||
+58
-41
@@ -9,7 +9,13 @@ import { uninstallRenderGovernor } from '../../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
|
||||
/** 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());
|
||||
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
|
||||
if (googleApiKey) {
|
||||
@@ -26,21 +32,25 @@ export async function createLocalScene({ googleApiKey, cesiumToken, loaderStatus
|
||||
creditContainer.id = 'cesium-credits';
|
||||
document.body.appendChild(creditContainer);
|
||||
defer(() => creditContainer.remove());
|
||||
const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer });
|
||||
const viewer = createApplicationViewer({
|
||||
container: 'cesiumContainer',
|
||||
creditContainer,
|
||||
});
|
||||
defer(() => {
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
registerDataCredits(viewer);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent = googleApiKey || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
// A provider can finish after cancellation; retain ownership of its result.
|
||||
defer(() => {
|
||||
if (tileset && !tileset.isDestroyed()) {
|
||||
@@ -48,40 +58,47 @@ const tileset = photoreal.tileset;
|
||||
}
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
if (tileset) {
|
||||
viewer.scene.primitives.add(tileset);
|
||||
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
|
||||
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
|
||||
viewer.scene.globe.show = false;
|
||||
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
|
||||
} else {
|
||||
if (photoreal.errors.length) {
|
||||
const tileError = photoreal.errors.at(-1);
|
||||
console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError);
|
||||
const tileErrorDetail = describeError(tileError);
|
||||
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
|
||||
if (tileset) {
|
||||
viewer.scene.primitives.add(tileset);
|
||||
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
|
||||
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
|
||||
viewer.scene.globe.show = false;
|
||||
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
|
||||
} else {
|
||||
if (photoreal.errors.length) {
|
||||
const tileError = photoreal.errors.at(-1);
|
||||
console.warn(
|
||||
'[Init] Google 3D Tiles unavailable, using 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, {
|
||||
googleTileset: tileset,
|
||||
cesiumToken,
|
||||
initialStack: tileset ? 'photoreal' : 'esri-imagery',
|
||||
// Task 5 (height-datum fix): rebroadcast stack changes as a window
|
||||
// CustomEvent so data layers (CCTV per-regime ground resolution) can
|
||||
// react without coupling MapStackController to layer modules. Fires on
|
||||
// 'switching'/'ready'/'error'; listeners derive the surface regime from
|
||||
// live scene state, so intermediate emissions are harmless.
|
||||
onChange: (state) => {
|
||||
window.dispatchEvent(new CustomEvent('gev:map-stack-changed', { detail: state }));
|
||||
},
|
||||
onError: (message) => console.warn('[MapStack]', message),
|
||||
});
|
||||
defer(() => mapStackController.destroy());
|
||||
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true });
|
||||
const mapStackController = new MapStackController(viewer, {
|
||||
googleTileset: tileset,
|
||||
cesiumToken,
|
||||
initialStack: tileset ? 'photoreal' : 'esri-imagery',
|
||||
// Task 5 (height-datum fix): rebroadcast stack changes as a window
|
||||
// CustomEvent so data layers (CCTV per-regime ground resolution) can
|
||||
// react without coupling MapStackController to layer modules. Fires on
|
||||
// 'switching'/'ready'/'error'; listeners derive the surface regime from
|
||||
// live scene state, so intermediate emissions are harmless.
|
||||
onChange: (state) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:map-stack-changed', { detail: state }),
|
||||
);
|
||||
},
|
||||
onError: (message) => console.warn('[MapStack]', message),
|
||||
});
|
||||
defer(() => mapStackController.destroy());
|
||||
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', {
|
||||
silent: true,
|
||||
});
|
||||
|
||||
signal.throwIfAborted();
|
||||
return { viewer, tileset, mapStackController };
|
||||
|
||||
@@ -2,12 +2,19 @@ import { initFirstRunExperience } from '../../firstRunExperience.js';
|
||||
import { initKeySetup } from '../../keySetup.js';
|
||||
|
||||
/** 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 firstRun;
|
||||
let revealTimer;
|
||||
let resolveDelay;
|
||||
const minimumDelay = new Promise((resolve) => { resolveDelay = resolve; });
|
||||
const minimumDelay = new Promise((resolve) => {
|
||||
resolveDelay = resolve;
|
||||
});
|
||||
const delayTimer = setTimeout(resolveDelay, 1000);
|
||||
const revealFirstRun = () => {
|
||||
if (disposed || signal.aborted || firstRun) return;
|
||||
@@ -16,16 +23,22 @@ export function startLocalChrome({ loadingScreen, styleManager, dataManager, sig
|
||||
loadingScreen.removeEventListener('transitionend', revealFirstRun);
|
||||
};
|
||||
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(() => {
|
||||
if (disposed || signal.aborted) return;
|
||||
loadingScreen.classList.add('hidden');
|
||||
loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true });
|
||||
loadingScreen.addEventListener('transitionend', revealFirstRun, {
|
||||
once: true,
|
||||
});
|
||||
revealTimer = setTimeout(revealFirstRun, 900);
|
||||
});
|
||||
const keySetup = initKeySetup({ signal });
|
||||
// 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 () => {
|
||||
disposed = true;
|
||||
clearTimeout(delayTimer);
|
||||
|
||||
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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('export function', 'function');
|
||||
|
||||
@@ -16,13 +19,21 @@ function fixture() {
|
||||
const controller = new AbortController();
|
||||
const context = {
|
||||
console,
|
||||
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
setTimeout(fn, delay) {
|
||||
const id = ++nextTimer;
|
||||
timers.set(id, { fn, delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
initFirstRunExperience() {
|
||||
events.push('welcome');
|
||||
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.runInContext(source, context);
|
||||
@@ -32,21 +43,34 @@ function fixture() {
|
||||
addEventListener: (type, listener) => listeners.set(type, listener),
|
||||
removeEventListener: (type) => listeners.delete(type),
|
||||
},
|
||||
styleManager: { initialRestorePromise: new Promise((resolve) => { restored = resolve; }) },
|
||||
styleManager: {
|
||||
initialRestorePromise: new Promise((resolve) => {
|
||||
restored = resolve;
|
||||
}),
|
||||
},
|
||||
dataManager: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
return {
|
||||
events, listeners, stop, restored, controller,
|
||||
events,
|
||||
listeners,
|
||||
stop,
|
||||
restored,
|
||||
controller,
|
||||
fire(delay) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
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 () => {
|
||||
const f = fixture();
|
||||
|
||||
+87
-62
@@ -3,13 +3,23 @@ import { initAnnotations } from '../../annotations/index.js';
|
||||
import { initGevVoiceCommands } from '../../voice/gevRealtime.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../../scopeMask.js';
|
||||
import {
|
||||
installRenderGovernor, getRenderGovernorDiagnostics, governorRequestRender,
|
||||
holdContinuousRender, releaseContinuousRender,
|
||||
installRenderGovernor,
|
||||
getRenderGovernorDiagnostics,
|
||||
governorRequestRender,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
} from '../../renderGovernor.js';
|
||||
import { startLocalChrome } from './startupChrome.js';
|
||||
|
||||
/** 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 { styleManager, weatherEffects, cockpitCloudEffects } = controls;
|
||||
const { dataManager } = data;
|
||||
@@ -21,68 +31,83 @@ export function createLocalTools({ scene, controls, data, loadingScreen, signal,
|
||||
annotations.destroy();
|
||||
});
|
||||
defer(startLocalChrome({ loadingScreen, styleManager, dataManager, signal }));
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2)
|
||||
installRenderGovernor(viewer);
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2)
|
||||
installRenderGovernor(viewer);
|
||||
|
||||
// Install the explicit scope mask used by the DISPLAY controls.
|
||||
installScopeMask(viewer);
|
||||
defer(() => destroyScopeMask());
|
||||
// Install the explicit scope mask used by the DISPLAY controls.
|
||||
installScopeMask(viewer);
|
||||
defer(() => destroyScopeMask());
|
||||
|
||||
// The follow camera recomputes the tracked target's dead-reckon position
|
||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
// The follow camera recomputes the tracked target's dead-reckon position
|
||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
|
||||
() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
},
|
||||
);
|
||||
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
// nobody, and browser rAF throttling still lets throttled frames burn
|
||||
// GPU. Holder/data state is untouched, so return is seamless: restore
|
||||
// the loop, refresh the one DOM surface we gated, render a frame.
|
||||
const syncVisibilitySuspension = () => {
|
||||
const hidden = document.hidden;
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
if (dataManager._panelRefreshPendingOnVisible) {
|
||||
dataManager._panelRefreshPendingOnVisible = false;
|
||||
dataManager._refreshTogglePanel();
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
// nobody, and browser rAF throttling still lets throttled frames burn
|
||||
// GPU. Holder/data state is untouched, so return is seamless: restore
|
||||
// the loop, refresh the one DOM surface we gated, render a frame.
|
||||
const syncVisibilitySuspension = () => {
|
||||
const hidden = document.hidden;
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
if (!hidden) {
|
||||
if (dataManager._panelRefreshPendingOnVisible) {
|
||||
dataManager._panelRefreshPendingOnVisible = false;
|
||||
dataManager._refreshTogglePanel();
|
||||
}
|
||||
governorRequestRender('visibility-restore');
|
||||
}
|
||||
governorRequestRender('visibility-restore');
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', syncVisibilitySuspension);
|
||||
defer(() => document.removeEventListener('visibilitychange', syncVisibilitySuspension));
|
||||
defer(() => { removeTrackingListener(); 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 = {
|
||||
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 };
|
||||
};
|
||||
document.addEventListener('visibilitychange', syncVisibilitySuspension);
|
||||
defer(() =>
|
||||
document.removeEventListener('visibilitychange', syncVisibilitySuspension),
|
||||
);
|
||||
defer(() => {
|
||||
removeTrackingListener();
|
||||
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 = {
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user