From 5756ae92a8dea993edc673420d79f542c625f47c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:35:38 -0700 Subject: [PATCH] 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', + ); +});