Merge branch 'main' into feat/import-geo-files

This commit is contained in:
kapturner committed 2026-10-11 21:18:37 -05:00
commit d8b4b0d80f
80 files changed
+3829 -184

No files matched your search

+16 -1
View File
@@ -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/);
+12 -38
View File
@@ -1,9 +1,9 @@
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';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -32,34 +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'],
mapillary: MAPILLARY_SOURCE_METHODS,
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
@@ -86,6 +58,7 @@ export function createApplicationCatalog({
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -93,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 = () => {
@@ -108,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,
@@ -154,6 +124,7 @@ export function createApplicationCatalog({
createApplicationStreetLevel({
surface,
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
@@ -162,8 +133,8 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
vessels,
installations,
createApplicationAwareness({
@@ -204,6 +175,9 @@ export function createApplicationCatalog({
);
return Object.freeze({
...catalog,
getSourceAvailability: sourceComposition.createAvailabilityLookup(
catalog.layers,
),
militaryRegistry,
surface,
weatherClock,
+52 -1
View File
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -116,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),
}),
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
eventTarget: null,
});
}
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const sources = fixtureSources([], []);
delete sources.mapillary;
const options = {
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 30);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
catalog.get('street-level'),
);
assert.match(
catalog.get('street-level').getUIState().coverage.hint,
/No street-level imagery providers/,
);
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const replacement = createApplicationCatalog({
...options,
streetLevelProviders: [provider],
});
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
assert.deepEqual(replacement.metadata, catalog.metadata);
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
assert.deepEqual(
createApplicationCatalog({
...options,
sources: fixtureSources([], []),
streetLevelProviders: [],
}).get('street-level').providerIds,
[],
);
assert.throws(
() =>
createApplicationCatalog({
...options,
sources: { ...sources, mapillary: {} },
}),
/Mapillary source/,
);
});
+1
View File
@@ -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();
+3 -9
View File
@@ -1,12 +1,6 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
/**
* Construct one Directions layer using the application scene owners.
*
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
/** Construct Directions with application scene owners and an explicit route source. */
export function createApplicationDirections({ source }) {
return createApplicationDirectionsLayer({ source });
}
+8 -8
View File
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
thumbnails: createThumbnailLoader(),
});
}
+13 -3
View File
@@ -5,10 +5,20 @@ import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.js';
/** A new provider registers here; its chip, credit and share bit follow. */
export function createApplicationStreetLevel({ surface, sources }) {
/**
* Inject providers, or use Mapillary only when its source is supplied.
* An explicit provider list replaces the default; unused sources.mapillary is
* neither constructed nor validated, including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = sources.mapillary == null
? []
: [createMapillaryProvider({ source: sources.mapillary })],
}) {
return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })],
providers,
services: {
sprites,
picking,
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
test('explicit providers replace the default source and an empty list removes it', () => {
const sources = { mapillary: {} };
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const layer = createApplicationStreetLevel({
sources,
providers: [provider],
});
assert.deepEqual(layer.providerIds, ['example']);
assert.deepEqual(
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
[],
);
assert.throws(
() => createApplicationStreetLevel({ sources }),
/Mapillary source/,
);
assert.throws(
() => createApplicationStreetLevel({ providers: null }),
/array/,
);
});
+113
View File
@@ -0,0 +1,113 @@
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({
directions: contract(['directions'], ['getRoute']),
'recent-imagery': contract(
['recent-imagery'],
['searchHls', 'getThumbnail', 'getTileTemplate'],
),
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?.();
},
};
}
+156
View File
@@ -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);
});