Merge pull request #982 from bilawalsidhu/fix/optional-street-level-providers

Fix startup with optional Street Level providers
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-09 19:37:14 -07:00
commit e626137a7a
13 files changed
+162 -12

No files matched your search

+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
- 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.
- Vector tile sources take their allowed tile origin from the configured - Vector tile sources take their allowed tile origin from the configured
`tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the `tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the
mirror or fails visibly instead of silently using OpenFreeMap. Thanks to mirror or fails visibly instead of silently using OpenFreeMap. Thanks to
+10
View File
@@ -1,5 +1,15 @@
# God's Eye View Current State # God's Eye View Current State
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
`sources.mapillary` is supplied. An absent source leaves Street Level registered
with a truthful no-provider state, preserving layer controls and share tokens.
Malformed selected sources and provider definitions still fail validation.
An explicit provider list bypasses the unused Mapillary source, including its
validation. Layer share identities are preserved; custom provider switches require
a corresponding serialization contract.
## God's Eye View in conversations — October 2, 2026 ## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers, Tool answers that can be shown in God's Eye View include a view: camera, layers,
+6
View File
@@ -622,11 +622,17 @@ async function main() {
await target.click( await target.click(
'.panel-collapse-btn[data-collapse-target="street-level-panel"]', '.panel-collapse-btn[data-collapse-target="street-level-panel"]',
); );
assert.ok(
await settle( await settle(
target, target,
() => () =>
!document
.getElementById('street-level-panel')
.classList.contains('collapsed') &&
getComputedStyle(document.getElementById('sl-body')).display === getComputedStyle(document.getElementById('sl-body')).display ===
'flex', 'flex',
),
'Street Level panel did not expand before the timeout',
); );
}; };
await expandStrip(); await expandStrip();
+2 -2
View File
@@ -3,7 +3,6 @@ import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js'; import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.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 { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js'; import { createApplicationFlights } from './layers/flights.js';
@@ -57,7 +56,6 @@ const SOURCE_METHODS = Object.freeze({
earthquakes: ['getSnapshot'], earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'], 'fire-perimeters': ['getSnapshot'],
cables: ['fetch'], cables: ['fetch'],
mapillary: MAPILLARY_SOURCE_METHODS,
}); });
/** /**
@@ -86,6 +84,7 @@ export function createApplicationCatalog({
vesselOptions, vesselOptions,
resolveAsset, resolveAsset,
nepalBoundaryResolver, nepalBoundaryResolver,
streetLevelProviders,
}) { }) {
if (!signal?.addEventListener) if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required'); throw new TypeError('An application lifetime signal is required');
@@ -154,6 +153,7 @@ export function createApplicationCatalog({
createApplicationStreetLevel({ createApplicationStreetLevel({
surface, surface,
sources: { mapillary: sources.mapillary }, sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}), }),
satellites, satellites,
createApplicationLaunches({ source: sources.launches, satellites }), createApplicationLaunches({ source: sources.launches, satellites }),
+51
View File
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js'; import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js'; import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
function fixtureSources(ids, calls) { function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources(); const sources = createStandaloneLayerSources();
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
eventTarget: null, 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/,
);
});
+13 -3
View File
@@ -5,10 +5,20 @@ import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js'; import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.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({ return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })], providers,
services: { services: {
sprites, sprites,
picking, 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/,
);
});
+14
View File
@@ -482,3 +482,17 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
assert.equal(provider.calls.activate, 1); assert.equal(provider.calls.activate, 1);
assert.equal(viewer.credits.length, 1); assert.equal(viewer.credits.length, 1);
}); });
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
+2 -2
View File
@@ -86,8 +86,8 @@ const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
* @returns {ReadonlyArray<StreetLevelProvider>} * @returns {ReadonlyArray<StreetLevelProvider>}
*/ */
export function validateProviders(providers) { export function validateProviders(providers) {
if (!Array.isArray(providers) || providers.length === 0) if (!Array.isArray(providers))
throw new TypeError('Street Level needs at least one imagery provider'); throw new TypeError('Street Level providers must be an array');
const ids = new Set(); const ids = new Set();
const prefixes = []; const prefixes = [];
for (const provider of providers) { for (const provider of providers) {
+8 -3
View File
@@ -26,9 +26,14 @@ test('a valid list is frozen in registration order', () => {
); );
}); });
test('an empty list, duplicate ids and malformed ids are rejected', () => { test('an empty provider list is valid and has no key requirement', () => {
assert.throws(() => validateProviders([]), /at least one/); assert.deepEqual(validateProviders([]), []);
assert.throws(() => validateProviders(null), /at least one/); assert.ok(Object.isFrozen(validateProviders([])));
assert.equal(requiresKeyIdFor([]), null);
});
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders(null), /array/);
assert.throws(() => validateProviders([def(), def()]), /Duplicate/); assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/); assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
}); });
+4 -1
View File
@@ -27,7 +27,10 @@ export function summarizeCoverage(providers) {
return { return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0), count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading), loading: active.some((p) => p.loading),
hint: active.find((p) => p.hint)?.hint || '', hint:
providers.length === 0
? 'No street-level imagery providers configured'
: active.find((p) => p.hint)?.hint || '',
error: active.find((p) => p.error)?.error || null, error: active.find((p) => p.error)?.error || null,
keyRequired: keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true), active.length > 0 && active.every((p) => p.keyRequired === true),
+2 -1
View File
@@ -125,6 +125,7 @@ function presentViewer(state) {
} }
function presentMeta(state) { function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.'; if (!state.enabled) return 'Switch a provider on to draw its coverage.';
if (state.sequence.selectedId) if (state.sequence.selectedId)
return state.sequence.loading return state.sequence.loading
@@ -147,7 +148,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
enabled, enabled,
keyRequired, keyRequired,
status: presentStatus(state), status: presentStatus(state),
controlsDisabled: keyRequired, controlsDisabled: keyRequired || state.providers.length === 0,
providers: presentProviders(state), providers: presentProviders(state),
error: keyMissing error: keyMissing
? null ? null
+17
View File
@@ -9,6 +9,23 @@ import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
const provider = providerSnapshot; const provider = providerSnapshot;
test('an empty composition explains unavailable imagery even while disabled', () => {
for (const enabled of [false, true]) {
const view = presentStreetLevelPanel(
snapshot({
enabled,
providers: [],
coverage: { hint: 'No street-level imagery providers configured' },
legend: [],
}),
);
assert.equal(view.controlsDisabled, true);
assert.equal(view.keyRequired, false);
assert.deepEqual(view.providers, []);
assert.equal(view.meta, 'No street-level imagery providers configured');
}
});
function snapshot(overrides = {}) { function snapshot(overrides = {}) {
const base = { const base = {
enabled: false, enabled: false,