From 940b277460b2c9321fedf1937b9d4dc7bbdb9486 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:18:49 -0700 Subject: [PATCH 1/3] Allow optional Street Level provider composition --- CHANGELOG.md | 4 ++ docs/CURRENT-STATE.md | 7 ++++ src/app/constructCatalog.js | 4 +- src/app/constructCatalog.test.mjs | 51 ++++++++++++++++++++++++ src/app/layers/streetLevel.js | 12 ++++-- src/app/layers/streetLevel.test.mjs | 29 ++++++++++++++ src/layers/streetLevel/index.test.mjs | 14 +++++++ src/layers/streetLevel/registry.js | 4 +- src/layers/streetLevel/registry.test.mjs | 11 +++-- src/layers/streetLevel/uiState.js | 5 ++- 10 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 src/app/layers/streetLevel.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f343b4d2..c95633b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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 `tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the mirror or fails visibly instead of silently using OpenFreeMap. Thanks to diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eea437dd6..f86b0d26f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # 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 supplied sources and provider definitions still fail validation. + ## 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, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 0a8ef4fe8..8baecf57c 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -3,7 +3,6 @@ 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'; @@ -57,7 +56,6 @@ const SOURCE_METHODS = Object.freeze({ earthquakes: ['getSnapshot'], 'fire-perimeters': ['getSnapshot'], cables: ['fetch'], - mapillary: MAPILLARY_SOURCE_METHODS, }); /** @@ -86,6 +84,7 @@ export function createApplicationCatalog({ vesselOptions, resolveAsset, nepalBoundaryResolver, + streetLevelProviders, }) { if (!signal?.addEventListener) throw new TypeError('An application lifetime signal is required'); @@ -154,6 +153,7 @@ export function createApplicationCatalog({ createApplicationStreetLevel({ surface, sources: { mapillary: sources.mapillary }, + providers: streetLevelProviders, }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 3a802edbe..a21bfb537 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -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(); @@ -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/, + ); +}); diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 56ba06590..41e8b2a8b 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -5,10 +5,16 @@ 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. */ +export function createApplicationStreetLevel({ + surface, + sources = {}, + providers = sources.mapillary == null + ? [] + : [createMapillaryProvider({ source: sources.mapillary })], +}) { return createStreetLevelLayer({ - providers: [createMapillaryProvider({ source: sources.mapillary })], + providers, services: { sprites, picking, diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs new file mode 100644 index 000000000..99f356599 --- /dev/null +++ b/src/app/layers/streetLevel.test.mjs @@ -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/, + ); +}); diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index d4990c6ab..41b6f917a 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -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(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, []); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 01d008ab4..b6b81fe35 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -86,8 +86,8 @@ const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/; * @returns {ReadonlyArray} */ export function validateProviders(providers) { - if (!Array.isArray(providers) || providers.length === 0) - throw new TypeError('Street Level needs at least one imagery provider'); + if (!Array.isArray(providers)) + throw new TypeError('Street Level providers must be an array'); const ids = new Set(); const prefixes = []; for (const provider of providers) { diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs index 278bf5ffa..74d9991dc 100644 --- a/src/layers/streetLevel/registry.test.mjs +++ b/src/layers/streetLevel/registry.test.mjs @@ -26,9 +26,14 @@ test('a valid list is frozen in registration order', () => { ); }); -test('an empty list, duplicate ids and malformed ids are rejected', () => { - assert.throws(() => validateProviders([]), /at least one/); - assert.throws(() => validateProviders(null), /at least one/); +test('an empty provider list is valid and has no key requirement', () => { + assert.deepEqual(validateProviders([]), []); + 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({ id: 'Bad Id' })]), /slug/); }); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 695c87653..a2420314f 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -27,7 +27,10 @@ export function summarizeCoverage(providers) { return { count: active.reduce((sum, p) => sum + (p.count || 0), 0), 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, keyRequired: active.length > 0 && active.every((p) => p.keyRequired === true), From 1ee07468c46f37707241dafedb75a85af7545891 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:21:31 -0700 Subject: [PATCH 2/3] Explain empty imagery configuration in Street Level controls --- src/ui/streetLevelPresentation.js | 3 ++- src/ui/streetLevelPresentation.test.mjs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index af6ad8c8c..da3140b24 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -125,6 +125,7 @@ function presentViewer(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.sequence.selectedId) return state.sequence.loading @@ -147,7 +148,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { enabled, keyRequired, status: presentStatus(state), - controlsDisabled: keyRequired, + controlsDisabled: keyRequired || state.providers.length === 0, providers: presentProviders(state), error: keyMissing ? null diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index d5075ac69..d2a24c94a 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -9,6 +9,23 @@ import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; 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 = {}) { const base = { enabled: false, From 893294c7d443aacd79df5dcc268ee744768b33ea Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:49:26 -0700 Subject: [PATCH 3/3] Assert Street Level expansion timeout and document provider precedence --- docs/CURRENT-STATE.md | 5 ++++- scripts/qa-street-level.mjs | 16 +++++++++++----- src/app/layers/streetLevel.js | 6 +++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f86b0d26f..b8e763835 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5,7 +5,10 @@ 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 supplied sources and provider definitions still fail validation. +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 diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 59b0584da..f8d50f85f 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -622,11 +622,17 @@ async function main() { await target.click( '.panel-collapse-btn[data-collapse-target="street-level-panel"]', ); - await settle( - target, - () => - getComputedStyle(document.getElementById('sl-body')).display === - 'flex', + assert.ok( + await settle( + target, + () => + !document + .getElementById('street-level-panel') + .classList.contains('collapsed') && + getComputedStyle(document.getElementById('sl-body')).display === + 'flex', + ), + 'Street Level panel did not expand before the timeout', ); }; await expandStrip(); diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 41e8b2a8b..18a3e7a3b 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -5,7 +5,11 @@ import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; import * as render from '../../renderGovernor.js'; -/** Inject providers, or use Mapillary only when its source is supplied. */ +/** + * 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 = {},