diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c84ebe29..ef83d9451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index b00b6311a..71b3e3994 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ecadda8da..c90aa51a8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/package.json b/package.json index 3f0d97a7f..ae62bc4de 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index 873989bf3..ba8966515 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f1196b9fd..644b34c26 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7cadae541..4463c9372 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/src/app/catalog.js b/src/app/catalog.js new file mode 100644 index 000000000..aa75899d3 --- /dev/null +++ b/src/app/catalog.js @@ -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]; + }), + ); +} diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs new file mode 100644 index 000000000..5cee846ba --- /dev/null +++ b/src/app/catalog.test.mjs @@ -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/, + ); +}); diff --git a/src/app/controls.js b/src/app/controls.js index 6ab7ab599..0b02debbe 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -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, }); diff --git a/src/app/data.js b/src/app/data.js index a2f161cd6..a2c73c338 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -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) diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs index b2a7f7122..907161b7d 100644 --- a/src/devCctv.test.mjs +++ b/src/devCctv.test.mjs @@ -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; + }); +}); diff --git a/src/standalone/application.js b/src/standalone/application.js index 1bac8caef..636181fd7 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -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 }), }); diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js new file mode 100644 index 000000000..61a093cf0 --- /dev/null +++ b/src/standalone/catalog.js @@ -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, + ); +} diff --git a/src/standalone/controls.js b/src/standalone/controls.js index 4d083868b..22074aa67 100644 --- a/src/standalone/controls.js +++ b/src/standalone/controls.js @@ -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, + }); +} diff --git a/src/standalone/data.js b/src/standalone/data.js index 38c18d17d..3df4db000 100644 --- a/src/standalone/data.js +++ b/src/standalone/data.js @@ -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, + }); +}