diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f343b4d2..b24ac95ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 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 + 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. + Explicitly removing either source leaves its layer unavailable; standalone + defaults preserve the existing providers. + +- Catalog source availability no longer modifies layer objects; unavailable + tooltips use layer display names. + +- Street Level with no providers uses the shared unavailable state and refuses + activation before initialization, while keeping its layer identity visible. + +- Application source configuration accepts replacements and explicit removal. + Missing catalog feeds leave their layers unavailable without blocking startup + or making fallback requests; malformed supplied feeds remain configuration errors. + +- Street Level providers are optional in application composition. Omitting a + 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 @@ -68,6 +95,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). + - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from the auth button and reports completion, failure, timeout, or expired diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eea437dd6..26d56e66b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,49 @@ # 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 +visibility lifecycle before initialization. Availability is held in an explicit +catalog lookup supplied to the lifecycle, without modifying layer instances; +unavailable reasons use the layer display name. Invalid supplied methods still fail +construction. `createStandaloneApplication` and `createStandaloneCatalog` accept +`sources` overrides; unspecified entries retain +standalone defaults, and null entries explicitly remove a source. Street Level +continues to accept an explicit provider list. An empty Street Level provider +list uses the same unavailable lifecycle and disabled layer toggle. Directions (`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 +all imagery providers. Without that option, Mapillary is registered only when +`sources.mapillary` is supplied. An absent source leaves Street Level registered +with a truthful no-provider state, preserving layer controls and share tokens. +Malformed selected sources and provider definitions still fail validation. +An explicit provider list bypasses the unused Mapillary source, including its +validation. Layer share identities and the legacy Mapillary `m` option remain +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. 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. + ## 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, @@ -1140,6 +1184,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/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 23f9f52ed..497be5b08 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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -934,6 +938,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", @@ -1203,6 +1208,7 @@ "src/layers/streetLevel/groundCast.js", "src/layers/streetLevel/meshSampler.js", "src/layers/streetLevel/pickRouter.js", + "src/data/idSwitches.js", "src/layers/streetLevel/params.js", "src/layers/streetLevel/registry.js", "src/layers/streetLevel/filter.js", @@ -1248,6 +1254,7 @@ "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1434,6 +1441,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", @@ -1535,6 +1543,7 @@ "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1993,6 +2002,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", @@ -2127,6 +2137,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", @@ -2222,6 +2233,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", @@ -2281,6 +2293,7 @@ "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", @@ -2571,6 +2584,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", @@ -2659,6 +2673,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", @@ -2800,6 +2815,7 @@ "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -2913,9 +2929,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": { @@ -3302,6 +3336,7 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", @@ -3424,6 +3459,7 @@ "exports": ["./view"], "modules": [ "src/view/index.js", + "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], 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/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 0a8ef4fe8..80315fe85 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,9 +1,9 @@ +import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; -import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createApplicationFlights } from './layers/flights.js'; @@ -32,34 +32,6 @@ import { localGeoJsonServices } from './localGeojsonServices.js'; import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js'; import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js'; -const SOURCE_METHODS = Object.freeze({ - flights: ['getSnapshot'], - military: ['getSnapshot'], - vessels: ['getSnapshot'], - cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'], - radio: ['getDirectory', 'recordClick'], - traffic: [ - 'requestRoads', - 'getStatus', - 'fetchFlowForBounds', - 'getFlowSessionStats', - 'resetFlowTileCache', - ], - bikeshare: ['getStations'], - installations: ['getMappedSites', 'searchNearby'], - satellites: ['readGroup'], - launches: ['getLaunches', 'getActiveTle'], - alpr: ['fetch'], - firms: ['getSnapshot'], - wind: ['getSnapshot'], - weather: ['getSnapshot'], - cyclones: ['getSnapshot'], - earthquakes: ['getSnapshot'], - 'fire-perimeters': ['getSnapshot'], - cables: ['fetch'], - mapillary: MAPILLARY_SOURCE_METHODS, -}); - /** * Hardware-local layers are registered like any other but never enter share * links or stored layer state: another browser cannot have this receiver. @@ -86,6 +58,7 @@ export function createApplicationCatalog({ vesselOptions, resolveAsset, nepalBoundaryResolver, + streetLevelProviders, }) { if (!signal?.addEventListener) throw new TypeError('An application lifetime signal is required'); @@ -93,12 +66,8 @@ export function createApplicationCatalog({ if (!surface?.groundFloor || !surface?.terrain) throw new TypeError('Application surface services are required'); - for (const [name, methods] of Object.entries(SOURCE_METHODS)) { - if ( - methods.some((method) => typeof sources?.[name]?.[method] !== 'function') - ) - throw new TypeError(`Invalid catalog source: ${name}`); - } + const sourceComposition = resolveCatalogSources(sources); + sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); const weatherClock = createWeatherClock(); const dispose = () => { @@ -108,7 +77,8 @@ export function createApplicationCatalog({ }; signal.addEventListener('abort', dispose, { once: true }); try { - militaryRegistry.configureSource(sources.military, { signal }); + if (sourceComposition.isConfigured('military')) + militaryRegistry.configureSource(sources.military, { signal }); const flights = createApplicationFlights({ surface, source: sources.flights, @@ -154,6 +124,7 @@ export function createApplicationCatalog({ createApplicationStreetLevel({ surface, sources: { mapillary: sources.mapillary }, + providers: streetLevelProviders, }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), @@ -162,8 +133,8 @@ export function createApplicationCatalog({ createApplicationRadio({ surface, source: sources.radio }), createApplicationTransit({ surface, source: sources.transit }), createApplicationBikeshare({ source: sources.bikeshare }), - createApplicationDirections(), - createApplicationRecentImagery(), + createApplicationDirections({ source: sources.directions }), + createApplicationRecentImagery({ source: sources['recent-imagery'] }), vessels, installations, createApplicationAwareness({ @@ -204,6 +175,9 @@ export function createApplicationCatalog({ ); return Object.freeze({ ...catalog, + getSourceAvailability: sourceComposition.createAvailabilityLookup( + catalog.layers, + ), militaryRegistry, surface, weatherClock, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 3a802edbe..f42e82c7c 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(); @@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can assert.throws( () => createApplicationCatalog({ - sources: {}, + sources: { flights: {} }, signal: lifetime.signal, surface: fixtureSurface(lifetime.signal), }), @@ -140,3 +141,53 @@ function fixtureSurface(signal) { eventTarget: null, }); } + +test('Mapillary can be omitted or replaced without changing catalog membership', (t) => { + const lifetime = new AbortController(); + t.after(() => lifetime.abort()); + const sources = fixtureSources([], []); + delete sources.mapillary; + const options = { + sources, + signal: lifetime.signal, + surface: fixtureSurface(lifetime.signal), + }; + const catalog = createApplicationCatalog(options); + assert.equal(catalog.layers.length, 30); + assert.deepEqual(catalog.get('street-level').providerIds, []); + assert.equal( + catalogControlServices(catalog).streetLevelLayer, + catalog.get('street-level'), + ); + assert.match( + catalog.get('street-level').getUIState().coverage.hint, + /No street-level imagery providers/, + ); + const provider = fakeStreetLevelProvider({ + id: 'example', + pickPrefix: 'example:', + }); + const replacement = createApplicationCatalog({ + ...options, + streetLevelProviders: [provider], + }); + assert.deepEqual(replacement.get('street-level').providerIds, ['example']); + assert.deepEqual(replacement.metadata, catalog.metadata); + assert.equal(provider.calls.activate, 0, 'construction must not acquire'); + assert.deepEqual( + createApplicationCatalog({ + ...options, + sources: fixtureSources([], []), + streetLevelProviders: [], + }).get('street-level').providerIds, + [], + ); + assert.throws( + () => + createApplicationCatalog({ + ...options, + sources: { ...sources, mapillary: {} }, + }), + /Mapillary source/, + ); +}); 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/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/layers/streetLevel.js b/src/app/layers/streetLevel.js index 56ba06590..18a3e7a3b 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -5,10 +5,20 @@ import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; import * as render from '../../renderGovernor.js'; -/** A new provider registers here; its chip, credit and share bit follow. */ -export function createApplicationStreetLevel({ surface, sources }) { +/** + * Inject providers, or use Mapillary only when its source is supplied. + * An explicit provider list replaces the default; unused sources.mapillary is + * neither constructed nor validated, including when the list is empty. + */ +export function createApplicationStreetLevel({ + surface, + sources = {}, + providers = sources.mapillary == null + ? [] + : [createMapillaryProvider({ source: sources.mapillary })], +}) { return createStreetLevelLayer({ - providers: [createMapillaryProvider({ source: sources.mapillary })], + providers, services: { sprites, picking, 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/app/sourceComposition.js b/src/app/sourceComposition.js new file mode 100644 index 000000000..14c864ac5 --- /dev/null +++ b/src/app/sourceComposition.js @@ -0,0 +1,113 @@ +const contract = (layers, methods) => + Object.freeze({ + layers: Object.freeze(layers), + methods: Object.freeze(methods), + }); + +/** Acquisition contracts and their consumers; records remain family-specific. */ +export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ + directions: contract(['directions'], ['getRoute']), + 'recent-imagery': contract( + ['recent-imagery'], + ['searchHls', 'getThumbnail', 'getTileTemplate'], + ), + flights: contract(['flights'], ['getSnapshot']), + military: contract(['military'], ['getSnapshot']), + vessels: contract(['ais-live-vessels'], ['getSnapshot']), + cctv: contract( + ['cctv'], + ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'], + ), + radio: contract(['radio'], ['getDirectory', 'recordClick']), + traffic: contract( + ['traffic'], + [ + 'requestRoads', + 'getStatus', + 'fetchFlowForBounds', + 'getFlowSessionStats', + 'resetFlowTileCache', + ], + ), + bikeshare: contract(['bikeshare'], ['getStations']), + installations: contract( + ['military-installations'], + ['getMappedSites', 'searchNearby'], + ), + satellites: contract(['satellites'], ['readGroup']), + launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']), + alpr: contract(['alpr-cameras'], ['fetch']), + firms: contract(['local-firms'], ['getSnapshot']), + wind: contract(['wind'], ['getSnapshot']), + weather: contract( + ['weather-radar', 'weather-satellite', 'weather-lightning'], + ['getSnapshot'], + ), + cyclones: contract(['weather-cyclones'], ['getSnapshot']), + earthquakes: contract(['earthquakes'], ['getSnapshot']), + 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + cables: contract(['telegeography-submarine-cables'], ['fetch']), + transit: contract(['transit'], ['requestSnapshot', 'getHistory']), +}); + +/** An omitted provider is unavailable, never an authoritative empty dataset. */ +export class SourceUnavailableError extends Error { + constructor(name) { + super(`Data source not configured: ${name}`); + this.name = 'SourceUnavailableError'; + this.code = 'unavailable'; + this.unavailable = true; + this.retryable = false; + } +} + +/** Validate supplied contracts and keep omitted sources inert during construction. */ +export function resolveCatalogSources(supplied = {}) { + if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied)) + throw new TypeError('Catalog sources must be an object'); + const sources = { ...supplied }; + const missing = new Set(); + for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) { + const source = sources[name]; + if (source == null) { + missing.add(name); + const unavailable = () => { + throw new SourceUnavailableError(name); + }; + sources[name] = Object.freeze({ + label: name, + ...Object.fromEntries(methods.map((method) => [method, unavailable])), + }); + } else if (methods.some((method) => typeof source[method] !== 'function')) { + throw new TypeError(`Invalid catalog source: ${name}`); + } + } + return { + sources, + isConfigured: (name) => !missing.has(name), + createAvailabilityLookup(layers) { + const byId = new Map(layers.map((layer) => [layer.id, layer])); + const availability = new Map(); + for (const [name, { layers: ids }] of Object.entries( + CATALOG_SOURCE_CONTRACTS, + )) { + for (const id of ids) { + const layer = byId.get(id); + if (!layer) throw new Error(`Missing source consumer: ${id}`); + availability.set( + id, + Object.freeze({ + available: !missing.has(name), + source: name, + reason: missing.has(name) + ? `${layer.name || id}: data source not configured` + : null, + }), + ); + } + } + return (id) => + availability.get(id) ?? byId.get(id)?.getSourceAvailability?.(); + }, + }; +} diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs new file mode 100644 index 000000000..44a7c8fa0 --- /dev/null +++ b/src/app/sourceComposition.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveCatalogSources, + CATALOG_SOURCE_CONTRACTS, +} from './sourceComposition.js'; +import { createApplicationCatalog } from './constructCatalog.js'; +import { createSurfaceServices } from './surfaceServices.js'; +import { createStandaloneLayerSources } from '../standalone/layerSources.js'; +import { LayerLifecycle } from '../data/lifecycle.js'; + +function catalog(sources, t) { + const lifetime = new AbortController(); + t.after(() => lifetime.abort()); + return createApplicationCatalog({ + sources, + signal: lifetime.signal, + surface: createSurfaceServices({ + terrainSource: { getHeights: async () => [] }, + signal: lifetime.signal, + eventTarget: null, + }), + }); +} + +test('every acquisition source can be omitted without losing catalog membership', (t) => { + const baseline = catalog(createStandaloneLayerSources(), t); + for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) { + const sources = createStandaloneLayerSources({ [name]: null }); + const actual = catalog(sources, t); + assert.deepEqual(actual.metadata, baseline.metadata, name); + for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) { + assert.equal(actual.getSourceAvailability(id).available, false, id); + } + } +}); + +test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { + const actual = catalog({}, t); + assert.equal(actual.layers.length, 30); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); + for (const layer of actual.layers) manager.register(layer); + for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) { + for (const id of ids) { + assert.equal(await manager.setEnabled(id, true), false, id); + assert.equal(manager.layers.get(id).initialized, false, id); + const row = manager.getAll().find((entry) => entry.id === id); + assert.equal(row.enabled, false); + assert.equal(row.stats.status, 'unavailable'); + assert.match(row.stats.error, /data source not configured/); + } + } +}); + +test('configured sources retain identity; invalid implementations never become unavailable silently', () => { + const source = { getSnapshot: async () => [] }; + assert.equal( + resolveCatalogSources({ earthquakes: source }).sources.earthquakes, + source, + ); + for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) { + assert.throws( + () => resolveCatalogSources({ [name]: {} }), + /Invalid catalog source/, + ); + } + const missing = resolveCatalogSources({}).sources.earthquakes; + assert.throws(() => missing.getSnapshot(), { code: 'unavailable' }); +}); + +test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => { + const source = { getSnapshot: async () => [] }; + const sources = createStandaloneLayerSources({ + flights: source, + military: null, + }); + assert.equal(sources.flights, source); + assert.equal(sources.military, null); + assert.equal(typeof sources.cctv.getCatalog, 'function'); + assert.throws( + () => createStandaloneLayerSources({ fligts: source }), + /Unknown source/, + ); +}); + +test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { + const actual = catalog({}, t); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); + manager.register(actual.get('street-level')); + assert.equal(await manager.setEnabled('street-level', true), false); + assert.equal(manager.layers.get('street-level').initialized, false); + const row = manager.getAll()[0]; + assert.equal(row.stats.status, 'unavailable'); + assert.equal(row.stats.error, 'No street-level imagery providers configured'); + assert.equal(await manager.setEnabled('street-level', false), true); + const configured = catalog(createStandaloneLayerSources(), t); + assert.equal( + configured.get('street-level').getSourceAvailability().available, + true, + ); +}); + +test('availability lookup accepts frozen layers and uses their display names', () => { + const composition = resolveCatalogSources({}); + const layers = Object.values(CATALOG_SOURCE_CONTRACTS) + .flatMap(({ layers }) => layers) + .map((id) => + Object.freeze({ + id, + name: id === 'fire-perimeters' ? 'Fire perimeters' : id, + }), + ); + const lookup = composition.createAvailabilityLookup(layers); + assert.equal( + lookup('fire-perimeters').reason, + 'Fire perimeters: data source not configured', + ); + assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false); + const manager = new LayerLifecycle({}, { getSourceAvailability: lookup }); + manager.register(layers.find(({ id }) => id === 'fire-perimeters')); + assert.equal( + manager.getAll()[0].stats.error, + 'Fire perimeters: data source not configured', + ); +}); + +test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected fallback acquisition'); + }); + const actual = catalog(createStandaloneLayerSources({ military: null }), t); + const registry = actual.militaryRegistry; + assert.equal(actual.getSourceAvailability('flights').available, true); + assert.equal(registry.isMilitaryLayerActive(), false); + assert.equal(registry.isMilitaryIcao('abc123'), false); + registry.registerMilitaryIcaos([' ABC123 ']); + await registry.refreshMilitaryRegistryIfStale(); + assert.equal(registry.isMilitaryIcao('abc123'), true); + assert.equal(registry.isMilitaryIcao('def456'), false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: actual.getSourceAvailability }, + ); + manager.register(actual.get('military')); + assert.equal(await manager.setEnabled('military', true), false); + assert.equal(registry.isMilitaryLayerActive(), false); + assert.equal(fetches, 0); +}); 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/idSwitches.js b/src/data/idSwitches.js new file mode 100644 index 000000000..0fe8f8de0 --- /dev/null +++ b/src/data/idSwitches.js @@ -0,0 +1,63 @@ +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']; + }) + : [], + ); +} + +/** 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 92a18179c..373eac20d 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -1,3 +1,4 @@ +import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js'; import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ @@ -411,8 +412,17 @@ 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', + patchRequested: patchRequestedIdSwitches, + token: 'r', + defaultValue: '', + normalize: (value) => normalizeIdSwitches(value, ['mapillary']), + encode: normalizeIdSwitches, + decode: (value) => normalizeIdSwitches(value, ['mapillary']), + }), 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..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( @@ -2660,12 +2671,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 +2706,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, }); @@ -2703,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/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 95e827416..5a02d85cf 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -98,6 +98,12 @@ function withPublishedBase( } function writeCodecFixture(cwd, rows, entries) { + const providerCodec = path.join(cwd, 'src/data/idSwitches.js'); + mkdirSync(path.dirname(providerCodec), { recursive: true }); + writeFileSync( + providerCodec, + readFileSync(new URL('./idSwitches.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/data/lifecycle.js b/src/data/lifecycle.js index 6c3659b9e..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,6 +232,7 @@ export class LayerLifecycle { _normalizedStats(entry) { const moduleStats = this._moduleStats(entry); + const availability = this._sourceAvailability(entry.module.id); const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; @@ -235,6 +240,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, @@ -2282,7 +2294,17 @@ export class LayerLifecycle { } } + _sourceAvailability(layerId) { + return ( + this._catalogSourceAvailability?.(layerId) ?? + this.layers.get(layerId)?.module.getSourceAvailability?.() + ); + } + async _visibilityBlockReason(change) { + const availability = this._sourceAvailability(change.layerId); + if (change.enabled && availability?.available === false) + return availability.reason || 'Data source unavailable'; for (const callback of this._visibilityGuards) { try { const result = await callback(change); 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/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) 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/params.js b/src/layers/streetLevel/params.js index 1d87b1ebc..4aed3ad8f 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -1,13 +1,19 @@ import { normalizeFilter } from './filter.js'; +import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.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 = encodeIdSwitches( + entries.filter(([id]) => id !== 'mapillary'), + ); + if (switches) params.providerSwitches = switches; params.pano = filter.pano; params.sinceDays = filter.sinceDays; return params; @@ -23,8 +29,13 @@ 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 = 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') + 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..ffe56272f 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.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs new file mode 100644 index 000000000..03a161dcb --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeParams, decodeParams } from './params.js'; +import { + normalizeIdSwitches, + patchRequestedIdSwitches, + decodeIdSwitches, + encodeIdSwitches, +} from '../../data/idSwitches.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/); + 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)), + ]) { + 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(normalizeIdSwitches(value, ['mapillary']), null); + } + assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0'); + assert.throws( + () => encodeIdSwitches([['a'.repeat(255), true]]), + /share-link/, + ); + assert.throws( + () => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]), + /share-link/, + ); + const decoded = decodeParams( + { 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); +}); + +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, + ); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 01d008ab4..29ec24869 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,3 +1,5 @@ +import { encodeIdSwitches } from '../../data/idSwitches.js'; + /** * The contract an imagery provider registers with the Street Level layer. * @@ -86,8 +88,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) { @@ -125,6 +127,19 @@ export function validateProviders(providers) { ids.add(provider.id); prefixes.push(provider.pickPrefix); } + // Validate the complete bounded share field once, before any instance exists. + 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]); } 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), 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), }; } 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/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..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, @@ -22,10 +24,14 @@ 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(), + directions: createDirectionsSource(), + 'recent-imagery': createRecentImagerySource(), flights: createFlightSource(), military: createMilitarySource(), vessels: createVesselSource({ @@ -51,4 +57,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/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, 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', + ); +}); 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,