Merge pull request #311 from bilawalsidhu/refactor/infrastructure-factories

Extract reusable datacenter and dam layers
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-11 13:39:54 -07:00
commit 162ac4f2c9
11 files changed
+1535 -1090

No files matched your search

+7
View File
@@ -13,6 +13,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [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.
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.
+11
View File
@@ -2,6 +2,17 @@
## 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
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
+62
View File
@@ -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.
+5
View File
@@ -56,5 +56,10 @@
"vite": "^6.0.0",
"vite-plugin-cesium": "^1.2.23",
"ws": "^8.21.0"
},
"exports": {
"./infrastructure": "./src/data/infrastructure.js",
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
"./infrastructure/lod": "./src/data/localGeojsonLod.js"
}
}
+38
View File
@@ -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];
}
+130
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+13 -1
View File
@@ -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', () => {
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\.CallbackProperty/);
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();
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
+142
View File
@@ -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
View File
@@ -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 submarineCablesLayer from './telegeographySubmarineCables.js';
// Use Vite's ?url import to properly resolve these assets in dev and build
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,
});
const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices);
// 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,