From 1c3748e3bd6a186ecd2ef752b597ff762f4c2b59 Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:02:20 +0300 Subject: [PATCH 01/12] fix(satellites): recover TLE parsing after incomplete sets --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 4 ++++ src/sources/tle.js | 5 +++-- src/tools/queries/space.test.mjs | 21 +++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39cf9b524..2ace48350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- Keep later satellites when a TLE catalog entry is missing an orbital line. + The parser skips the incomplete set and resumes at the next complete named + set, instead of losing the rest of the group (#906). + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7c06724e2..19897526b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1049,6 +1049,10 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories; `sources/space` supplies fixed upstream URL builders with no I/O or environment access. Callers retain validation, transport and response policy. +The shared TLE text parser skips incomplete named sets and resumes at the next +complete set. A missing orbital line does not discard the later satellites in +the catalog; satellite layers and next-pass tools use the same parser. + ## Build configuration and local provider boundaries `vite.config.js` delegates to `server/standalone/vite.config.js`, which loads diff --git a/src/sources/tle.js b/src/sources/tle.js index 423651e6b..ac7aedc2c 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -1,6 +1,6 @@ /** * Parse three-line TLE catalog text into `{ name, line1, line2 }` entries. - * Blocks whose second and third lines are not TLE lines 1 and 2 are skipped. + * Malformed sets are skipped without dropping the valid sets that follow. */ export function parseTleText(text) { const lines = String(text) @@ -9,12 +9,13 @@ export function parseTleText(text) { .map((line) => line.trim()) .filter((line) => line.length > 0); const result = []; - for (let i = 0; i < lines.length - 2; i += 3) { + for (let i = 0; i < lines.length - 2; i++) { const name = lines[i]; const line1 = lines[i + 1]; const line2 = lines[i + 2]; if (line1.startsWith('1 ') && line2.startsWith('2 ')) { result.push({ name, line1, line2 }); + i += 2; } } return result; diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 376cf54d7..f6c2a8df8 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => { assert.equal(tleCatalogNumber('1 xx'), null); }); +for (const missingLine of [1, 2]) { + test(`TLE parsing resumes after a set missing line ${missingLine}`, () => { + const sets = Array.from({ length: 8 }, (_, i) => { + const lines = TLE.trim().split('\n').slice(0, 3); + lines[0] = `SAT ${i}`; + return lines; + }); + sets[1].splice(missingLine, 1); + const entries = parseTleText(sets.flat().join('\r\n')); + assert.deepEqual( + entries.map((entry) => entry.name), + ['SAT 0', 'SAT 2', 'SAT 3', 'SAT 4', 'SAT 5', 'SAT 6', 'SAT 7'], + ); + assert.ok( + entries.every( + (entry) => entry.line1.startsWith('1 ') && entry.line2.startsWith('2 '), + ), + ); + }); +} + test('the next pass defaults to the ISS and reports times, peak and direction', async () => { const catalog = composeCatalog({ tools: coreTools, From 940b277460b2c9321fedf1937b9d4dc7bbdb9486 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:18:49 -0700 Subject: [PATCH 02/12] 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 03/12] 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 5756ae92a8dea993edc673420d79f542c625f47c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:35:38 -0700 Subject: [PATCH 04/12] Make catalog feed configuration optional and explicit --- CHANGELOG.md | 4 ++ docs/CURRENT-STATE.md | 10 +++ scripts/package-boundaries.json | 2 + src/app/constructCatalog.js | 40 ++---------- src/app/constructCatalog.test.mjs | 2 +- src/app/sourceComposition.js | 101 +++++++++++++++++++++++++++++ src/app/sourceComposition.test.mjs | 84 ++++++++++++++++++++++++ src/data/lifecycle.js | 13 ++++ src/standalone/application.js | 4 ++ src/standalone/catalog.js | 5 +- src/standalone/layerSources.js | 11 +++- src/ui/layerPanel.js | 20 ++++-- src/ui/layerPanel.test.mjs | 30 +++++++++ 13 files changed, 283 insertions(+), 43 deletions(-) create mode 100644 src/app/sourceComposition.js create mode 100644 src/app/sourceComposition.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c95633b1c..20b6fccdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f86b0d26f..77e0cf0fc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 23f9f52ed..c5a5a3b89 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 8baecf57c..74cbcf47f 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index a21bfb537..f42e82c7c 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -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), }), diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js new file mode 100644 index 000000000..77b3ebe10 --- /dev/null +++ b/src/app/sourceComposition.js @@ -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, + }); + } + } + }, + }; +} diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs new file mode 100644 index 000000000..9d6823e40 --- /dev/null +++ b/src/app/sourceComposition.test.mjs @@ -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/, + ); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c3659b9e..6c40b82be 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -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); diff --git a/src/standalone/application.js b/src/standalone/application.js index 5dd1b9641..5c5b048d9 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -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', diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index 7e4e66808..1ac6ce931 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -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, diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index ffd975231..4e461f331 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -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 }; } diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index ca97e2635..e4c240f23 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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. diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index b1c58f747..1258cc76a 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -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', + ); +}); From 99988cb34868def45776b2cb3373dbc5f8ab2bce Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:42:00 -0700 Subject: [PATCH 05/12] Separate route and recent imagery acquisition from application assembly --- CHANGELOG.md | 5 +++ docs/CURRENT-STATE.md | 10 ++++- package.json | 2 + scripts/package-boundaries.json | 25 +++++++++++- src/app/constructCatalog.js | 4 +- src/app/layers/directions.js | 12 ++---- src/app/layers/recentImagery.js | 16 ++++---- src/app/sourceComposition.js | 5 +++ src/data/directions.js | 7 +++- src/data/sharedOsmDisplays.test.mjs | 3 +- src/layers/directions/index.js | 29 +++++-------- src/layers/directions/source.js | 34 ++++++++++++++++ src/layers/directions/source.test.mjs | 43 ++++++++++++++++++++ src/layers/recentImagery/rendering.js | 6 ++- src/layers/recentImagery/rendering.test.mjs | 21 +++++++++- src/layers/recentImagery/source.js | 32 +++++++++++++++ src/layers/recentImagery/source.test.mjs | 42 +++++++++++++++++++ src/layers/recentImagery/thumbnails.js | 14 +++---- src/layers/recentImagery/thumbnails.test.mjs | 21 ++++++++++ src/standalone/layerSources.js | 4 ++ 20 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 src/layers/directions/source.js create mode 100644 src/layers/directions/source.test.mjs create mode 100644 src/layers/recentImagery/source.js create mode 100644 src/layers/recentImagery/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b6fccdb..d658b4212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Directions and Recent Imagery accept replaceable acquisition sources, including + route transport, imagery catalog, thumbnails, tile templates and attribution. + Explicitly removing either source leaves its layer unavailable; standalone + defaults preserve the existing providers. + - 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 77e0cf0fc..6f4e3e546 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,8 +7,14 @@ 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. +continues to accept an explicit provider list. Directions (`getRoute`) and +Recent Imagery (`searchHls`, `getThumbnail`, `getTileTemplate`) use the same source +configuration boundary. Standalone selects their default adapters; layers retain +route normalization, imagery selection and rendering. Recent Imagery adapters +consume the existing HLS/VIIRS product schema and provide their own tile attribution. +The source factories are exported at `layers/directions/source` and +`layers/recent-imagery/source`. Configuration is applied at construction time; +this does not introduce live source replacement. Application catalog composition accepts an optional `streetLevelProviders` list. An explicit list replaces the default Mapillary provider; an empty list removes diff --git a/package.json b/package.json index 9222cd6c4..d9bf6cf8a 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "./layers/vessels": "./src/layers/vessels/index.js", "./layers/earthquakes": "./src/layers/earthquakes/index.js", "./layers/recent-imagery": "./src/layers/recentImagery/index.js", + "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", @@ -173,6 +174,7 @@ "./layers/traffic": "./src/layers/traffic/index.js", "./layers/bikeshare": "./src/layers/bikeshare/index.js", "./layers/directions": "./src/layers/directions/index.js", + "./layers/directions/source": "./src/layers/directions/source.js", "./layers/cctv": "./src/layers/cctv/index.js", "./layers/alpr": "./src/layers/alpr/index.js", "./layers/radio": "./src/layers/radio/index.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c5a5a3b89..5b10693ab 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -934,6 +934,7 @@ "exports": ["./layers/recent-imagery"], "modules": [ "src/layers/recentImagery/index.js", + "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", @@ -1994,6 +1995,7 @@ "src/annotations/drawTool.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/layers/directions/source.js", "src/data/directions.js", "src/app/layers/directions.js", "src/search/coordinateGeocoder.js", @@ -2128,6 +2130,7 @@ "src/ui/localSdrPresentation.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", + "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", @@ -2573,6 +2576,7 @@ "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/layers/directions/source.js", "src/services/terrainHeights.js", "src/data/terrainHeights.js", "src/data/groundFloor.js", @@ -2661,6 +2665,7 @@ "src/data/modelEligibility.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", + "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", @@ -2915,9 +2920,27 @@ ], "external": [] }, + "directions-source": { + "exports": ["./layers/directions/source"], + "modules": ["src/layers/directions/source.js"], + "external": [] + }, + "recent-imagery-source": { + "exports": ["./layers/recent-imagery/source"], + "modules": [ + "src/layers/recentImagery/source.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/model.js" + ], + "external": [] + }, "directions-layer": { "exports": ["./layers/directions"], - "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], + "modules": [ + "src/layers/directions/index.js", + "src/layers/directions/source.js", + "src/data/routeSteps.js" + ], "external": ["cesium"] }, "radio-browser-source": { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 74cbcf47f..54d2bae3b 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -133,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({ diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js index 1e535fc88..8b16c5cff 100644 --- a/src/app/layers/directions.js +++ b/src/app/layers/directions.js @@ -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 }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index 1de4b22d4..cc4659727 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -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(), }); } diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 77b3ebe10..e40c67dd4 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -6,6 +6,11 @@ const contract = (layers, 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']), diff --git a/src/data/directions.js b/src/data/directions.js index ddb1109f7..ef5565488 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -1,3 +1,4 @@ +import { createDirectionsSource } from '../layers/directions/source.js'; /** * @module directions * @description Service bundle for the Directions layer. @@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({ }); /** Construct one Directions layer over the application scene owners. */ -export function createApplicationDirectionsLayer() { - return createDirectionsLayer({ services: directionsServices }); +export function createApplicationDirectionsLayer({ + source = createDirectionsSource(), +} = {}) { + return createDirectionsLayer({ services: directionsServices, source }); } export { diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index d4f322a57..273a9dc56 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js'; import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js'; import * as annotations from '../annotations/worldAnnotationRenderer.js'; import { createDirectionsLayer } from '../layers/directions/index.js'; +import { createDirectionsSource } from '../layers/directions/source.js'; import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js'; import * as input from './inputOwnership.js'; import { createOverpassAlprSource } from '../layers/alpr/source.js'; @@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => { test('Directions introduces the shared OSM credit when the requested route draws', async (t) => { const v = viewer(t); t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] })); - const layer = createDirectionsLayer({ services: { + const layer = createDirectionsLayer({ source: createDirectionsSource(), services: { credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {}, overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop }, diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index bc1ec7296..0a06d5219 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) { * @param {{lat:number, lon:number}} b * @returns {string} */ -export function directionsRequestUrl(mode, a, b) { - const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`; - return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`; -} +export { directionsRequestUrl } from './source.js'; /** * Validate a proxy payload into the route record the layer keeps, or null. @@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) { * route, the markers, the pointer claim or the flight this layer owns. The * shared services are injected, which is what keeps this module's own imports * down to Cesium and the pure step formatter. - * @param {{services: object}} options + * @param {{services: object, source: {getRoute: Function}}} options * @returns {object} The layer module the data manager registers. */ -export function createDirectionsLayer({ services }) { +export function createDirectionsLayer({ services, source }) { + if (typeof source?.getRoute !== 'function') + throw new TypeError('Directions require a route source'); /** * Where the maneuver card is drawn. Defaults to the shared world-overlay host; * tests swap it. Resolved on use, not at import, because the services arrive @@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) { _error = null; notifyRow(); try { - const response = await fetch(directionsRequestUrl(mode, _a, _b), { - signal: controller.signal, - headers: { Accept: 'application/json' }, - }); - const payload = await response.json(); - if (response.status === 429) { - throw new Error( - typeof payload?.error === 'string' && payload.error - ? `${payload.error} — try again in a moment` - : 'Routing is rate limited — try again in a moment', - ); - } + const payload = await source.getRoute( + { mode, a: _a, b: _b }, + { + signal: controller.signal, + }, + ); if (seq !== _routeSeq || !_enabled) return; const route = normalizeRoutePayload(payload, mode); if (!route) { diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js new file mode 100644 index 000000000..f4f353653 --- /dev/null +++ b/src/layers/directions/source.js @@ -0,0 +1,34 @@ +/** Build the existing route-proxy query from geographic endpoints. */ +export function directionsRequestUrl(mode, a, b) { + const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`; + return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`; +} + +/** Acquire route records using caller-owned transport and an optional endpoint. */ +export function createDirectionsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + endpoint = '/api/route', +} = {}) { + return { + async getRoute({ mode, a, b }, { signal } = {}) { + signal?.throwIfAborted(); + const query = directionsRequestUrl(mode, a, b).split('?')[1]; + const response = await fetchImpl(`${endpoint}?${query}`, { + signal, + headers: { Accept: 'application/json' }, + }); + const payload = await response.json(); + signal?.throwIfAborted(); + if (response.status === 429) { + throw new Error( + typeof payload?.error === 'string' && payload.error + ? `${payload.error} — try again in a moment` + : 'Routing is rate limited — try again in a moment', + ); + } + if (!response.ok && !payload?.error) + throw new Error(`Routing unavailable (HTTP ${response.status})`); + return payload; + }, + }; +} diff --git a/src/layers/directions/source.test.mjs b/src/layers/directions/source.test.mjs new file mode 100644 index 000000000..824b1221b --- /dev/null +++ b/src/layers/directions/source.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDirectionsSource } from './source.js'; + +const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } }; + +test('route acquisition uses only the supplied transport and endpoint', async () => { + const controller = new AbortController(); + const payload = { ok: true, geometry: [[1, 2], [3, 4]] }; + const calls = []; + const source = createDirectionsSource({ + endpoint: 'https://routes.example/route', + fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); }, + }); + assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload); + assert.equal(calls.length, 1); + const [url, init] = calls[0]; + assert.equal(new URL(url).origin, 'https://routes.example'); + assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000'); + assert.equal(new URL(url).searchParams.get('profile'), 'foot'); + assert.equal(init.signal, controller.signal); + assert.equal(init.headers.Accept, 'application/json'); +}); + +test('aborting during route parsing discards the result', async () => { + const controller = new AbortController(); + const source = createDirectionsSource({ fetchImpl: async () => ({ + ok: true, json: async () => { controller.abort(); return { ok: true }; }, + }) }); + await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' }); +}); + +test('route failures are surfaced without fallback requests', async () => { + for (const status of [429, 503]) { + let calls = 0; + const source = createDirectionsSource({ fetchImpl: async () => { + calls++; + return Response.json({}, { status }); + } }); + await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/); + assert.equal(calls, 1); + } +}); diff --git a/src/layers/recentImagery/rendering.js b/src/layers/recentImagery/rendering.js index 1d0a7893f..b241d0f52 100644 --- a/src/layers/recentImagery/rendering.js +++ b/src/layers/recentImagery/rendering.js @@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) { */ export function createRecentImageryRenderer({ cesium = Cesium, + tileTemplate = gibsTemplate, + credit = 'NASA GIBS', maxTileRequests = 6, requestRender = () => {}, setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms), @@ -101,7 +103,7 @@ export function createRecentImageryRenderer({ function createProvider(candidate, box) { const spec = PRODUCTS[candidate.product]; const provider = new cesium.UrlTemplateImageryProvider({ - url: gibsTemplate(candidate.product, candidate.day), + url: tileTemplate(candidate.product, candidate.day), subdomains: ['a', 'b', 'c'], minimumLevel: 0, maximumLevel: spec.maxLevel, @@ -112,7 +114,7 @@ export function createRecentImageryRenderer({ box.east, box.north, ), - credit: 'NASA GIBS', + credit, hasAlphaChannel: spec.format === 'png', }); return admitTileRequests(provider, admission, requestRender, retry); diff --git a/src/layers/recentImagery/rendering.test.mjs b/src/layers/recentImagery/rendering.test.mjs index ad7e166a8..c1d38cacc 100644 --- a/src/layers/recentImagery/rendering.test.mjs +++ b/src/layers/recentImagery/rendering.test.mjs @@ -53,10 +53,11 @@ const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' }; const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' }; const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' }; -function fixture({ requestImage, maxTileRequests } = {}) { +function fixture({ requestImage, maxTileRequests, ...options } = {}) { const renders = []; const timers = manualTimers(); const renderer = createRecentImageryRenderer({ + ...options, cesium: fakeCesium(requestImage), maxTileRequests, requestRender: (reason) => renders.push(reason), @@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t assert.equal(layer.splitDirection, 0); assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']); }); + +test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => { + const { renderer, globe } = fixture({ + tileTemplate: (product, day) => + `https://tiles.example/${product}/${day}/{z}/{x}/{y}`, + credit: 'Example imagery', + }); + assert.equal(renderer.showSlot('a', S30, BOX), true); + const layer = globe.layers[0]; + assert.equal( + layer.provider.options.url, + 'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}', + ); + assert.equal(layer.provider.options.credit, 'Example imagery'); + renderer.destroy(); + assert.equal(globe.layers.length, 0); + assert.equal(layer.destroyed, true); +}); diff --git a/src/layers/recentImagery/source.js b/src/layers/recentImagery/source.js new file mode 100644 index 000000000..258e8e2e1 --- /dev/null +++ b/src/layers/recentImagery/source.js @@ -0,0 +1,32 @@ +import { searchHls } from './catalog.js'; +import { gibsTemplate, wvsSnapshotUrl } from './model.js'; + +/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */ +export function createRecentImagerySource({ + fetchImpl = (...args) => globalThis.fetch(...args), + search = searchHls, + thumbnailUrl = wvsSnapshotUrl, + tileTemplate = gibsTemplate, + credit = 'NASA GIBS', +} = {}) { + return { + credit, + searchHls: (query) => search({ ...query, fetchImpl }), + async getThumbnail(candidate, box, { size = 256, signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl( + thumbnailUrl({ + product: candidate.product, + day: candidate.day, + box, + width: size, + height: size, + }), + { signal }, + ); + signal?.throwIfAborted(); + return response; + }, + getTileTemplate: (product, day) => tileTemplate(product, day), + }; +} diff --git a/src/layers/recentImagery/source.test.mjs b/src/layers/recentImagery/source.test.mjs new file mode 100644 index 000000000..d3cafc5b0 --- /dev/null +++ b/src/layers/recentImagery/source.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRecentImagerySource } from './source.js'; + +test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => { + const controller = new AbortController(); + const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal }; + const candidate = { product: 'S30', day: '2026-10-01' }; + const calls = []; + const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); }; + const source = createRecentImagerySource({ + fetchImpl, + search: async (request) => { + assert.equal(request.fetchImpl, fetchImpl); + assert.equal(request.signal, controller.signal); + assert.equal(request.box, query.box); + return [candidate]; + }, + thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`, + tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`, + credit: 'Example imagery', + }); + assert.deepEqual(await source.searchHls(query), [candidate]); + assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image'); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256'); + assert.equal(calls[0][1].signal, controller.signal); + assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}'); + assert.equal(source.credit, 'Example imagery'); +}); + +test('cancelled thumbnail acquisition does not return stale responses or retry', async () => { + const controller = new AbortController(); + let calls = 0; + const source = createRecentImagerySource({ + thumbnailUrl: () => 'https://imagery.example/thumbnail', + fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); }, + }); + await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' }); + await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' }); + assert.equal(calls, 1); +}); diff --git a/src/layers/recentImagery/thumbnails.js b/src/layers/recentImagery/thumbnails.js index 47c383f13..23fe4dd49 100644 --- a/src/layers/recentImagery/thumbnails.js +++ b/src/layers/recentImagery/thumbnails.js @@ -8,7 +8,8 @@ * card on every render, so a read that counted would evict the visible cards * first). Eviction revokes the image only; what the probe proved is kept. */ -import { thumbnailOrder, wvsSnapshotUrl } from './model.js'; +import { thumbnailOrder } from './model.js'; +import { createRecentImagerySource } from './source.js'; const EMPTY = Object.freeze({ status: 'unknown', @@ -22,6 +23,7 @@ const EMPTY = Object.freeze({ */ export function createThumbnailLoader({ fetchImpl = globalThis.fetch?.bind(globalThis), + requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail, maxInFlight = 3, maxDecoded = 16, createObjectUrl = (blob) => URL.createObjectURL(blob), @@ -90,14 +92,10 @@ export function createThumbnailLoader({ let acquisitionTime = null; let cancelled = false; try { - const url = wvsSnapshotUrl({ - product: entry.candidate.product, - day: entry.candidate.day, - box: entry.box, - width: size, - height: size, + const response = await requestThumbnail(entry.candidate, entry.box, { + size, + signal: controller.signal, }); - const response = await fetchImpl(url, { signal: controller.signal }); if (controller.signal.aborted) { cancelled = true; } else if (response?.ok) { diff --git a/src/layers/recentImagery/thumbnails.test.mjs b/src/layers/recentImagery/thumbnails.test.mjs index 891b2e8dd..ada857f9a 100644 --- a/src/layers/recentImagery/thumbnails.test.mjs +++ b/src/layers/recentImagery/thumbnails.test.mjs @@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl tracked: 1, }); }); + +test('injected thumbnail acquisition owns requests while the loader releases object URLs', async () => { + const calls = []; + const { loader, fetch, revoked } = fixture({ + requestThumbnail: async (...args) => { + calls.push(args); + return response({ headers: { 'Data-Present': 'true' } }); + }, + }); + const entry = candidate('S30', '2026-09-18'); + loader.request(entry, BOX, 0); + await settle(); + assert.equal(fetch.calls.length, 0); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], entry); + assert.deepEqual(calls[0][1], BOX); + assert.ok(calls[0][2].signal instanceof AbortSignal); + assert.equal(loader.get(entry.key).status, 'present'); + loader.destroy(); + assert.deepEqual(revoked, ['blob:1']); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 4e461f331..935351685 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -1,3 +1,5 @@ +import { createDirectionsSource } from '../layers/directions/source.js'; +import { createRecentImagerySource } from '../layers/recentImagery/source.js'; import { createOpenFreeMapSource } from '../sources/openFreeMap.js'; import { createFlightSource, @@ -28,6 +30,8 @@ export function createStandaloneLayerSources(overrides = {}) { const mapTiles = createOpenFreeMapSource(); const defaults = { ...createReferenceSources(), + directions: createDirectionsSource(), + 'recent-imagery': createRecentImagerySource(), flights: createFlightSource(), military: createMilitarySource(), vessels: createVesselSource({ From 893294c7d443aacd79df5dcc268ee744768b33ea Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:49:26 -0700 Subject: [PATCH 06/12] 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 = {}, From b78ff7a915b94308d06dc776765f4290a8e74a64 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:49:58 -0700 Subject: [PATCH 07/12] Use shared availability for an empty Street Level provider list --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 3 ++- src/app/sourceComposition.test.mjs | 17 +++++++++++++++++ src/layers/streetLevel/index.js | 10 ++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b6fccdb..02a869424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0cfa5c1b..dc460fb3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,7 +7,8 @@ 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 +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. diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 9d6823e40..beafa5c51 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -82,3 +82,20 @@ test('standalone defaults allow exact replacements and explicit removal, and rej /Unknown source/, ); }); + +test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { + const actual = catalog({}, t); + const manager = new LayerLifecycle({}); + 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, + ); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index dd4478c58..60a31691e 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -333,6 +333,16 @@ export function createStreetLevelLayer({ requiresKeyId: requiresKeyIdFor(definitionsFrozen), /** Registered providers, in chip order. */ 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) { if (state.initialized) From 7b1b2df76426c2a92a6936f163ef1d24d36b905f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 17:53:47 -0700 Subject: [PATCH 08/12] Preserve custom Street Level provider switches in shared state --- CHANGELOG.md | 3 + docs/CURRENT-STATE.md | 9 +- scripts/package-boundaries.json | 11 ++ src/data/layerState.js | 11 +- src/data/layerState.test.mjs | 3 + src/data/layerStateTokenLedger.test.mjs | 11 ++ src/layers/streetLevel/params.js | 21 ++- src/layers/streetLevel/params.test.mjs | 8 +- src/layers/streetLevel/providerSwitches.js | 45 +++++++ .../streetLevel/providerSwitches.test.mjs | 121 ++++++++++++++++++ src/layers/streetLevel/registry.js | 4 + 11 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 src/layers/streetLevel/providerSwitches.js create mode 100644 src/layers/streetLevel/providerSwitches.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a2620b1..b2a4906ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Custom Street Level provider switches survive share links and stored state + using stable provider IDs; existing Mapillary links keep their meaning. + - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. Explicitly removing either source leaves its layer unavailable; standalone diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27a5815e9..696c94d35 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -24,8 +24,13 @@ all imagery providers. Without that option, Mapillary is registered only when 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. +validation. Layer share identities and the legacy Mapillary `m` option remain +stable. Custom provider switches use the `r` option, sorted by provider ID rather +than registration order, and round-trip through links and stored state. A receiver +applies only switches for its registered providers; absent custom switches leave +current defaults unchanged. The custom switch field is bounded to 256 characters; +provider registration rejects compositions that cannot fit rather than truncating +identities. Existing whole-link size limits still apply. ## God's Eye View in conversations — October 2, 2026 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5b10693ab..72a8217c6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -552,6 +552,7 @@ "src/contextModePolicy.js", "src/data/detectionPolicy.js", "src/data/installationFeedback.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -800,6 +801,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -842,6 +844,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -879,6 +882,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1204,6 +1208,7 @@ "src/layers/streetLevel/groundCast.js", "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", + "src/layers/streetLevel/providerSwitches.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", @@ -1249,6 +1254,7 @@ "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1537,6 +1543,7 @@ "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -2286,6 +2293,7 @@ "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", @@ -2807,6 +2815,7 @@ "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3327,6 +3336,7 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", @@ -3449,6 +3459,7 @@ "exports": ["./view"], "modules": [ "src/view/index.js", + "src/layers/streetLevel/providerSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/layerState.js b/src/data/layerState.js index 92a18179c..fad2f112e 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,3 +1,4 @@ +import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -411,8 +412,16 @@ const OPTION_GROUPS = Object.freeze({ booleanOption('viirs', 'v', false), ]), 'street-level': Object.freeze([ - // One switch per registered imagery provider (src/app/layers/streetLevel.js). + // Preserve the published Mapillary token; custom switches use stable IDs. booleanOption('mapillary', 'm', true), + Object.freeze({ + key: 'providerSwitches', + token: 'r', + defaultValue: '', + normalize: normalizeProviderSwitches, + encode: normalizeProviderSwitches, + decode: normalizeProviderSwitches, + }), enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], { all: 'a', pano: 'p', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1ece6750f..7377bdaa4 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2660,12 +2660,14 @@ test('Street Level: every option round-trips through a share link and stored sta state.enabledLayerIds = ['street-level']; state.options['street-level'] = { mapillary: false, + providerSwitches: '', pano: 'flat', sinceDays: MAX_SINCE_DAYS, }; const decoded = decodeLayerStateParams(new URLSearchParams(encode(state))); assert.deepEqual(decoded.options['street-level'], { mapillary: false, + providerSwitches: '', pano: 'flat', sinceDays: MAX_SINCE_DAYS, }); @@ -2693,6 +2695,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps' test('Street Level: a link without the provider switch keeps Mapillary on', () => { assert.deepEqual(streetLevelOptions(''), { mapillary: true, + providerSwitches: '', pano: 'all', sinceDays: 0, }); diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 95e827416..4b8b23c04 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -98,6 +98,17 @@ function withPublishedBase( } function writeCodecFixture(cwd, rows, entries) { + const providerCodec = path.join( + cwd, + 'src/layers/streetLevel/providerSwitches.js', + ); + mkdirSync(path.dirname(providerCodec), { recursive: true }); + writeFileSync( + providerCodec, + readFileSync( + new URL('../layers/streetLevel/providerSwitches.js', import.meta.url), + ), + ); const source = readFileSync(CODEC_SOURCE_PATH, 'utf8'); const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS'; assert.equal(source.split(registryEnd).length, 2); diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js index 1d87b1ebc..8af631ffc 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -1,13 +1,20 @@ import { normalizeFilter } from './filter.js'; +import { + encodeProviderSwitches, + decodeProviderSwitches, +} from './providerSwitches.js'; /** - * Share-link params: one boolean per provider plus the filter. The codec in - * src/data/layerState.js names the same keys, so a new provider needs one there. + * Provider params plus the filter. Custom switches share a stable ID-based + * field; Mapillary retains its existing boolean and share token. * @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input */ export function encodeParams({ providers, filter }) { const params = {}; - for (const [id, on] of providers) params[id] = on === true; + const entries = [...providers]; + for (const [id, on] of entries) params[id] = on === true; + const switches = encodeProviderSwitches(entries); + if (switches) params.providerSwitches = switches; params.pano = filter.pano; params.sinceDays = filter.sinceDays; return params; @@ -23,8 +30,12 @@ export function encodeParams({ providers, filter }) { export function decodeParams(params, { providerIds, filter }) { const providers = new Map(); const source = params && typeof params === 'object' ? params : {}; - for (const id of providerIds) - if (typeof source[id] === 'boolean') providers.set(id, source[id]); + const switches = decodeProviderSwitches(source.providerSwitches); + for (const id of providerIds) { + if (switches.has(id)) providers.set(id, switches.get(id)); + else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean') + providers.set(id, source[id]); + } const next = {}; if ('pano' in source) next.pano = source.pano; if ('sinceDays' in source) next.sinceDays = source.sinceDays; diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs index 19a89a1ca..856f33d97 100644 --- a/src/layers/streetLevel/params.test.mjs +++ b/src/layers/streetLevel/params.test.mjs @@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => { ], filter: { pano: 'flat', sinceDays: 365 }, }), - { mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 }, + { + mapillary: true, + panoramax: false, + providerSwitches: 'panoramax=0', + pano: 'flat', + sinceDays: 365, + }, ); }); diff --git a/src/layers/streetLevel/providerSwitches.js b/src/layers/streetLevel/providerSwitches.js new file mode 100644 index 000000000..cb1237542 --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.js @@ -0,0 +1,45 @@ +const MAX_SWITCHES_LENGTH = 256; +const ENTRY = /^([a-z][a-z0-9-]*)=([01])$/; + +/** Canonical, bounded provider switches; Mapillary retains its legacy token. */ +export function normalizeProviderSwitches(value) { + if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH) + return null; + if (!value) return ''; + const entries = new Map(); + for (const field of value.split(',')) { + const match = ENTRY.exec(field); + if (!match || match[1] === 'mapillary' || entries.has(match[1])) + return null; + entries.set(match[1], match[2] === '1'); + } + return [...entries] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([id, on]) => `${id}=${on ? '1' : '0'}`) + .join(','); +} + +/** Encode by stable IDs rather than registration order or generated tokens. */ +export function encodeProviderSwitches(providers) { + const value = [...providers] + .filter(([id]) => id !== 'mapillary') + .map(([id, on]) => `${id}=${on === true ? '1' : '0'}`) + .join(','); + const normalized = normalizeProviderSwitches(value); + if (normalized === null) + throw new TypeError('Invalid or oversized Street Level provider switches'); + return normalized; +} + +/** A Map keeps provider IDs separate from object prototype properties. */ +export function decodeProviderSwitches(value) { + const normalized = normalizeProviderSwitches(value); + return new Map( + normalized + ? normalized.split(',').map((entry) => { + const [id, on] = entry.split('='); + return [id, on === '1']; + }) + : [], + ); +} diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs new file mode 100644 index 000000000..96253dfab --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeParams, decodeParams } from './params.js'; +import { + normalizeProviderSwitches, + encodeProviderSwitches, +} from './providerSwitches.js'; +import { + createDefaultLayerState, + encodeLayerStateParams, + decodeLayerStateParams, + serializeStoredLayerState, + parseStoredLayerState, +} from '../../data/layerState.js'; +import { validateProviders } from './registry.js'; +import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs'; + +const filter = { pano: 'flat', sinceDays: 365 }; + +test('custom switches survive sharing and storage without depending on provider order', () => { + const providers = [ + ['panoramax', false], + ['example', true], + ['mapillary', false], + ]; + const options = encodeParams({ providers, filter }); + assert.equal(options.providerSwitches, 'example=1,panoramax=0'); + assert.equal( + encodeParams({ providers: [...providers].reverse(), filter }) + .providerSwitches, + options.providerSwitches, + ); + const state = createDefaultLayerState(); + state.enabledLayerIds = ['street-level']; + state.options['street-level'] = options; + const params = new URLSearchParams({ v: '2' }); + encodeLayerStateParams(params, state); + assert.match(params.get('lo'), /0\.m\.0/); + assert.match(params.get('lo'), /0\.r\.example=1,panoramax=0/); + for (const restored of [ + decodeLayerStateParams(params), + parseStoredLayerState(serializeStoredLayerState(state)), + ]) { + const decoded = decodeParams(restored.options['street-level'], { + providerIds: ['example', 'mapillary', 'panoramax'], + filter: { pano: 'all', sinceDays: 0 }, + }); + assert.deepEqual( + [...decoded.providers], + [ + ['example', true], + ['mapillary', false], + ['panoramax', false], + ], + ); + assert.deepEqual(decoded.filter, filter); + assert.deepEqual( + [ + ...decodeParams(restored.options['street-level'], { + providerIds: ['panoramax'], + filter, + }).providers, + ], + [['panoramax', false]], + ); + assert.equal( + decodeParams(restored.options['street-level'], { + providerIds: [], + filter, + }).providers.size, + 0, + ); + } +}); + +test('legacy links retain Mapillary semantics and leave custom defaults alone', () => { + for (const [lo, on] of [ + ['', true], + ['0.m.0', false], + ]) { + const state = decodeLayerStateParams( + new URLSearchParams({ v: '2', l: '0', lo }), + ); + const restored = decodeParams(state.options['street-level'], { + providerIds: ['mapillary', 'example'], + filter, + }); + assert.deepEqual([...restored.providers], [['mapillary', on]]); + } +}); + +test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => { + for (const value of [ + 'example=0,example=1', + 'mapillary=0', + '__proto__=1', + 'a=2', + 'a=1,', + 'a.b=1', + 'a_b=0', + `${'a'.repeat(255)}=1`, + null, + {}, + ]) { + assert.equal(normalizeProviderSwitches(value), null); + } + assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0'); + assert.throws( + () => encodeProviderSwitches([['a'.repeat(255), true]]), + /oversized/, + ); + assert.throws( + () => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]), + /oversized/, + ); + const decoded = decodeParams( + { providerSwitches: 'mapillary=0', mapillary: true }, + { providerIds: ['mapillary'], filter }, + ); + assert.equal(decoded.providers.get('mapillary'), true); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index b6b81fe35..aefb14ae1 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,3 +1,5 @@ +import { encodeProviderSwitches } from './providerSwitches.js'; + /** * The contract an imagery provider registers with the Street Level layer. * @@ -125,6 +127,8 @@ export function validateProviders(providers) { ids.add(provider.id); prefixes.push(provider.pickPrefix); } + // Validate the complete bounded share field once, before any instance exists. + encodeProviderSwitches(providers.map(({ id }) => [id, true])); return Object.freeze([...providers]); } From 161a3a796ae3fabac73b83afcdcb9766c8684493 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 18:43:44 -0700 Subject: [PATCH 09/12] Keep source availability in the catalog and cover absent military acquisition --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 4 +- src/app/catalog.test.mjs | 17 +++++++- src/app/constructCatalog.js | 4 +- src/app/data.js | 1 + src/app/sourceComposition.js | 25 +++++++----- src/app/sourceComposition.test.mjs | 63 ++++++++++++++++++++++++++++-- src/data/lifecycle.js | 19 ++++++--- 8 files changed, 115 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a869424..9477ea2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc460fb3d..4d02c2224 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3,7 +3,9 @@ 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 +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 diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index fc51c7fcd..ff6d73db5 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -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/); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 74cbcf47f..e78a9b249 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -173,9 +173,11 @@ export function createApplicationCatalog({ ], metadata, ); - sourceComposition.bind(catalog.layers); return Object.freeze({ ...catalog, + getSourceAvailability: sourceComposition.createAvailabilityLookup( + catalog.layers, + ), militaryRegistry, surface, weatherClock, diff --git a/src/app/data.js b/src/app/data.js index 85a3183fc..ce3ad0731 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -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(); diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 77b3ebe10..b813a83bc 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -80,22 +80,29 @@ export function resolveCatalogSources(supplied = {}) { return { sources, isConfigured: (name) => !missing.has(name), - bind(layers) { + 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 = layers.find((entry) => entry.id === id); + const layer = byId.get(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, - }); + 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?.(); }, }; } diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index beafa5c51..44a7c8fa0 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -30,7 +30,7 @@ test('every acquisition source can be omitted without losing catalog membership' 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); + assert.equal(actual.getSourceAvailability(id).available, false, id); } } }); @@ -38,7 +38,10 @@ test('every acquisition source can be omitted without losing catalog membership' 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({}); + 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) { @@ -47,7 +50,7 @@ test('all acquisition sources may be absent, and enable fails before initializin 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/); + assert.match(row.stats.error, /data source not configured/); } } }); @@ -85,7 +88,10 @@ test('standalone defaults allow exact replacements and explicit removal, and rej test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); - const manager = new LayerLifecycle({}); + 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); @@ -99,3 +105,52 @@ test('empty Street Level uses the shared unavailable lifecycle without initializ 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); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 6c40b82be..0df5e87a4 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) { * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { - constructor(viewer, { allowQaRegistration = false } = {}) { + constructor( + viewer, + { allowQaRegistration = false, getSourceAvailability } = {}, + ) { + this._catalogSourceAvailability = getSourceAvailability; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -228,7 +232,7 @@ export class LayerLifecycle { _normalizedStats(entry) { const moduleStats = this._moduleStats(entry); - const availability = entry.module.getSourceAvailability?.(); + const availability = this._sourceAvailability(entry.module.id); const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; @@ -2290,10 +2294,15 @@ export class LayerLifecycle { } } + _sourceAvailability(layerId) { + return ( + this._catalogSourceAvailability?.(layerId) ?? + this.layers.get(layerId)?.module.getSourceAvailability?.() + ); + } + async _visibilityBlockReason(change) { - const availability = this.layers - .get(change.layerId) - ?.module.getSourceAvailability?.(); + const availability = this._sourceAvailability(change.layerId); if (change.enabled && availability?.available === false) return availability.reason || 'Data source unavailable'; for (const callback of this._visibilityGuards) { From 3ba8c6d61b74a36519fc7a563bf30b93c293b70e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 18:47:04 -0700 Subject: [PATCH 10/12] Use a neutral compact switch codec and clarify provider size limits --- CHANGELOG.md | 3 +- docs/CURRENT-STATE.md | 6 +- scripts/package-boundaries.json | 22 ++++---- src/data/idSwitches.js | 47 ++++++++++++++++ src/data/layerState.js | 8 +-- src/data/layerStateTokenLedger.test.mjs | 9 +-- src/layers/streetLevel/params.js | 12 ++-- src/layers/streetLevel/params.test.mjs | 2 +- src/layers/streetLevel/providerSwitches.js | 45 --------------- .../streetLevel/providerSwitches.test.mjs | 56 ++++++++++++------- src/layers/streetLevel/registry.js | 15 ++++- 11 files changed, 127 insertions(+), 98 deletions(-) create mode 100644 src/data/idSwitches.js delete mode 100644 src/layers/streetLevel/providerSwitches.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f0e172707..0bebc8806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog - Custom Street Level provider switches survive share links and stored state - using stable provider IDs; existing Mapillary links keep their meaning. + using URL-safe stable provider IDs and a shared boolean-switch codec; existing + Mapillary links keep their meaning. - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a3aeeee03..f2ad3225e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -27,12 +27,14 @@ 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 and the legacy Mapillary `m` option remain -stable. Custom provider switches use the `r` option, sorted by provider ID rather +stable. Custom provider switches use the `r` option (`example-1*panoramax-0`), +with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather than registration order, and round-trip through links and stored state. A receiver applies only switches for its registered providers; absent custom switches leave current defaults unchanged. The custom switch field is bounded to 256 characters; provider registration rejects compositions that cannot fit rather than truncating -identities. Existing whole-link size limits still apply. +identities. A malformed or duplicate entry invalidates the complete custom-switch +field. Existing whole-link size limits still apply. ## God's Eye View in conversations — October 2, 2026 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 72a8217c6..497be5b08 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -552,7 +552,7 @@ "src/contextModePolicy.js", "src/data/detectionPolicy.js", "src/data/installationFeedback.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -801,7 +801,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -844,7 +844,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -882,7 +882,7 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1208,7 +1208,7 @@ "src/layers/streetLevel/groundCast.js", "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", @@ -1254,7 +1254,7 @@ "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1543,7 +1543,7 @@ "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -2293,7 +2293,7 @@ "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", @@ -2815,7 +2815,7 @@ "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3336,7 +3336,7 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", @@ -3459,7 +3459,7 @@ "exports": ["./view"], "modules": [ "src/view/index.js", - "src/layers/streetLevel/providerSwitches.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/idSwitches.js b/src/data/idSwitches.js new file mode 100644 index 000000000..7b75b5da4 --- /dev/null +++ b/src/data/idSwitches.js @@ -0,0 +1,47 @@ +const MAX_SWITCHES_LENGTH = 256; +const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/; + +/** Canonical, bounded boolean switches keyed by stable slugs. */ +export function normalizeIdSwitches(value, reservedIds = []) { + if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH) + return null; + if (!value) return ''; + const entries = new Map(); + for (const field of value.split('*')) { + const match = ENTRY.exec(field); + if (!match || reservedIds.includes(match[1]) || entries.has(match[1])) + return null; + entries.set(match[1], match[2] === '1'); + } + return [...entries] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([id, on]) => `${id}-${on ? '1' : '0'}`) + .join('*'); +} + +/** Encode by stable IDs rather than registration order or generated tokens. */ +export function encodeIdSwitches(switches) { + const value = [...switches] + .map(([id, on]) => `${id}-${on === true ? '1' : '0'}`) + .join('*'); + if (value.length > MAX_SWITCHES_LENGTH) + throw new RangeError( + `Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`, + ); + const normalized = normalizeIdSwitches(value); + if (normalized === null) throw new TypeError('Invalid boolean switch IDs'); + return normalized; +} + +/** A Map keeps switch IDs separate from object prototype properties. */ +export function decodeIdSwitches(value) { + const normalized = normalizeIdSwitches(value); + return new Map( + normalized + ? normalized.split('*').map((entry) => { + const [, id, on] = ENTRY.exec(entry); + return [id, on === '1']; + }) + : [], + ); +} diff --git a/src/data/layerState.js b/src/data/layerState.js index fad2f112e..50b61c66c 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,4 +1,4 @@ -import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js'; +import { normalizeIdSwitches } from './idSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -418,9 +418,9 @@ const OPTION_GROUPS = Object.freeze({ key: 'providerSwitches', token: 'r', defaultValue: '', - normalize: normalizeProviderSwitches, - encode: normalizeProviderSwitches, - decode: normalizeProviderSwitches, + normalize: (value) => normalizeIdSwitches(value, ['mapillary']), + encode: normalizeIdSwitches, + decode: (value) => normalizeIdSwitches(value, ['mapillary']), }), enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], { all: 'a', diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 4b8b23c04..5a02d85cf 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -98,16 +98,11 @@ function withPublishedBase( } function writeCodecFixture(cwd, rows, entries) { - const providerCodec = path.join( - cwd, - 'src/layers/streetLevel/providerSwitches.js', - ); + const providerCodec = path.join(cwd, 'src/data/idSwitches.js'); mkdirSync(path.dirname(providerCodec), { recursive: true }); writeFileSync( providerCodec, - readFileSync( - new URL('../layers/streetLevel/providerSwitches.js', import.meta.url), - ), + readFileSync(new URL('./idSwitches.js', import.meta.url)), ); const source = readFileSync(CODEC_SOURCE_PATH, 'utf8'); const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS'; diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js index 8af631ffc..4aed3ad8f 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -1,8 +1,5 @@ import { normalizeFilter } from './filter.js'; -import { - encodeProviderSwitches, - decodeProviderSwitches, -} from './providerSwitches.js'; +import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js'; /** * Provider params plus the filter. Custom switches share a stable ID-based @@ -13,7 +10,9 @@ export function encodeParams({ providers, filter }) { const params = {}; const entries = [...providers]; for (const [id, on] of entries) params[id] = on === true; - const switches = encodeProviderSwitches(entries); + const switches = encodeIdSwitches( + entries.filter(([id]) => id !== 'mapillary'), + ); if (switches) params.providerSwitches = switches; params.pano = filter.pano; params.sinceDays = filter.sinceDays; @@ -30,7 +29,8 @@ export function encodeParams({ providers, filter }) { export function decodeParams(params, { providerIds, filter }) { const providers = new Map(); const source = params && typeof params === 'object' ? params : {}; - const switches = decodeProviderSwitches(source.providerSwitches); + const switches = decodeIdSwitches(source.providerSwitches); + switches.delete('mapillary'); for (const id of providerIds) { if (switches.has(id)) providers.set(id, switches.get(id)); else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean') diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs index 856f33d97..ffe56272f 100644 --- a/src/layers/streetLevel/params.test.mjs +++ b/src/layers/streetLevel/params.test.mjs @@ -14,7 +14,7 @@ test('encodeParams writes one boolean per provider plus the filter', () => { { mapillary: true, panoramax: false, - providerSwitches: 'panoramax=0', + providerSwitches: 'panoramax-0', pano: 'flat', sinceDays: 365, }, diff --git a/src/layers/streetLevel/providerSwitches.js b/src/layers/streetLevel/providerSwitches.js deleted file mode 100644 index cb1237542..000000000 --- a/src/layers/streetLevel/providerSwitches.js +++ /dev/null @@ -1,45 +0,0 @@ -const MAX_SWITCHES_LENGTH = 256; -const ENTRY = /^([a-z][a-z0-9-]*)=([01])$/; - -/** Canonical, bounded provider switches; Mapillary retains its legacy token. */ -export function normalizeProviderSwitches(value) { - if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH) - return null; - if (!value) return ''; - const entries = new Map(); - for (const field of value.split(',')) { - const match = ENTRY.exec(field); - if (!match || match[1] === 'mapillary' || entries.has(match[1])) - return null; - entries.set(match[1], match[2] === '1'); - } - return [...entries] - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([id, on]) => `${id}=${on ? '1' : '0'}`) - .join(','); -} - -/** Encode by stable IDs rather than registration order or generated tokens. */ -export function encodeProviderSwitches(providers) { - const value = [...providers] - .filter(([id]) => id !== 'mapillary') - .map(([id, on]) => `${id}=${on === true ? '1' : '0'}`) - .join(','); - const normalized = normalizeProviderSwitches(value); - if (normalized === null) - throw new TypeError('Invalid or oversized Street Level provider switches'); - return normalized; -} - -/** A Map keeps provider IDs separate from object prototype properties. */ -export function decodeProviderSwitches(value) { - const normalized = normalizeProviderSwitches(value); - return new Map( - normalized - ? normalized.split(',').map((entry) => { - const [id, on] = entry.split('='); - return [id, on === '1']; - }) - : [], - ); -} diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs index 96253dfab..78abe6d3e 100644 --- a/src/layers/streetLevel/providerSwitches.test.mjs +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -2,9 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { encodeParams, decodeParams } from './params.js'; import { - normalizeProviderSwitches, - encodeProviderSwitches, -} from './providerSwitches.js'; + normalizeIdSwitches, + decodeIdSwitches, + encodeIdSwitches, +} from '../../data/idSwitches.js'; import { createDefaultLayerState, encodeLayerStateParams, @@ -24,7 +25,7 @@ test('custom switches survive sharing and storage without depending on provider ['mapillary', false], ]; const options = encodeParams({ providers, filter }); - assert.equal(options.providerSwitches, 'example=1,panoramax=0'); + assert.equal(options.providerSwitches, 'example-1*panoramax-0'); assert.equal( encodeParams({ providers: [...providers].reverse(), filter }) .providerSwitches, @@ -36,7 +37,11 @@ test('custom switches survive sharing and storage without depending on provider const params = new URLSearchParams({ v: '2' }); encodeLayerStateParams(params, state); assert.match(params.get('lo'), /0\.m\.0/); - assert.match(params.get('lo'), /0\.r\.example=1,panoramax=0/); + assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/); + assert.ok( + params.toString().includes('0.r.example-1*panoramax-0'), + 'custom switches need no percent escaping', + ); for (const restored of [ decodeLayerStateParams(params), parseStoredLayerState(serializeStoredLayerState(state)), @@ -91,31 +96,44 @@ test('legacy links retain Mapillary semantics and leave custom defaults alone', test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => { for (const value of [ - 'example=0,example=1', - 'mapillary=0', - '__proto__=1', - 'a=2', - 'a=1,', - 'a.b=1', - 'a_b=0', - `${'a'.repeat(255)}=1`, + 'example-0*example-1', + 'mapillary-0', + '__proto__-1', + 'a-2', + 'a-1*', + 'a.b-1', + 'a_b-0', + `${'a'.repeat(255)}-1`, null, {}, ]) { - assert.equal(normalizeProviderSwitches(value), null); + assert.equal(normalizeIdSwitches(value, ['mapillary']), null); } - assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0'); + assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0'); assert.throws( - () => encodeProviderSwitches([['a'.repeat(255), true]]), - /oversized/, + () => encodeIdSwitches([['a'.repeat(255), true]]), + /share-link/, ); assert.throws( () => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]), - /oversized/, + /share-link/, ); const decoded = decodeParams( - { providerSwitches: 'mapillary=0', mapillary: true }, + { providerSwitches: 'mapillary-0', mapillary: true }, { providerIds: ['mapillary'], filter }, ); assert.equal(decoded.providers.get('mapillary'), true); }); + +test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => { + const switches = [ + ['provider-0', false], + ['provider-1', true], + ['a-b-c', true], + ]; + const encoded = encodeIdSwitches(switches); + assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1'); + const decoded = decodeIdSwitches(encoded); + for (const [id, on] of switches) assert.equal(decoded.get(id), on); + assert.equal(decodeIdSwitches('valid-1*broken').size, 0); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index aefb14ae1..29ec24869 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,4 +1,4 @@ -import { encodeProviderSwitches } from './providerSwitches.js'; +import { encodeIdSwitches } from '../../data/idSwitches.js'; /** * The contract an imagery provider registers with the Street Level layer. @@ -128,7 +128,18 @@ export function validateProviders(providers) { prefixes.push(provider.pickPrefix); } // Validate the complete bounded share field once, before any instance exists. - encodeProviderSwitches(providers.map(({ id }) => [id, true])); + try { + encodeIdSwitches( + providers + .filter(({ id }) => id !== 'mapillary') + .map(({ id }) => [id, true]), + ); + } catch (error) { + if (!(error instanceof RangeError)) throw error; + throw new RangeError( + 'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers', + ); + } return Object.freeze([...providers]); } From eacfc1b06a9a95b57df3f59951960f5233e0b773 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 19:09:30 -0700 Subject: [PATCH 11/12] Persist explicit provider toggles through the layer state coordinator --- CHANGELOG.md | 3 +- docs/CURRENT-STATE.md | 4 +- src/data/idSwitches.js | 16 +++ src/data/layerState.js | 3 +- src/data/layerState.test.mjs | 117 +++++++++++++++--- src/data/layerStateCoordinator.js | 18 ++- .../streetLevel/providerSwitches.test.mjs | 14 +++ 7 files changed, 152 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bebc8806..a93296425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ - Custom Street Level provider switches survive share links and stored state using URL-safe stable provider IDs and a shared boolean-switch codec; existing - Mapillary links keep their meaning. + Mapillary links keep their meaning. Explicit provider toggles reach durable + state without adopting unrelated live switch changes. - Directions and Recent Imagery accept replaceable acquisition sources, including route transport, imagery catalog, thumbnails, tile templates and attribution. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f2ad3225e..dc9a9278b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -31,7 +31,9 @@ stable. Custom provider switches use the `r` option (`example-1*panoramax-0`), with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather than registration order, and round-trip through links and stored state. A receiver applies only switches for its registered providers; absent custom switches leave -current defaults unchanged. The custom switch field is bounded to 256 characters; +current defaults unchanged. Explicit provider toggles persist only their requested +switch IDs through the state coordinator; unrelated live values remain transient. +The custom switch field is bounded to 256 characters; provider registration rejects compositions that cannot fit rather than truncating identities. A malformed or duplicate entry invalidates the complete custom-switch field. Existing whole-link size limits still apply. diff --git a/src/data/idSwitches.js b/src/data/idSwitches.js index 7b75b5da4..0fe8f8de0 100644 --- a/src/data/idSwitches.js +++ b/src/data/idSwitches.js @@ -45,3 +45,19 @@ export function decodeIdSwitches(value) { : [], ); } + +/** Persist only explicitly requested switch IDs, not unrelated live changes. */ +export function patchRequestedIdSwitches(previous, applied, requested) { + const current = decodeIdSwitches(applied); + const next = new Map( + [...decodeIdSwitches(previous)].filter(([id]) => current.has(id)), + ); + let changed = false; + for (const [id, on] of current) { + if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') { + next.set(id, on); + changed = true; + } + } + return changed ? encodeIdSwitches(next) : undefined; +} diff --git a/src/data/layerState.js b/src/data/layerState.js index 50b61c66c..373eac20d 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,4 +1,4 @@ -import { normalizeIdSwitches } from './idSwitches.js'; +import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -416,6 +416,7 @@ const OPTION_GROUPS = Object.freeze({ booleanOption('mapillary', 'm', true), Object.freeze({ key: 'providerSwitches', + patchRequested: patchRequestedIdSwitches, token: 'r', defaultValue: '', normalize: (value) => normalizeIdSwitches(value, ['mapillary']), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7377bdaa4..08120a604 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -1,3 +1,4 @@ +import { encodeParams, decodeParams } from '../layers/streetLevel/params.js'; import { readShellSource } from '../testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../../build/application-html.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; @@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts' } assert.equal(nextLayerStateToken(), '3'); assert.equal( - nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }), + nextLayerStateToken({ + ...LAYER_STATE_TOKEN_RESERVATIONS, + alpha: '3', + bravo: '4', + }), '5', ); const digitsExhausted = { @@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts' [ ...'0123456789', ...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) => - [...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`), + [...LAYER_STATE_TOKEN_ALPHABET].map( + (second) => `${first}${second}`, + ), ), ].map((token, index) => [`occupied-${index}`, token]), ), @@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts' true, ); assert.equal( - validateLayerStateAllocations( - LAYER_STATE_TOKEN_RESERVATIONS, - { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, - ), + validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { + ...LAYER_STATE_TOKEN_RESERVATIONS, + future: '3', + next: '4', + }), true, ); assert.throws( - () => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { - ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '00', - }), + () => + validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { + ...LAYER_STATE_TOKEN_RESERVATIONS, + future: '00', + }), /next free token 3/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; assert.equal( - validateLayerStateAllocations( - beforeLastDigit, - { ...beforeLastDigit, futurePair: '00', futureDigit: '9' }, - ), + validateLayerStateAllocations(beforeLastDigit, { + ...beforeLastDigit, + futurePair: '00', + futureDigit: '9', + }), true, ); assert.equal( - validateLayerStateAllocations( - digitsExhausted, - { ...digitsExhausted, pairB: '01', pairA: '00' }, - ), + validateLayerStateAllocations(digitsExhausted, { + ...digitsExhausted, + pairB: '01', + pairA: '00', + }), true, ); assert.throws( @@ -2706,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () = true, ); }); + +test('explicit provider switches reach durable state without adopting unrelated live switches', () => { + const manager = new DataLayerManager({}); + const providers = new Map([ + ['mapillary', true], + ['alpha', true], + ['beta', true], + ]); + const filter = { pano: 'all', sinceDays: 0 }; + for (const id of REGISTERED_LAYER_IDS) { + manager.register( + id === 'street-level' + ? { + ...fakeLayer(id), + getParams: () => encodeParams({ providers, filter }), + setParams(params) { + const decoded = decodeParams(params, { + providerIds: providers.keys(), + filter, + }); + for (const [id, on] of decoded.providers) providers.set(id, on); + return true; + }, + } + : fakeLayer(id), + ); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + try { + providers.set('beta', false); // Passive state does not belong to the next action. + assert.equal( + manager.setLayerParams( + 'street-level', + { alpha: false }, + { origin: 'user' }, + ), + true, + ); + let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0'); + const shared = decodeLayerStateParams(new URLSearchParams(encode(saved))); + const restored = decodeParams(shared.options['street-level'], { + providerIds: ['beta', 'alpha'], + filter, + }); + assert.deepEqual([...restored.providers], [['alpha', false]]); + manager.setLayerParams('street-level', { beta: true }, { origin: 'user' }); + saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal( + saved.options['street-level'].providerSwitches, + 'alpha-0*beta-1', + ); + manager.setLayerParams( + 'street-level', + { alpha: true }, + { origin: 'local-restore' }, + ); + saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)); + assert.equal( + saved.options['street-level'].providerSwitches, + 'alpha-0*beta-1', + ); + } finally { + coordinator.destroy(); + } +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 86b6af8e7..687b87dfb 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -255,8 +255,22 @@ export class LayerStateCoordinator { const explicitlyRequested = Object.hasOwn(requestedParams, spec.key); const implicitTrackingSync = !explicitlyRequested && spec.key === trackingOptionKey; - if (!explicitlyRequested && !implicitTrackingSync) continue; - const value = spec.normalize(change.params[spec.key]); + // Packed options can persist only the subkeys owned by this request. + const requestedValue = + explicitlyRequested || implicitTrackingSync + ? change.params[spec.key] + : spec.patchRequested?.( + nextOwnerOptions[spec.key], + change.params[spec.key], + requestedParams, + ); + if ( + !explicitlyRequested && + !implicitTrackingSync && + requestedValue === undefined + ) + continue; + const value = spec.normalize(requestedValue); if (value === null) { if (implicitTrackingSync) { nextOwnerOptions[spec.key] = null; diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs index 78abe6d3e..03a161dcb 100644 --- a/src/layers/streetLevel/providerSwitches.test.mjs +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { encodeParams, decodeParams } from './params.js'; import { normalizeIdSwitches, + patchRequestedIdSwitches, decodeIdSwitches, encodeIdSwitches, } from '../../data/idSwitches.js'; @@ -137,3 +138,16 @@ test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => for (const [id, on] of switches) assert.equal(decoded.get(id), on); assert.equal(decodeIdSwitches('valid-1*broken').size, 0); }); + +test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => { + const previous = `${'x'.repeat(254)}-1`; + assert.equal(normalizeIdSwitches(previous), previous); + assert.equal( + patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }), + 'alpha-0', + ); + assert.equal( + patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }), + undefined, + ); +}); From 27a986f40f10aa2f12f58cea5637d08fdbbc4b1b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 9 Oct 2026 20:50:19 -0700 Subject: [PATCH 12/12] Accept authoritative empty vessel coverage without an outage --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 6 ++++ src/layers/vessels/ingestion.js | 2 +- src/layers/vessels/ownership.test.mjs | 44 +++++++++++++++++++++++++++ src/layers/vessels/queries.js | 16 ++++++++-- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a93296425..68ab1c52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Treat explicitly current, complete empty vessel coverage as a successful update, + clearing obsolete contacts while retaining stale or incomplete snapshots. + - Custom Street Level provider switches survive share links and stored state using URL-safe stable provider IDs and a shared boolean-switch codec; existing Mapillary links keep their meaning. Explicit provider toggles reach durable diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dc9a9278b..274294b5d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Vessel sources can report healthy empty coverage using a current, complete +snapshot with zero raw rows, a healthy transport and a positive last-message +time. This clears obsolete contacts and settles startup without manufacturing +position timestamps. Stale, incomplete and malformed empty results retain the +existing recovery behavior. + 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 diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index fb7597a31..408e3bec2 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -109,7 +109,7 @@ export function createIngestion({ feed.acceptedRowCount = snapshot.acceptedRowCount; feed.partial = payload?.complete === false; - if (snapshot.acceptedRowCount === 0) { + if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) { feed.count = readCount(); feed.stale = feed.count > 0 || Boolean(payload?.refreshing); if (isDefinitiveTransportFailure(snapshot.transportStatus)) { diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index ce55aaf0f..28455a293 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u layer.destroy(); assert.equal(layer.getStats().partial, false); }); + +test('authoritative empty coverage clears old contacts without declaring an outage', async () => { + let current = snapshot([observation('111')]); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([], { observedAtMs: null, lastMessageAt: 500 }); + await layer.update(); + assert.ok(!layer.hasContact('111')); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().stale, false); + assert.equal(layer.getStats().lastUpdate, null); +}); + +test('empty coverage cannot hide stale, incomplete or rejected source data', async () => { + for (const patch of [ + { freshness: 'unknown' }, + { freshness: 'stale' }, + { complete: false }, + { rawRowCount: 1 }, + { transportStatus: 'stale' }, + { lastMessageAt: null }, + ]) { + let current = snapshot([observation('111')]); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); + await layer.update(); + current = snapshot([], { + observedAtMs: null, + lastMessageAt: 500, + ...patch, + }); + await layer.update(); + assert.equal(layer.hasContact('111'), true, JSON.stringify(patch)); + assert.ok(layer.getStats().error, JSON.stringify(patch)); + } +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 520af4b0b..7b06ee23b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -119,7 +119,16 @@ export function createQueries({ typeof payload?.status === 'string' ? payload.status : null; const lastMessageAt = payload?.lastMessageAt ?? null; const acceptedRowCount = acceptedRows.length; + const emptyCoverage = + rawRows.length === 0 && + payload?.rawRowCount === 0 && + payload?.complete === true && + payload?.freshness === 'current' && + AIS_HEALTHY_STATUSES.has(transportStatus) && + Number.isFinite(Number(lastMessageAt)) && + Number(lastMessageAt) > 0; return { + emptyCoverage, transportStatus, lastMessageAt, rawRows, @@ -130,9 +139,10 @@ export function createQueries({ ? payload.rawRowCount : rawRows.length, acceptedRowCount, - error: - deriveAisFeedError(payload, acceptedRowCount) || - (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null), + error: emptyCoverage + ? null + : deriveAisFeedError(payload, acceptedRowCount) || + (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null), }; }