From 5756ae92a8dea993edc673420d79f542c625f47c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:35:38 -0700 Subject: [PATCH 1/3] Make catalog feed configuration optional and explicit --- CHANGELOG.md | 4 ++ docs/CURRENT-STATE.md | 10 +++ scripts/package-boundaries.json | 2 + src/app/constructCatalog.js | 40 ++---------- src/app/constructCatalog.test.mjs | 2 +- src/app/sourceComposition.js | 101 +++++++++++++++++++++++++++++ src/app/sourceComposition.test.mjs | 84 ++++++++++++++++++++++++ src/data/lifecycle.js | 13 ++++ src/standalone/application.js | 4 ++ src/standalone/catalog.js | 5 +- src/standalone/layerSources.js | 11 +++- src/ui/layerPanel.js | 20 ++++-- src/ui/layerPanel.test.mjs | 30 +++++++++ 13 files changed, 283 insertions(+), 43 deletions(-) create mode 100644 src/app/sourceComposition.js create mode 100644 src/app/sourceComposition.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c95633b1c..20b6fccdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Application source configuration accepts replacements and explicit removal. + Missing catalog feeds leave their layers unavailable without blocking startup + or making fallback requests; malformed supplied feeds remain configuration errors. + - Street Level providers are optional in application composition. Omitting a Mapillary source no longer prevents startup; callers can inject a different provider list or an empty list without changing layer identities or share links. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f86b0d26f..77e0cf0fc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +Catalog source contracts and their consuming layers are declared in +`src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer +registration and serialization, report unavailable, and are refused by the existing +visibility lifecycle before initialization. Invalid supplied methods still fail +construction. `createStandaloneApplication` and `createStandaloneCatalog` accept +`sources` overrides; unspecified entries retain +standalone defaults, and null entries explicitly remove a source. Street Level +continues to accept an explicit provider list. Directions and Recent Imagery +acquisition are still selected by their application constructors. + Application catalog composition accepts an optional `streetLevelProviders` list. An explicit list replaces the default Mapillary provider; an empty list removes all imagery providers. Without that option, Mapillary is registered only when diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 23f9f52ed..c5a5a3b89 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1434,6 +1434,7 @@ "src/annotations/worldAnnotationRenderer.js", "src/app/catalog.js", "src/app/constructCatalog.js", + "src/app/sourceComposition.js", "src/app/controls.js", "src/app/data.js", "src/app/errors.js", @@ -2222,6 +2223,7 @@ "src/annotations/resolver.js", "src/app/catalog.js", "src/app/constructCatalog.js", + "src/app/sourceComposition.js", "src/layers/wind/index.js", "src/layers/wind/model.js", "src/layers/wind/fields.js", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 8baecf57c..74cbcf47f 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,3 +1,4 @@ +import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; @@ -31,33 +32,6 @@ import { localGeoJsonServices } from './localGeojsonServices.js'; import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js'; import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js'; -const SOURCE_METHODS = Object.freeze({ - flights: ['getSnapshot'], - military: ['getSnapshot'], - vessels: ['getSnapshot'], - cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'], - radio: ['getDirectory', 'recordClick'], - traffic: [ - 'requestRoads', - 'getStatus', - 'fetchFlowForBounds', - 'getFlowSessionStats', - 'resetFlowTileCache', - ], - bikeshare: ['getStations'], - installations: ['getMappedSites', 'searchNearby'], - satellites: ['readGroup'], - launches: ['getLaunches', 'getActiveTle'], - alpr: ['fetch'], - firms: ['getSnapshot'], - wind: ['getSnapshot'], - weather: ['getSnapshot'], - cyclones: ['getSnapshot'], - earthquakes: ['getSnapshot'], - 'fire-perimeters': ['getSnapshot'], - cables: ['fetch'], -}); - /** * Hardware-local layers are registered like any other but never enter share * links or stored layer state: another browser cannot have this receiver. @@ -92,12 +66,8 @@ export function createApplicationCatalog({ if (!surface?.groundFloor || !surface?.terrain) throw new TypeError('Application surface services are required'); - for (const [name, methods] of Object.entries(SOURCE_METHODS)) { - if ( - methods.some((method) => typeof sources?.[name]?.[method] !== 'function') - ) - throw new TypeError(`Invalid catalog source: ${name}`); - } + const sourceComposition = resolveCatalogSources(sources); + sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); const weatherClock = createWeatherClock(); const dispose = () => { @@ -107,7 +77,8 @@ export function createApplicationCatalog({ }; signal.addEventListener('abort', dispose, { once: true }); try { - militaryRegistry.configureSource(sources.military, { signal }); + if (sourceComposition.isConfigured('military')) + militaryRegistry.configureSource(sources.military, { signal }); const flights = createApplicationFlights({ surface, source: sources.flights, @@ -202,6 +173,7 @@ export function createApplicationCatalog({ ], metadata, ); + sourceComposition.bind(catalog.layers); return Object.freeze({ ...catalog, militaryRegistry, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index a21bfb537..f42e82c7c 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -117,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can assert.throws( () => createApplicationCatalog({ - sources: {}, + sources: { flights: {} }, signal: lifetime.signal, surface: fixtureSurface(lifetime.signal), }), diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js new file mode 100644 index 000000000..77b3ebe10 --- /dev/null +++ b/src/app/sourceComposition.js @@ -0,0 +1,101 @@ +const contract = (layers, methods) => + Object.freeze({ + layers: Object.freeze(layers), + methods: Object.freeze(methods), + }); + +/** Acquisition contracts and their consumers; records remain family-specific. */ +export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ + flights: contract(['flights'], ['getSnapshot']), + military: contract(['military'], ['getSnapshot']), + vessels: contract(['ais-live-vessels'], ['getSnapshot']), + cctv: contract( + ['cctv'], + ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'], + ), + radio: contract(['radio'], ['getDirectory', 'recordClick']), + traffic: contract( + ['traffic'], + [ + 'requestRoads', + 'getStatus', + 'fetchFlowForBounds', + 'getFlowSessionStats', + 'resetFlowTileCache', + ], + ), + bikeshare: contract(['bikeshare'], ['getStations']), + installations: contract( + ['military-installations'], + ['getMappedSites', 'searchNearby'], + ), + satellites: contract(['satellites'], ['readGroup']), + launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']), + alpr: contract(['alpr-cameras'], ['fetch']), + firms: contract(['local-firms'], ['getSnapshot']), + wind: contract(['wind'], ['getSnapshot']), + weather: contract( + ['weather-radar', 'weather-satellite', 'weather-lightning'], + ['getSnapshot'], + ), + cyclones: contract(['weather-cyclones'], ['getSnapshot']), + earthquakes: contract(['earthquakes'], ['getSnapshot']), + 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + cables: contract(['telegeography-submarine-cables'], ['fetch']), + transit: contract(['transit'], ['requestSnapshot', 'getHistory']), +}); + +/** An omitted provider is unavailable, never an authoritative empty dataset. */ +export class SourceUnavailableError extends Error { + constructor(name) { + super(`Data source not configured: ${name}`); + this.name = 'SourceUnavailableError'; + this.code = 'unavailable'; + this.unavailable = true; + this.retryable = false; + } +} + +/** Validate supplied contracts and keep omitted sources inert during construction. */ +export function resolveCatalogSources(supplied = {}) { + if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied)) + throw new TypeError('Catalog sources must be an object'); + const sources = { ...supplied }; + const missing = new Set(); + for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) { + const source = sources[name]; + if (source == null) { + missing.add(name); + const unavailable = () => { + throw new SourceUnavailableError(name); + }; + sources[name] = Object.freeze({ + label: name, + ...Object.fromEntries(methods.map((method) => [method, unavailable])), + }); + } else if (methods.some((method) => typeof source[method] !== 'function')) { + throw new TypeError(`Invalid catalog source: ${name}`); + } + } + return { + sources, + isConfigured: (name) => !missing.has(name), + bind(layers) { + for (const [name, { layers: ids }] of Object.entries( + CATALOG_SOURCE_CONTRACTS, + )) { + for (const id of ids) { + const layer = layers.find((entry) => entry.id === id); + if (!layer) throw new Error(`Missing source consumer: ${id}`); + layer.getSourceAvailability = () => ({ + available: !missing.has(name), + source: name, + reason: missing.has(name) + ? `Data source not configured: ${name}` + : null, + }); + } + } + }, + }; +} diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs new file mode 100644 index 000000000..9d6823e40 --- /dev/null +++ b/src/app/sourceComposition.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveCatalogSources, + CATALOG_SOURCE_CONTRACTS, +} from './sourceComposition.js'; +import { createApplicationCatalog } from './constructCatalog.js'; +import { createSurfaceServices } from './surfaceServices.js'; +import { createStandaloneLayerSources } from '../standalone/layerSources.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; + +function catalog(sources, t) { + const lifetime = new AbortController(); + t.after(() => lifetime.abort()); + return createApplicationCatalog({ + sources, + signal: lifetime.signal, + surface: createSurfaceServices({ + terrainSource: { getHeights: async () => [] }, + signal: lifetime.signal, + eventTarget: null, + }), + }); +} + +test('every acquisition source can be omitted without losing catalog membership', (t) => { + const baseline = catalog(createStandaloneLayerSources(), t); + for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) { + const sources = createStandaloneLayerSources({ [name]: null }); + const actual = catalog(sources, t); + assert.deepEqual(actual.metadata, baseline.metadata, name); + for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) { + assert.equal(actual.get(id).getSourceAvailability().available, false, id); + } + } +}); + +test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { + const actual = catalog({}, t); + assert.equal(actual.layers.length, 30); + const manager = new LayerLifecycle({}); + for (const layer of actual.layers) manager.register(layer); + for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) { + for (const id of ids) { + assert.equal(await manager.setEnabled(id, true), false, id); + assert.equal(manager.layers.get(id).initialized, false, id); + const row = manager.getAll().find((entry) => entry.id === id); + assert.equal(row.enabled, false); + assert.equal(row.stats.status, 'unavailable'); + assert.match(row.stats.error, /not configured/); + } + } +}); + +test('configured sources retain identity; invalid implementations never become unavailable silently', () => { + const source = { getSnapshot: async () => [] }; + assert.equal( + resolveCatalogSources({ earthquakes: source }).sources.earthquakes, + source, + ); + for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) { + assert.throws( + () => resolveCatalogSources({ [name]: {} }), + /Invalid catalog source/, + ); + } + const missing = resolveCatalogSources({}).sources.earthquakes; + assert.throws(() => missing.getSnapshot(), { code: 'unavailable' }); +}); + +test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => { + const source = { getSnapshot: async () => [] }; + const sources = createStandaloneLayerSources({ + flights: source, + military: null, + }); + assert.equal(sources.flights, source); + assert.equal(sources.military, null); + assert.equal(typeof sources.cctv.getCatalog, 'function'); + assert.throws( + () => createStandaloneLayerSources({ fligts: source }), + /Unknown source/, + ); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c3659b9e..6c40b82be 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -228,6 +228,7 @@ export class LayerLifecycle { _normalizedStats(entry) { const moduleStats = this._moduleStats(entry); + const availability = entry.module.getSourceAvailability?.(); const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; @@ -235,6 +236,13 @@ export class LayerLifecycle { count: 0, lastUpdate: null, ...moduleStats, + ...(availability?.available === false + ? { + status: 'unavailable', + sourceUnavailable: true, + error: availability.reason, + } + : {}), loading: lifecycleLoading || moduleStats.loading === true, refreshing: entry.refreshing || moduleStats.refreshing === true, managerRefreshError: entry.managerRefreshError, @@ -2283,6 +2291,11 @@ export class LayerLifecycle { } async _visibilityBlockReason(change) { + const availability = this.layers + .get(change.layerId) + ?.module.getSourceAvailability?.(); + if (change.enabled && availability?.available === false) + return availability.reason || 'Data source unavailable'; for (const callback of this._visibilityGuards) { try { const result = await callback(change); diff --git a/src/standalone/application.js b/src/standalone/application.js index 5dd1b9641..5c5b048d9 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -17,6 +17,8 @@ export function createStandaloneApplication({ cesiumToken, geospatial = {}, voice = {}, + sources = {}, + streetLevelProviders, allowQaRegistration = false, }) { if (constructed) @@ -47,6 +49,8 @@ export function createStandaloneApplication({ loaderStatus, }); catalog = createStandaloneCatalog({ + sources, + streetLevelProviders, nepalBoundaryResolver: (signal) => scene.operations.annotationResolver.resolveRegionRingForQuery( 'Nepal', diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index 7e4e66808..1ac6ce931 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -7,6 +7,8 @@ export { createStandaloneReferenceSources } from './layerSources.js'; /** Create fresh layer instances using the existing standalone source choices. */ export function createStandaloneCatalog({ nepalBoundaryResolver, + sources = {}, + streetLevelProviders, signal = new AbortController().signal, surface = createSurfaceServices({ terrainSource: createApplicationRequestServices().terrain, @@ -16,7 +18,8 @@ export function createStandaloneCatalog({ return createApplicationCatalog({ nepalBoundaryResolver, surface, - sources: createStandaloneLayerSources(), + sources: createStandaloneLayerSources(sources), + streetLevelProviders, signal, vesselOptions: { maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index ffd975231..4e461f331 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -22,9 +22,11 @@ import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; /** Select standalone providers without starting their acquisition. */ -export function createStandaloneLayerSources() { +export function createStandaloneLayerSources(overrides = {}) { + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) + throw new TypeError('Source overrides must be an object'); const mapTiles = createOpenFreeMapSource(); - return { + const defaults = { ...createReferenceSources(), flights: createFlightSource(), military: createMilitarySource(), @@ -51,4 +53,9 @@ export function createStandaloneLayerSources() { token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '', }), }; + for (const name of Object.keys(overrides)) { + if (!Object.hasOwn(defaults, name)) + throw new TypeError(`Unknown source: ${name}`); + } + return { ...defaults, ...overrides }; } diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index ca97e2635..e4c240f23 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -588,7 +588,12 @@ export class LayerPanel { } _syncToggleButton(button, layer) { - const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off'; + const unavailable = layer.stats?.sourceUnavailable === true; + const feedState = unavailable + ? 'unavailable' + : layer.enabled + ? layerFeedState(layer.stats) + : 'off'; const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling'; @@ -601,7 +606,7 @@ export class LayerPanel { for (const state of Object.keys(FEED_STATE_LABELS)) { button.classList.toggle( `feed-${state}`, - layer.enabled && !uncertain && feedState === state, + (layer.enabled || unavailable) && !uncertain && feedState === state, ); } button.dataset.feedState = transitioning @@ -613,16 +618,21 @@ export class LayerPanel { // click guard above prevents repeat activation without the focus loss caused // by native `disabled`. button.disabled = false; - button.setAttribute('aria-disabled', String(transitioning)); + button.setAttribute( + 'aria-disabled', + String(transitioning || (unavailable && !layer.enabled)), + ); button.setAttribute('aria-busy', String(transitioning)); button.textContent = transitioning ? layer.lifecycleState.toUpperCase() : uncertain ? 'UNCERTAIN' - : layer.enabled + : layer.enabled || unavailable ? FEED_STATE_LABELS[feedState] : 'OFF'; - const keyGuidance = layerKeyRequirementTooltip(layer); + const keyGuidance = unavailable + ? String(layer.stats.error || 'Data source unavailable') + : layerKeyRequirementTooltip(layer); // Name the missing key on the control itself: a row reading KEY REQUIRED // without saying WHICH key leaves a dead control and no next step. Empty // when the layer needs no key, or already has one. diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index b1c58f747..1258cc76a 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou globalThis.document = previousDocument; } }); + +test('an omitted source is visibly unavailable and cannot be toggled on', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const attrs = new Map(); + const button = { + classList: { toggle() {} }, + dataset: {}, + setAttribute: (key, value) => attrs.set(key, value), + }; + const layer = { + id: 'flights', + name: 'Flights', + enabled: false, + stats: { + sourceUnavailable: true, + status: 'unavailable', + error: 'Data source not configured: flights', + }, + }; + LayerPanel.prototype._syncToggleButton(button, layer); + assert.equal(button.textContent, 'UNAVAILABLE'); + assert.equal(attrs.get('aria-disabled'), 'true'); + assert.match(button.title, /not configured/); + LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true }); + assert.equal( + attrs.get('aria-disabled'), + 'false', + 'an active layer can always be switched off', + ); +}); From b78ff7a915b94308d06dc776765f4290a8e74a64 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:49:58 -0700 Subject: [PATCH 2/3] Use shared availability for an empty Street Level provider list --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 3 ++- src/app/sourceComposition.test.mjs | 17 +++++++++++++++++ src/layers/streetLevel/index.js | 10 ++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b6fccdb..02a869424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Street Level with no providers uses the shared unavailable state and refuses + activation before initialization, while keeping its layer identity visible. + - Application source configuration accepts replacements and explicit removal. Missing catalog feeds leave their layers unavailable without blocking startup or making fallback requests; malformed supplied feeds remain configuration errors. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0cfa5c1b..dc460fb3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,7 +7,8 @@ visibility lifecycle before initialization. Invalid supplied methods still fail construction. `createStandaloneApplication` and `createStandaloneCatalog` accept `sources` overrides; unspecified entries retain standalone defaults, and null entries explicitly remove a source. Street Level -continues to accept an explicit provider list. Directions and Recent Imagery +continues to accept an explicit provider list. An empty Street Level provider +list uses the same unavailable lifecycle and disabled layer toggle. Directions and Recent Imagery acquisition are still selected by their application constructors. Application catalog composition accepts an optional `streetLevelProviders` list. diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 9d6823e40..beafa5c51 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -82,3 +82,20 @@ test('standalone defaults allow exact replacements and explicit removal, and rej /Unknown source/, ); }); + +test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { + const actual = catalog({}, t); + const manager = new LayerLifecycle({}); + manager.register(actual.get('street-level')); + assert.equal(await manager.setEnabled('street-level', true), false); + assert.equal(manager.layers.get('street-level').initialized, false); + const row = manager.getAll()[0]; + assert.equal(row.stats.status, 'unavailable'); + assert.equal(row.stats.error, 'No street-level imagery providers configured'); + assert.equal(await manager.setEnabled('street-level', false), true); + const configured = catalog(createStandaloneLayerSources(), t); + assert.equal( + configured.get('street-level').getSourceAvailability().available, + true, + ); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index dd4478c58..60a31691e 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -333,6 +333,16 @@ export function createStreetLevelLayer({ requiresKeyId: requiresKeyIdFor(definitionsFrozen), /** Registered providers, in chip order. */ providerIds: definitionsFrozen.map((def) => def.id), + getSourceAvailability() { + const available = definitionsFrozen.length > 0; + return { + available, + source: STREET_LEVEL_LAYER_ID, + reason: available + ? null + : 'No street-level imagery providers configured', + }; + }, init(viewer) { if (state.initialized) From 161a3a796ae3fabac73b83afcdcb9766c8684493 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 18:43:44 -0700 Subject: [PATCH 3/3] Keep source availability in the catalog and cover absent military acquisition --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 4 +- src/app/catalog.test.mjs | 17 +++++++- src/app/constructCatalog.js | 4 +- src/app/data.js | 1 + src/app/sourceComposition.js | 25 +++++++----- src/app/sourceComposition.test.mjs | 63 ++++++++++++++++++++++++++++-- src/data/lifecycle.js | 19 ++++++--- 8 files changed, 115 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a869424..9477ea2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Catalog source availability no longer modifies layer objects; unavailable + tooltips use layer display names. + - Street Level with no providers uses the shared unavailable state and refuses activation before initialization, while keeping its layer identity visible. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc460fb3d..4d02c2224 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3,7 +3,9 @@ Catalog source contracts and their consuming layers are declared in `src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer registration and serialization, report unavailable, and are refused by the existing -visibility lifecycle before initialization. Invalid supplied methods still fail +visibility lifecycle before initialization. Availability is held in an explicit +catalog lookup supplied to the lifecycle, without modifying layer instances; +unavailable reasons use the layer display name. Invalid supplied methods still fail construction. `createStandaloneApplication` and `createStandaloneCatalog` accept `sources` overrides; unspecified entries retain standalone defaults, and null entries explicitly remove a source. Street Level diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index fc51c7fcd..ff6d73db5 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains }, }, }, - catalog: createLayerCatalog(layers, metadata(layers)), + catalog: { + ...createLayerCatalog(layers, metadata(layers)), + getSourceAvailability: (id) => + id === 'one' + ? { + available: false, + reason: 'One: data source not configured', + } + : undefined, + }, defer: (release) => releases.push(release), onData: (manager) => { observed = manager; }, }); assert.equal(observed, dataManager); + assert.equal(await dataManager.setEnabled('one', true), false); + assert.equal(dataManager.layers.get('one').initialized, false); + assert.equal( + dataManager.getAll().find(({ id }) => id === 'one').stats.error, + 'One: data source not configured', + ); assert.deepEqual(attached, [['one', 'two']]); assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']); assert.throws(() => dataManager.register({ id: 'late' }), /finalized/); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 74cbcf47f..e78a9b249 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -173,9 +173,11 @@ export function createApplicationCatalog({ ], metadata, ); - sourceComposition.bind(catalog.layers); return Object.freeze({ ...catalog, + getSourceAvailability: sourceComposition.createAvailabilityLookup( + catalog.layers, + ), militaryRegistry, surface, weatherClock, diff --git a/src/app/data.js b/src/app/data.js index 85a3183fc..ce3ad0731 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { await dataManager.destroyAll(); diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 77b3ebe10..b813a83bc 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -80,22 +80,29 @@ export function resolveCatalogSources(supplied = {}) { return { sources, isConfigured: (name) => !missing.has(name), - bind(layers) { + createAvailabilityLookup(layers) { + const byId = new Map(layers.map((layer) => [layer.id, layer])); + const availability = new Map(); for (const [name, { layers: ids }] of Object.entries( CATALOG_SOURCE_CONTRACTS, )) { for (const id of ids) { - const layer = layers.find((entry) => entry.id === id); + const layer = byId.get(id); if (!layer) throw new Error(`Missing source consumer: ${id}`); - layer.getSourceAvailability = () => ({ - available: !missing.has(name), - source: name, - reason: missing.has(name) - ? `Data source not configured: ${name}` - : null, - }); + availability.set( + id, + Object.freeze({ + available: !missing.has(name), + source: name, + reason: missing.has(name) + ? `${layer.name || id}: data source not configured` + : null, + }), + ); } } + return (id) => + availability.get(id) ?? byId.get(id)?.getSourceAvailability?.(); }, }; } diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index beafa5c51..44a7c8fa0 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -30,7 +30,7 @@ test('every acquisition source can be omitted without losing catalog membership' const actual = catalog(sources, t); assert.deepEqual(actual.metadata, baseline.metadata, name); for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) { - assert.equal(actual.get(id).getSourceAvailability().available, false, id); + assert.equal(actual.getSourceAvailability(id).available, false, id); } } }); @@ -38,7 +38,10 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); assert.equal(actual.layers.length, 30); - const manager = new LayerLifecycle({}); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); for (const layer of actual.layers) manager.register(layer); for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) { for (const id of ids) { @@ -47,7 +50,7 @@ test('all acquisition sources may be absent, and enable fails before initializin const row = manager.getAll().find((entry) => entry.id === id); assert.equal(row.enabled, false); assert.equal(row.stats.status, 'unavailable'); - assert.match(row.stats.error, /not configured/); + assert.match(row.stats.error, /data source not configured/); } } }); @@ -85,7 +88,10 @@ test('standalone defaults allow exact replacements and explicit removal, and rej test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); - const manager = new LayerLifecycle({}); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); manager.register(actual.get('street-level')); assert.equal(await manager.setEnabled('street-level', true), false); assert.equal(manager.layers.get('street-level').initialized, false); @@ -99,3 +105,52 @@ test('empty Street Level uses the shared unavailable lifecycle without initializ true, ); }); + +test('availability lookup accepts frozen layers and uses their display names', () => { + const composition = resolveCatalogSources({}); + const layers = Object.values(CATALOG_SOURCE_CONTRACTS) + .flatMap(({ layers }) => layers) + .map((id) => + Object.freeze({ + id, + name: id === 'fire-perimeters' ? 'Fire perimeters' : id, + }), + ); + const lookup = composition.createAvailabilityLookup(layers); + assert.equal( + lookup('fire-perimeters').reason, + 'Fire perimeters: data source not configured', + ); + assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false); + const manager = new LayerLifecycle({}, { getSourceAvailability: lookup }); + manager.register(layers.find(({ id }) => id === 'fire-perimeters')); + assert.equal( + manager.getAll()[0].stats.error, + 'Fire perimeters: data source not configured', + ); +}); + +test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected fallback acquisition'); + }); + const actual = catalog(createStandaloneLayerSources({ military: null }), t); + const registry = actual.militaryRegistry; + assert.equal(actual.getSourceAvailability('flights').available, true); + assert.equal(registry.isMilitaryLayerActive(), false); + assert.equal(registry.isMilitaryIcao('abc123'), false); + registry.registerMilitaryIcaos([' ABC123 ']); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(registry.isMilitaryIcao('abc123'), true); + assert.equal(registry.isMilitaryIcao('def456'), false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); + manager.register(actual.get('military')); + assert.equal(await manager.setEnabled('military', true), false); + assert.equal(registry.isMilitaryLayerActive(), false); + assert.equal(fetches, 0); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c40b82be..0df5e87a4 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) { * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { - constructor(viewer, { allowQaRegistration = false } = {}) { + constructor( + viewer, + { allowQaRegistration = false, getSourceAvailability } = {}, + ) { + this._catalogSourceAvailability = getSourceAvailability; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -228,7 +232,7 @@ export class LayerLifecycle { _normalizedStats(entry) { const moduleStats = this._moduleStats(entry); - const availability = entry.module.getSourceAvailability?.(); + const availability = this._sourceAvailability(entry.module.id); const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; @@ -2290,10 +2294,15 @@ export class LayerLifecycle { } } + _sourceAvailability(layerId) { + return ( + this._catalogSourceAvailability?.(layerId) ?? + this.layers.get(layerId)?.module.getSourceAvailability?.() + ); + } + async _visibilityBlockReason(change) { - const availability = this.layers - .get(change.layerId) - ?.module.getSourceAvailability?.(); + const availability = this._sourceAvailability(change.layerId); if (change.enabled && availability?.available === false) return availability.reason || 'Data source unavailable'; for (const callback of this._visibilityGuards) {