mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Preserve narrow behavior and test changes while reconciling unrelated upstream work. Original reported/maintainer credit remains in PR history.
This commit is contained in:
commit
a24b1cde3d
52 files changed
+1369
-147
No files matched your search
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# 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
|
- Vector tile sources take their allowed tile origin from the configured
|
||||||
`tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the
|
`tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the
|
||||||
mirror or fails visibly instead of silently using OpenFreeMap. Thanks to
|
mirror or fails visibly instead of silently using OpenFreeMap. Thanks to
|
||||||
@@ -68,6 +95,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
- 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
|
Settings. API-key voice remains the default. Local sign-in can start from
|
||||||
the auth button and reports completion, failure, timeout, or expired
|
the auth button and reports completion, failure, timeout, or expired
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
# God's Eye View Current State
|
# 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
|
## God's Eye View in conversations — October 2, 2026
|
||||||
|
|
||||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||||
@@ -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
|
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||||
access. Callers retain validation, transport and response policy.
|
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
|
## Build configuration and local provider boundaries
|
||||||
|
|
||||||
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
"./layers/vessels": "./src/layers/vessels/index.js",
|
"./layers/vessels": "./src/layers/vessels/index.js",
|
||||||
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
||||||
"./layers/recent-imagery": "./src/layers/recentImagery/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",
|
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
||||||
"./layers/directions": "./src/layers/directions/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/cctv": "./src/layers/cctv/index.js",
|
||||||
"./layers/alpr": "./src/layers/alpr/index.js",
|
"./layers/alpr": "./src/layers/alpr/index.js",
|
||||||
"./layers/radio": "./src/layers/radio/index.js",
|
"./layers/radio": "./src/layers/radio/index.js",
|
||||||
|
|||||||
@@ -552,6 +552,7 @@
|
|||||||
"src/contextModePolicy.js",
|
"src/contextModePolicy.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -800,6 +801,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -842,6 +844,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -879,6 +882,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -934,6 +938,7 @@
|
|||||||
"exports": ["./layers/recent-imagery"],
|
"exports": ["./layers/recent-imagery"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
@@ -1203,6 +1208,7 @@
|
|||||||
"src/layers/streetLevel/groundCast.js",
|
"src/layers/streetLevel/groundCast.js",
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
"src/layers/streetLevel/meshSampler.js",
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
@@ -1248,6 +1254,7 @@
|
|||||||
"exports": ["./layers/radio"],
|
"exports": ["./layers/radio"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/radioCountry.js",
|
"src/data/radioCountry.js",
|
||||||
@@ -1434,6 +1441,7 @@
|
|||||||
"src/annotations/worldAnnotationRenderer.js",
|
"src/annotations/worldAnnotationRenderer.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
|
"src/app/sourceComposition.js",
|
||||||
"src/app/controls.js",
|
"src/app/controls.js",
|
||||||
"src/app/data.js",
|
"src/app/data.js",
|
||||||
"src/app/errors.js",
|
"src/app/errors.js",
|
||||||
@@ -1535,6 +1543,7 @@
|
|||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -1993,6 +2002,7 @@
|
|||||||
"src/annotations/drawTool.js",
|
"src/annotations/drawTool.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
|
"src/layers/directions/source.js",
|
||||||
"src/data/directions.js",
|
"src/data/directions.js",
|
||||||
"src/app/layers/directions.js",
|
"src/app/layers/directions.js",
|
||||||
"src/search/coordinateGeocoder.js",
|
"src/search/coordinateGeocoder.js",
|
||||||
@@ -2127,6 +2137,7 @@
|
|||||||
"src/ui/localSdrPresentation.js",
|
"src/ui/localSdrPresentation.js",
|
||||||
"src/app/layers/recentImagery.js",
|
"src/app/layers/recentImagery.js",
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
@@ -2222,6 +2233,7 @@
|
|||||||
"src/annotations/resolver.js",
|
"src/annotations/resolver.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
|
"src/app/sourceComposition.js",
|
||||||
"src/layers/wind/index.js",
|
"src/layers/wind/index.js",
|
||||||
"src/layers/wind/model.js",
|
"src/layers/wind/model.js",
|
||||||
"src/layers/wind/fields.js",
|
"src/layers/wind/fields.js",
|
||||||
@@ -2281,6 +2293,7 @@
|
|||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/satellitePass.js",
|
"src/data/satellitePass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/localGeojson.js",
|
"src/data/localGeojson.js",
|
||||||
@@ -2571,6 +2584,7 @@
|
|||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
|
"src/layers/directions/source.js",
|
||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
"src/data/terrainHeights.js",
|
"src/data/terrainHeights.js",
|
||||||
"src/data/groundFloor.js",
|
"src/data/groundFloor.js",
|
||||||
@@ -2659,6 +2673,7 @@
|
|||||||
"src/data/modelEligibility.js",
|
"src/data/modelEligibility.js",
|
||||||
"src/app/layers/recentImagery.js",
|
"src/app/layers/recentImagery.js",
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
@@ -2800,6 +2815,7 @@
|
|||||||
"exports": ["./ui/share-restoration"],
|
"exports": ["./ui/share-restoration"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -2913,9 +2929,27 @@
|
|||||||
],
|
],
|
||||||
"external": []
|
"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": {
|
"directions-layer": {
|
||||||
"exports": ["./layers/directions"],
|
"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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
"radio-browser-source": {
|
"radio-browser-source": {
|
||||||
@@ -3302,6 +3336,7 @@
|
|||||||
"src/tools/queries/brief.js",
|
"src/tools/queries/brief.js",
|
||||||
"src/tools/queries/maritime.js",
|
"src/tools/queries/maritime.js",
|
||||||
"src/tools/queries/share.js",
|
"src/tools/queries/share.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/sources/gbfsStations.js",
|
"src/sources/gbfsStations.js",
|
||||||
@@ -3424,6 +3459,7 @@
|
|||||||
"exports": ["./view"],
|
"exports": ["./view"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/view/index.js",
|
"src/view/index.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json"
|
"src/data/layerStateTokenReservations.json"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -622,11 +622,17 @@ async function main() {
|
|||||||
await target.click(
|
await target.click(
|
||||||
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
|
||||||
);
|
);
|
||||||
await settle(
|
assert.ok(
|
||||||
target,
|
await settle(
|
||||||
() =>
|
target,
|
||||||
getComputedStyle(document.getElementById('sl-body')).display ===
|
() =>
|
||||||
'flex',
|
!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();
|
await expandStrip();
|
||||||
|
|||||||
@@ -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),
|
defer: (release) => releases.push(release),
|
||||||
onData: (manager) => {
|
onData: (manager) => {
|
||||||
observed = manager;
|
observed = manager;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(observed, dataManager);
|
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(attached, [['one', 'two']]);
|
||||||
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
||||||
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
||||||
|
|||||||
+12
-38
@@ -1,9 +1,9 @@
|
|||||||
|
import { resolveCatalogSources } from './sourceComposition.js';
|
||||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||||
import { createWindLayer } from '../layers/wind/index.js';
|
import { createWindLayer } from '../layers/wind/index.js';
|
||||||
import { createLayerCatalog } from './catalog.js';
|
import { createLayerCatalog } from './catalog.js';
|
||||||
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
|
|
||||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||||
import { createApplicationFlights } from './layers/flights.js';
|
import { createApplicationFlights } from './layers/flights.js';
|
||||||
@@ -32,34 +32,6 @@ import { localGeoJsonServices } from './localGeojsonServices.js';
|
|||||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.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
|
* Hardware-local layers are registered like any other but never enter share
|
||||||
* links or stored layer state: another browser cannot have this receiver.
|
* links or stored layer state: another browser cannot have this receiver.
|
||||||
@@ -86,6 +58,7 @@ export function createApplicationCatalog({
|
|||||||
vesselOptions,
|
vesselOptions,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
|
streetLevelProviders,
|
||||||
}) {
|
}) {
|
||||||
if (!signal?.addEventListener)
|
if (!signal?.addEventListener)
|
||||||
throw new TypeError('An application lifetime signal is required');
|
throw new TypeError('An application lifetime signal is required');
|
||||||
@@ -93,12 +66,8 @@ export function createApplicationCatalog({
|
|||||||
if (!surface?.groundFloor || !surface?.terrain)
|
if (!surface?.groundFloor || !surface?.terrain)
|
||||||
throw new TypeError('Application surface services are required');
|
throw new TypeError('Application surface services are required');
|
||||||
|
|
||||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
const sourceComposition = resolveCatalogSources(sources);
|
||||||
if (
|
sources = sourceComposition.sources;
|
||||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
|
||||||
)
|
|
||||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
|
||||||
}
|
|
||||||
const militaryRegistry = createMilitaryRegistry();
|
const militaryRegistry = createMilitaryRegistry();
|
||||||
const weatherClock = createWeatherClock();
|
const weatherClock = createWeatherClock();
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
@@ -108,7 +77,8 @@ export function createApplicationCatalog({
|
|||||||
};
|
};
|
||||||
signal.addEventListener('abort', dispose, { once: true });
|
signal.addEventListener('abort', dispose, { once: true });
|
||||||
try {
|
try {
|
||||||
militaryRegistry.configureSource(sources.military, { signal });
|
if (sourceComposition.isConfigured('military'))
|
||||||
|
militaryRegistry.configureSource(sources.military, { signal });
|
||||||
const flights = createApplicationFlights({
|
const flights = createApplicationFlights({
|
||||||
surface,
|
surface,
|
||||||
source: sources.flights,
|
source: sources.flights,
|
||||||
@@ -154,6 +124,7 @@ export function createApplicationCatalog({
|
|||||||
createApplicationStreetLevel({
|
createApplicationStreetLevel({
|
||||||
surface,
|
surface,
|
||||||
sources: { mapillary: sources.mapillary },
|
sources: { mapillary: sources.mapillary },
|
||||||
|
providers: streetLevelProviders,
|
||||||
}),
|
}),
|
||||||
satellites,
|
satellites,
|
||||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||||
@@ -162,8 +133,8 @@ export function createApplicationCatalog({
|
|||||||
createApplicationRadio({ surface, source: sources.radio }),
|
createApplicationRadio({ surface, source: sources.radio }),
|
||||||
createApplicationTransit({ surface, source: sources.transit }),
|
createApplicationTransit({ surface, source: sources.transit }),
|
||||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||||
createApplicationDirections(),
|
createApplicationDirections({ source: sources.directions }),
|
||||||
createApplicationRecentImagery(),
|
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||||
vessels,
|
vessels,
|
||||||
installations,
|
installations,
|
||||||
createApplicationAwareness({
|
createApplicationAwareness({
|
||||||
@@ -204,6 +175,9 @@ export function createApplicationCatalog({
|
|||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...catalog,
|
...catalog,
|
||||||
|
getSourceAvailability: sourceComposition.createAvailabilityLookup(
|
||||||
|
catalog.layers,
|
||||||
|
),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
surface,
|
surface,
|
||||||
weatherClock,
|
weatherClock,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
|
|||||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||||
import { catalogControlServices } from './catalog.js';
|
import { catalogControlServices } from './catalog.js';
|
||||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||||
|
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
function fixtureSources(ids, calls) {
|
function fixtureSources(ids, calls) {
|
||||||
const sources = createStandaloneLayerSources();
|
const sources = createStandaloneLayerSources();
|
||||||
@@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createApplicationCatalog({
|
createApplicationCatalog({
|
||||||
sources: {},
|
sources: { flights: {} },
|
||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
surface: fixtureSurface(lifetime.signal),
|
surface: fixtureSurface(lifetime.signal),
|
||||||
}),
|
}),
|
||||||
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
|
|||||||
eventTarget: null,
|
eventTarget: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
t.after(() => lifetime.abort());
|
||||||
|
const sources = fixtureSources([], []);
|
||||||
|
delete sources.mapillary;
|
||||||
|
const options = {
|
||||||
|
sources,
|
||||||
|
signal: lifetime.signal,
|
||||||
|
surface: fixtureSurface(lifetime.signal),
|
||||||
|
};
|
||||||
|
const catalog = createApplicationCatalog(options);
|
||||||
|
assert.equal(catalog.layers.length, 30);
|
||||||
|
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||||
|
assert.equal(
|
||||||
|
catalogControlServices(catalog).streetLevelLayer,
|
||||||
|
catalog.get('street-level'),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
catalog.get('street-level').getUIState().coverage.hint,
|
||||||
|
/No street-level imagery providers/,
|
||||||
|
);
|
||||||
|
const provider = fakeStreetLevelProvider({
|
||||||
|
id: 'example',
|
||||||
|
pickPrefix: 'example:',
|
||||||
|
});
|
||||||
|
const replacement = createApplicationCatalog({
|
||||||
|
...options,
|
||||||
|
streetLevelProviders: [provider],
|
||||||
|
});
|
||||||
|
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
|
||||||
|
assert.deepEqual(replacement.metadata, catalog.metadata);
|
||||||
|
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
|
||||||
|
assert.deepEqual(
|
||||||
|
createApplicationCatalog({
|
||||||
|
...options,
|
||||||
|
sources: fixtureSources([], []),
|
||||||
|
streetLevelProviders: [],
|
||||||
|
}).get('street-level').providerIds,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createApplicationCatalog({
|
||||||
|
...options,
|
||||||
|
sources: { ...sources, mapillary: {} },
|
||||||
|
}),
|
||||||
|
/Mapillary source/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
|||||||
// Initialize data layer manager
|
// Initialize data layer manager
|
||||||
const dataManager = new LayerLifecycle(viewer, {
|
const dataManager = new LayerLifecycle(viewer, {
|
||||||
allowQaRegistration,
|
allowQaRegistration,
|
||||||
|
getSourceAvailability: catalog?.getSourceAvailability,
|
||||||
});
|
});
|
||||||
defer(async () => {
|
defer(async () => {
|
||||||
await dataManager.destroyAll();
|
await dataManager.destroyAll();
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
||||||
|
|
||||||
/**
|
/** Construct Directions with application scene owners and an explicit route source. */
|
||||||
* Construct one Directions layer using the application scene owners.
|
export function createApplicationDirections({ source }) {
|
||||||
*
|
return createApplicationDirectionsLayer({ source });
|
||||||
* 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();
|
|
||||||
}
|
}
|
||||||
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
|
|||||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||||
import { governorRequestRender } from '../../renderGovernor.js';
|
import { governorRequestRender } from '../../renderGovernor.js';
|
||||||
|
|
||||||
/**
|
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||||
* Construct the Recent Imagery layer with the real GIBS renderer and the
|
export function createApplicationRecentImagery({ source }) {
|
||||||
* 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() {
|
|
||||||
return createRecentImageryLayer({
|
return createRecentImageryLayer({
|
||||||
|
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||||
renderer: createRecentImageryRenderer({
|
renderer: createRecentImageryRenderer({
|
||||||
requestRender: governorRequestRender,
|
requestRender: governorRequestRender,
|
||||||
|
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||||
|
credit: source.credit || source.label || 'Imagery',
|
||||||
|
}),
|
||||||
|
thumbnails: createThumbnailLoader({
|
||||||
|
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||||
}),
|
}),
|
||||||
thumbnails: createThumbnailLoader(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,20 @@ import * as picking from '../../data/pickRegistry.js';
|
|||||||
import * as input from '../../data/inputOwnership.js';
|
import * as input from '../../data/inputOwnership.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** A new provider registers here; its chip, credit and share bit follow. */
|
/**
|
||||||
export function createApplicationStreetLevel({ surface, sources }) {
|
* Inject providers, or use Mapillary only when its source is supplied.
|
||||||
|
* An explicit provider list replaces the default; unused sources.mapillary is
|
||||||
|
* neither constructed nor validated, including when the list is empty.
|
||||||
|
*/
|
||||||
|
export function createApplicationStreetLevel({
|
||||||
|
surface,
|
||||||
|
sources = {},
|
||||||
|
providers = sources.mapillary == null
|
||||||
|
? []
|
||||||
|
: [createMapillaryProvider({ source: sources.mapillary })],
|
||||||
|
}) {
|
||||||
return createStreetLevelLayer({
|
return createStreetLevelLayer({
|
||||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
providers,
|
||||||
services: {
|
services: {
|
||||||
sprites,
|
sprites,
|
||||||
picking,
|
picking,
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||||
/**
|
/**
|
||||||
* @module directions
|
* @module directions
|
||||||
* @description Service bundle for the Directions layer.
|
* @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. */
|
/** Construct one Directions layer over the application scene owners. */
|
||||||
export function createApplicationDirectionsLayer() {
|
export function createApplicationDirectionsLayer({
|
||||||
return createDirectionsLayer({ services: directionsServices });
|
source = createDirectionsSource(),
|
||||||
|
} = {}) {
|
||||||
|
return createDirectionsLayer({ services: directionsServices, source });
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+11
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||||
|
|
||||||
const VALID_DISPOSITIONS = new Set([
|
const VALID_DISPOSITIONS = new Set([
|
||||||
@@ -411,8 +412,17 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
booleanOption('viirs', 'v', false),
|
booleanOption('viirs', 'v', false),
|
||||||
]),
|
]),
|
||||||
'street-level': Object.freeze([
|
'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),
|
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'], {
|
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||||
all: 'a',
|
all: 'a',
|
||||||
pano: 'p',
|
pano: 'p',
|
||||||
|
|||||||
+102
-18
@@ -1,3 +1,4 @@
|
|||||||
|
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
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(), '3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
nextLayerStateToken({
|
||||||
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
|
alpha: '3',
|
||||||
|
bravo: '4',
|
||||||
|
}),
|
||||||
'5',
|
'5',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
[
|
[
|
||||||
...'0123456789',
|
...'0123456789',
|
||||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
...[...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]),
|
].map((token, index) => [`occupied-${index}`, token]),
|
||||||
),
|
),
|
||||||
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
future: '3',
|
||||||
),
|
next: '4',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
() =>
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
future: '00',
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
}),
|
future: '00',
|
||||||
|
}),
|
||||||
/next free token 3/,
|
/next free token 3/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(beforeLastDigit, {
|
||||||
beforeLastDigit,
|
...beforeLastDigit,
|
||||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
futurePair: '00',
|
||||||
),
|
futureDigit: '9',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(digitsExhausted, {
|
||||||
digitsExhausted,
|
...digitsExhausted,
|
||||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
pairB: '01',
|
||||||
),
|
pairA: '00',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
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.enabledLayerIds = ['street-level'];
|
||||||
state.options['street-level'] = {
|
state.options['street-level'] = {
|
||||||
mapillary: false,
|
mapillary: false,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: MAX_SINCE_DAYS,
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
};
|
};
|
||||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||||
assert.deepEqual(decoded.options['street-level'], {
|
assert.deepEqual(decoded.options['street-level'], {
|
||||||
mapillary: false,
|
mapillary: false,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: MAX_SINCE_DAYS,
|
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', () => {
|
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||||
assert.deepEqual(streetLevelOptions(''), {
|
assert.deepEqual(streetLevelOptions(''), {
|
||||||
mapillary: true,
|
mapillary: true,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'all',
|
pano: 'all',
|
||||||
sinceDays: 0,
|
sinceDays: 0,
|
||||||
});
|
});
|
||||||
@@ -2703,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
|
|||||||
true,
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
|
|||||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||||
const implicitTrackingSync =
|
const implicitTrackingSync =
|
||||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
// Packed options can persist only the subkeys owned by this request.
|
||||||
const value = spec.normalize(change.params[spec.key]);
|
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 (value === null) {
|
||||||
if (implicitTrackingSync) {
|
if (implicitTrackingSync) {
|
||||||
nextOwnerOptions[spec.key] = null;
|
nextOwnerOptions[spec.key] = null;
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ function withPublishedBase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCodecFixture(cwd, rows, entries) {
|
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 source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||||
assert.equal(source.split(registryEnd).length, 2);
|
assert.equal(source.split(registryEnd).length, 2);
|
||||||
|
|||||||
+23
-1
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
*/
|
*/
|
||||||
export class LayerLifecycle {
|
export class LayerLifecycle {
|
||||||
constructor(viewer, { allowQaRegistration = false } = {}) {
|
constructor(
|
||||||
|
viewer,
|
||||||
|
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||||
|
) {
|
||||||
|
this._catalogSourceAvailability = getSourceAvailability;
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this._activityListeners = new Set();
|
this._activityListeners = new Set();
|
||||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||||
@@ -228,6 +232,7 @@ export class LayerLifecycle {
|
|||||||
|
|
||||||
_normalizedStats(entry) {
|
_normalizedStats(entry) {
|
||||||
const moduleStats = this._moduleStats(entry);
|
const moduleStats = this._moduleStats(entry);
|
||||||
|
const availability = this._sourceAvailability(entry.module.id);
|
||||||
const lifecycleLoading =
|
const lifecycleLoading =
|
||||||
entry.lifecycleState === 'enabling' ||
|
entry.lifecycleState === 'enabling' ||
|
||||||
entry.lifecycleState === 'disabling';
|
entry.lifecycleState === 'disabling';
|
||||||
@@ -235,6 +240,13 @@ export class LayerLifecycle {
|
|||||||
count: 0,
|
count: 0,
|
||||||
lastUpdate: null,
|
lastUpdate: null,
|
||||||
...moduleStats,
|
...moduleStats,
|
||||||
|
...(availability?.available === false
|
||||||
|
? {
|
||||||
|
status: 'unavailable',
|
||||||
|
sourceUnavailable: true,
|
||||||
|
error: availability.reason,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
loading: lifecycleLoading || moduleStats.loading === true,
|
loading: lifecycleLoading || moduleStats.loading === true,
|
||||||
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
||||||
managerRefreshError: entry.managerRefreshError,
|
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) {
|
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) {
|
for (const callback of this._visibilityGuards) {
|
||||||
try {
|
try {
|
||||||
const result = await callback(change);
|
const result = await callback(change);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
|
|||||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||||
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||||
import { createDirectionsLayer } from '../layers/directions/index.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 { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||||
import * as input from './inputOwnership.js';
|
import * as input from './inputOwnership.js';
|
||||||
import { createOverpassAlprSource } from '../layers/alpr/source.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) => {
|
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
||||||
const v = viewer(t);
|
const v = viewer(t);
|
||||||
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
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: {},
|
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
||||||
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
||||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||||
|
|||||||
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
|
|||||||
* @param {{lat:number, lon:number}} b
|
* @param {{lat:number, lon:number}} b
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function directionsRequestUrl(mode, a, b) {
|
export { directionsRequestUrl } from './source.js';
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a proxy payload into the route record the layer keeps, or null.
|
* 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
|
* 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
|
* shared services are injected, which is what keeps this module's own imports
|
||||||
* down to Cesium and the pure step formatter.
|
* 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.
|
* @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;
|
* 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
|
* tests swap it. Resolved on use, not at import, because the services arrive
|
||||||
@@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyRow();
|
notifyRow();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
|
const payload = await source.getRoute(
|
||||||
signal: controller.signal,
|
{ mode, a: _a, b: _b },
|
||||||
headers: { Accept: 'application/json' },
|
{
|
||||||
});
|
signal: controller.signal,
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seq !== _routeSeq || !_enabled) return;
|
if (seq !== _routeSeq || !_enabled) return;
|
||||||
const route = normalizeRoutePayload(payload, mode);
|
const route = normalizeRoutePayload(payload, mode);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
|
|||||||
*/
|
*/
|
||||||
export function createRecentImageryRenderer({
|
export function createRecentImageryRenderer({
|
||||||
cesium = Cesium,
|
cesium = Cesium,
|
||||||
|
tileTemplate = gibsTemplate,
|
||||||
|
credit = 'NASA GIBS',
|
||||||
maxTileRequests = 6,
|
maxTileRequests = 6,
|
||||||
requestRender = () => {},
|
requestRender = () => {},
|
||||||
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||||
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
|
|||||||
function createProvider(candidate, box) {
|
function createProvider(candidate, box) {
|
||||||
const spec = PRODUCTS[candidate.product];
|
const spec = PRODUCTS[candidate.product];
|
||||||
const provider = new cesium.UrlTemplateImageryProvider({
|
const provider = new cesium.UrlTemplateImageryProvider({
|
||||||
url: gibsTemplate(candidate.product, candidate.day),
|
url: tileTemplate(candidate.product, candidate.day),
|
||||||
subdomains: ['a', 'b', 'c'],
|
subdomains: ['a', 'b', 'c'],
|
||||||
minimumLevel: 0,
|
minimumLevel: 0,
|
||||||
maximumLevel: spec.maxLevel,
|
maximumLevel: spec.maxLevel,
|
||||||
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
|
|||||||
box.east,
|
box.east,
|
||||||
box.north,
|
box.north,
|
||||||
),
|
),
|
||||||
credit: 'NASA GIBS',
|
credit,
|
||||||
hasAlphaChannel: spec.format === 'png',
|
hasAlphaChannel: spec.format === 'png',
|
||||||
});
|
});
|
||||||
return admitTileRequests(provider, admission, requestRender, retry);
|
return admitTileRequests(provider, admission, requestRender, retry);
|
||||||
|
|||||||
@@ -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 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' };
|
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 renders = [];
|
||||||
const timers = manualTimers();
|
const timers = manualTimers();
|
||||||
const renderer = createRecentImageryRenderer({
|
const renderer = createRecentImageryRenderer({
|
||||||
|
...options,
|
||||||
cesium: fakeCesium(requestImage),
|
cesium: fakeCesium(requestImage),
|
||||||
maxTileRequests,
|
maxTileRequests,
|
||||||
requestRender: (reason) => renders.push(reason),
|
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.equal(layer.splitDirection, 0);
|
||||||
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
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);
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
* card on every render, so a read that counted would evict the visible cards
|
* 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.
|
* 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({
|
const EMPTY = Object.freeze({
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
@@ -22,6 +23,7 @@ const EMPTY = Object.freeze({
|
|||||||
*/
|
*/
|
||||||
export function createThumbnailLoader({
|
export function createThumbnailLoader({
|
||||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||||
|
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
|
||||||
maxInFlight = 3,
|
maxInFlight = 3,
|
||||||
maxDecoded = 16,
|
maxDecoded = 16,
|
||||||
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
||||||
@@ -90,14 +92,10 @@ export function createThumbnailLoader({
|
|||||||
let acquisitionTime = null;
|
let acquisitionTime = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
try {
|
try {
|
||||||
const url = wvsSnapshotUrl({
|
const response = await requestThumbnail(entry.candidate, entry.box, {
|
||||||
product: entry.candidate.product,
|
size,
|
||||||
day: entry.candidate.day,
|
signal: controller.signal,
|
||||||
box: entry.box,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
});
|
});
|
||||||
const response = await fetchImpl(url, { signal: controller.signal });
|
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
} else if (response?.ok) {
|
} else if (response?.ok) {
|
||||||
|
|||||||
@@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl
|
|||||||
tracked: 1,
|
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']);
|
||||||
|
});
|
||||||
@@ -333,6 +333,16 @@ export function createStreetLevelLayer({
|
|||||||
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
||||||
/** Registered providers, in chip order. */
|
/** Registered providers, in chip order. */
|
||||||
providerIds: definitionsFrozen.map((def) => def.id),
|
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) {
|
init(viewer) {
|
||||||
if (state.initialized)
|
if (state.initialized)
|
||||||
|
|||||||
@@ -482,3 +482,17 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
|
|||||||
assert.equal(provider.calls.activate, 1);
|
assert.equal(provider.calls.activate, 1);
|
||||||
assert.equal(viewer.credits.length, 1);
|
assert.equal(viewer.credits.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||||||
|
const { layer, viewer } = await enabledLayer(t, []);
|
||||||
|
assert.deepEqual(layer.providerIds, []);
|
||||||
|
assert.deepEqual(layer.getUIState().providers, []);
|
||||||
|
assert.equal(layer.getUIState().keyRequired, false);
|
||||||
|
assert.match(
|
||||||
|
layer.getUIState().coverage.hint,
|
||||||
|
/No street-level imagery providers/,
|
||||||
|
);
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.deepEqual(layer.getUIState().providers, []);
|
||||||
|
});
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { normalizeFilter } from './filter.js';
|
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
|
* Provider params plus the filter. Custom switches share a stable ID-based
|
||||||
* src/data/layerState.js names the same keys, so a new provider needs one there.
|
* field; Mapillary retains its existing boolean and share token.
|
||||||
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||||
*/
|
*/
|
||||||
export function encodeParams({ providers, filter }) {
|
export function encodeParams({ providers, filter }) {
|
||||||
const params = {};
|
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.pano = filter.pano;
|
||||||
params.sinceDays = filter.sinceDays;
|
params.sinceDays = filter.sinceDays;
|
||||||
return params;
|
return params;
|
||||||
@@ -23,8 +29,13 @@ export function encodeParams({ providers, filter }) {
|
|||||||
export function decodeParams(params, { providerIds, filter }) {
|
export function decodeParams(params, { providerIds, filter }) {
|
||||||
const providers = new Map();
|
const providers = new Map();
|
||||||
const source = params && typeof params === 'object' ? params : {};
|
const source = params && typeof params === 'object' ? params : {};
|
||||||
for (const id of providerIds)
|
const switches = decodeIdSwitches(source.providerSwitches);
|
||||||
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
|
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 = {};
|
const next = {};
|
||||||
if ('pano' in source) next.pano = source.pano;
|
if ('pano' in source) next.pano = source.pano;
|
||||||
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
|
|||||||
],
|
],
|
||||||
filter: { pano: 'flat', sinceDays: 365 },
|
filter: { pano: 'flat', sinceDays: 365 },
|
||||||
}),
|
}),
|
||||||
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
|
{
|
||||||
|
mapillary: true,
|
||||||
|
panoramax: false,
|
||||||
|
providerSwitches: 'panoramax-0',
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: 365,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The contract an imagery provider registers with the Street Level layer.
|
* 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<StreetLevelProvider>}
|
* @returns {ReadonlyArray<StreetLevelProvider>}
|
||||||
*/
|
*/
|
||||||
export function validateProviders(providers) {
|
export function validateProviders(providers) {
|
||||||
if (!Array.isArray(providers) || providers.length === 0)
|
if (!Array.isArray(providers))
|
||||||
throw new TypeError('Street Level needs at least one imagery provider');
|
throw new TypeError('Street Level providers must be an array');
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
const prefixes = [];
|
const prefixes = [];
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
@@ -125,6 +127,19 @@ export function validateProviders(providers) {
|
|||||||
ids.add(provider.id);
|
ids.add(provider.id);
|
||||||
prefixes.push(provider.pickPrefix);
|
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]);
|
return Object.freeze([...providers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ test('a valid list is frozen in registration order', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an empty list, duplicate ids and malformed ids are rejected', () => {
|
test('an empty provider list is valid and has no key requirement', () => {
|
||||||
assert.throws(() => validateProviders([]), /at least one/);
|
assert.deepEqual(validateProviders([]), []);
|
||||||
assert.throws(() => validateProviders(null), /at least one/);
|
assert.ok(Object.isFrozen(validateProviders([])));
|
||||||
|
assert.equal(requiresKeyIdFor([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
|
||||||
|
assert.throws(() => validateProviders(null), /array/);
|
||||||
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
|
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
|
||||||
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
|
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export function summarizeCoverage(providers) {
|
|||||||
return {
|
return {
|
||||||
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||||
loading: active.some((p) => p.loading),
|
loading: active.some((p) => p.loading),
|
||||||
hint: active.find((p) => p.hint)?.hint || '',
|
hint:
|
||||||
|
providers.length === 0
|
||||||
|
? 'No street-level imagery providers configured'
|
||||||
|
: active.find((p) => p.hint)?.hint || '',
|
||||||
error: active.find((p) => p.error)?.error || null,
|
error: active.find((p) => p.error)?.error || null,
|
||||||
keyRequired:
|
keyRequired:
|
||||||
active.length > 0 && active.every((p) => p.keyRequired === true),
|
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function createIngestion({
|
|||||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||||
feed.partial = payload?.complete === false;
|
feed.partial = payload?.complete === false;
|
||||||
|
|
||||||
if (snapshot.acceptedRowCount === 0) {
|
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||||
feed.count = readCount();
|
feed.count = readCount();
|
||||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||||
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
||||||
|
|||||||
@@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u
|
|||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(layer.getStats().partial, false);
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -119,7 +119,16 @@ export function createQueries({
|
|||||||
typeof payload?.status === 'string' ? payload.status : null;
|
typeof payload?.status === 'string' ? payload.status : null;
|
||||||
const lastMessageAt = payload?.lastMessageAt ?? null;
|
const lastMessageAt = payload?.lastMessageAt ?? null;
|
||||||
const acceptedRowCount = acceptedRows.length;
|
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 {
|
return {
|
||||||
|
emptyCoverage,
|
||||||
transportStatus,
|
transportStatus,
|
||||||
lastMessageAt,
|
lastMessageAt,
|
||||||
rawRows,
|
rawRows,
|
||||||
@@ -130,9 +139,10 @@ export function createQueries({
|
|||||||
? payload.rawRowCount
|
? payload.rawRowCount
|
||||||
: rawRows.length,
|
: rawRows.length,
|
||||||
acceptedRowCount,
|
acceptedRowCount,
|
||||||
error:
|
error: emptyCoverage
|
||||||
deriveAisFeedError(payload, acceptedRowCount) ||
|
? null
|
||||||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
: deriveAisFeedError(payload, acceptedRowCount) ||
|
||||||
|
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
|
* 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) {
|
export function parseTleText(text) {
|
||||||
const lines = String(text)
|
const lines = String(text)
|
||||||
@@ -9,12 +9,13 @@ export function parseTleText(text) {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
const result = [];
|
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 name = lines[i];
|
||||||
const line1 = lines[i + 1];
|
const line1 = lines[i + 1];
|
||||||
const line2 = lines[i + 2];
|
const line2 = lines[i + 2];
|
||||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||||
result.push({ name, line1, line2 });
|
result.push({ name, line1, line2 });
|
||||||
|
i += 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function createStandaloneApplication({
|
|||||||
cesiumToken,
|
cesiumToken,
|
||||||
geospatial = {},
|
geospatial = {},
|
||||||
voice = {},
|
voice = {},
|
||||||
|
sources = {},
|
||||||
|
streetLevelProviders,
|
||||||
allowQaRegistration = false,
|
allowQaRegistration = false,
|
||||||
}) {
|
}) {
|
||||||
if (constructed)
|
if (constructed)
|
||||||
@@ -47,6 +49,8 @@ export function createStandaloneApplication({
|
|||||||
loaderStatus,
|
loaderStatus,
|
||||||
});
|
});
|
||||||
catalog = createStandaloneCatalog({
|
catalog = createStandaloneCatalog({
|
||||||
|
sources,
|
||||||
|
streetLevelProviders,
|
||||||
nepalBoundaryResolver: (signal) =>
|
nepalBoundaryResolver: (signal) =>
|
||||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||||
'Nepal',
|
'Nepal',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export { createStandaloneReferenceSources } from './layerSources.js';
|
|||||||
/** Create fresh layer instances using the existing standalone source choices. */
|
/** Create fresh layer instances using the existing standalone source choices. */
|
||||||
export function createStandaloneCatalog({
|
export function createStandaloneCatalog({
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
|
sources = {},
|
||||||
|
streetLevelProviders,
|
||||||
signal = new AbortController().signal,
|
signal = new AbortController().signal,
|
||||||
surface = createSurfaceServices({
|
surface = createSurfaceServices({
|
||||||
terrainSource: createApplicationRequestServices().terrain,
|
terrainSource: createApplicationRequestServices().terrain,
|
||||||
@@ -16,7 +18,8 @@ export function createStandaloneCatalog({
|
|||||||
return createApplicationCatalog({
|
return createApplicationCatalog({
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
surface,
|
surface,
|
||||||
sources: createStandaloneLayerSources(),
|
sources: createStandaloneLayerSources(sources),
|
||||||
|
streetLevelProviders,
|
||||||
signal,
|
signal,
|
||||||
vesselOptions: {
|
vesselOptions: {
|
||||||
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||||
|
|||||||
@@ -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 { createOpenFreeMapSource } from '../sources/openFreeMap.js';
|
||||||
import {
|
import {
|
||||||
createFlightSource,
|
createFlightSource,
|
||||||
@@ -22,10 +24,14 @@ import { createReferenceSources } from '../sources/reference.js';
|
|||||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||||
|
|
||||||
/** Select standalone providers without starting their acquisition. */
|
/** 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();
|
const mapTiles = createOpenFreeMapSource();
|
||||||
return {
|
const defaults = {
|
||||||
...createReferenceSources(),
|
...createReferenceSources(),
|
||||||
|
directions: createDirectionsSource(),
|
||||||
|
'recent-imagery': createRecentImagerySource(),
|
||||||
flights: createFlightSource(),
|
flights: createFlightSource(),
|
||||||
military: createMilitarySource(),
|
military: createMilitarySource(),
|
||||||
vessels: createVesselSource({
|
vessels: createVesselSource({
|
||||||
@@ -51,4 +57,9 @@ export function createStandaloneLayerSources() {
|
|||||||
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
|
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 };
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => {
|
|||||||
assert.equal(tleCatalogNumber('1 xx'), null);
|
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 () => {
|
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||||
const catalog = composeCatalog({
|
const catalog = composeCatalog({
|
||||||
tools: coreTools,
|
tools: coreTools,
|
||||||
|
|||||||
+15
-5
@@ -588,7 +588,12 @@ export class LayerPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_syncToggleButton(button, layer) {
|
_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 =
|
const transitioning =
|
||||||
layer.lifecycleState === 'enabling' ||
|
layer.lifecycleState === 'enabling' ||
|
||||||
layer.lifecycleState === 'disabling';
|
layer.lifecycleState === 'disabling';
|
||||||
@@ -601,7 +606,7 @@ export class LayerPanel {
|
|||||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||||
button.classList.toggle(
|
button.classList.toggle(
|
||||||
`feed-${state}`,
|
`feed-${state}`,
|
||||||
layer.enabled && !uncertain && feedState === state,
|
(layer.enabled || unavailable) && !uncertain && feedState === state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
button.dataset.feedState = transitioning
|
button.dataset.feedState = transitioning
|
||||||
@@ -613,16 +618,21 @@ export class LayerPanel {
|
|||||||
// click guard above prevents repeat activation without the focus loss caused
|
// click guard above prevents repeat activation without the focus loss caused
|
||||||
// by native `disabled`.
|
// by native `disabled`.
|
||||||
button.disabled = false;
|
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.setAttribute('aria-busy', String(transitioning));
|
||||||
button.textContent = transitioning
|
button.textContent = transitioning
|
||||||
? layer.lifecycleState.toUpperCase()
|
? layer.lifecycleState.toUpperCase()
|
||||||
: uncertain
|
: uncertain
|
||||||
? 'UNCERTAIN'
|
? 'UNCERTAIN'
|
||||||
: layer.enabled
|
: layer.enabled || unavailable
|
||||||
? FEED_STATE_LABELS[feedState]
|
? FEED_STATE_LABELS[feedState]
|
||||||
: 'OFF';
|
: '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
|
// 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
|
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||||
// when the layer needs no key, or already has one.
|
// when the layer needs no key, or already has one.
|
||||||
|
|||||||
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
|||||||
globalThis.document = previousDocument;
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -125,6 +125,7 @@ function presentViewer(state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function presentMeta(state) {
|
function presentMeta(state) {
|
||||||
|
if (state.providers.length === 0) return state.coverage.hint || '';
|
||||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||||
if (state.sequence.selectedId)
|
if (state.sequence.selectedId)
|
||||||
return state.sequence.loading
|
return state.sequence.loading
|
||||||
@@ -147,7 +148,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
|||||||
enabled,
|
enabled,
|
||||||
keyRequired,
|
keyRequired,
|
||||||
status: presentStatus(state),
|
status: presentStatus(state),
|
||||||
controlsDisabled: keyRequired,
|
controlsDisabled: keyRequired || state.providers.length === 0,
|
||||||
providers: presentProviders(state),
|
providers: presentProviders(state),
|
||||||
error: keyMissing
|
error: keyMissing
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
|||||||
|
|
||||||
const provider = providerSnapshot;
|
const provider = providerSnapshot;
|
||||||
|
|
||||||
|
test('an empty composition explains unavailable imagery even while disabled', () => {
|
||||||
|
for (const enabled of [false, true]) {
|
||||||
|
const view = presentStreetLevelPanel(
|
||||||
|
snapshot({
|
||||||
|
enabled,
|
||||||
|
providers: [],
|
||||||
|
coverage: { hint: 'No street-level imagery providers configured' },
|
||||||
|
legend: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(view.controlsDisabled, true);
|
||||||
|
assert.equal(view.keyRequired, false);
|
||||||
|
assert.deepEqual(view.providers, []);
|
||||||
|
assert.equal(view.meta, 'No street-level imagery providers configured');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function snapshot(overrides = {}) {
|
function snapshot(overrides = {}) {
|
||||||
const base = {
|
const base = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
Reference in new issue
Block a user