Make catalog feed configuration optional and explicit

This commit is contained in:
Sameh Khamis committed 2026-10-09 17:35:38 -07:00
1 parent 1ee07468c4
commit 5756ae92a8
13 files changed
+283 -43

No files matched your search

+4
View File
@@ -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.
+10
View File
@@ -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
+2
View File
@@ -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",
+6 -34
View File
@@ -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,
+1 -1
View File
@@ -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),
}),
+101
View File
@@ -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,
});
}
}
},
};
}
+84
View File
@@ -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/,
);
});
+13
View File
@@ -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);
+4
View File
@@ -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',
+4 -1
View File
@@ -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,
+9 -2
View File
@@ -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 };
}
+15 -5
View File
@@ -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.
+30
View File
@@ -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',
);
});