mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #311 from bilawalsidhu/refactor/infrastructure-factories
Extract reusable datacenter and dam layers
This commit is contained in:
11 files changed
+1535
-1090
No files matched your search
@@ -13,6 +13,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Datacenter and dam factories are available through scoped package exports with
|
||||||
|
explicit context, overlay and render callbacks. The standalone app uses the
|
||||||
|
same implementation and bundled datasets.
|
||||||
|
|
||||||
|
- Local GeoJSON layers share concurrent loads, cancel pending fetches on destruction,
|
||||||
|
discard late results, and remove their entity-context records on teardown.
|
||||||
|
|
||||||
- Unchanged local infrastructure overlays no longer sustain idle rendering.
|
- Unchanged local infrastructure overlays no longer sustain idle rendering.
|
||||||
Ground samples wait for visible terrain to settle and cannot place a marker
|
Ground samples wait for visible terrain to settle and cannot place a marker
|
||||||
below its loaded surface; roofs and valid below-sea-level heights are retained.
|
below its loaded surface; roofs and valid below-sea-level heights are retained.
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
## Infrastructure marker visibility
|
## Infrastructure marker visibility
|
||||||
|
|
||||||
|
Datacenter/dam registration uses fresh reusable factories with the application's
|
||||||
|
existing context, overlay and render functions. The scoped package exports do
|
||||||
|
not import standalone application globals; see
|
||||||
|
[the infrastructure interface](INFRASTRUCTURE-LAYERS.md) for lifecycle and asset
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
Local GeoJSON layers coalesce concurrent enables into one load. Disabling while
|
||||||
|
loading keeps the result hidden; destruction aborts the fetch and rejects late
|
||||||
|
parse/add results. Destruction and failed setup remove owned context records,
|
||||||
|
so replacing a layer cannot retain stale entities or listeners.
|
||||||
|
|
||||||
Datacenters and dams retain their full datasets while limiting active marker
|
Datacenters and dams retain their full datasets while limiting active marker
|
||||||
stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km,
|
stems per layer: 80 at camera heights of 3,000 km or above, 200 from 200 km,
|
||||||
and 420 below 200 km. Selection favors existing label priority and nearby
|
and 420 below 200 km. Selection favors existing label priority and nearby
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Reusing infrastructure layers
|
||||||
|
|
||||||
|
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
|
||||||
|
It returns fresh datacenter and dam layers in that order, with the existing IDs,
|
||||||
|
appearance, label budgets and bundled public datasets. Importing the module or
|
||||||
|
calling the factory does not load data, create a viewer, or start the application.
|
||||||
|
The asset URLs resolve relative to the module; Vite rewrites them for production
|
||||||
|
base paths. This browser source export expects a Vite-compatible asset build.
|
||||||
|
|
||||||
|
Pass the application's existing functions:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createInfrastructureLayers } from 'gods-eye-view/infrastructure';
|
||||||
|
|
||||||
|
const layers = createInfrastructureLayers({
|
||||||
|
overlayHost: { setEntries, setVisible, clearSource },
|
||||||
|
registerEntityContext,
|
||||||
|
selectEntityContext,
|
||||||
|
clearSelectedEntityContextForLayer,
|
||||||
|
removeEntityContextsForLayer,
|
||||||
|
governorRequestRender,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register these layers with the existing data-layer manager and viewer.
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Arguments / responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `overlayHost.setEntries` | `(sourceId, entries, options)`; publish the existing infrastructure overlay records and paint budget |
|
||||||
|
| `overlayHost.setVisible` | `(sourceId, visible)`; toggle a source |
|
||||||
|
| `overlayHost.clearSource` | `(sourceId)`; remove that source's overlay entries |
|
||||||
|
| `registerEntityContext` | `(entity, metadata)`; register selectable feature metadata, including the owning layer and data source |
|
||||||
|
| `selectEntityContext` | `(entity)`; select that registered feature |
|
||||||
|
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
|
||||||
|
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||||
|
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||||
|
|
||||||
|
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||||
|
`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`.
|
||||||
|
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
|
||||||
|
host permits one live instance per layer ID; destroy an old instance before
|
||||||
|
replacing it. Separate applications may use identical IDs with separate hosts.
|
||||||
|
Callbacks must be ready before enable and remain usable through destruction.
|
||||||
|
|
||||||
|
Disable retains the loaded dataset for reuse and clears selection and listeners.
|
||||||
|
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
|
||||||
|
discards late results, and clears the owned data source, context records, overlays,
|
||||||
|
input handler and listeners. The layer never creates an application context store,
|
||||||
|
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
|
||||||
|
Cesium installation shared with its viewer; it must not bundle separate copies.
|
||||||
|
|
||||||
|
`gods-eye-view/infrastructure/geojson` exports the lower-level
|
||||||
|
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
|
||||||
|
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
|
||||||
|
The standalone app's `src/data/localGeojson.js` keeps its original single-argument
|
||||||
|
factory and supplies its existing functions. Other consumers should use the package
|
||||||
|
exports, which do not import standalone application globals.
|
||||||
|
|
||||||
|
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||||
|
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
|
||||||
|
expand multipart geometries into multiple entities; entity and stem counts are
|
||||||
|
different measurements. This change does not refresh or relicense the datasets.
|
||||||
@@ -56,5 +56,10 @@
|
|||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vite-plugin-cesium": "^1.2.23",
|
"vite-plugin-cesium": "^1.2.23",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
"./infrastructure": "./src/data/infrastructure.js",
|
||||||
|
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
|
||||||
|
"./infrastructure/lod": "./src/data/localGeojsonLod.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||||
|
|
||||||
|
// Resolved by Vite in builds and relative to this module in other consumers.
|
||||||
|
const datacentersUrl = new URL('./local_data/datacenters/datacenters.geojsonl', import.meta.url).href;
|
||||||
|
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create fresh datacenter and dam layers without starting or loading them.
|
||||||
|
* @param {object} services Caller-owned context, overlay and render operations.
|
||||||
|
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||||
|
*/
|
||||||
|
export function createInfrastructureLayers(services) {
|
||||||
|
const datacenters = createLocalGeoJsonLayer({
|
||||||
|
id: 'local-datacenters',
|
||||||
|
url: datacentersUrl,
|
||||||
|
name: 'Datacenters',
|
||||||
|
color: '#00ffff', // Cyan
|
||||||
|
icon: '▣',
|
||||||
|
source: 'Local',
|
||||||
|
labels: true,
|
||||||
|
labelMax: 700,
|
||||||
|
labelGridPx: 138,
|
||||||
|
}, services);
|
||||||
|
|
||||||
|
const dams = createLocalGeoJsonLayer({
|
||||||
|
id: 'local-dams',
|
||||||
|
url: damsUrl,
|
||||||
|
name: 'Dams',
|
||||||
|
color: '#0088ff', // Blue
|
||||||
|
icon: '▰',
|
||||||
|
source: 'USACE',
|
||||||
|
labels: true,
|
||||||
|
labelMax: 900,
|
||||||
|
labelGridPx: 132,
|
||||||
|
}, services);
|
||||||
|
|
||||||
|
return [datacenters, dams];
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createInfrastructureLayers } from 'gods-eye-view/infrastructure';
|
||||||
|
import { createLocalGeoJsonLayer } from 'gods-eye-view/infrastructure/geojson';
|
||||||
|
|
||||||
|
function services() {
|
||||||
|
const records = new Map();
|
||||||
|
let selection;
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||||
|
registerEntityContext(entity, metadata) { records.set(metadata.id, { entity, ...metadata }); },
|
||||||
|
selectEntityContext(entity) { selection = entity; },
|
||||||
|
clearSelectedEntityContextForLayer() { selection = undefined; },
|
||||||
|
removeEntityContextsForLayer(id) {
|
||||||
|
for (const [key, record] of records) if (record.layerId === id) records.delete(key);
|
||||||
|
},
|
||||||
|
governorRequestRender() {},
|
||||||
|
selection: () => selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('package exports import without an application, DOM, fetch, or timers', () => {
|
||||||
|
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||||
|
globalThis.fetch = () => { throw new Error('unexpected fetch'); };
|
||||||
|
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
|
||||||
|
for (const key of ['window', 'document']) {
|
||||||
|
delete globalThis[key];
|
||||||
|
}
|
||||||
|
await import('gods-eye-view/infrastructure');
|
||||||
|
await import('gods-eye-view/infrastructure/geojson');
|
||||||
|
await import('gods-eye-view/infrastructure/lod');
|
||||||
|
`], { cwd: new URL('../..', import.meta.url), encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('infrastructure factory preserves identity and creates independent state without loading', t => {
|
||||||
|
t.mock.method(globalThis, 'fetch', () => { throw new Error('factory must not fetch'); });
|
||||||
|
const first = createInfrastructureLayers(services());
|
||||||
|
const second = createInfrastructureLayers(services());
|
||||||
|
assert.deepEqual(first.map(({ id, name, source }) => ({ id, name, source })), [
|
||||||
|
{ id: 'local-datacenters', name: 'Datacenters', source: 'Local' },
|
||||||
|
{ id: 'local-dams', name: 'Dams', source: 'USACE' },
|
||||||
|
]);
|
||||||
|
first.forEach((layer, index) => {
|
||||||
|
assert.notEqual(layer, second[index]);
|
||||||
|
layer.destroy();
|
||||||
|
assert.deepEqual(second[index].getStats(), { count: 0, lastUpdate: null, error: null });
|
||||||
|
second[index].destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dataset URLs still name the complete bundled sources', () => {
|
||||||
|
for (const [file, count] of [['datacenters', 4351], ['dams', 704]]) {
|
||||||
|
const lines = readFileSync(new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url), 'utf8')
|
||||||
|
.split('\n').filter(line => line.trim());
|
||||||
|
assert.equal(lines.length, count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two viewers use their supplied contexts and dispose independently', async t => {
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({
|
||||||
|
type: 'Feature', id: 'same-id', properties: { name: 'Dam' },
|
||||||
|
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
|
||||||
|
}) }));
|
||||||
|
const hosts = [services(), services()];
|
||||||
|
const instances = hosts.map(host => {
|
||||||
|
const sources = new Cesium.DataSourceCollection();
|
||||||
|
let click;
|
||||||
|
const layer = createLocalGeoJsonLayer({
|
||||||
|
id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl',
|
||||||
|
screenSpaceEventHandlerFactory: () => ({ setInputAction(fn) { click = fn; }, destroy() {} }),
|
||||||
|
}, host);
|
||||||
|
const viewer = {
|
||||||
|
dataSources: sources,
|
||||||
|
scene: {
|
||||||
|
canvas: {}, preRender: new Cesium.Event(), requestRender() {},
|
||||||
|
screenSpaceCameraController: { enableInputs: true },
|
||||||
|
pick: () => ({ id: sources.get(0).entities.values[0] }),
|
||||||
|
},
|
||||||
|
camera: { moveEnd: new Cesium.Event(), flyTo() {} },
|
||||||
|
};
|
||||||
|
return { layer, viewer, click: () => click({ position: {} }) };
|
||||||
|
});
|
||||||
|
t.after(() => instances.forEach(({ layer, viewer }) => layer.destroy(viewer)));
|
||||||
|
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||||
|
assert.equal(hosts[0].records.size, 1);
|
||||||
|
assert.equal(hosts[1].records.size, 1);
|
||||||
|
instances[0].click();
|
||||||
|
assert.ok(hosts[0].selection());
|
||||||
|
assert.equal(hosts[1].selection(), undefined);
|
||||||
|
instances[0].layer.destroy(instances[0].viewer);
|
||||||
|
assert.equal(hosts[0].records.size, 0);
|
||||||
|
assert.equal(hosts[1].records.size, 1);
|
||||||
|
assert.equal(instances[1].viewer.dataSources.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consumer build includes only infrastructure code and resolves assets under a non-root base', async () => {
|
||||||
|
const { build } = await import('vite');
|
||||||
|
const { fileURLToPath } = await import('node:url');
|
||||||
|
const result = await build({
|
||||||
|
configFile: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
base: '/example/',
|
||||||
|
build: {
|
||||||
|
write: false,
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
rollupOptions: {
|
||||||
|
input: fileURLToPath(import.meta.resolve('gods-eye-view/infrastructure')),
|
||||||
|
external: ['cesium'],
|
||||||
|
preserveEntrySignatures: 'strict',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const output = result.output;
|
||||||
|
const entry = output.find(item => item.type === 'chunk' && item.isEntry);
|
||||||
|
const sources = Object.keys(entry.modules).filter(id => id.endsWith('.js'));
|
||||||
|
assert.deepEqual(sources.map(id => id.split('/').at(-1)).sort(), [
|
||||||
|
'infrastructure.js', 'localGeojsonCore.js', 'localGeojsonLod.js',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(entry.imports, ['cesium'], 'the viewer supplies the same Cesium dependency');
|
||||||
|
for (const name of ['datacenters', 'dams']) {
|
||||||
|
const asset = output.find(item => item.type === 'asset' && item.fileName.includes(name));
|
||||||
|
assert.ok(asset, `${name} must be emitted`);
|
||||||
|
assert.ok(entry.code.includes(`/example/${asset.fileName}`), `${name} must retain the consumer base path`);
|
||||||
|
}
|
||||||
|
});
|
||||||
+29
-1057
File diff suppressed because it is too large.
Load diff
@@ -489,7 +489,7 @@ for (const [heightM, minStemM, maxStemM] of [[500, 45, 90], [10000, 1100, 1400]]
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('local infrastructure creates no native labels or per-frame geometry callbacks', () => {
|
test('local infrastructure creates no native labels or per-frame geometry callbacks', () => {
|
||||||
const source = readFileSync(new URL('./localGeojson.js', import.meta.url), 'utf8');
|
const source = readFileSync(new URL('./localGeojsonCore.js', import.meta.url), 'utf8');
|
||||||
assert.doesNotMatch(source, /new Cesium\.LabelGraphics/);
|
assert.doesNotMatch(source, /new Cesium\.LabelGraphics/);
|
||||||
assert.doesNotMatch(source, /new Cesium\.CallbackProperty/);
|
assert.doesNotMatch(source, /new Cesium\.CallbackProperty/);
|
||||||
assert.match(source, /feature\.position = tip/);
|
assert.match(source, /feature\.position = tip/);
|
||||||
@@ -1336,3 +1336,15 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
|
|||||||
env.preRender.raise();
|
env.preRender.raise();
|
||||||
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('standalone publisher retains its default host for an undefined override', () => {
|
||||||
|
const publisher = createLocalInfrastructureOverlayPublisher({
|
||||||
|
sourceId: 'local-default-host-test', host: undefined,
|
||||||
|
});
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
publisher.show();
|
||||||
|
publisher.hide();
|
||||||
|
publisher.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createLocalGeoJsonLayer } from './localGeojson.js';
|
||||||
|
import { getContextStore } from './contextStore.js';
|
||||||
|
|
||||||
|
const dataset = JSON.stringify({
|
||||||
|
type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
|
||||||
|
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
|
||||||
|
});
|
||||||
|
const response = () => ({ ok: true, text: async () => dataset });
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise(done => { resolve = done; });
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
function harness(t) {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
globalThis.window = { dispatchEvent() {} };
|
||||||
|
t.after(() => {
|
||||||
|
layer.destroy(viewer);
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
});
|
||||||
|
const sources = new Set();
|
||||||
|
const listeners = new Set();
|
||||||
|
const event = { addEventListener(fn) { listeners.add(fn); return () => listeners.delete(fn); } };
|
||||||
|
let handlers = 0;
|
||||||
|
const viewer = {
|
||||||
|
dataSources: {
|
||||||
|
async add(source) { sources.add(source); return source; },
|
||||||
|
remove(source) { return sources.delete(source); },
|
||||||
|
},
|
||||||
|
scene: { canvas: {}, preRender: event, requestRender() {} },
|
||||||
|
camera: { moveEnd: event },
|
||||||
|
};
|
||||||
|
const create = () => createLocalGeoJsonLayer({
|
||||||
|
id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl',
|
||||||
|
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
|
||||||
|
screenSpaceEventHandlerFactory() {
|
||||||
|
handlers++;
|
||||||
|
return { setInputAction() {}, destroy() { handlers--; } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const layer = create();
|
||||||
|
return { layer, create, viewer, sources, listeners, handlers: () => handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('destroy removes context records and permits a fresh replacement', async t => {
|
||||||
|
const env = harness(t);
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => response());
|
||||||
|
await env.layer.enable(env.viewer);
|
||||||
|
const oldEntity = [...getContextStore().entities.values()][0].entity;
|
||||||
|
assert.equal(getContextStore().entities.size, 1);
|
||||||
|
env.layer.disable(env.viewer);
|
||||||
|
assert.equal(getContextStore().entities.size, 1, 'disabled cached source retains its records');
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
assert.equal(getContextStore().entities.size, 0);
|
||||||
|
assert.equal(env.sources.size, 0);
|
||||||
|
assert.equal(env.listeners.size, 0);
|
||||||
|
assert.equal(env.handlers(), 0);
|
||||||
|
const replacement = env.create();
|
||||||
|
await replacement.enable(env.viewer);
|
||||||
|
assert.equal(getContextStore().entities.size, 1);
|
||||||
|
assert.notEqual([...getContextStore().entities.values()][0].entity, oldEntity);
|
||||||
|
replacement.destroy(env.viewer);
|
||||||
|
await env.layer.enable(env.viewer);
|
||||||
|
assert.equal(getContextStore().entities.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const phase of ['fetch', 'text', 'parse', 'add']) {
|
||||||
|
test(`destroy during ${phase} leaves no late objects or context records`, async t => {
|
||||||
|
const env = harness(t);
|
||||||
|
const entered = deferred();
|
||||||
|
const release = deferred();
|
||||||
|
const pause = async value => { entered.resolve(); await release.promise; return value; };
|
||||||
|
let signal;
|
||||||
|
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||||
|
signal = options.signal;
|
||||||
|
if (phase === 'fetch') return pause(response());
|
||||||
|
if (phase === 'text') return { ok: true, text: () => pause(dataset) };
|
||||||
|
return response();
|
||||||
|
});
|
||||||
|
if (phase === 'parse') {
|
||||||
|
const load = Cesium.GeoJsonDataSource.load;
|
||||||
|
t.mock.method(Cesium.GeoJsonDataSource, 'load', async (...args) => pause(await load(...args)));
|
||||||
|
}
|
||||||
|
if (phase === 'add') {
|
||||||
|
t.mock.method(env.viewer.dataSources, 'add', async source => {
|
||||||
|
await pause();
|
||||||
|
env.sources.add(source);
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const loading = env.layer.enable(env.viewer);
|
||||||
|
await entered.promise;
|
||||||
|
env.layer.destroy(env.viewer);
|
||||||
|
assert.equal(signal.aborted, true);
|
||||||
|
release.resolve();
|
||||||
|
await loading;
|
||||||
|
assert.equal(env.sources.size, 0);
|
||||||
|
assert.equal(env.listeners.size, 0);
|
||||||
|
assert.equal(env.handlers(), 0);
|
||||||
|
assert.equal(getContextStore().entities.size, 0);
|
||||||
|
assert.deepEqual(env.layer.getStats(), { count: 0, lastUpdate: null, error: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('concurrent enable and disable/re-enable share one pending dataset load', async t => {
|
||||||
|
const env = harness(t);
|
||||||
|
const release = deferred();
|
||||||
|
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
await release.promise;
|
||||||
|
return response();
|
||||||
|
});
|
||||||
|
const first = env.layer.enable(env.viewer);
|
||||||
|
env.layer.disable(env.viewer);
|
||||||
|
const second = env.layer.enable(env.viewer);
|
||||||
|
release.resolve();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
assert.equal(fetchMock.mock.callCount(), 1);
|
||||||
|
assert.equal(env.sources.size, 1);
|
||||||
|
assert.equal(env.handlers(), 1);
|
||||||
|
assert.equal(env.listeners.size, 2);
|
||||||
|
assert.equal([...env.sources][0].show, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disable during loading keeps the completed source hidden until re-enabled', async t => {
|
||||||
|
const env = harness(t);
|
||||||
|
const release = deferred();
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => { await release.promise; return response(); });
|
||||||
|
const loading = env.layer.enable(env.viewer);
|
||||||
|
env.layer.disable(env.viewer);
|
||||||
|
release.resolve();
|
||||||
|
await loading;
|
||||||
|
assert.equal([...env.sources][0].show, false);
|
||||||
|
assert.equal(env.listeners.size, 0);
|
||||||
|
await env.layer.enable(env.viewer);
|
||||||
|
assert.equal([...env.sources][0].show, true);
|
||||||
|
assert.equal(env.sources.size, 1);
|
||||||
|
});
|
||||||
+3
-32
@@ -1,38 +1,9 @@
|
|||||||
import { createLocalGeoJsonLayer } from './localGeojson.js';
|
import { localGeoJsonServices } from './localGeojson.js';
|
||||||
|
import { createInfrastructureLayers } from './infrastructure.js';
|
||||||
import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
|
import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
|
||||||
import submarineCablesLayer from './telegeographySubmarineCables.js';
|
import submarineCablesLayer from './telegeographySubmarineCables.js';
|
||||||
|
|
||||||
// Use Vite's ?url import to properly resolve these assets in dev and build
|
const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices);
|
||||||
import datacentersUrl from './local_data/datacenters/datacenters.geojsonl?url';
|
|
||||||
import damsUrl from './local_data/dams/dams.geojsonl?url';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registry of local GeoJSON datasets.
|
|
||||||
* These are lazily loaded natively into Cesium when enabled.
|
|
||||||
*/
|
|
||||||
const datacenters = createLocalGeoJsonLayer({
|
|
||||||
id: 'local-datacenters',
|
|
||||||
url: datacentersUrl,
|
|
||||||
name: 'Datacenters',
|
|
||||||
color: '#00ffff', // Cyan
|
|
||||||
icon: '▣',
|
|
||||||
source: 'Local',
|
|
||||||
labels: true,
|
|
||||||
labelMax: 700,
|
|
||||||
labelGridPx: 138,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dams = createLocalGeoJsonLayer({
|
|
||||||
id: 'local-dams',
|
|
||||||
url: damsUrl,
|
|
||||||
name: 'Dams',
|
|
||||||
color: '#0088ff', // Blue
|
|
||||||
icon: '▰',
|
|
||||||
source: 'USACE',
|
|
||||||
labels: true,
|
|
||||||
labelMax: 900,
|
|
||||||
labelGridPx: 132,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps
|
// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps
|
||||||
// the historical `local-` prefix for persistence + voice-tool-enum compat,
|
// the historical `local-` prefix for persistence + voice-tool-enum compat,
|
||||||
|
|||||||
Reference in new issue
Block a user