refactor: supply application catalogs explicitly

This commit is contained in:
Sameh Khamis committed 2026-09-14 13:56:51 -07:00
1 parent fad93f22f8
commit c3de679c61
16 files changed
+288 -50

No files matched your search

+2
View File
@@ -91,6 +91,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Let application data and controls receive the same explicit layer catalog; keep standalone defaults and registration-before-restoration ordering.
- Expose existing FIRMS CSV parsing and UTC time-window helpers through a portable package export, with shared contract fixtures.
- Separate map source factories from switching and resource ownership; retain current source IDs, attribution and fallbacks.
+13
View File
@@ -396,3 +396,16 @@ its engines. Its explicit module graph includes the smaller layer/UI groups;
those independent groups retain their narrower gates. Source adapters and
request services enter through construction, without replacing global fetch.
The Node build group also owns the allowlisted static HTML template assembler.
## Application catalog
`gods-eye-view/application/catalog` captures caller-supplied layer instances and
matching registration metadata. `application/data` registers that catalog, attaches
coordinators after registration and seals it before controls start restoration.
`application/controls` binds its layer services from the same catalog. The existing
control surface and v2 sharing codec retain their established layer IDs; changing
that schema requires a corresponding codec change.
`standalone/catalog` selects the existing page-scoped default instances and metadata.
Reusable data setup imports no standalone layer defaults. The current compatibility
source setters remain available while callers migrate to instance construction.
+4
View File
@@ -1,5 +1,9 @@
# God's Eye View Current State
Application data setup receives an ordered layer catalog and serialization metadata.
Controls use the same instances. The standalone composition selects the existing
default catalog, and restoration starts only after all registrations are sealed.
The `sources/firms-csv` package export exposes existing FIRMS CSV parsing and
UTC acquisition-window helpers without Node middleware or rendering dependencies.
The standalone FIRMS provider, source defaults and parsing behavior are unchanged.
+3 -1
View File
@@ -191,6 +191,8 @@
"./context": "./src/data/contextStore.js",
"./build/html": {
"node": "./build/application-html.js"
}
},
"./application/catalog": "./src/app/catalog.js",
"./standalone/catalog": "./src/standalone/catalog.js"
}
}
+2 -2
View File
@@ -246,8 +246,8 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
exit 1
fi
if ! grep -q "dataManager.register(cctvLayer)" "$SOURCE_ROOT/src/app/data.js"; then
echo "error: CCTV layer not wired in src/app/data.js"
if ! grep -q "^[[:space:]]*cctvLayer," "$SOURCE_ROOT/src/standalone/catalog.js"; then
echo "error: CCTV layer not wired in src/standalone/catalog.js"
exit 1
fi
+4 -1
View File
@@ -555,5 +555,8 @@
"src/app/startupChrome.js",
"src/app/errors.js",
"scripts/project-root.mjs",
"src/toolProjectRoot.test.mjs"
"src/toolProjectRoot.test.mjs",
"src/app/catalog.js",
"src/standalone/catalog.js",
"src/app/catalog.test.mjs"
]
+10 -2
View File
@@ -1043,7 +1043,8 @@
"./standalone/settings",
"./voice/actions",
"./annotations",
"./context"
"./context",
"./standalone/catalog"
],
"modules": [
"src/annotations/annotationEngine.js",
@@ -1500,7 +1501,9 @@
"src/voice/realtimeController.js",
"src/voice/voiceCost.js",
"src/weatherEffectsMath.js",
"src/worldFocus.js"
"src/worldFocus.js",
"src/app/catalog.js",
"src/standalone/catalog.js"
],
"external": [
"@mapbox/vector-tile",
@@ -1510,5 +1513,10 @@
"pbf",
"satellite.js"
]
},
"application-catalog": {
"exports": ["./application/catalog"],
"modules": ["src/app/catalog.js"],
"external": []
}
}
+58
View File
@@ -0,0 +1,58 @@
const CONTROL_LAYER_IDS = Object.freeze({
trafficLayer: 'traffic',
flightsLayer: 'flights',
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
aisLiveVesselsLayer: 'ais-live-vessels',
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
rocketLaunchesLayer: 'rocket-launches',
});
/** Capture the ordered application instances and their serialization metadata. */
export function createLayerCatalog(layers, metadata) {
if (!Array.isArray(layers) || !Array.isArray(metadata))
throw new TypeError(
'Layer instances and serialization metadata are required',
);
const byId = new Map();
for (const layer of layers) {
if (typeof layer?.id !== 'string' || !layer.id || byId.has(layer.id))
throw new TypeError(`Invalid or duplicate catalog layer: ${layer?.id}`);
byId.set(layer.id, layer);
}
const ids = new Set();
for (const entry of metadata) {
if (!byId.has(entry?.id) || ids.has(entry.id))
throw new TypeError(
`Unmatched or duplicate catalog metadata: ${entry?.id}`,
);
ids.add(entry.id);
}
if (ids.size !== byId.size)
throw new TypeError('Catalog metadata is incomplete');
return Object.freeze({
layers: Object.freeze([...layers]),
metadata: Object.freeze(
metadata.map((entry) => Object.freeze({ ...entry })),
),
get: (id) => byId.get(id),
});
}
/** Bind the current control surface to the exact instances registered by the app. */
export function catalogControlServices(catalog) {
if (!catalog?.get)
throw new TypeError('An application layer catalog is required');
return Object.fromEntries(
Object.entries(CONTROL_LAYER_IDS).map(([role, id]) => {
const layer = catalog.get(id);
if (!layer)
throw new TypeError(`Control layer missing from catalog: ${id}`);
return [role, layer];
}),
);
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLayerCatalog, catalogControlServices } from './catalog.js';
import { createApplicationData } from './data.js';
const metadata = (layers) =>
layers.map(({ id }) => ({ id, disposition: 'enabled-only' }));
test('catalog captures membership and metadata while preserving the supplied instances', () => {
const first = { id: 'first' };
const layers = [first];
const schema = metadata(layers);
const catalog = createLayerCatalog(layers, schema);
layers.push({ id: 'later' });
schema[0].disposition = 'invalid';
assert.deepEqual(catalog.layers, [first]);
assert.equal(catalog.get('first'), first);
assert.equal(catalog.get('later'), undefined);
assert.equal(catalog.metadata[0].disposition, 'enabled-only');
assert.throws(() => catalog.layers.push({ id: 'later' }), TypeError);
});
test('duplicate, missing and extra metadata fail before application registration', () => {
const layers = [{ id: 'first' }];
assert.throws(
() => createLayerCatalog([...layers, ...layers], metadata(layers)),
/duplicate/,
);
assert.throws(() => createLayerCatalog(layers, []), /incomplete/);
assert.throws(
() => createLayerCatalog(layers, metadata([{ id: 'other' }])),
/Unmatched/,
);
assert.throws(
() =>
createLayerCatalog(layers, [...metadata(layers), ...metadata(layers)]),
/duplicate/,
);
});
test('data setup seals the caller catalog before controls can restore and drains its exact instances', async (t) => {
const previous = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
});
const attached = [];
const layers = [
{
id: 'one',
attachDataManager: (manager) => attached.push([...manager.layers.keys()]),
},
{ id: 'two' },
];
const releases = [];
let observed;
const { dataManager } = createApplicationData({
scene: { viewer: {} },
controls: {
styleManager: {
attachDataManager(manager) {
assert.equal(manager.registrationsFinalized, true);
assert.equal(manager.layers.get('one').module, layers[0]);
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
for (const release of releases.reverse()) await release();
assert.equal(dataManager.layers.size, 0);
});
test('controls bind catalog instances rather than similarly named defaults', () => {
const ids = [
'traffic',
'flights',
'military',
'satellites',
'cctv',
'radio',
'bikeshare',
'ais-live-vessels',
'military-awareness',
'military-installations',
'rocket-launches',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
const services = catalogControlServices(catalog);
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.aisLiveVesselsLayer, layers[7]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
/Control layer missing/,
);
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
@@ -8,12 +9,13 @@ export function createApplicationControls({
loaderStatus,
Controls = StyleManager,
services,
catalog,
placeSearch,
defer,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
services,
services: { ...services, ...catalogControlServices(catalog) },
mapStackController,
placeSearch,
});
+7 -36
View File
@@ -1,24 +1,9 @@
import { DataLayerManager } from '../data/manager.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import trafficLayer from '../data/traffic.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import localDataLayers from '../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer },
controls: { styleManager },
catalog,
allowQaRegistration,
onData,
defer,
@@ -35,26 +20,12 @@ export function createApplicationData({
);
});
onData?.(dataManager);
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(alprCamerasLayer);
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 (!catalog?.layers || !catalog?.metadata)
throw new TypeError('An application layer catalog is required');
for (const layer of catalog.layers) dataManager.register(layer);
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
// Restoration starts only after the caller's complete registry is sealed.
dataManager.finalizeRegistrations(catalog.metadata);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
+11 -3
View File
@@ -10,7 +10,7 @@ import { promisify } from 'node:util';
const run = promisify(execFile);
const bashTest = process.platform === 'win32' ? test.skip : test;
async function launch(overrides = {}, dotenv = '') {
async function launch(overrides = {}, dotenv = '', omitCctv = false) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gev-cctv-launch-'));
try {
await fs.mkdir(path.join(root, 'scripts'));
@@ -20,8 +20,9 @@ async function launch(overrides = {}, dotenv = '') {
for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) {
await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name));
}
await fs.mkdir(path.join(root, 'src', 'app'), { recursive: true });
await fs.copyFile(new URL('./app/data.js', import.meta.url), path.join(root, 'src', 'app', 'data.js'));
await fs.mkdir(path.join(root, 'src', 'standalone'), { recursive: true });
const catalog = await fs.readFile(new URL('./standalone/catalog.js', import.meta.url), 'utf8');
await fs.writeFile(path.join(root, 'src', 'standalone', 'catalog.js'), omitCctv ? catalog.replace(/^\s*cctvLayer,\s*$/m, '') : catalog);
await fs.mkdir(path.join(root, 'node_modules'));
await fs.symlink(fileURLToPath(new URL('.', import.meta.resolve('vite/package.json'))), path.join(root, 'node_modules', 'vite'), 'dir');
await fs.writeFile(path.join(root, '.env'), dotenv);
@@ -79,3 +80,10 @@ bashTest('CCTV preset shares dotenv precedence and names-only credential provena
assert.equal(result.env.GEV_KEY_SETUP_EXTERNAL_KEYS, 'GOOGLE_MAPS_API_KEY');
assert.doesNotMatch(result.output, /fixture-shell-maps|fixture-file-maps|fixture-file-voice/);
});
bashTest('CCTV preset refuses a catalog that no longer registers its layer', async () => {
await assert.rejects(launch({}, '', true), (error) => {
assert.match(error.stdout, /CCTV layer not wired in src\/standalone\/catalog.js/);
return true;
});
});
+9 -2
View File
@@ -1,3 +1,4 @@
import { createStandaloneCatalog } from './catalog.js';
import { createStandalonePlaceSearch } from './placeSearch.js';
import { createApplication } from '../app/application.js';
import { createStandaloneScene } from './scene.js';
@@ -22,6 +23,7 @@ export function createStandaloneApplication({
const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status');
let placeSearch;
const catalog = createStandaloneCatalog();
return createApplication({
createScene: (context) => {
placeSearch = createStandalonePlaceSearch({
@@ -37,9 +39,14 @@ export function createStandaloneApplication({
});
},
createControls: (context) =>
createStandaloneControls({ ...context, loaderStatus, placeSearch }),
createStandaloneControls({
...context,
loaderStatus,
placeSearch,
catalog,
}),
createData: (context) =>
createStandaloneData({ ...context, allowQaRegistration }),
createStandaloneData({ ...context, allowQaRegistration, catalog }),
createTools: (context) =>
createStandaloneTools({ ...context, loadingScreen, placeSearch, voice }),
});
+40
View File
@@ -0,0 +1,40 @@
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import trafficLayer from '../data/traffic.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import localDataLayers from '../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createLayerCatalog } from '../app/catalog.js';
/** Select the existing standalone instances and current persistence schema. */
export function createStandaloneCatalog() {
return createLayerCatalog(
[
flightsLayer,
militaryFlightsLayer,
earthquakesLayer,
alprCamerasLayer,
satellitesLayer,
rocketLaunchesLayer,
trafficLayer,
cctvLayer,
radioLayer,
bikeshareLayer,
aisLiveVesselsLayer,
militaryInstallationsLayer,
militaryAwarenessLayer,
...localDataLayers,
],
LAYER_STATE_REGISTRY,
);
}
+8 -1
View File
@@ -1 +1,8 @@
export { createApplicationControls as createStandaloneControls } from '../app/controls.js';
import { createApplicationControls } from '../app/controls.js';
import { createStandaloneCatalog } from './catalog.js';
export function createStandaloneControls(options) {
return createApplicationControls({
catalog: createStandaloneCatalog(),
...options,
});
}
+8 -1
View File
@@ -1 +1,8 @@
export { createApplicationData as createStandaloneData } from '../app/data.js';
import { createApplicationData } from '../app/data.js';
import { createStandaloneCatalog } from './catalog.js';
export function createStandaloneData(options) {
return createApplicationData({
catalog: createStandaloneCatalog(),
...options,
});
}