mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(maps): separate source factories and scene coordination
This commit is contained in:
1 parent
561a781099
commit
e59282dc9c
22 files changed
+1807
-798
No files matched your search
@@ -59,6 +59,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Separate map source factories from switching and resource ownership; retain current source IDs, attribution and fallbacks.
|
||||
|
||||
- Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source.
|
||||
|
||||
- Separate submarine cable sources and rendering components, and export bundled geography lookup modules.
|
||||
|
||||
@@ -363,3 +363,25 @@ return an unsubscribe function. Initial state is emitted by default; pass
|
||||
immutable plain data. Reentrant publications retain delivery order; removing a
|
||||
listener or destroying its owner prevents further queued delivery. These APIs
|
||||
perform no network requests and discover no additional modules.
|
||||
|
||||
## Maps
|
||||
|
||||
`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`,
|
||||
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
|
||||
standard sources and their setup/fallback policy. The standalone facade wires
|
||||
these to the application's render governor.
|
||||
|
||||
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
|
||||
Each source has a user-facing `descriptor`, availability/reason, and either an
|
||||
`imagery({ signal })` factory or a supplied `tileset`/`createTileset({ signal })`.
|
||||
Imagery sources can share a terrain definition with a stable `id` and
|
||||
`create({ signal })` returning `{ provider }` or `{ terrain }`. Cache IDs must
|
||||
identify the same source for the lifetime of that registry. Sources may supply
|
||||
trusted credit markup and construction/tile-error fallback policy. Provider
|
||||
configuration stays in factories; descriptor values are the presentation API.
|
||||
|
||||
Factories should honor cancellation where their SDK supports it. The controller
|
||||
also checks scene ownership after asynchronous work, so late results cannot
|
||||
replace a newer selection. Supplied tilesets are caller-owned; factory-created
|
||||
tilesets and provider caches are controller-owned. Construct a fresh controller
|
||||
for a new viewer or configuration lifetime.
|
||||
@@ -3318,3 +3318,21 @@ Natural Earth regions and neighborhood polygon lookup are package exports.
|
||||
Their existing lazy loaders, retry behavior, bundled datasets and attribution
|
||||
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
|
||||
the project's MIT license; see `DATA_SOURCES.md`.
|
||||
|
||||
## Map source factories and coordination
|
||||
|
||||
Map selection is composed from separate imagery, terrain and 3D factories.
|
||||
The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and
|
||||
OSM, including their setup guidance and existing preset/share IDs. Esri still
|
||||
falls back to OSM on construction failure or two active tile failures, and
|
||||
credits follow the source actually displayed. Terrain remains lazy while the
|
||||
photoreal globe is hidden.
|
||||
|
||||
Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults.
|
||||
|
||||
The controller accepts other source registries without adding provider branches.
|
||||
Each instance caches source construction, ignores superseded scene changes and
|
||||
releases imagery layers/listeners on replacement. Destroy invalidates pending
|
||||
work and releases owned resources, including late factory results. Supplied 3D
|
||||
tilesets remain owned by the caller; tilesets created through the controller's
|
||||
factory are added to its viewer and removed on destruction.
|
||||
+7
-1
@@ -160,6 +160,12 @@
|
||||
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
|
||||
"./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js",
|
||||
"./geography/natural-earth": "./src/data/naturalEarthRegions.js",
|
||||
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js"
|
||||
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
|
||||
"./maps/controller": "./src/maps/controller.js",
|
||||
"./maps/catalog": "./src/maps/catalog.js",
|
||||
"./maps/imagery": "./src/maps/imagery.js",
|
||||
"./maps/terrain": "./src/maps/terrain.js",
|
||||
"./maps/3d": "./src/maps/google3d.js",
|
||||
"./maps/defaults": "./src/maps/defaultSources.js"
|
||||
}
|
||||
}
|
||||
@@ -495,5 +495,21 @@
|
||||
"src/data/telegeographySubmarineCables.test.mjs",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/layers/submarineCables/bundledSource.test.mjs"
|
||||
"src/layers/submarineCables/bundledSource.test.mjs",
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/controller.test.mjs",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/mapStackController.js",
|
||||
"src/mapStartup.js",
|
||||
"src/mapStackChips.test.mjs",
|
||||
"src/mapStackUnavailableReason.test.mjs",
|
||||
"src/mapStartup.test.mjs",
|
||||
"src/maps/sourceFactories.test.mjs"
|
||||
]
|
||||
+319
-95
@@ -10,27 +10,47 @@
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"application": {
|
||||
"exports": ["./application"],
|
||||
"modules": ["src/app/application.js"],
|
||||
"exports": [
|
||||
"./application"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/application.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"external": ["cesium"]
|
||||
"exports": [
|
||||
"./application/viewer"
|
||||
],
|
||||
"modules": [
|
||||
"src/app/viewer.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"vite-build": {
|
||||
"runtime": "node",
|
||||
"exports": ["./build/vite"],
|
||||
"modules": ["build/vite.js"],
|
||||
"external": ["vite-plugin-cesium"]
|
||||
"exports": [
|
||||
"./build/vite"
|
||||
],
|
||||
"modules": [
|
||||
"build/vite.js"
|
||||
],
|
||||
"external": [
|
||||
"vite-plugin-cesium"
|
||||
]
|
||||
},
|
||||
"live-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/live"],
|
||||
"exports": [
|
||||
"./server/providers/live"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/live.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -45,16 +65,24 @@
|
||||
"src/data/aisStreamAdapter.js",
|
||||
"src/data/aisWatchdog.js"
|
||||
],
|
||||
"external": ["ws"]
|
||||
"external": [
|
||||
"ws"
|
||||
]
|
||||
},
|
||||
"adsb-lol-source": {
|
||||
"exports": ["./sources/adsb-lol"],
|
||||
"modules": ["src/data/adsbLolFallback.js"],
|
||||
"exports": [
|
||||
"./sources/adsb-lol"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/adsbLolFallback.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"place-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/places"],
|
||||
"exports": [
|
||||
"./server/providers/places"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/places.js",
|
||||
"server/providers/places/google.js",
|
||||
@@ -69,13 +97,19 @@
|
||||
"external": []
|
||||
},
|
||||
"place-payloads": {
|
||||
"exports": ["./sources/places"],
|
||||
"modules": ["src/data/placeProviderPayloads.js"],
|
||||
"exports": [
|
||||
"./sources/places"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/placeProviderPayloads.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"space-providers": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/space"],
|
||||
"exports": [
|
||||
"./server/providers/space"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/space.js",
|
||||
"server/providers/space/celestrak.js",
|
||||
@@ -86,13 +120,19 @@
|
||||
"external": []
|
||||
},
|
||||
"space-requests": {
|
||||
"exports": ["./sources/space"],
|
||||
"modules": ["src/data/spaceProviderRequests.js"],
|
||||
"exports": [
|
||||
"./sources/space"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/spaceProviderRequests.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"terrain-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/terrain"],
|
||||
"exports": [
|
||||
"./server/providers/terrain"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/terrain.js",
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
@@ -100,30 +140,50 @@
|
||||
"external": []
|
||||
},
|
||||
"terrain-source": {
|
||||
"exports": ["./sources/terrain"],
|
||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
||||
"exports": [
|
||||
"./sources/terrain"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/terrainHeightsProxy.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"traffic-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/traffic"],
|
||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||
"exports": [
|
||||
"./server/providers/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/traffic.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source": {
|
||||
"exports": ["./sources/traffic"],
|
||||
"modules": ["src/data/tomtomTiles.js"],
|
||||
"exports": [
|
||||
"./sources/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"firms-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/firms"],
|
||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||
"exports": [
|
||||
"./server/providers/firms"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/firms.js",
|
||||
"src/data/firmsCsv.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/gbfs"],
|
||||
"exports": [
|
||||
"./server/providers/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/gbfs.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -132,13 +192,19 @@
|
||||
"external": []
|
||||
},
|
||||
"gbfs-source": {
|
||||
"exports": ["./sources/gbfs"],
|
||||
"modules": ["src/data/gbfsSource.js"],
|
||||
"exports": [
|
||||
"./sources/gbfs"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/gbfsSource.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"cctv-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"exports": [
|
||||
"./server/providers/cctv"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
@@ -155,7 +221,9 @@
|
||||
},
|
||||
"radio-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/radio"],
|
||||
"exports": [
|
||||
"./server/providers/radio"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/radio.js",
|
||||
"server/providers/radio/catalog.js",
|
||||
@@ -169,7 +237,9 @@
|
||||
},
|
||||
"overpass-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/overpass"],
|
||||
"exports": [
|
||||
"./server/providers/overpass"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/common/http.js",
|
||||
@@ -188,7 +258,9 @@
|
||||
},
|
||||
"military-installations-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/military-installations"],
|
||||
"exports": [
|
||||
"./server/providers/military-installations"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -205,7 +277,9 @@
|
||||
},
|
||||
"regional-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/regional"],
|
||||
"exports": [
|
||||
"./server/providers/regional"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/query.js",
|
||||
@@ -224,7 +298,9 @@
|
||||
},
|
||||
"openai-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
"exports": [
|
||||
"./server/providers/openai"
|
||||
],
|
||||
"modules": [
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
@@ -244,7 +320,9 @@
|
||||
},
|
||||
"standalone-key-setup": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/standalone/key-setup"],
|
||||
"exports": [
|
||||
"./server/standalone/key-setup"
|
||||
],
|
||||
"modules": [
|
||||
"scripts/pinokio-environment.mjs",
|
||||
"server/providers/common/source-root.js",
|
||||
@@ -255,7 +333,9 @@
|
||||
"external": []
|
||||
},
|
||||
"place-search": {
|
||||
"exports": ["./search"],
|
||||
"exports": [
|
||||
"./search"
|
||||
],
|
||||
"modules": [
|
||||
"src/search/index.js",
|
||||
"src/search/placeSearch.js",
|
||||
@@ -265,17 +345,27 @@
|
||||
"external": []
|
||||
},
|
||||
"panel-disclosure": {
|
||||
"exports": ["./ui/panels"],
|
||||
"modules": ["src/ui/panelDisclosure.js"],
|
||||
"exports": [
|
||||
"./ui/panels"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/panelDisclosure.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"surface-keyboard": {
|
||||
"exports": ["./ui/surfaces"],
|
||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
||||
"exports": [
|
||||
"./ui/surfaces"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/surfaceKeyboard.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"panel-rail-layout": {
|
||||
"exports": ["./ui/layout"],
|
||||
"exports": [
|
||||
"./ui/layout"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
@@ -287,7 +377,9 @@
|
||||
"external": []
|
||||
},
|
||||
"visual-input": {
|
||||
"exports": ["./ui/input"],
|
||||
"exports": [
|
||||
"./ui/input"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/visualInput.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
@@ -296,12 +388,19 @@
|
||||
"external": []
|
||||
},
|
||||
"display-controls": {
|
||||
"exports": ["./ui/display"],
|
||||
"modules": ["src/ui/displayControls.js"],
|
||||
"exports": [
|
||||
"./ui/display"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/displayControls.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"visual-effects": {
|
||||
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
||||
"exports": [
|
||||
"./ui/effects",
|
||||
"./ui/effects/bloom"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/effects.js",
|
||||
"src/ui/visualEffects.js",
|
||||
@@ -314,10 +413,14 @@
|
||||
"src/styles/noir.js",
|
||||
"src/styles/snow.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"map-source-controls": {
|
||||
"exports": ["./ui/maps"],
|
||||
"exports": [
|
||||
"./ui/maps"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
@@ -327,7 +430,10 @@
|
||||
"external": []
|
||||
},
|
||||
"layer-panel": {
|
||||
"exports": ["./ui/layers", "./ui/layers/feedback"],
|
||||
"exports": [
|
||||
"./ui/layers",
|
||||
"./ui/layers/feedback"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
@@ -338,7 +444,9 @@
|
||||
"external": []
|
||||
},
|
||||
"location-controls": {
|
||||
"exports": ["./ui/location"],
|
||||
"exports": [
|
||||
"./ui/location"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/location.js",
|
||||
"src/ui/locationControls.js",
|
||||
@@ -349,7 +457,9 @@
|
||||
"external": []
|
||||
},
|
||||
"radio-controls": {
|
||||
"exports": ["./ui/radio"],
|
||||
"exports": [
|
||||
"./ui/radio"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/radio.js",
|
||||
"src/ui/radioControls.js",
|
||||
@@ -360,7 +470,9 @@
|
||||
"external": []
|
||||
},
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"exports": [
|
||||
"./ui/cctv"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
@@ -372,7 +484,10 @@
|
||||
"external": []
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": ["./ui/context", "./ui/context/policy"],
|
||||
"exports": [
|
||||
"./ui/context",
|
||||
"./ui/context/policy"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
@@ -414,10 +529,14 @@
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"scene-controls": {
|
||||
"exports": ["./ui/scenes"],
|
||||
"exports": [
|
||||
"./ui/scenes"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
@@ -426,7 +545,9 @@
|
||||
"external": []
|
||||
},
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"exports": [
|
||||
"./ui/shell"
|
||||
],
|
||||
"modules": [
|
||||
"src/bloom.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
@@ -523,10 +644,14 @@
|
||||
"src/app/stateChannel.js",
|
||||
"src/ui/frameRateMonitor.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"ui-styles": {
|
||||
"exports": ["./ui/styles"],
|
||||
"exports": [
|
||||
"./ui/styles"
|
||||
],
|
||||
"modules": [
|
||||
"src/ui/styles.js",
|
||||
"style.css",
|
||||
@@ -553,7 +678,9 @@
|
||||
"external": []
|
||||
},
|
||||
"scenes": {
|
||||
"exports": ["./scenes"],
|
||||
"exports": [
|
||||
"./scenes"
|
||||
],
|
||||
"modules": [
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/recipes.js",
|
||||
@@ -565,10 +692,14 @@
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/contextModePolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"live-observations": {
|
||||
"exports": ["./sources/live"],
|
||||
"exports": [
|
||||
"./sources/live"
|
||||
],
|
||||
"modules": [
|
||||
"src/sources/live/aircraft.js",
|
||||
"src/sources/live/contract.js",
|
||||
@@ -579,7 +710,9 @@
|
||||
"external": []
|
||||
},
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"exports": [
|
||||
"./layers/flights"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
@@ -610,10 +743,14 @@
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/tracking.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"military-flights": {
|
||||
"exports": ["./layers/military"],
|
||||
"exports": [
|
||||
"./layers/military"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
@@ -641,10 +778,14 @@
|
||||
"src/layers/military/testing.js",
|
||||
"src/layers/military/tracking.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"aircraft-mechanics": {
|
||||
"exports": ["./aircraft"],
|
||||
"exports": [
|
||||
"./aircraft"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
@@ -664,10 +805,14 @@
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/index.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"vessel-layer": {
|
||||
"exports": ["./layers/vessels"],
|
||||
"exports": [
|
||||
"./layers/vessels"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
@@ -685,19 +830,27 @@
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/vesselLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"earthquakes": {
|
||||
"exports": ["./layers/earthquakes"],
|
||||
"exports": [
|
||||
"./layers/earthquakes"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/earthquakes/index.js",
|
||||
"src/layers/earthquakes/model.js",
|
||||
"src/layers/earthquakes/source.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"fire-layer": {
|
||||
"exports": ["./layers/firms"],
|
||||
"exports": [
|
||||
"./layers/firms"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/firmsAdapt.js",
|
||||
"src/data/firmsLabels.js",
|
||||
@@ -716,10 +869,14 @@
|
||||
"src/layers/firms/state.js",
|
||||
"src/layers/firms/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"satellites-layer": {
|
||||
"exports": ["./layers/satellites"],
|
||||
"exports": [
|
||||
"./layers/satellites"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/issPass.js",
|
||||
"src/data/satelliteClass.js",
|
||||
@@ -738,10 +895,15 @@
|
||||
"src/layers/satellites/testing.js",
|
||||
"src/layers/satellites/tracking.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
"external": [
|
||||
"cesium",
|
||||
"satellite.js"
|
||||
]
|
||||
},
|
||||
"launches-layer": {
|
||||
"exports": ["./layers/launches"],
|
||||
"exports": [
|
||||
"./layers/launches"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/launches/camera.js",
|
||||
"src/layers/launches/controls.js",
|
||||
@@ -763,10 +925,14 @@
|
||||
"src/layers/launches/state.js",
|
||||
"src/layers/launches/testing.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"installations-layer": {
|
||||
"exports": ["./layers/installations"],
|
||||
"exports": [
|
||||
"./layers/installations"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/militaryInstallationData.js",
|
||||
@@ -782,10 +948,14 @@
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
"exports": [
|
||||
"./layers/awareness"
|
||||
],
|
||||
"modules": [
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
@@ -808,10 +978,14 @@
|
||||
"src/layers/awareness/testing.js",
|
||||
"src/ui/panelRailGeometry.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"traffic-layer": {
|
||||
"exports": ["./layers/traffic"],
|
||||
"exports": [
|
||||
"./layers/traffic"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
@@ -836,10 +1010,16 @@
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/viewport.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
"external": [
|
||||
"cesium",
|
||||
"pbf",
|
||||
"@mapbox/vector-tile"
|
||||
]
|
||||
},
|
||||
"bikeshare-layer": {
|
||||
"exports": ["./layers/bikeshare"],
|
||||
"exports": [
|
||||
"./layers/bikeshare"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
@@ -856,10 +1036,14 @@
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/viewport.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"cctv-layer": {
|
||||
"exports": ["./layers/cctv"],
|
||||
"exports": [
|
||||
"./layers/cctv"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/cctvCards.js",
|
||||
"src/data/cctvGizmo.js",
|
||||
@@ -892,10 +1076,14 @@
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/overlays/worldOverlayTokens.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"radio-layer": {
|
||||
"exports": ["./layers/radio"],
|
||||
"exports": [
|
||||
"./layers/radio"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
@@ -924,10 +1112,14 @@
|
||||
"src/layers/radio/volume.js",
|
||||
"src/ui/radioTunerModel.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"submarine-cables": {
|
||||
"exports": ["./layers/submarine-cables"],
|
||||
"exports": [
|
||||
"./layers/submarine-cables"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/submarineCables/geometry.js",
|
||||
"src/layers/submarineCables/index.js",
|
||||
@@ -940,10 +1132,14 @@
|
||||
"src/layers/submarineCables/state.js",
|
||||
"src/layers/submarineCables/surface.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
},
|
||||
"bundled-cables": {
|
||||
"exports": ["./sources/bundled-cables"],
|
||||
"exports": [
|
||||
"./sources/bundled-cables"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/data/local_data/telegeography_submarine_cables/cable-geo.json",
|
||||
@@ -952,7 +1148,10 @@
|
||||
"external": []
|
||||
},
|
||||
"geography": {
|
||||
"exports": ["./geography/natural-earth", "./geography/neighborhoods"],
|
||||
"exports": [
|
||||
"./geography/natural-earth",
|
||||
"./geography/neighborhoods"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
@@ -962,5 +1161,30 @@
|
||||
"src/data/local_data/natural_earth/marine.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"maps": {
|
||||
"exports": [
|
||||
"./maps/controller",
|
||||
"./maps/catalog",
|
||||
"./maps/imagery",
|
||||
"./maps/terrain",
|
||||
"./maps/3d",
|
||||
"./maps/defaults"
|
||||
],
|
||||
"modules": [
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/keySetupCore.mjs"
|
||||
],
|
||||
"external": [
|
||||
"cesium"
|
||||
]
|
||||
}
|
||||
}
|
||||
+312
-149
@@ -34,7 +34,9 @@ function makeElement(tagName = 'div') {
|
||||
children: [],
|
||||
classList: {
|
||||
toggle(name, force) {
|
||||
const classes = new Set(String(element.className).split(/\s+/).filter(Boolean));
|
||||
const classes = new Set(
|
||||
String(element.className).split(/\s+/).filter(Boolean),
|
||||
);
|
||||
const next = force === undefined ? !classes.has(name) : !!force;
|
||||
if (next) classes.add(name);
|
||||
else classes.delete(name);
|
||||
@@ -44,15 +46,30 @@ function makeElement(tagName = 'div') {
|
||||
return String(element.className).split(/\s+/).includes(name);
|
||||
},
|
||||
},
|
||||
appendChild(child) { element.children.push(child); return child; },
|
||||
setAttribute(name, value) { element.attributes[name] = String(value); },
|
||||
getAttribute(name) { return element.attributes[name] ?? null; },
|
||||
addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); },
|
||||
click() { for (const handler of element.listeners.click || []) handler(); },
|
||||
appendChild(child) {
|
||||
element.children.push(child);
|
||||
return child;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
element.attributes[name] = String(value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return element.attributes[name] ?? null;
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
(element.listeners[type] ||= []).push(handler);
|
||||
},
|
||||
click() {
|
||||
for (const handler of element.listeners.click || []) handler();
|
||||
},
|
||||
};
|
||||
Object.defineProperty(element, 'innerHTML', {
|
||||
get() { return ''; },
|
||||
set() { element.children.length = 0; },
|
||||
get() {
|
||||
return '';
|
||||
},
|
||||
set() {
|
||||
element.children.length = 0;
|
||||
},
|
||||
});
|
||||
return element;
|
||||
}
|
||||
@@ -60,37 +77,93 @@ function makeElement(tagName = 'div') {
|
||||
const doc = { createElement: (tagName) => makeElement(tagName) };
|
||||
|
||||
/** Text a chip renders, label + optional requirement badge. */
|
||||
const chipText = (chip) => chip.children.map((child) => child.textContent).join(' ');
|
||||
const chipText = (chip) =>
|
||||
chip.children.map((child) => child.textContent).join(' ');
|
||||
|
||||
// Shaped exactly like MapStackController.getStacks() output.
|
||||
const CONTROLLER_STACKS = [
|
||||
{ id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null },
|
||||
{ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null },
|
||||
{ id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
|
||||
{ id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
|
||||
{ id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
requiresIon: false,
|
||||
available: true,
|
||||
unavailableReason: null,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
requiresIon: true,
|
||||
available: true,
|
||||
unavailableReason: null,
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
requiresIon: true,
|
||||
available: true,
|
||||
unavailableReason: null,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
requiresIon: false,
|
||||
available: true,
|
||||
unavailableReason: null,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
requiresIon: false,
|
||||
available: true,
|
||||
unavailableReason: null,
|
||||
},
|
||||
];
|
||||
|
||||
test('the row renders exactly the five owner-approved sources', () => {
|
||||
const container = makeElement();
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, {
|
||||
activeId: 'photoreal',
|
||||
doc,
|
||||
});
|
||||
|
||||
assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
container.children.map((chip) => chip.dataset.stackId),
|
||||
['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
|
||||
);
|
||||
assert.deepEqual(container.children.map(chipText), [
|
||||
'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM',
|
||||
'Google 3D',
|
||||
'Bing Aerial',
|
||||
'Bing Labels',
|
||||
'Esri Satellite',
|
||||
'OSM',
|
||||
]);
|
||||
assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
|
||||
assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
|
||||
assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
|
||||
assert.deepEqual(PRESENTED_MAP_STACK_IDS, [
|
||||
'photoreal',
|
||||
'bing-aerial',
|
||||
'bing-labels',
|
||||
'esri-imagery',
|
||||
'osm',
|
||||
]);
|
||||
assert.ok(
|
||||
container.children.every(
|
||||
(chip) => chip.tagName === 'button' && chip.type === 'button',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
container.children.every((chip) =>
|
||||
chip.classList.contains(MAP_STACK_CHIP_CLASS),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('internal and future stacks stay outside the approved presentation set', () => {
|
||||
const container = makeElement();
|
||||
// A future Hybrid stack may land in the controller, but it must not appear
|
||||
// until the owner-approved presentation allowlist explicitly includes it.
|
||||
const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }];
|
||||
const withHybrid = [
|
||||
...CONTROLLER_STACKS,
|
||||
{ id: 'hybrid', label: 'Hybrid', available: true },
|
||||
];
|
||||
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
|
||||
|
||||
assert.equal(container.children.length, 5);
|
||||
@@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', ()
|
||||
|
||||
test('re-rendering replaces the previous chips instead of stacking a second row', () => {
|
||||
const container = makeElement();
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, {
|
||||
activeId: 'photoreal',
|
||||
doc,
|
||||
});
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
|
||||
|
||||
assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length);
|
||||
@@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo
|
||||
|
||||
test('the active chip is the pressed chip, and exactly one is pressed', () => {
|
||||
const container = makeElement();
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc });
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, {
|
||||
activeId: 'bing-labels',
|
||||
doc,
|
||||
});
|
||||
|
||||
const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true');
|
||||
assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']);
|
||||
const pressed = container.children.filter(
|
||||
(chip) => chip.getAttribute('aria-pressed') === 'true',
|
||||
);
|
||||
assert.deepEqual(
|
||||
pressed.map((chip) => chip.dataset.stackId),
|
||||
['bing-labels'],
|
||||
);
|
||||
assert.ok(pressed[0].classList.contains('active'));
|
||||
assert.ok(container.children
|
||||
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
|
||||
.every((chip) => !chip.classList.contains('active')));
|
||||
assert.ok(
|
||||
container.children
|
||||
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
|
||||
.every((chip) => !chip.classList.contains('active')),
|
||||
);
|
||||
});
|
||||
|
||||
test('the lit chip tracks controller state, not the click', () => {
|
||||
const container = makeElement();
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
|
||||
renderMapStackChips(container, CONTROLLER_STACKS, {
|
||||
activeId: 'photoreal',
|
||||
doc,
|
||||
});
|
||||
|
||||
// A rejected/superseded switch reports the stack that is genuinely active.
|
||||
syncMapStackChips(container, 'photoreal');
|
||||
@@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => {
|
||||
|
||||
test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
|
||||
const container = makeElement();
|
||||
const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
|
||||
...stack,
|
||||
available: false,
|
||||
unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||
} : stack));
|
||||
const keyless = CONTROLLER_STACKS.map((stack) =>
|
||||
stack.requiresIon
|
||||
? {
|
||||
...stack,
|
||||
available: false,
|
||||
unavailableReason:
|
||||
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||
}
|
||||
: stack,
|
||||
);
|
||||
const selected = [];
|
||||
renderMapStackChips(container, keyless, {
|
||||
activeId: 'photoreal',
|
||||
@@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
|
||||
});
|
||||
|
||||
const bingAerial = container.children[1];
|
||||
assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable');
|
||||
assert.equal(
|
||||
bingAerial.disabled,
|
||||
false,
|
||||
'unavailable sources remain keyboard-reachable',
|
||||
);
|
||||
assert.equal(bingAerial.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(
|
||||
bingAerial.getAttribute('aria-label'),
|
||||
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||
);
|
||||
assert.ok(bingAerial.classList.contains('unavailable'));
|
||||
assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings');
|
||||
assert.equal(
|
||||
bingAerial.title,
|
||||
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||
);
|
||||
assert.equal(chipText(bingAerial), 'Bing Aerial ION');
|
||||
|
||||
bingAerial.click();
|
||||
assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path');
|
||||
assert.deepEqual(
|
||||
selected,
|
||||
[],
|
||||
'an unavailable stack must not reach the switch path',
|
||||
);
|
||||
|
||||
assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable');
|
||||
assert.equal(
|
||||
container.children[3].getAttribute('aria-disabled'),
|
||||
'false',
|
||||
'OSM stays selectable',
|
||||
);
|
||||
});
|
||||
|
||||
test('a non-ion stack that fails never claims an ion token is required', () => {
|
||||
// The startup fallback-to-OSM case: Google 3D tiles failed to load, so
|
||||
// photoreal is unavailable for a reason that has nothing to do with ion.
|
||||
const container = makeElement();
|
||||
const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
|
||||
...stack,
|
||||
available: false,
|
||||
unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
|
||||
} : stack));
|
||||
const tilesFailed = CONTROLLER_STACKS.map((stack) =>
|
||||
stack.id === 'photoreal'
|
||||
? {
|
||||
...stack,
|
||||
available: false,
|
||||
unavailableReason:
|
||||
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
|
||||
}
|
||||
: stack,
|
||||
);
|
||||
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
|
||||
|
||||
const google = container.children[0];
|
||||
assert.equal(google.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
|
||||
assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
|
||||
assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
|
||||
assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged');
|
||||
assert.equal(
|
||||
google.getAttribute('aria-label'),
|
||||
'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
|
||||
);
|
||||
assert.equal(
|
||||
chipText(google),
|
||||
'Google 3D',
|
||||
'no ION badge on a stack that does not need ion',
|
||||
);
|
||||
assert.equal(
|
||||
google.title,
|
||||
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
|
||||
);
|
||||
assert.equal(
|
||||
chipText(container.children[1]),
|
||||
'Bing Aerial',
|
||||
'available ion stacks stay unbadged',
|
||||
);
|
||||
});
|
||||
|
||||
test('models carry the stack\'s own reason and never invent an active chip', () => {
|
||||
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
available: true,
|
||||
active: false,
|
||||
requiresIon: false,
|
||||
requirement: '',
|
||||
unavailableHint: '',
|
||||
title: 'OSM',
|
||||
}]);
|
||||
test("models carry the stack's own reason and never invent an active chip", () => {
|
||||
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
available: true,
|
||||
active: false,
|
||||
requiresIon: false,
|
||||
requirement: '',
|
||||
unavailableHint: '',
|
||||
title: 'OSM',
|
||||
},
|
||||
]);
|
||||
|
||||
// A stack list without a controller-supplied reason still explains itself.
|
||||
assert.deepEqual(
|
||||
[
|
||||
mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null),
|
||||
mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null),
|
||||
].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })),
|
||||
mapStackChipModel(
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
requiresIon: true,
|
||||
available: false,
|
||||
},
|
||||
null,
|
||||
),
|
||||
mapStackChipModel(
|
||||
{ id: 'hybrid', label: 'Hybrid', available: false },
|
||||
null,
|
||||
),
|
||||
].map(({ requirement, unavailableHint, title }) => ({
|
||||
requirement,
|
||||
unavailableHint,
|
||||
title,
|
||||
})),
|
||||
[
|
||||
{
|
||||
requirement: 'ION',
|
||||
@@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
|
||||
assert.deepEqual(mapStackChipModels(undefined, 'osm'), []);
|
||||
});
|
||||
|
||||
test('the controller is the single source of the unavailability reason', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
|
||||
assert.doesNotMatch(
|
||||
controller,
|
||||
/bing-road/,
|
||||
'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
controller,
|
||||
/getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/,
|
||||
'getStacks() must expose why a stack is unavailable, not just that it is',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/,
|
||||
'the toast and the chip tooltip must read the same reason',
|
||||
);
|
||||
assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/);
|
||||
});
|
||||
|
||||
test('a missing row or document is inert rather than throwing during boot', () => {
|
||||
assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []);
|
||||
assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []);
|
||||
assert.deepEqual(
|
||||
renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }),
|
||||
[],
|
||||
);
|
||||
assert.doesNotThrow(() => syncMapStackChips(null, 'osm'));
|
||||
});
|
||||
|
||||
@@ -268,8 +394,14 @@ test('the active cyan survives hover', () => {
|
||||
const active = css.indexOf('.map-stack-chip.active {');
|
||||
const unavailable = css.indexOf('.map-stack-chip.unavailable');
|
||||
|
||||
assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity');
|
||||
assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up');
|
||||
assert.ok(
|
||||
hover > 0 && active > hover,
|
||||
'active must follow hover so it wins at equal specificity',
|
||||
);
|
||||
assert.ok(
|
||||
unavailable > active,
|
||||
'unavailable must follow both so a keyless chip never lights up',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css.slice(hover, active),
|
||||
/:not\(/,
|
||||
@@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
|
||||
// chip-state rule sets.
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
|
||||
.map(([, selector, body], order) => ({
|
||||
selector: stripComments(selector).trim(),
|
||||
body: stripComments(body),
|
||||
order,
|
||||
}));
|
||||
const chipRules = [
|
||||
...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g),
|
||||
].map(([, selector, body], order) => ({
|
||||
selector: stripComments(selector).trim(),
|
||||
body: stripComments(body),
|
||||
order,
|
||||
}));
|
||||
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
|
||||
|
||||
const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible')
|
||||
&& /outline:\s*(?!none)\S/.test(body)
|
||||
&& /outline-offset:/.test(body));
|
||||
assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring');
|
||||
const ringIndex = chipRules.findIndex(
|
||||
({ selector, body }) =>
|
||||
selector.includes(':focus-visible') &&
|
||||
/outline:\s*(?!none)\S/.test(body) &&
|
||||
/outline-offset:/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
ringIndex >= 0,
|
||||
'a :focus-visible rule must draw a real outline ring',
|
||||
);
|
||||
|
||||
// Nothing after it may touch outline again, so the ring cannot be erased by
|
||||
// .active, :disabled, or anything added later.
|
||||
@@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
|
||||
`"${rule.selector}" must not touch outline — it would erase the focus ring`,
|
||||
);
|
||||
}
|
||||
for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) {
|
||||
for (const selector of [
|
||||
'.map-stack-chip.active',
|
||||
'.map-stack-chip.unavailable',
|
||||
]) {
|
||||
assert.ok(
|
||||
chipRules.some((rule) => rule.selector.includes(selector)),
|
||||
`expected a ${selector} rule to exist for this check to mean anything`,
|
||||
@@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
|
||||
// `transition: all` animates outline-width off the `outline: none` base, and
|
||||
// Chrome parks that transition at 0px for chips on a wrapped line — the ring
|
||||
// never appeared on rows 2 and 3. The base rule must list its properties.
|
||||
const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip'));
|
||||
const base = chipRules.find((rule) =>
|
||||
rule.selector.endsWith('.map-stack-chip'),
|
||||
);
|
||||
assert.ok(base, 'expected the base .map-stack-chip rule');
|
||||
assert.doesNotMatch(
|
||||
base.body,
|
||||
/transition:\s*all\b/,
|
||||
'transition: all animates outline-width and kills the focus ring on wrapped rows',
|
||||
);
|
||||
assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated');
|
||||
assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates');
|
||||
assert.doesNotMatch(
|
||||
base.body,
|
||||
/transition:[^;]*outline/,
|
||||
'the focus ring must not be animated',
|
||||
);
|
||||
assert.match(
|
||||
base.body,
|
||||
/transition:\s*\n?\s*color/,
|
||||
'the hover/active treatment still animates',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(
|
||||
new URL('./ui/applicationShell.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/map-stack-select/,
|
||||
'the SOURCE dropdown is replaced by the chip row',
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
|
||||
);
|
||||
assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired');
|
||||
assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/id="stack-panel"/,
|
||||
'the duplicate left MAP STACK panel is retired',
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
|
||||
'the compact wing must expose a semantic keyboard disclosure',
|
||||
);
|
||||
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
|
||||
const panels = readFileSync(
|
||||
new URL('./ui/panelDisclosure.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
|
||||
assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/);
|
||||
|
||||
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||
'the application action uses the component selection path');
|
||||
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
|
||||
'chip clicks use the same component selection path');
|
||||
assert.match(controls, /await controller\.setStack\(stackId\)/,
|
||||
'selection still delegates source loading to the map controller');
|
||||
assert.match(controls, /render\(controller\.getState\(\)\)/,
|
||||
'the active chip is re-synced from actual controller state');
|
||||
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'provider-driven fallback reaches the component without a user click');
|
||||
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'the provider-driven subscription has an explicit remover');
|
||||
assert.match(controls, /unsubscribe\?\.\(\)/,
|
||||
'component destruction releases its subscription');
|
||||
|
||||
});
|
||||
|
||||
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
controller,
|
||||
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
|
||||
'construction failure must resolve truthfully to OSM',
|
||||
ui,
|
||||
/querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/,
|
||||
);
|
||||
|
||||
const controls = readFileSync(
|
||||
new URL('./ui/mapSourceControls.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
|
||||
'the active chip must follow the effective provider, not the requested stack',
|
||||
ui,
|
||||
/return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||
'the application action uses the component selection path',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
|
||||
'Esri credit must follow the effective provider',
|
||||
controls,
|
||||
/onSelect: [\s\S]*?select\(id\)/,
|
||||
'chip clicks use the same component selection path',
|
||||
);
|
||||
});
|
||||
|
||||
test('repeated active Esri tile failures fall back to OSM and one transient does not', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
assert.match(controller, /let failures = 0/);
|
||||
assert.match(controller, /if \(failures < 2 \|\| this\._esriFallbackPending\) return/);
|
||||
assert.match(controller, /this\.setStack\('osm', \{ silent: true \}\)/);
|
||||
assert.match(controller, /state\?\.activeId === 'osm'[\s\S]*?this\._emitChange\('error'\)/);
|
||||
assert.match(
|
||||
controller,
|
||||
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
|
||||
'a stale provider error must not replace a newer user selection',
|
||||
controls,
|
||||
/await controller\.setStack\(stackId\)/,
|
||||
'selection still delegates source loading to the map controller',
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/render\(controller\.getState\(\)\)/,
|
||||
'the active chip is re-synced from actual controller state',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.addEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'provider-driven fallback reaches the component without a user click',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'the provider-driven subscription has an explicit remover',
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/unsubscribe\?\.\(\)/,
|
||||
'component destruction releases its subscription',
|
||||
);
|
||||
});
|
||||
+25
-487
@@ -1,494 +1,32 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { MapSourceController } from './maps/controller.js';
|
||||
import { createDefaultMapSources } from './maps/defaultSources.js';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { keySetupRequirement } from './keySetupCore.mjs';
|
||||
export { MAP_STACKS } from './maps/catalog.js';
|
||||
export { photorealUnavailableReason } from './maps/availability.js';
|
||||
|
||||
/**
|
||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
||||
* key or ion token configured, the tileset failed for another reason —
|
||||
* restrictions, quota, an EEA-billed key, or the network — and telling the
|
||||
* user to add a key they already added is the wrong advice.
|
||||
* @param {boolean} hasCredentials
|
||||
* @returns {string}
|
||||
*/
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
|
||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_OSM_CREDIT = '© OpenStreetMap contributors';
|
||||
|
||||
// Esri World Imagery — the keyless satellite basemap and the default keyless
|
||||
// landing (a spy-satellite simulator should open on satellite imagery, not a
|
||||
// street map). The classic ArcGIS Online tile service answers without a key;
|
||||
// attribution is required and the provider carries the service's own credit
|
||||
// line. Terms note in DATA_SOURCES.md.
|
||||
const ESRI_WORLD_IMAGERY_URL =
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer';
|
||||
const ESRI_IMAGERY_CREDIT =
|
||||
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
|
||||
// The on-screen notice Esri requires when a third-party library draws its
|
||||
// service. Rendered via an explicit static credit (see _syncEsriAttribution) —
|
||||
// the provider's own `credit` option is ignored for tiled ArcGIS servers.
|
||||
const ESRI_ATTRIBUTION_HTML =
|
||||
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
|
||||
|
||||
// Keyless global ellipsoidal terrain (Re:Earth Terrain / Mapterhorn, CC BY 4.0,
|
||||
// EGM2008 geoid via NGA) — quantized-mesh 1.0, `ellipsoid` data-type. Fixes
|
||||
// regime C (keyless globe stacks previously rendered a flat
|
||||
// EllipsoidTerrainProvider — see docs/superpowers/specs/2026-07-05-entity-height-datum-design.md
|
||||
// §1a). Constructed via `.fromUrl()`, never a hand-built `{z}/{x}/{y}.terrain`
|
||||
// URL (review correction, spec §1a).
|
||||
const REEARTH_TERRAIN_URL = 'https://terrain.reearth.land/cesium-mesh/ellipsoid';
|
||||
|
||||
/**
|
||||
* Controls the active globe/map stack. Google Photorealistic 3D Tiles remain
|
||||
* the cinematic default, while Cesium ion world imagery and OSM run as globe
|
||||
* imagery stacks.
|
||||
*/
|
||||
export class MapStackController {
|
||||
constructor(viewer, {
|
||||
googleTileset = null,
|
||||
cesiumToken = '',
|
||||
initialStack = 'photoreal',
|
||||
onChange = null,
|
||||
onError = null,
|
||||
} = {}) {
|
||||
this.viewer = viewer;
|
||||
this.googleTileset = googleTileset;
|
||||
this.cesiumToken = String(cesiumToken || '').trim();
|
||||
this._onChange = onChange;
|
||||
this._onError = onError;
|
||||
this._activeId = googleTileset ? initialStack : 'esri-imagery';
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._esriFallbackPending = false;
|
||||
this._imageryProviders = new Map();
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
// Tracks which terrain PROVIDER is actually installed on the scene, not
|
||||
// just an ion-available boolean: 'world' (Cesium World Terrain, ion
|
||||
// token), 'keyless' (Re:Earth or its Ellipsoid fallback), or null (never
|
||||
// set yet — Cesium's own startup default). Using a tri-state here (rather
|
||||
// than the `enabled` boolean `_setWorldTerrainEnabled` receives) matters
|
||||
// because both the "never set" and "keyless" states pass `enabled=false`;
|
||||
// collapsing them to a boolean would make the first real keyless switch
|
||||
// a no-op against the initial `false` default and leave Cesium's built-in
|
||||
// provider in place instead of installing Re:Earth terrain.
|
||||
this._terrainMode = null;
|
||||
// Cache of the constructed keyless Re:Earth CesiumTerrainProvider, so
|
||||
// repeat switches into a keyless globe stack don't refetch `layer.json`.
|
||||
// Lives independently of `_switchGen` — construction is async and racy
|
||||
// switches are guarded where it's awaited (`_setWorldTerrainEnabled`).
|
||||
this._reearthTerrainProvider = null;
|
||||
// Monotonic switch counter. setStack() awaits network-bound provider
|
||||
// creation; a rapid A→B switch where A (e.g. slow Bing) resolves AFTER B
|
||||
// (fast OSM) would otherwise revert the user's last choice (M7). Each call
|
||||
// captures a generation and aborts its own commit once superseded.
|
||||
this._switchGen = 0;
|
||||
|
||||
if (!this.getStack(this._activeId) || !this.isStackAvailable(this._activeId)) {
|
||||
this._activeId = googleTileset ? 'photoreal' : 'esri-imagery';
|
||||
}
|
||||
}
|
||||
|
||||
getStacks() {
|
||||
return MAP_STACKS.map((stack) => {
|
||||
const available = this.isStackAvailable(stack.id);
|
||||
return {
|
||||
...stack,
|
||||
available,
|
||||
// Why this stack can't be picked, from the ONE place that decides it.
|
||||
// A stack can be unavailable for reasons other than a missing ion
|
||||
// token (photoreal is unavailable when the Google tileset failed to
|
||||
// load), so callers must not infer the reason from `available` alone.
|
||||
unavailableReason: available ? null : this._unavailableReason(stack),
|
||||
};
|
||||
/** Preserve the standalone entry point; applications can compose the source controller directly. */
|
||||
export class MapStackController extends MapSourceController {
|
||||
constructor(viewer, options = {}) {
|
||||
const googleApiKey =
|
||||
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
const registry = createDefaultMapSources({ ...options, googleApiKey });
|
||||
super(viewer, {
|
||||
registry,
|
||||
initialStack: options.googleTileset
|
||||
? options.initialStack || 'photoreal'
|
||||
: registry.defaultId,
|
||||
...options,
|
||||
requestRender: governorRequestRender,
|
||||
});
|
||||
this.googleTileset = options.googleTileset || null;
|
||||
this.cesiumToken = String(options.cesiumToken || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable reason a stack can't be activated. Shared by `getStacks()`
|
||||
* and `setStack()` so the tooltip and the toast never drift apart.
|
||||
* @param {object} stack - Stack descriptor.
|
||||
* @returns {string}
|
||||
*/
|
||||
_unavailableReason(stack) {
|
||||
if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
|
||||
if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
|
||||
return `${stack?.label || 'This map stack'} is unavailable`;
|
||||
}
|
||||
|
||||
/** A direct Google key or an ion token is enough to attempt Google 3D. */
|
||||
_hasPhotorealCredentials() {
|
||||
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
|
||||
}
|
||||
|
||||
getStack(id) {
|
||||
return MAP_STACKS.find((stack) => stack.id === id) || null;
|
||||
}
|
||||
|
||||
getActiveId() {
|
||||
return this._activeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic id of the most recently STARTED switch.
|
||||
*
|
||||
* A switch is only superseded by another `setStack()` — nothing else moves
|
||||
* this number — so a caller that must know whether the globe it is looking
|
||||
* at is still the one IT asked for can compare this across its own await.
|
||||
* Unchanged (or advanced by exactly its own call) means no newer switch has
|
||||
* claimed the globe.
|
||||
* @returns {number}
|
||||
*/
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
|
||||
getActiveStack() {
|
||||
return this.getStack(this._activeId);
|
||||
}
|
||||
|
||||
isStackAvailable(id) {
|
||||
const stack = this.getStack(id);
|
||||
if (!stack) return false;
|
||||
if (stack.kind === 'photoreal') return !!this.googleTileset;
|
||||
if (stack.requiresIon) return !!this.cesiumToken;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Invalidate pending switches before the viewer is destroyed. */
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._switchGen++;
|
||||
this._isSwitching = false;
|
||||
this._removeImageryErrorListener?.();
|
||||
this._removeImageryErrorListener = null;
|
||||
this._onChange = null;
|
||||
this._onError = null;
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack('photoreal');
|
||||
if (!stack) return null;
|
||||
|
||||
if (!this.isStackAvailable(stack.id)) {
|
||||
const message = this._unavailableReason(stack);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
const gen = ++this._switchGen;
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
|
||||
try {
|
||||
let activation = null;
|
||||
if (stack.kind === 'photoreal') {
|
||||
await this._activatePhotoreal(gen);
|
||||
} else {
|
||||
activation = await this._activateGlobeStack(stack, gen);
|
||||
}
|
||||
// A newer switch started while we were awaiting the provider — that call
|
||||
// owns the final state now, so don't commit ours or emit a stale 'ready'.
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
}
|
||||
// Show/hide of tilesets + imagery swaps need a frame in idle mode;
|
||||
// subsequent tile loads self-request via Cesium. (perf wave 2)
|
||||
governorRequestRender('map-stack');
|
||||
if (!silent) this._emitChange('ready');
|
||||
} catch (error) {
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
const message = error?.message || String(error);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
if (this.googleTileset) {
|
||||
await this._activatePhotoreal(gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = 'photoreal';
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
// Only the latest switch clears the switching flag; a superseded call
|
||||
// must not stomp a newer switch that is still in progress.
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
}
|
||||
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
activeStack: this.getActiveStack(),
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
hasCesiumIonToken: !!this.cesiumToken,
|
||||
};
|
||||
}
|
||||
|
||||
async _activatePhotoreal(gen) {
|
||||
this._removeImageryLayer();
|
||||
this._syncEsriAttribution(null); // Esri is no longer on screen.
|
||||
if (this.googleTileset) this.googleTileset.show = true;
|
||||
this.viewer.scene.globe.show = false;
|
||||
// Terrain is left UNTOUCHED here. The photoreal globe is hidden
|
||||
// (`globe.show = false`), so the terrain provider is inert — it renders and
|
||||
// streams nothing. Routing this through `_setWorldTerrainEnabled(false)`
|
||||
// would make the DEFAULT startup stack await a keyless Re:Earth `layer.json`
|
||||
// fetch it can't use, delaying photoreal boot on a slow/blocked network and
|
||||
// (on failure) caching the flat `EllipsoidTerrainProvider` fallback for
|
||||
// later OSM switches. The Re:Earth fetch is therefore lazy: it happens on
|
||||
// the first switch to an actual globe stack (`_activateGlobeStack`).
|
||||
// `_terrainMode` is intentionally not changed — every globe-stack transition
|
||||
// re-derives the correct provider from it (null/'world'/'keyless'), so
|
||||
// leaving it as-is keeps the next switch correct without a photoreal fetch.
|
||||
void gen;
|
||||
}
|
||||
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
// A newer switch started while the provider was resolving — don't touch the
|
||||
// scene's imagery layers, the winning switch already owns them (M7).
|
||||
if (gen != null && gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
|
||||
this._imageryLayer = new Cesium.ImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
this._syncEsriAttribution(resolution.effectiveStackId);
|
||||
this._watchEsriProvider(resolution, gen);
|
||||
|
||||
if (this.googleTileset) this.googleTileset.show = false;
|
||||
this.viewer.scene.globe.show = true;
|
||||
await this._setWorldTerrainEnabled(!!this.cesiumToken, gen);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the required "Powered by Esri" notice with the Esri layer's
|
||||
* own lifecycle.
|
||||
*
|
||||
* This cannot ride on the provider's `credit` option: Cesium IGNORES that
|
||||
* option for tiled ArcGIS MapServer sources, so passing it there displays
|
||||
* nothing and the app would be using the service without the attribution
|
||||
* Esri requires of third-party libraries. It is an ON-SCREEN credit (not the
|
||||
* lightbox, where per-layer data credits live) because that is what the
|
||||
* requirement asks for, and it is removed when another stack takes over so
|
||||
* the globe never claims a source it is not showing.
|
||||
*/
|
||||
_syncEsriAttribution(activeStackId) {
|
||||
const creditDisplay = this.viewer?.scene?.frameState?.creditDisplay;
|
||||
if (!creditDisplay) return;
|
||||
const wanted = activeStackId === 'esri-imagery';
|
||||
if (wanted === !!this._esriCreditShown) return;
|
||||
if (!this._esriCredit) {
|
||||
this._esriCredit = new Cesium.Credit(ESRI_ATTRIBUTION_HTML, true);
|
||||
}
|
||||
try {
|
||||
if (wanted) creditDisplay.addStaticCredit(this._esriCredit);
|
||||
else creditDisplay.removeStaticCredit(this._esriCredit);
|
||||
this._esriCreditShown = wanted;
|
||||
} catch {
|
||||
// A Cesium build without static-credit removal must not break switching.
|
||||
}
|
||||
}
|
||||
|
||||
async _getImageryProvider(stack) {
|
||||
if (this._imageryProviders.has(stack.id)) {
|
||||
return this._imageryProviders.get(stack.id);
|
||||
}
|
||||
|
||||
let provider;
|
||||
let effectiveStackId = stack.id;
|
||||
let fallbackMessage = null;
|
||||
if (stack.kind === 'ion') {
|
||||
provider = await Cesium.createWorldImageryAsync({ style: stack.style });
|
||||
} else if (stack.kind === 'esri-imagery') {
|
||||
try {
|
||||
provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(ESRI_WORLD_IMAGERY_URL, {
|
||||
credit: ESRI_IMAGERY_CREDIT,
|
||||
enablePickFeatures: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// The keyless DEFAULT landing must never strand a first run on a blank
|
||||
// globe because Esri is unreachable — fall back to OSM tiles for this
|
||||
// session. (The fallback is cached under this stack id like any other
|
||||
// provider, so the session won't re-probe Esri; a restart does.)
|
||||
console.warn('[MapStack] Esri World Imagery unavailable, falling back to OSM:', error?.message || error);
|
||||
provider = new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: DEFAULT_OSM_CREDIT,
|
||||
});
|
||||
effectiveStackId = 'osm';
|
||||
fallbackMessage = 'Esri Satellite is unavailable; using OSM';
|
||||
}
|
||||
} else if (stack.kind === 'osm') {
|
||||
provider = new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: DEFAULT_OSM_CREDIT,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported map stack: ${stack.id}`);
|
||||
}
|
||||
|
||||
const resolution = { provider, effectiveStackId, fallbackMessage };
|
||||
this._imageryProviders.set(stack.id, resolution);
|
||||
if (effectiveStackId === 'osm' && !this._imageryProviders.has('osm')) {
|
||||
this._imageryProviders.set('osm', { provider, effectiveStackId: 'osm', fallbackMessage: null });
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Esri provider construction can succeed while its first tile requests fail.
|
||||
* Two failures for the active provider trigger the same truthful OSM fallback
|
||||
* as a construction failure; one transient error is left to Cesium's retry.
|
||||
*/
|
||||
_watchEsriProvider(resolution, gen) {
|
||||
if (resolution.effectiveStackId !== 'esri-imagery') return;
|
||||
const errorEvent = resolution.provider?.errorEvent;
|
||||
if (!errorEvent?.addEventListener) return;
|
||||
let failures = 0;
|
||||
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
|
||||
if (gen !== this._switchGen || this._activeImageryProvider !== resolution.provider) return;
|
||||
const retryCount = Number(error?.timesRetried);
|
||||
failures = Number.isInteger(retryCount) && retryCount >= 0
|
||||
? Math.max(failures + 1, retryCount + 1)
|
||||
: failures + 1;
|
||||
if (failures < 2 || this._esriFallbackPending) return;
|
||||
this._esriFallbackPending = true;
|
||||
const message = 'Esri Satellite tile requests failed; using OSM';
|
||||
this._onError?.(message, this.getStack('esri-imagery'));
|
||||
void this.setStack('osm', { silent: true }).then((state) => {
|
||||
if (state?.activeId === 'osm') {
|
||||
this._lastError = message;
|
||||
this._emitChange('error');
|
||||
}
|
||||
}).finally(() => {
|
||||
this._esriFallbackPending = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_removeImageryLayer() {
|
||||
if (this._removeImageryErrorListener) {
|
||||
this._removeImageryErrorListener();
|
||||
this._removeImageryErrorListener = null;
|
||||
}
|
||||
if (!this._imageryLayer) return;
|
||||
this.viewer.imageryLayers.remove(this._imageryLayer, false);
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scene's terrain provider for the current globe stack.
|
||||
*
|
||||
* `enabled` selects Cesium World Terrain (ion token present — regime B,
|
||||
* unchanged). Disabled/keyless (regime C: OSM or any globe stack without an
|
||||
* ion token) now tries the keyless Re:Earth ellipsoidal terrain instead of
|
||||
* the flat `EllipsoidTerrainProvider`, falling back to the flat provider
|
||||
* (today's behavior) if construction fails — no worse than before this fix.
|
||||
*
|
||||
* `CesiumTerrainProvider.fromUrl()` is async (fetches `layer.json`), so this
|
||||
* method is async-safe: `gen` is the caller's switch generation (from
|
||||
* `setStack`'s `_switchGen`, threaded through `_activatePhotoreal` /
|
||||
* `_activateGlobeStack`, mirroring the M7 pattern in `_activateGlobeStack`
|
||||
* for imagery providers). If a newer switch starts while the Re:Earth
|
||||
* fetch is in flight, this call's result is discarded instead of
|
||||
* clobbering the newer switch's terrain.
|
||||
* @param {boolean} enabled
|
||||
* @param {number} [gen] — switch generation this call belongs to
|
||||
*/
|
||||
async _setWorldTerrainEnabled(enabled, gen) {
|
||||
const targetMode = enabled ? 'world' : 'keyless';
|
||||
if (targetMode === this._terrainMode) return;
|
||||
if (enabled) {
|
||||
this.viewer.scene.setTerrain(Cesium.Terrain.fromWorldTerrain({
|
||||
requestVertexNormals: true,
|
||||
}));
|
||||
} else {
|
||||
const provider = await this._getKeylessTerrainProvider();
|
||||
// A newer switch started while the Re:Earth layer.json fetch was in
|
||||
// flight — that call owns terrain now; don't stomp it (M7 pattern).
|
||||
if (gen != null && gen !== this._switchGen) return;
|
||||
this.viewer.terrainProvider = provider;
|
||||
}
|
||||
this._terrainMode = targetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves (and caches) the keyless terrain provider for globe stacks
|
||||
* without an ion token: Re:Earth ellipsoidal quantized-mesh terrain, or
|
||||
* `EllipsoidTerrainProvider` (flat — current/prior behavior) if the
|
||||
* Re:Earth endpoint can't be constructed. Never throws.
|
||||
* @returns {Promise<Cesium.TerrainProvider>}
|
||||
*/
|
||||
async _getKeylessTerrainProvider() {
|
||||
if (this._reearthTerrainProvider) return this._reearthTerrainProvider;
|
||||
try {
|
||||
this._reearthTerrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(REEARTH_TERRAIN_URL);
|
||||
} catch (error) {
|
||||
console.warn('[mapStackController] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:', error);
|
||||
this._reearthTerrainProvider = new Cesium.EllipsoidTerrainProvider();
|
||||
}
|
||||
return this._reearthTerrainProvider;
|
||||
}
|
||||
|
||||
_emitChange(status) {
|
||||
this._onChange?.(this.getState(status));
|
||||
const googleKey =
|
||||
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
return (
|
||||
Boolean(String(googleKey || '').trim()) ||
|
||||
Boolean(String(this.cesiumToken || '').trim())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MapStackController, photorealUnavailableReason } from './mapStackController.js';
|
||||
import {
|
||||
MapStackController,
|
||||
photorealUnavailableReason,
|
||||
} from './mapStackController.js';
|
||||
|
||||
test('missing photoreal credentials explain both supported setup routes', () => {
|
||||
assert.match(photorealUnavailableReason(false), /Needs GOOGLE_MAPS_API_KEY.*Provider Settings/);
|
||||
assert.match(
|
||||
photorealUnavailableReason(false),
|
||||
/Needs GOOGLE_MAPS_API_KEY.*Provider Settings/,
|
||||
);
|
||||
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
|
||||
});
|
||||
|
||||
|
||||
+4
-51
@@ -1,51 +1,4 @@
|
||||
const clean = (value) => String(value || '').trim();
|
||||
|
||||
/**
|
||||
* Decide which map provider can deliver the best startup experience.
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {'google-direct'|'google-ion'|'osm'}
|
||||
*/
|
||||
export function selectMapStartupRoute({ googleApiKey = '', cesiumToken = '' } = {}) {
|
||||
if (clean(googleApiKey)) return 'google-direct';
|
||||
if (clean(cesiumToken)) return 'google-ion';
|
||||
return 'osm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
if (ionToken) Cesium.Ion.defaultAccessToken = ionToken;
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
Cesium.GoogleMaps.defaultApiKey = attempt.googleKey;
|
||||
try {
|
||||
const tileset = await Cesium.createGooglePhotorealistic3DTileset({
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
Cesium.GoogleMaps.defaultApiKey = undefined;
|
||||
return { tileset: null, route: 'osm', errors };
|
||||
}
|
||||
export {
|
||||
selectMapStartupRoute,
|
||||
loadPhotorealisticTileset,
|
||||
} from './maps/google3d.js';
|
||||
+50
-12
@@ -7,25 +7,42 @@ import {
|
||||
|
||||
function fakeCesium(outcomes = []) {
|
||||
const calls = [];
|
||||
const next = () => {
|
||||
const outcome = outcomes.shift();
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome;
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
Ion: { defaultAccessToken: undefined },
|
||||
GoogleMaps: { defaultApiKey: undefined },
|
||||
async createGooglePhotorealistic3DTileset(options) {
|
||||
calls.push({
|
||||
options,
|
||||
googleKey: this.GoogleMaps.defaultApiKey,
|
||||
ionToken: this.Ion.defaultAccessToken,
|
||||
});
|
||||
const outcome = outcomes.shift();
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome;
|
||||
calls.push({ options, googleKey: options.key });
|
||||
return next();
|
||||
},
|
||||
IonResource: {
|
||||
async fromAssetId(assetId, options) {
|
||||
return { assetId, ...options };
|
||||
},
|
||||
},
|
||||
Cesium3DTileset: {
|
||||
async fromUrl(resource, options) {
|
||||
calls.push({
|
||||
options,
|
||||
ionToken: resource.accessToken,
|
||||
assetId: resource.assetId,
|
||||
});
|
||||
return next();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('map startup route reflects the best configured provider', () => {
|
||||
assert.equal(selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }), 'google-direct');
|
||||
assert.equal(
|
||||
selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }),
|
||||
'google-direct',
|
||||
);
|
||||
assert.equal(selectMapStartupRoute({ cesiumToken: 'ion' }), 'google-ion');
|
||||
assert.equal(selectMapStartupRoute(), 'osm');
|
||||
});
|
||||
@@ -53,13 +70,17 @@ test('a direct Google key is preferred', async () => {
|
||||
test('an ion-only setup loads the hosted Google 3D asset', async () => {
|
||||
const tileset = { id: 'ion' };
|
||||
const Cesium = fakeCesium([tileset]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, { cesiumToken: 'ion-secret' });
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
cesiumToken: 'ion-secret',
|
||||
});
|
||||
assert.equal(result.tileset, tileset);
|
||||
assert.equal(result.route, 'google-ion');
|
||||
assert.equal(Cesium.calls.length, 1);
|
||||
assert.equal(Cesium.calls[0].googleKey, undefined);
|
||||
assert.equal(Cesium.calls[0].ionToken, 'ion-secret');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, 'ion-secret');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, undefined);
|
||||
assert.equal(Cesium.calls[0].assetId, 2275207);
|
||||
assert.equal(Cesium.calls[0].options.enableCollision, true);
|
||||
});
|
||||
|
||||
test('a failed direct request retries through ion before falling back', async () => {
|
||||
@@ -91,7 +112,10 @@ test('a failed direct-only request does not consume an implicit Cesium token', a
|
||||
});
|
||||
|
||||
test('failed direct and ion requests preserve the keyless OSM fallback', async () => {
|
||||
const Cesium = fakeCesium([new Error('direct denied'), new Error('ion denied')]);
|
||||
const Cesium = fakeCesium([
|
||||
new Error('direct denied'),
|
||||
new Error('ion denied'),
|
||||
]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey: 'google-secret',
|
||||
cesiumToken: 'ion-secret',
|
||||
@@ -102,3 +126,17 @@ test('failed direct and ion requests preserve the keyless OSM fallback', async (
|
||||
assert.equal(Cesium.calls.length, 2);
|
||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, undefined);
|
||||
});
|
||||
|
||||
test('independent source configurations never mutate shared SDK credentials', async () => {
|
||||
const Cesium = fakeCesium([{ id: 'a' }, { id: 'b' }]);
|
||||
Cesium.Ion.defaultAccessToken = 'untouched-ion';
|
||||
Cesium.GoogleMaps.defaultApiKey = 'untouched-google';
|
||||
await Promise.all([
|
||||
loadPhotorealisticTileset(Cesium, { googleApiKey: 'source-a' }),
|
||||
loadPhotorealisticTileset(Cesium, { cesiumToken: 'source-b' }),
|
||||
]);
|
||||
assert.equal(Cesium.calls[0].googleKey, 'source-a');
|
||||
assert.equal(Cesium.calls[1].ionToken, 'source-b');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
|
||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
/**
|
||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
||||
* key or ion token configured, the tileset failed for another reason —
|
||||
* restrictions, quota, an EEA-billed key, or the network — and telling the
|
||||
* user to add a key they already added is the wrong advice.
|
||||
* @param {boolean} hasCredentials
|
||||
* @returns {string}
|
||||
*/
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials)
|
||||
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
|
||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as Cesium from 'cesium';
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,335 @@
|
||||
import { indexMapSources } from './registry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMapCredits } from './credits.js';
|
||||
|
||||
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
||||
export class MapSourceController {
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
registry,
|
||||
initialStack,
|
||||
onChange = null,
|
||||
onError = null,
|
||||
requestRender = () => viewer?.scene?.requestRender?.(),
|
||||
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
|
||||
},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this._registry = registry;
|
||||
this._sources = indexMapSources(registry.sources);
|
||||
this._activeId = this.isStackAvailable(initialStack)
|
||||
? initialStack
|
||||
: registry.defaultId;
|
||||
this._onChange = onChange;
|
||||
this._onError = onError;
|
||||
this._requestRender = requestRender;
|
||||
this._createImageryLayer = createImageryLayer;
|
||||
this._credits = createMapCredits(viewer);
|
||||
this._abort = new AbortController();
|
||||
this._imageryProviders = new Map();
|
||||
this._terrainProviders = new Map();
|
||||
this._tilesets = new Map();
|
||||
this._ownedTilesets = new Set();
|
||||
this._disposed = new WeakSet();
|
||||
this._switchGen = 0;
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._terrainMode = null;
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
getStack(id) {
|
||||
return this._sources.get(id)?.descriptor || null;
|
||||
}
|
||||
getStacks() {
|
||||
return [...this._sources.values()].map(({ descriptor }) => {
|
||||
const stack = descriptor;
|
||||
const available = this.isStackAvailable(stack.id);
|
||||
return {
|
||||
...stack,
|
||||
available,
|
||||
unavailableReason: available ? null : this._unavailableReason(stack),
|
||||
};
|
||||
});
|
||||
}
|
||||
isStackAvailable(id) {
|
||||
const source = this._sources.get(id);
|
||||
return Boolean(source && source.available !== false);
|
||||
}
|
||||
_unavailableReason(stack) {
|
||||
return (
|
||||
this._sources.get(stack?.id)?.unavailableReason ||
|
||||
`${stack?.label || 'This map stack'} is unavailable`
|
||||
);
|
||||
}
|
||||
getActiveId() {
|
||||
return this._activeId;
|
||||
}
|
||||
getActiveStack() {
|
||||
return this.getStack(this._activeId);
|
||||
}
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
activeStack: this.getActiveStack(),
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
...this._registry.state,
|
||||
};
|
||||
}
|
||||
_emitChange(status) {
|
||||
this._onChange?.(this.getState(status));
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
|
||||
if (!stack) return null;
|
||||
if (!this.isStackAvailable(stack.id)) {
|
||||
const message = this._unavailableReason(stack);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
return this.getState();
|
||||
}
|
||||
const gen = ++this._switchGen;
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
try {
|
||||
const activation = await this._activate(stack, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
}
|
||||
this._requestRender('map-stack');
|
||||
if (!silent) this._emitChange('ready');
|
||||
} catch (error) {
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
const message = error?.message || String(error);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
const recovery = this.getStack(this._registry.recoveryId);
|
||||
if (
|
||||
recovery &&
|
||||
recovery.id !== stack.id &&
|
||||
this.isStackAvailable(recovery.id)
|
||||
) {
|
||||
await this._activate(recovery, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = recovery.id;
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
_activate(stack, gen) {
|
||||
const source = this._sources.get(stack.id);
|
||||
return source.imagery
|
||||
? this._activateGlobeStack(stack, gen)
|
||||
: this._activateTileset(source, gen);
|
||||
}
|
||||
|
||||
async _activateTileset(source, gen) {
|
||||
let tileset = source.tileset;
|
||||
if (!tileset) {
|
||||
if (!source.createTileset)
|
||||
throw new Error(`Missing 3D source: ${source.descriptor.id}`);
|
||||
tileset = await this._cached(
|
||||
this._tilesets,
|
||||
source.descriptor.id,
|
||||
async () => {
|
||||
const value = await source.createTileset({
|
||||
signal: this._abort.signal,
|
||||
});
|
||||
if (this._destroyed) {
|
||||
this._dispose(value);
|
||||
return value;
|
||||
}
|
||||
value.show = false;
|
||||
this.viewer.scene.primitives.add(value);
|
||||
this._ownedTilesets.add(value);
|
||||
return value;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
this._credits.show(source.credit || null);
|
||||
this._showTileset(tileset);
|
||||
this.viewer.scene.globe.show = false;
|
||||
// Terrain is intentionally untouched while the globe is hidden.
|
||||
}
|
||||
|
||||
_showTileset(active) {
|
||||
for (const source of this._sources.values())
|
||||
if (source.tileset) source.tileset.show = source.tileset === active;
|
||||
for (const tileset of this._ownedTilesets)
|
||||
tileset.show = tileset === active;
|
||||
}
|
||||
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
if (gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
this._imageryLayer = this._createImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
const source = this._sources.get(resolution.effectiveStackId);
|
||||
this._credits.show(source?.credit || null);
|
||||
this._watchProvider(resolution, gen);
|
||||
this._showTileset(null);
|
||||
this.viewer.scene.globe.show = true;
|
||||
this._activeId = resolution.effectiveStackId;
|
||||
if (source?.terrain && source.terrain.id !== this._terrainMode) {
|
||||
const terrain = source.terrain;
|
||||
const result = await this._cached(
|
||||
this._terrainProviders,
|
||||
terrain.id,
|
||||
() => terrain.create({ signal: this._abort.signal }),
|
||||
);
|
||||
if (gen !== this._switchGen) return;
|
||||
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
|
||||
else this.viewer.terrainProvider = result.provider;
|
||||
this._terrainMode = terrain.id;
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
_cached(cache, id, create) {
|
||||
if (cache.has(id)) return cache.get(id);
|
||||
const promise = Promise.resolve()
|
||||
.then(() => {
|
||||
this._abort.signal.throwIfAborted();
|
||||
return create();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cache.get(id) === promise) cache.delete(id);
|
||||
throw error;
|
||||
});
|
||||
cache.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
_getImageryProvider(stack, visited = new Set()) {
|
||||
if (visited.has(stack.id))
|
||||
return Promise.reject(new Error('Map source fallback cycle'));
|
||||
return this._cached(this._imageryProviders, stack.id, async () => {
|
||||
const source = this._sources.get(stack.id);
|
||||
try {
|
||||
const provider = await source.imagery({ signal: this._abort.signal });
|
||||
if (this._destroyed) this._dispose(provider);
|
||||
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
|
||||
} catch (error) {
|
||||
const fallback = source.constructionFallback;
|
||||
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
|
||||
throw error;
|
||||
const next = new Set(visited).add(stack.id);
|
||||
const resolution = await this._getImageryProvider(
|
||||
this.getStack(fallback.id),
|
||||
next,
|
||||
);
|
||||
return { ...resolution, fallbackMessage: fallback.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_watchProvider(resolution, gen) {
|
||||
const fallback = this._sources.get(
|
||||
resolution.effectiveStackId,
|
||||
)?.tileFailureFallback;
|
||||
const errorEvent = resolution.provider?.errorEvent;
|
||||
if (!fallback || !errorEvent?.addEventListener) return;
|
||||
let failures = 0;
|
||||
let pending = false;
|
||||
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
|
||||
if (
|
||||
gen !== this._switchGen ||
|
||||
this._activeImageryProvider !== resolution.provider
|
||||
)
|
||||
return;
|
||||
const retryCount = Number(error?.timesRetried);
|
||||
failures =
|
||||
Number.isInteger(retryCount) && retryCount >= 0
|
||||
? Math.max(failures + 1, retryCount + 1)
|
||||
: failures + 1;
|
||||
if (failures < fallback.threshold || pending) return;
|
||||
pending = true;
|
||||
this._onError?.(
|
||||
fallback.message,
|
||||
this.getStack(resolution.effectiveStackId),
|
||||
);
|
||||
const expectedGen = this._switchGen + 1;
|
||||
void this.setStack(fallback.id, { silent: true })
|
||||
.then((state) => {
|
||||
if (
|
||||
!this._destroyed &&
|
||||
this._switchGen === expectedGen &&
|
||||
state?.activeId === fallback.id
|
||||
) {
|
||||
this._lastError = fallback.message;
|
||||
this._emitChange('error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
pending = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_removeImageryLayer() {
|
||||
this._removeImageryErrorListener?.();
|
||||
this._removeImageryErrorListener = null;
|
||||
if (this._imageryLayer)
|
||||
this.viewer.imageryLayers.remove(this._imageryLayer, true);
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
}
|
||||
_dispose(value) {
|
||||
if (!value || this._disposed.has(value)) return;
|
||||
this._disposed.add(value);
|
||||
if (typeof value.destroy === 'function' && !value.isDestroyed?.())
|
||||
value.destroy();
|
||||
}
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._switchGen++;
|
||||
this._abort.abort();
|
||||
this._isSwitching = false;
|
||||
this._removeImageryLayer();
|
||||
this._credits.destroy();
|
||||
for (const promise of this._imageryProviders.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value.provider),
|
||||
() => {},
|
||||
);
|
||||
for (const promise of this._terrainProviders.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value.provider),
|
||||
() => {},
|
||||
);
|
||||
for (const tileset of this._ownedTilesets) {
|
||||
this.viewer.scene.primitives.remove(tileset);
|
||||
this._dispose(tileset);
|
||||
}
|
||||
this._imageryProviders.clear();
|
||||
this._terrainProviders.clear();
|
||||
this._tilesets.clear();
|
||||
this._ownedTilesets.clear();
|
||||
this._onChange = null;
|
||||
this._onError = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MapSourceController } from './controller.js';
|
||||
import { createDefaultMapSources } from './defaultSources.js';
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
addEventListener(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
raise(value = {}) {
|
||||
for (const fn of [...listeners]) fn(value);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(registry, options = {}) {
|
||||
const imagery = [],
|
||||
credits = new Set(),
|
||||
primitives = [],
|
||||
removed = [],
|
||||
changes = [];
|
||||
const viewer = {
|
||||
scene: {
|
||||
globe: { show: true },
|
||||
requestRender() {},
|
||||
frameState: {
|
||||
creditDisplay: {
|
||||
addStaticCredit(value) {
|
||||
credits.add(value);
|
||||
},
|
||||
removeStaticCredit(value) {
|
||||
credits.delete(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
primitives: {
|
||||
add(value) {
|
||||
primitives.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
primitives.splice(primitives.indexOf(value), 1);
|
||||
value.destroy?.();
|
||||
},
|
||||
},
|
||||
},
|
||||
imageryLayers: {
|
||||
add(value) {
|
||||
imagery.push(value);
|
||||
},
|
||||
remove(value, destroy) {
|
||||
imagery.splice(imagery.indexOf(value), 1);
|
||||
removed.push({ value, destroy });
|
||||
},
|
||||
},
|
||||
};
|
||||
const controller = new MapSourceController(viewer, {
|
||||
registry,
|
||||
createImageryLayer: (provider) => ({ provider }),
|
||||
onChange: (state) => changes.push(state),
|
||||
...options,
|
||||
});
|
||||
return { viewer, controller, imagery, credits, primitives, removed, changes };
|
||||
}
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
|
||||
function publicFixture() {
|
||||
const tileset = { show: true };
|
||||
const registry = createDefaultMapSources({ googleTileset: tileset });
|
||||
const providers = new Map();
|
||||
for (const source of registry.sources) {
|
||||
if (!source.imagery) continue;
|
||||
const provider = { id: source.descriptor.id, errorEvent: event() };
|
||||
providers.set(source.descriptor.id, provider);
|
||||
source.imagery = async () => provider;
|
||||
source.terrain = {
|
||||
id: 'keyless',
|
||||
create: async () => ({ provider: { id: 'terrain' } }),
|
||||
};
|
||||
}
|
||||
return { ...fixture(registry), registry, providers, tileset };
|
||||
}
|
||||
|
||||
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
|
||||
let reads = 0,
|
||||
destroys = 0,
|
||||
terrainReads = 0;
|
||||
const provider = {
|
||||
destroy() {
|
||||
destroys++;
|
||||
},
|
||||
};
|
||||
const terrain = {
|
||||
id: 'custom-floor',
|
||||
create: async () => {
|
||||
terrainReads++;
|
||||
return { provider: { id: 'terrain' } };
|
||||
},
|
||||
};
|
||||
const registry = {
|
||||
defaultId: 'custom',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('custom'),
|
||||
imagery: async () => {
|
||||
reads++;
|
||||
return provider;
|
||||
},
|
||||
terrain,
|
||||
},
|
||||
],
|
||||
};
|
||||
const env = fixture(registry);
|
||||
await env.controller.setStack('custom');
|
||||
await env.controller.setStack('custom');
|
||||
assert.equal(env.controller.getActiveId(), 'custom');
|
||||
assert.equal(env.viewer.terrainProvider.id, 'terrain');
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(terrainReads, 1);
|
||||
assert.equal(env.imagery.length, 1);
|
||||
assert.equal(env.removed[0].destroy, true);
|
||||
env.controller.destroy();
|
||||
env.controller.destroy();
|
||||
await settle();
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
|
||||
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
|
||||
let resolve,
|
||||
signal,
|
||||
destroyed = 0;
|
||||
const env = fixture({
|
||||
defaultId: 'slow',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('slow'),
|
||||
imagery: (request) => {
|
||||
signal = request.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('slow');
|
||||
await settle();
|
||||
env.controller.destroy();
|
||||
assert.equal(signal.aborted, true);
|
||||
resolve({
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
});
|
||||
await loading;
|
||||
await settle();
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.deepEqual(
|
||||
env.changes.map((state) => state.status),
|
||||
['switching'],
|
||||
);
|
||||
});
|
||||
|
||||
test('slow terrain cannot overwrite a newer source and a 3D view never starts unused terrain', async () => {
|
||||
const env = publicFixture();
|
||||
let resolve,
|
||||
reads = 0;
|
||||
const osm = env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'osm',
|
||||
);
|
||||
osm.terrain = {
|
||||
id: 'slow-floor',
|
||||
create: () => {
|
||||
reads++;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
};
|
||||
await env.controller.setStack('photoreal');
|
||||
assert.equal(reads, 0);
|
||||
const loading = env.controller.setStack('osm');
|
||||
await settle();
|
||||
await env.controller.setStack('photoreal');
|
||||
resolve({ provider: { id: 'late-terrain' } });
|
||||
await loading;
|
||||
assert.equal(env.viewer.terrainProvider, undefined);
|
||||
assert.equal(env.viewer.scene.globe.show, false);
|
||||
assert.equal(env.tileset.show, true);
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('Esri construction fallback reports and attributes the source actually rendered', async () => {
|
||||
const env = publicFixture();
|
||||
env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'esri-imagery',
|
||||
).imagery = async () => {
|
||||
throw new Error('unreachable');
|
||||
};
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(
|
||||
env.controller.getState().lastError,
|
||||
'Esri Satellite is unavailable; using OSM',
|
||||
);
|
||||
assert.equal(env.imagery[0].provider, env.providers.get('osm'));
|
||||
assert.equal(env.credits.size, 0);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('one Esri tile failure stays put, two fall back, and stale errors cannot replace a selection', async () => {
|
||||
const env = publicFixture();
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.credits.size, 1);
|
||||
const errorEvent = env.providers.get('esri-imagery').errorEvent;
|
||||
errorEvent.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||
errorEvent.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(env.credits.size, 0);
|
||||
assert.equal(
|
||||
env.controller.getState().lastError,
|
||||
'Esri Satellite tile requests failed; using OSM',
|
||||
);
|
||||
assert.equal(errorEvent.size, 0);
|
||||
await env.controller.setStack('photoreal');
|
||||
errorEvent.raise({ timesRetried: 9 });
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('tooltips and rejected selection share the registry reason, including retired map IDs', async () => {
|
||||
const errors = [],
|
||||
env = publicFixture();
|
||||
env.controller._onError = (message) => errors.push(message);
|
||||
const reason = env.controller
|
||||
.getStacks()
|
||||
.find((stack) => stack.id === 'bing-aerial').unavailableReason;
|
||||
await env.controller.setStack('bing-aerial');
|
||||
assert.deepEqual(errors, [reason]);
|
||||
assert.match(reason, /CESIUM_ION_TOKEN/);
|
||||
await env.controller.setStack('bing-road');
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('a factory-owned 3D tileset is released after late completion and supplied tilesets stay caller-owned', async () => {
|
||||
let resolve,
|
||||
destroyed = 0;
|
||||
const supplied = {
|
||||
show: true,
|
||||
destroy() {
|
||||
assert.fail('caller-owned tileset');
|
||||
},
|
||||
};
|
||||
const env = fixture({
|
||||
defaultId: 'three',
|
||||
sources: [
|
||||
{
|
||||
descriptor: { id: 'three' },
|
||||
createTileset: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
},
|
||||
{ descriptor: { id: 'supplied' }, tileset: supplied },
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('three');
|
||||
await settle();
|
||||
env.controller.destroy();
|
||||
resolve({
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
});
|
||||
await loading;
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(env.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('invalid source graphs fail before constructing or caching a provider', () => {
|
||||
const a = { descriptor: descriptor('a'), constructionFallback: { id: 'b' } };
|
||||
const b = { descriptor: descriptor('b'), constructionFallback: { id: 'a' } };
|
||||
assert.throws(() => fixture({ sources: [a, b] }), /fallback cycle/);
|
||||
assert.throws(() => fixture({ sources: [a, a] }), /unique/);
|
||||
assert.throws(() => fixture({ sources: [a] }), /Unknown map fallback/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Static credits follow the source actually on screen, including fallback. */
|
||||
export function createMapCredits(viewer) {
|
||||
let active = null;
|
||||
let markup = null;
|
||||
return {
|
||||
show(html) {
|
||||
if (html === markup) return;
|
||||
const display = viewer?.scene?.frameState?.creditDisplay;
|
||||
if (!display) return;
|
||||
try {
|
||||
if (active) display.removeStaticCredit(active);
|
||||
active = html ? new Cesium.Credit(html, true) : null;
|
||||
markup = html;
|
||||
if (active) display.addStaticCredit(active);
|
||||
} catch {
|
||||
/* Switching remains usable on older credit displays. */
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
this.show(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MAP_STACKS } from './catalog.js';
|
||||
import { photorealUnavailableReason } from './availability.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
createOsmImagery,
|
||||
createEsriImagery,
|
||||
createIonImagery,
|
||||
ESRI_ATTRIBUTION_HTML,
|
||||
} from './imagery.js';
|
||||
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
|
||||
|
||||
/** Select sources and setup guidance without putting provider branches in the controller. */
|
||||
export function createDefaultMapSources({
|
||||
googleTileset = null,
|
||||
cesiumToken = '',
|
||||
googleApiKey = '',
|
||||
} = {}) {
|
||||
const ionToken = String(cesiumToken || '').trim();
|
||||
const hasIon = Boolean(ionToken);
|
||||
const hasGoogle = Boolean(String(googleApiKey || '').trim());
|
||||
const terrain = {
|
||||
id: hasIon ? 'world' : 'keyless',
|
||||
create: hasIon
|
||||
? (request) => createWorldTerrain(ionToken, request)
|
||||
: createKeylessTerrain,
|
||||
};
|
||||
return {
|
||||
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
|
||||
unknownId: 'photoreal',
|
||||
recoveryId: googleTileset ? 'photoreal' : null,
|
||||
state: { hasCesiumIonToken: hasIon },
|
||||
sources: MAP_STACKS.map((descriptor) => {
|
||||
const common = {
|
||||
descriptor,
|
||||
available: !descriptor.requiresIon || hasIon,
|
||||
unavailableReason: descriptor.requiresIon
|
||||
? keySetupRequirement('cesium-ion')
|
||||
: null,
|
||||
};
|
||||
if (descriptor.kind === 'photoreal')
|
||||
return {
|
||||
...common,
|
||||
available: Boolean(googleTileset),
|
||||
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
|
||||
tileset: googleTileset,
|
||||
};
|
||||
const imagery =
|
||||
descriptor.kind === 'ion'
|
||||
? () => createIonImagery(descriptor.style, ionToken)
|
||||
: descriptor.id === 'osm'
|
||||
? createOsmImagery
|
||||
: createEsriImagery;
|
||||
return {
|
||||
...common,
|
||||
imagery,
|
||||
terrain,
|
||||
...(descriptor.id === 'esri-imagery'
|
||||
? {
|
||||
credit: ESRI_ATTRIBUTION_HTML,
|
||||
constructionFallback: {
|
||||
id: 'osm',
|
||||
message: 'Esri Satellite is unavailable; using OSM',
|
||||
},
|
||||
tileFailureFallback: {
|
||||
id: 'osm',
|
||||
threshold: 2,
|
||||
message: 'Esri Satellite tile requests failed; using OSM',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const clean = (value) => String(value || '').trim();
|
||||
|
||||
/**
|
||||
* Decide which map provider can deliver the best startup experience.
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {'google-direct'|'google-ion'|'osm'}
|
||||
*/
|
||||
export function selectMapStartupRoute({
|
||||
googleApiKey = '',
|
||||
cesiumToken = '',
|
||||
} = {}) {
|
||||
if (clean(googleApiKey)) return 'google-direct';
|
||||
if (clean(cesiumToken)) return 'google-ion';
|
||||
return 'osm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const tileset = attempt.googleKey
|
||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
||||
: await createGoogleIonTileset(Cesium, ionToken);
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
return { tileset: null, route: 'osm', errors };
|
||||
}
|
||||
|
||||
/** Pass credentials to the source instead of changing SDK-wide defaults. */
|
||||
export function createGoogleDirectTileset(Cesium, key) {
|
||||
key = clean(key);
|
||||
if (!key) throw new Error('Google 3D requires an explicit browser key');
|
||||
return Cesium.createGooglePhotorealistic3DTileset({
|
||||
key,
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGoogleIonTileset(
|
||||
Cesium,
|
||||
accessToken,
|
||||
{ signal } = {},
|
||||
) {
|
||||
accessToken = clean(accessToken);
|
||||
if (!accessToken)
|
||||
throw new Error('Google 3D through ion requires an explicit token');
|
||||
signal?.throwIfAborted();
|
||||
const resource = await Cesium.IonResource.fromAssetId(2275207, {
|
||||
accessToken,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
// Match the installed SDK's Google helper rendering/cache defaults.
|
||||
return Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||
cacheBytes: 1536 * 1024 * 1024,
|
||||
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
|
||||
enableCollision: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Attribution and service rights are documented in DATA_SOURCES.md.
|
||||
export const ESRI_ATTRIBUTION_HTML =
|
||||
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
|
||||
|
||||
export function createOsmImagery() {
|
||||
return new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: '© OpenStreetMap contributors',
|
||||
});
|
||||
}
|
||||
|
||||
export function createEsriImagery() {
|
||||
return Cesium.ArcGisMapServerImageryProvider.fromUrl(
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
|
||||
{
|
||||
credit:
|
||||
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
||||
enablePickFeatures: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createIonImagery(style, accessToken) {
|
||||
accessToken = String(accessToken || '').trim();
|
||||
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
|
||||
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Catch invalid source graphs before a cached fallback can wait on itself. */
|
||||
export function indexMapSources(sources) {
|
||||
const index = new Map();
|
||||
for (const source of sources) {
|
||||
const id = source?.descriptor?.id;
|
||||
if (typeof id !== 'string' || !id || index.has(id))
|
||||
throw new TypeError('Map source IDs must be nonempty and unique');
|
||||
index.set(id, source);
|
||||
}
|
||||
for (const id of index.keys()) {
|
||||
const seen = new Set();
|
||||
let next = id;
|
||||
while (next) {
|
||||
if (seen.has(next)) throw new TypeError('Map source fallback cycle');
|
||||
if (!index.has(next)) throw new TypeError('Unknown map fallback source');
|
||||
seen.add(next);
|
||||
next = index.get(next).constructionFallback?.id;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createIonImagery } from './imagery.js';
|
||||
import { createWorldTerrain } from './terrain.js';
|
||||
|
||||
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
|
||||
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const calls = [];
|
||||
const defaultToken = Cesium.Ion.defaultAccessToken;
|
||||
try {
|
||||
Cesium.IonImageryProvider.fromAssetId = async (id, options) => {
|
||||
calls.push({ kind: 'imagery', id, options });
|
||||
return { id };
|
||||
};
|
||||
Cesium.IonResource.fromAssetId = async (id, options) => {
|
||||
calls.push({ kind: 'resource', id, options });
|
||||
return { id };
|
||||
};
|
||||
Cesium.CesiumTerrainProvider.fromUrl = async (resource, options) => {
|
||||
calls.push({ kind: 'terrain', resource, options });
|
||||
return { id: 'terrain' };
|
||||
};
|
||||
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
|
||||
const result = await createWorldTerrain('terrain-token');
|
||||
assert.equal(calls[0].options.accessToken, 'imagery-token');
|
||||
assert.equal(calls[1].options.accessToken, 'terrain-token');
|
||||
assert.equal(calls[1].id, 1);
|
||||
assert.equal(calls[2].options.requestVertexNormals, true);
|
||||
assert.equal(result.provider.id, 'terrain');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
|
||||
} finally {
|
||||
Cesium.IonImageryProvider.fromAssetId = originalImagery;
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation after ion metadata prevents terrain construction', async () => {
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
Cesium.IonResource.fromAssetId = async () => {
|
||||
controller.abort();
|
||||
return {};
|
||||
};
|
||||
Cesium.CesiumTerrainProvider.fromUrl = () =>
|
||||
assert.fail('cancelled terrain construction');
|
||||
await assert.rejects(
|
||||
createWorldTerrain('test-token', { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
} finally {
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
}
|
||||
});
|
||||
|
||||
test('credentialed source factories reject an omitted token instead of consuming an SDK default', async () => {
|
||||
assert.throws(
|
||||
() => createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, ''),
|
||||
/explicit token/,
|
||||
);
|
||||
await assert.rejects(createWorldTerrain(' '), /explicit ion token/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** These factories are lazy: a hidden globe must not trigger terrain loading. */
|
||||
export async function createWorldTerrain(accessToken, { signal } = {}) {
|
||||
accessToken = String(accessToken || '').trim();
|
||||
if (!accessToken)
|
||||
throw new Error('World terrain requires an explicit ion token');
|
||||
signal?.throwIfAborted();
|
||||
const resource = await Cesium.IonResource.fromAssetId(1, { accessToken });
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
provider: await Cesium.CesiumTerrainProvider.fromUrl(resource, {
|
||||
requestVertexNormals: true,
|
||||
requestWaterMask: false,
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createKeylessTerrain() {
|
||||
try {
|
||||
// Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0.
|
||||
return {
|
||||
provider: await Cesium.CesiumTerrainProvider.fromUrl(
|
||||
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
|
||||
error,
|
||||
);
|
||||
return { provider: new Cesium.EllipsoidTerrainProvider() };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user