mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge pull request #983 from bilawalsidhu/feat/optional-catalog-sources
Make catalog feeds optional and configurable at startup
This commit is contained in:
16 files changed
+409
-44
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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.
|
||||||
|
|
||||||
|
- 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
|
- Street Level providers are optional in application composition. Omitting a
|
||||||
Mapillary source no longer prevents startup; callers can inject a different
|
Mapillary source no longer prevents startup; callers can inject a different
|
||||||
provider list or an empty list without changing layer identities or share links.
|
provider list or an empty list without changing layer identities or share links.
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# God's Eye View Current State
|
# 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. 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
|
||||||
|
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.
|
Application catalog composition accepts an optional `streetLevelProviders` list.
|
||||||
An explicit list replaces the default Mapillary provider; an empty list removes
|
An explicit list replaces the default Mapillary provider; an empty list removes
|
||||||
all imagery providers. Without that option, Mapillary is registered only when
|
all imagery providers. Without that option, Mapillary is registered only when
|
||||||
|
|||||||
@@ -1434,6 +1434,7 @@
|
|||||||
"src/annotations/worldAnnotationRenderer.js",
|
"src/annotations/worldAnnotationRenderer.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
|
"src/app/sourceComposition.js",
|
||||||
"src/app/controls.js",
|
"src/app/controls.js",
|
||||||
"src/app/data.js",
|
"src/app/data.js",
|
||||||
"src/app/errors.js",
|
"src/app/errors.js",
|
||||||
@@ -2222,6 +2223,7 @@
|
|||||||
"src/annotations/resolver.js",
|
"src/annotations/resolver.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
|
"src/app/sourceComposition.js",
|
||||||
"src/layers/wind/index.js",
|
"src/layers/wind/index.js",
|
||||||
"src/layers/wind/model.js",
|
"src/layers/wind/model.js",
|
||||||
"src/layers/wind/fields.js",
|
"src/layers/wind/fields.js",
|
||||||
|
|||||||
@@ -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),
|
defer: (release) => releases.push(release),
|
||||||
onData: (manager) => {
|
onData: (manager) => {
|
||||||
observed = manager;
|
observed = manager;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(observed, dataManager);
|
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(attached, [['one', 'two']]);
|
||||||
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
||||||
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveCatalogSources } from './sourceComposition.js';
|
||||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||||
import { createCyclonesLayer } from '../layers/cyclones/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 { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.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
|
* Hardware-local layers are registered like any other but never enter share
|
||||||
* links or stored layer state: another browser cannot have this receiver.
|
* links or stored layer state: another browser cannot have this receiver.
|
||||||
@@ -92,12 +66,8 @@ export function createApplicationCatalog({
|
|||||||
if (!surface?.groundFloor || !surface?.terrain)
|
if (!surface?.groundFloor || !surface?.terrain)
|
||||||
throw new TypeError('Application surface services are required');
|
throw new TypeError('Application surface services are required');
|
||||||
|
|
||||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
const sourceComposition = resolveCatalogSources(sources);
|
||||||
if (
|
sources = sourceComposition.sources;
|
||||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
|
||||||
)
|
|
||||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
|
||||||
}
|
|
||||||
const militaryRegistry = createMilitaryRegistry();
|
const militaryRegistry = createMilitaryRegistry();
|
||||||
const weatherClock = createWeatherClock();
|
const weatherClock = createWeatherClock();
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
@@ -107,6 +77,7 @@ export function createApplicationCatalog({
|
|||||||
};
|
};
|
||||||
signal.addEventListener('abort', dispose, { once: true });
|
signal.addEventListener('abort', dispose, { once: true });
|
||||||
try {
|
try {
|
||||||
|
if (sourceComposition.isConfigured('military'))
|
||||||
militaryRegistry.configureSource(sources.military, { signal });
|
militaryRegistry.configureSource(sources.military, { signal });
|
||||||
const flights = createApplicationFlights({
|
const flights = createApplicationFlights({
|
||||||
surface,
|
surface,
|
||||||
@@ -204,6 +175,9 @@ export function createApplicationCatalog({
|
|||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...catalog,
|
...catalog,
|
||||||
|
getSourceAvailability: sourceComposition.createAvailabilityLookup(
|
||||||
|
catalog.layers,
|
||||||
|
),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
surface,
|
surface,
|
||||||
weatherClock,
|
weatherClock,
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createApplicationCatalog({
|
createApplicationCatalog({
|
||||||
sources: {},
|
sources: { flights: {} },
|
||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
surface: fixtureSurface(lifetime.signal),
|
surface: fixtureSurface(lifetime.signal),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
|||||||
// Initialize data layer manager
|
// Initialize data layer manager
|
||||||
const dataManager = new LayerLifecycle(viewer, {
|
const dataManager = new LayerLifecycle(viewer, {
|
||||||
allowQaRegistration,
|
allowQaRegistration,
|
||||||
|
getSourceAvailability: catalog?.getSourceAvailability,
|
||||||
});
|
});
|
||||||
defer(async () => {
|
defer(async () => {
|
||||||
await dataManager.destroyAll();
|
await dataManager.destroyAll();
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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),
|
||||||
|
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 = byId.get(id);
|
||||||
|
if (!layer) throw new Error(`Missing source consumer: ${id}`);
|
||||||
|
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?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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.getSourceAvailability(id).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(
|
||||||
|
{},
|
||||||
|
{ 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) {
|
||||||
|
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, /data source 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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||||
|
const actual = catalog({}, t);
|
||||||
|
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);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
+23
-1
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
*/
|
*/
|
||||||
export class LayerLifecycle {
|
export class LayerLifecycle {
|
||||||
constructor(viewer, { allowQaRegistration = false } = {}) {
|
constructor(
|
||||||
|
viewer,
|
||||||
|
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||||
|
) {
|
||||||
|
this._catalogSourceAvailability = getSourceAvailability;
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this._activityListeners = new Set();
|
this._activityListeners = new Set();
|
||||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||||
@@ -228,6 +232,7 @@ export class LayerLifecycle {
|
|||||||
|
|
||||||
_normalizedStats(entry) {
|
_normalizedStats(entry) {
|
||||||
const moduleStats = this._moduleStats(entry);
|
const moduleStats = this._moduleStats(entry);
|
||||||
|
const availability = this._sourceAvailability(entry.module.id);
|
||||||
const lifecycleLoading =
|
const lifecycleLoading =
|
||||||
entry.lifecycleState === 'enabling' ||
|
entry.lifecycleState === 'enabling' ||
|
||||||
entry.lifecycleState === 'disabling';
|
entry.lifecycleState === 'disabling';
|
||||||
@@ -235,6 +240,13 @@ export class LayerLifecycle {
|
|||||||
count: 0,
|
count: 0,
|
||||||
lastUpdate: null,
|
lastUpdate: null,
|
||||||
...moduleStats,
|
...moduleStats,
|
||||||
|
...(availability?.available === false
|
||||||
|
? {
|
||||||
|
status: 'unavailable',
|
||||||
|
sourceUnavailable: true,
|
||||||
|
error: availability.reason,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
loading: lifecycleLoading || moduleStats.loading === true,
|
loading: lifecycleLoading || moduleStats.loading === true,
|
||||||
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
||||||
managerRefreshError: entry.managerRefreshError,
|
managerRefreshError: entry.managerRefreshError,
|
||||||
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_sourceAvailability(layerId) {
|
||||||
|
return (
|
||||||
|
this._catalogSourceAvailability?.(layerId) ??
|
||||||
|
this.layers.get(layerId)?.module.getSourceAvailability?.()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async _visibilityBlockReason(change) {
|
async _visibilityBlockReason(change) {
|
||||||
|
const availability = this._sourceAvailability(change.layerId);
|
||||||
|
if (change.enabled && availability?.available === false)
|
||||||
|
return availability.reason || 'Data source unavailable';
|
||||||
for (const callback of this._visibilityGuards) {
|
for (const callback of this._visibilityGuards) {
|
||||||
try {
|
try {
|
||||||
const result = await callback(change);
|
const result = await callback(change);
|
||||||
|
|||||||
@@ -333,6 +333,16 @@ export function createStreetLevelLayer({
|
|||||||
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
||||||
/** Registered providers, in chip order. */
|
/** Registered providers, in chip order. */
|
||||||
providerIds: definitionsFrozen.map((def) => def.id),
|
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) {
|
init(viewer) {
|
||||||
if (state.initialized)
|
if (state.initialized)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function createStandaloneApplication({
|
|||||||
cesiumToken,
|
cesiumToken,
|
||||||
geospatial = {},
|
geospatial = {},
|
||||||
voice = {},
|
voice = {},
|
||||||
|
sources = {},
|
||||||
|
streetLevelProviders,
|
||||||
allowQaRegistration = false,
|
allowQaRegistration = false,
|
||||||
}) {
|
}) {
|
||||||
if (constructed)
|
if (constructed)
|
||||||
@@ -47,6 +49,8 @@ export function createStandaloneApplication({
|
|||||||
loaderStatus,
|
loaderStatus,
|
||||||
});
|
});
|
||||||
catalog = createStandaloneCatalog({
|
catalog = createStandaloneCatalog({
|
||||||
|
sources,
|
||||||
|
streetLevelProviders,
|
||||||
nepalBoundaryResolver: (signal) =>
|
nepalBoundaryResolver: (signal) =>
|
||||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||||
'Nepal',
|
'Nepal',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export { createStandaloneReferenceSources } from './layerSources.js';
|
|||||||
/** Create fresh layer instances using the existing standalone source choices. */
|
/** Create fresh layer instances using the existing standalone source choices. */
|
||||||
export function createStandaloneCatalog({
|
export function createStandaloneCatalog({
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
|
sources = {},
|
||||||
|
streetLevelProviders,
|
||||||
signal = new AbortController().signal,
|
signal = new AbortController().signal,
|
||||||
surface = createSurfaceServices({
|
surface = createSurfaceServices({
|
||||||
terrainSource: createApplicationRequestServices().terrain,
|
terrainSource: createApplicationRequestServices().terrain,
|
||||||
@@ -16,7 +18,8 @@ export function createStandaloneCatalog({
|
|||||||
return createApplicationCatalog({
|
return createApplicationCatalog({
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
surface,
|
surface,
|
||||||
sources: createStandaloneLayerSources(),
|
sources: createStandaloneLayerSources(sources),
|
||||||
|
streetLevelProviders,
|
||||||
signal,
|
signal,
|
||||||
vesselOptions: {
|
vesselOptions: {
|
||||||
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ import { createReferenceSources } from '../sources/reference.js';
|
|||||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||||
|
|
||||||
/** Select standalone providers without starting their acquisition. */
|
/** 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();
|
const mapTiles = createOpenFreeMapSource();
|
||||||
return {
|
const defaults = {
|
||||||
...createReferenceSources(),
|
...createReferenceSources(),
|
||||||
flights: createFlightSource(),
|
flights: createFlightSource(),
|
||||||
military: createMilitarySource(),
|
military: createMilitarySource(),
|
||||||
@@ -51,4 +53,9 @@ export function createStandaloneLayerSources() {
|
|||||||
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
|
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
@@ -588,7 +588,12 @@ export class LayerPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_syncToggleButton(button, layer) {
|
_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 =
|
const transitioning =
|
||||||
layer.lifecycleState === 'enabling' ||
|
layer.lifecycleState === 'enabling' ||
|
||||||
layer.lifecycleState === 'disabling';
|
layer.lifecycleState === 'disabling';
|
||||||
@@ -601,7 +606,7 @@ export class LayerPanel {
|
|||||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||||
button.classList.toggle(
|
button.classList.toggle(
|
||||||
`feed-${state}`,
|
`feed-${state}`,
|
||||||
layer.enabled && !uncertain && feedState === state,
|
(layer.enabled || unavailable) && !uncertain && feedState === state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
button.dataset.feedState = transitioning
|
button.dataset.feedState = transitioning
|
||||||
@@ -613,16 +618,21 @@ export class LayerPanel {
|
|||||||
// click guard above prevents repeat activation without the focus loss caused
|
// click guard above prevents repeat activation without the focus loss caused
|
||||||
// by native `disabled`.
|
// by native `disabled`.
|
||||||
button.disabled = false;
|
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.setAttribute('aria-busy', String(transitioning));
|
||||||
button.textContent = transitioning
|
button.textContent = transitioning
|
||||||
? layer.lifecycleState.toUpperCase()
|
? layer.lifecycleState.toUpperCase()
|
||||||
: uncertain
|
: uncertain
|
||||||
? 'UNCERTAIN'
|
? 'UNCERTAIN'
|
||||||
: layer.enabled
|
: layer.enabled || unavailable
|
||||||
? FEED_STATE_LABELS[feedState]
|
? FEED_STATE_LABELS[feedState]
|
||||||
: 'OFF';
|
: '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
|
// 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
|
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||||
// when the layer needs no key, or already has one.
|
// when the layer needs no key, or already has one.
|
||||||
|
|||||||
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
|||||||
globalThis.document = previousDocument;
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user