mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor(maps): separate source factories and scene coordination
This commit is contained in:
1 parent
561a781099
commit
e59282dc9c
22 files changed
+1787
-778
No files matched your search
@@ -59,6 +59,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 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.
|
- 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
|
immutable plain data. Reentrant publications retain delivery order; removing a
|
||||||
listener or destroying its owner prevents further queued delivery. These APIs
|
listener or destroying its owner prevents further queued delivery. These APIs
|
||||||
perform no network requests and discover no additional modules.
|
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
|
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
|
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`.
|
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",
|
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
|
||||||
"./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js",
|
"./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js",
|
||||||
"./geography/natural-earth": "./src/data/naturalEarthRegions.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/telegeographySubmarineCables.test.mjs",
|
||||||
"src/data/naturalEarthRegions.js",
|
"src/data/naturalEarthRegions.js",
|
||||||
"src/data/neighborhoodPolygons.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/localGeojsonCore.js",
|
||||||
"src/data/localGeojsonLod.js"
|
"src/data/localGeojsonLod.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"application": {
|
"application": {
|
||||||
"exports": ["./application"],
|
"exports": [
|
||||||
"modules": ["src/app/application.js"],
|
"./application"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/app/application.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": ["./application/viewer"],
|
"exports": [
|
||||||
"modules": ["src/app/viewer.js"],
|
"./application/viewer"
|
||||||
"external": ["cesium"]
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/app/viewer.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vite-build": {
|
"vite-build": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./build/vite"],
|
"exports": [
|
||||||
"modules": ["build/vite.js"],
|
"./build/vite"
|
||||||
"external": ["vite-plugin-cesium"]
|
],
|
||||||
|
"modules": [
|
||||||
|
"build/vite.js"
|
||||||
|
],
|
||||||
|
"external": [
|
||||||
|
"vite-plugin-cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"live-providers": {
|
"live-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/live"],
|
"exports": [
|
||||||
|
"./server/providers/live"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/live.js",
|
"server/providers/live.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -45,16 +65,24 @@
|
|||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js"
|
"src/data/aisWatchdog.js"
|
||||||
],
|
],
|
||||||
"external": ["ws"]
|
"external": [
|
||||||
|
"ws"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"adsb-lol-source": {
|
"adsb-lol-source": {
|
||||||
"exports": ["./sources/adsb-lol"],
|
"exports": [
|
||||||
"modules": ["src/data/adsbLolFallback.js"],
|
"./sources/adsb-lol"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/adsbLolFallback.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-providers": {
|
"place-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/places"],
|
"exports": [
|
||||||
|
"./server/providers/places"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/places.js",
|
"server/providers/places.js",
|
||||||
"server/providers/places/google.js",
|
"server/providers/places/google.js",
|
||||||
@@ -69,13 +97,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-payloads": {
|
"place-payloads": {
|
||||||
"exports": ["./sources/places"],
|
"exports": [
|
||||||
"modules": ["src/data/placeProviderPayloads.js"],
|
"./sources/places"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/placeProviderPayloads.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"space-providers": {
|
"space-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/space"],
|
"exports": [
|
||||||
|
"./server/providers/space"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/space.js",
|
"server/providers/space.js",
|
||||||
"server/providers/space/celestrak.js",
|
"server/providers/space/celestrak.js",
|
||||||
@@ -86,13 +120,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"space-requests": {
|
"space-requests": {
|
||||||
"exports": ["./sources/space"],
|
"exports": [
|
||||||
"modules": ["src/data/spaceProviderRequests.js"],
|
"./sources/space"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/spaceProviderRequests.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"terrain-provider": {
|
"terrain-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/terrain"],
|
"exports": [
|
||||||
|
"./server/providers/terrain"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/terrain.js",
|
"server/providers/terrain.js",
|
||||||
"src/data/terrainHeightsProxy.js"
|
"src/data/terrainHeightsProxy.js"
|
||||||
@@ -100,30 +140,50 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"terrain-source": {
|
"terrain-source": {
|
||||||
"exports": ["./sources/terrain"],
|
"exports": [
|
||||||
"modules": ["src/data/terrainHeightsProxy.js"],
|
"./sources/terrain"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/terrainHeightsProxy.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-provider": {
|
"traffic-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/traffic"],
|
"exports": [
|
||||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
"./server/providers/traffic"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"server/providers/traffic.js",
|
||||||
|
"src/data/tomtomTiles.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-source": {
|
"traffic-source": {
|
||||||
"exports": ["./sources/traffic"],
|
"exports": [
|
||||||
"modules": ["src/data/tomtomTiles.js"],
|
"./sources/traffic"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/tomtomTiles.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"firms-provider": {
|
"firms-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/firms"],
|
"exports": [
|
||||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
"./server/providers/firms"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"server/providers/firms.js",
|
||||||
|
"src/data/firmsCsv.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-provider": {
|
"gbfs-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/gbfs"],
|
"exports": [
|
||||||
|
"./server/providers/gbfs"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/gbfs.js",
|
"server/providers/gbfs.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -132,13 +192,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-source": {
|
"gbfs-source": {
|
||||||
"exports": ["./sources/gbfs"],
|
"exports": [
|
||||||
"modules": ["src/data/gbfsSource.js"],
|
"./sources/gbfs"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/data/gbfsSource.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"cctv-provider": {
|
"cctv-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/cctv"],
|
"exports": [
|
||||||
|
"./server/providers/cctv"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/cctv.js",
|
"server/providers/cctv.js",
|
||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
@@ -155,7 +221,9 @@
|
|||||||
},
|
},
|
||||||
"radio-provider": {
|
"radio-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/radio"],
|
"exports": [
|
||||||
|
"./server/providers/radio"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/radio.js",
|
"server/providers/radio.js",
|
||||||
"server/providers/radio/catalog.js",
|
"server/providers/radio/catalog.js",
|
||||||
@@ -169,7 +237,9 @@
|
|||||||
},
|
},
|
||||||
"overpass-provider": {
|
"overpass-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/overpass"],
|
"exports": [
|
||||||
|
"./server/providers/overpass"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/geo.js",
|
"server/providers/common/geo.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -188,7 +258,9 @@
|
|||||||
},
|
},
|
||||||
"military-installations-provider": {
|
"military-installations-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/military-installations"],
|
"exports": [
|
||||||
|
"./server/providers/military-installations"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -205,7 +277,9 @@
|
|||||||
},
|
},
|
||||||
"regional-provider": {
|
"regional-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/regional"],
|
"exports": [
|
||||||
|
"./server/providers/regional"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -224,7 +298,9 @@
|
|||||||
},
|
},
|
||||||
"openai-provider": {
|
"openai-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/openai"],
|
"exports": [
|
||||||
|
"./server/providers/openai"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
@@ -244,7 +320,9 @@
|
|||||||
},
|
},
|
||||||
"standalone-key-setup": {
|
"standalone-key-setup": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/standalone/key-setup"],
|
"exports": [
|
||||||
|
"./server/standalone/key-setup"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"scripts/pinokio-environment.mjs",
|
"scripts/pinokio-environment.mjs",
|
||||||
"server/providers/common/source-root.js",
|
"server/providers/common/source-root.js",
|
||||||
@@ -255,7 +333,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-search": {
|
"place-search": {
|
||||||
"exports": ["./search"],
|
"exports": [
|
||||||
|
"./search"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/search/index.js",
|
"src/search/index.js",
|
||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
@@ -265,17 +345,27 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"panel-disclosure": {
|
"panel-disclosure": {
|
||||||
"exports": ["./ui/panels"],
|
"exports": [
|
||||||
"modules": ["src/ui/panelDisclosure.js"],
|
"./ui/panels"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/panelDisclosure.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"surface-keyboard": {
|
"surface-keyboard": {
|
||||||
"exports": ["./ui/surfaces"],
|
"exports": [
|
||||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
"./ui/surfaces"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/surfaceKeyboard.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"panel-rail-layout": {
|
"panel-rail-layout": {
|
||||||
"exports": ["./ui/layout"],
|
"exports": [
|
||||||
|
"./ui/layout"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -287,7 +377,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-input": {
|
"visual-input": {
|
||||||
"exports": ["./ui/input"],
|
"exports": [
|
||||||
|
"./ui/input"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/applicationShortcuts.js",
|
"src/ui/applicationShortcuts.js",
|
||||||
@@ -296,12 +388,19 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"display-controls": {
|
"display-controls": {
|
||||||
"exports": ["./ui/display"],
|
"exports": [
|
||||||
"modules": ["src/ui/displayControls.js"],
|
"./ui/display"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/displayControls.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-effects": {
|
"visual-effects": {
|
||||||
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
"exports": [
|
||||||
|
"./ui/effects",
|
||||||
|
"./ui/effects/bloom"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/visualEffects.js",
|
"src/ui/visualEffects.js",
|
||||||
@@ -314,10 +413,14 @@
|
|||||||
"src/styles/noir.js",
|
"src/styles/noir.js",
|
||||||
"src/styles/snow.js"
|
"src/styles/snow.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"map-source-controls": {
|
"map-source-controls": {
|
||||||
"exports": ["./ui/maps"],
|
"exports": [
|
||||||
|
"./ui/maps"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/mapSource.js",
|
"src/ui/mapSource.js",
|
||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
@@ -327,7 +430,10 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"layer-panel": {
|
"layer-panel": {
|
||||||
"exports": ["./ui/layers", "./ui/layers/feedback"],
|
"exports": [
|
||||||
|
"./ui/layers",
|
||||||
|
"./ui/layers/feedback"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
@@ -338,7 +444,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"location-controls": {
|
"location-controls": {
|
||||||
"exports": ["./ui/location"],
|
"exports": [
|
||||||
|
"./ui/location"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/location.js",
|
"src/ui/location.js",
|
||||||
"src/ui/locationControls.js",
|
"src/ui/locationControls.js",
|
||||||
@@ -349,7 +457,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"radio-controls": {
|
"radio-controls": {
|
||||||
"exports": ["./ui/radio"],
|
"exports": [
|
||||||
|
"./ui/radio"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/radio.js",
|
"src/ui/radio.js",
|
||||||
"src/ui/radioControls.js",
|
"src/ui/radioControls.js",
|
||||||
@@ -360,7 +470,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"cctv-controls": {
|
"cctv-controls": {
|
||||||
"exports": ["./ui/cctv"],
|
"exports": [
|
||||||
|
"./ui/cctv"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
"src/ui/cctvBindings.js",
|
"src/ui/cctvBindings.js",
|
||||||
@@ -372,7 +484,10 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"context-controls": {
|
"context-controls": {
|
||||||
"exports": ["./ui/context", "./ui/context/policy"],
|
"exports": [
|
||||||
|
"./ui/context",
|
||||||
|
"./ui/context/policy"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/context.js",
|
"src/ui/context.js",
|
||||||
"src/ui/contextActions.js",
|
"src/ui/contextActions.js",
|
||||||
@@ -414,10 +529,14 @@
|
|||||||
"src/cockpitVisionPolicy.js",
|
"src/cockpitVisionPolicy.js",
|
||||||
"src/ui/panelRailGeometry.js"
|
"src/ui/panelRailGeometry.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"scene-controls": {
|
"scene-controls": {
|
||||||
"exports": ["./ui/scenes"],
|
"exports": [
|
||||||
|
"./ui/scenes"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
@@ -426,7 +545,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"ui-shell": {
|
"ui-shell": {
|
||||||
"exports": ["./ui/shell"],
|
"exports": [
|
||||||
|
"./ui/shell"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/bloom.js",
|
"src/bloom.js",
|
||||||
"src/cctvFocusPolicy.js",
|
"src/cctvFocusPolicy.js",
|
||||||
@@ -523,10 +644,14 @@
|
|||||||
"src/app/stateChannel.js",
|
"src/app/stateChannel.js",
|
||||||
"src/ui/frameRateMonitor.js"
|
"src/ui/frameRateMonitor.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ui-styles": {
|
"ui-styles": {
|
||||||
"exports": ["./ui/styles"],
|
"exports": [
|
||||||
|
"./ui/styles"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/styles.js",
|
"src/ui/styles.js",
|
||||||
"style.css",
|
"style.css",
|
||||||
@@ -553,7 +678,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"scenes": {
|
"scenes": {
|
||||||
"exports": ["./scenes"],
|
"exports": [
|
||||||
|
"./scenes"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/scenes/director.js",
|
"src/scenes/director.js",
|
||||||
"src/scenes/recipes.js",
|
"src/scenes/recipes.js",
|
||||||
@@ -565,10 +692,14 @@
|
|||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/contextModePolicy.js"
|
"src/contextModePolicy.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"live-observations": {
|
"live-observations": {
|
||||||
"exports": ["./sources/live"],
|
"exports": [
|
||||||
|
"./sources/live"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/sources/live/aircraft.js",
|
"src/sources/live/aircraft.js",
|
||||||
"src/sources/live/contract.js",
|
"src/sources/live/contract.js",
|
||||||
@@ -579,7 +710,9 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"civil-flights": {
|
"civil-flights": {
|
||||||
"exports": ["./layers/flights"],
|
"exports": [
|
||||||
|
"./layers/flights"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
@@ -610,10 +743,14 @@
|
|||||||
"src/layers/flights/testing.js",
|
"src/layers/flights/testing.js",
|
||||||
"src/layers/flights/tracking.js"
|
"src/layers/flights/tracking.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"military-flights": {
|
"military-flights": {
|
||||||
"exports": ["./layers/military"],
|
"exports": [
|
||||||
|
"./layers/military"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
@@ -641,10 +778,14 @@
|
|||||||
"src/layers/military/testing.js",
|
"src/layers/military/testing.js",
|
||||||
"src/layers/military/tracking.js"
|
"src/layers/military/tracking.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"aircraft-mechanics": {
|
"aircraft-mechanics": {
|
||||||
"exports": ["./aircraft"],
|
"exports": [
|
||||||
|
"./aircraft"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
@@ -664,10 +805,14 @@
|
|||||||
"src/layers/aircraft/classification.js",
|
"src/layers/aircraft/classification.js",
|
||||||
"src/layers/aircraft/index.js"
|
"src/layers/aircraft/index.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vessel-layer": {
|
"vessel-layer": {
|
||||||
"exports": ["./layers/vessels"],
|
"exports": [
|
||||||
|
"./layers/vessels"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/vessels/cards.js",
|
"src/layers/vessels/cards.js",
|
||||||
"src/layers/vessels/evidence.js",
|
"src/layers/vessels/evidence.js",
|
||||||
@@ -685,19 +830,27 @@
|
|||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/vesselLabels.js"
|
"src/data/vesselLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"earthquakes": {
|
"earthquakes": {
|
||||||
"exports": ["./layers/earthquakes"],
|
"exports": [
|
||||||
|
"./layers/earthquakes"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/earthquakes/index.js",
|
"src/layers/earthquakes/index.js",
|
||||||
"src/layers/earthquakes/model.js",
|
"src/layers/earthquakes/model.js",
|
||||||
"src/layers/earthquakes/source.js"
|
"src/layers/earthquakes/source.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"fire-layer": {
|
"fire-layer": {
|
||||||
"exports": ["./layers/firms"],
|
"exports": [
|
||||||
|
"./layers/firms"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/firmsAdapt.js",
|
"src/data/firmsAdapt.js",
|
||||||
"src/data/firmsLabels.js",
|
"src/data/firmsLabels.js",
|
||||||
@@ -716,10 +869,14 @@
|
|||||||
"src/layers/firms/state.js",
|
"src/layers/firms/state.js",
|
||||||
"src/layers/firms/viewport.js"
|
"src/layers/firms/viewport.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"satellites-layer": {
|
"satellites-layer": {
|
||||||
"exports": ["./layers/satellites"],
|
"exports": [
|
||||||
|
"./layers/satellites"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/satelliteClass.js",
|
"src/data/satelliteClass.js",
|
||||||
@@ -738,10 +895,15 @@
|
|||||||
"src/layers/satellites/testing.js",
|
"src/layers/satellites/testing.js",
|
||||||
"src/layers/satellites/tracking.js"
|
"src/layers/satellites/tracking.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "satellite.js"]
|
"external": [
|
||||||
|
"cesium",
|
||||||
|
"satellite.js"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"launches-layer": {
|
"launches-layer": {
|
||||||
"exports": ["./layers/launches"],
|
"exports": [
|
||||||
|
"./layers/launches"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/launches/camera.js",
|
"src/layers/launches/camera.js",
|
||||||
"src/layers/launches/controls.js",
|
"src/layers/launches/controls.js",
|
||||||
@@ -763,10 +925,14 @@
|
|||||||
"src/layers/launches/state.js",
|
"src/layers/launches/state.js",
|
||||||
"src/layers/launches/testing.js"
|
"src/layers/launches/testing.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"installations-layer": {
|
"installations-layer": {
|
||||||
"exports": ["./layers/installations"],
|
"exports": [
|
||||||
|
"./layers/installations"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/militaryInstallationData.js",
|
"src/data/militaryInstallationData.js",
|
||||||
@@ -782,10 +948,14 @@
|
|||||||
"src/layers/installations/state.js",
|
"src/layers/installations/state.js",
|
||||||
"src/layers/installations/viewport.js"
|
"src/layers/installations/viewport.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"awareness-layer": {
|
"awareness-layer": {
|
||||||
"exports": ["./layers/awareness"],
|
"exports": [
|
||||||
|
"./layers/awareness"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/cockpitMath.js",
|
"src/cockpitMath.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
@@ -808,10 +978,14 @@
|
|||||||
"src/layers/awareness/testing.js",
|
"src/layers/awareness/testing.js",
|
||||||
"src/ui/panelRailGeometry.js"
|
"src/ui/panelRailGeometry.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"traffic-layer": {
|
"traffic-layer": {
|
||||||
"exports": ["./layers/traffic"],
|
"exports": [
|
||||||
|
"./layers/traffic"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/flowMatch.js",
|
"src/data/flowMatch.js",
|
||||||
"src/data/tomtomTiles.js",
|
"src/data/tomtomTiles.js",
|
||||||
@@ -836,10 +1010,16 @@
|
|||||||
"src/layers/traffic/timing.js",
|
"src/layers/traffic/timing.js",
|
||||||
"src/layers/traffic/viewport.js"
|
"src/layers/traffic/viewport.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
"external": [
|
||||||
|
"cesium",
|
||||||
|
"pbf",
|
||||||
|
"@mapbox/vector-tile"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"bikeshare-layer": {
|
"bikeshare-layer": {
|
||||||
"exports": ["./layers/bikeshare"],
|
"exports": [
|
||||||
|
"./layers/bikeshare"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/bikeshare/controls.js",
|
"src/layers/bikeshare/controls.js",
|
||||||
"src/layers/bikeshare/index.js",
|
"src/layers/bikeshare/index.js",
|
||||||
@@ -856,10 +1036,14 @@
|
|||||||
"src/layers/bikeshare/testing.js",
|
"src/layers/bikeshare/testing.js",
|
||||||
"src/layers/bikeshare/viewport.js"
|
"src/layers/bikeshare/viewport.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"cctv-layer": {
|
"cctv-layer": {
|
||||||
"exports": ["./layers/cctv"],
|
"exports": [
|
||||||
|
"./layers/cctv"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/cctvCards.js",
|
"src/data/cctvCards.js",
|
||||||
"src/data/cctvGizmo.js",
|
"src/data/cctvGizmo.js",
|
||||||
@@ -892,10 +1076,14 @@
|
|||||||
"src/layers/cctv/testing.js",
|
"src/layers/cctv/testing.js",
|
||||||
"src/overlays/worldOverlayTokens.js"
|
"src/overlays/worldOverlayTokens.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"radio-layer": {
|
"radio-layer": {
|
||||||
"exports": ["./layers/radio"],
|
"exports": [
|
||||||
|
"./layers/radio"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
@@ -924,10 +1112,14 @@
|
|||||||
"src/layers/radio/volume.js",
|
"src/layers/radio/volume.js",
|
||||||
"src/ui/radioTunerModel.js"
|
"src/ui/radioTunerModel.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"submarine-cables": {
|
"submarine-cables": {
|
||||||
"exports": ["./layers/submarine-cables"],
|
"exports": [
|
||||||
|
"./layers/submarine-cables"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/submarineCables/geometry.js",
|
"src/layers/submarineCables/geometry.js",
|
||||||
"src/layers/submarineCables/index.js",
|
"src/layers/submarineCables/index.js",
|
||||||
@@ -940,10 +1132,14 @@
|
|||||||
"src/layers/submarineCables/state.js",
|
"src/layers/submarineCables/state.js",
|
||||||
"src/layers/submarineCables/surface.js"
|
"src/layers/submarineCables/surface.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": [
|
||||||
|
"cesium"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"bundled-cables": {
|
"bundled-cables": {
|
||||||
"exports": ["./sources/bundled-cables"],
|
"exports": [
|
||||||
|
"./sources/bundled-cables"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/submarineCables/bundledSource.js",
|
"src/layers/submarineCables/bundledSource.js",
|
||||||
"src/data/local_data/telegeography_submarine_cables/cable-geo.json",
|
"src/data/local_data/telegeography_submarine_cables/cable-geo.json",
|
||||||
@@ -952,7 +1148,10 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"geography": {
|
"geography": {
|
||||||
"exports": ["./geography/natural-earth", "./geography/neighborhoods"],
|
"exports": [
|
||||||
|
"./geography/natural-earth",
|
||||||
|
"./geography/neighborhoods"
|
||||||
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/naturalEarthRegions.js",
|
"src/data/naturalEarthRegions.js",
|
||||||
"src/data/neighborhoodPolygons.js",
|
"src/data/neighborhoodPolygons.js",
|
||||||
@@ -962,5 +1161,30 @@
|
|||||||
"src/data/local_data/natural_earth/marine.json"
|
"src/data/local_data/natural_earth/marine.json"
|
||||||
],
|
],
|
||||||
"external": []
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+295
-132
@@ -34,7 +34,9 @@ function makeElement(tagName = 'div') {
|
|||||||
children: [],
|
children: [],
|
||||||
classList: {
|
classList: {
|
||||||
toggle(name, force) {
|
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;
|
const next = force === undefined ? !classes.has(name) : !!force;
|
||||||
if (next) classes.add(name);
|
if (next) classes.add(name);
|
||||||
else classes.delete(name);
|
else classes.delete(name);
|
||||||
@@ -44,15 +46,30 @@ function makeElement(tagName = 'div') {
|
|||||||
return String(element.className).split(/\s+/).includes(name);
|
return String(element.className).split(/\s+/).includes(name);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
appendChild(child) { element.children.push(child); return child; },
|
appendChild(child) {
|
||||||
setAttribute(name, value) { element.attributes[name] = String(value); },
|
element.children.push(child);
|
||||||
getAttribute(name) { return element.attributes[name] ?? null; },
|
return child;
|
||||||
addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); },
|
},
|
||||||
click() { for (const handler of element.listeners.click || []) handler(); },
|
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', {
|
Object.defineProperty(element, 'innerHTML', {
|
||||||
get() { return ''; },
|
get() {
|
||||||
set() { element.children.length = 0; },
|
return '';
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
element.children.length = 0;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -60,37 +77,93 @@ function makeElement(tagName = 'div') {
|
|||||||
const doc = { createElement: (tagName) => makeElement(tagName) };
|
const doc = { createElement: (tagName) => makeElement(tagName) };
|
||||||
|
|
||||||
/** Text a chip renders, label + optional requirement badge. */
|
/** 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.
|
// Shaped exactly like MapStackController.getStacks() output.
|
||||||
const CONTROLLER_STACKS = [
|
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: 'photoreal',
|
||||||
{ id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
|
label: 'Google 3D',
|
||||||
{ id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
|
requiresIon: false,
|
||||||
{ id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
|
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', () => {
|
test('the row renders exactly the five owner-approved sources', () => {
|
||||||
const container = makeElement();
|
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), [
|
assert.deepEqual(
|
||||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
container.children.map((chip) => chip.dataset.stackId),
|
||||||
]);
|
['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
|
||||||
|
);
|
||||||
assert.deepEqual(container.children.map(chipText), [
|
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.deepEqual(PRESENTED_MAP_STACK_IDS, [
|
||||||
assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
|
'photoreal',
|
||||||
assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
|
'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', () => {
|
test('internal and future stacks stay outside the approved presentation set', () => {
|
||||||
const container = makeElement();
|
const container = makeElement();
|
||||||
// A future Hybrid stack may land in the controller, but it must not appear
|
// A future Hybrid stack may land in the controller, but it must not appear
|
||||||
// until the owner-approved presentation allowlist explicitly includes it.
|
// 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 });
|
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
|
||||||
|
|
||||||
assert.equal(container.children.length, 5);
|
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', () => {
|
test('re-rendering replaces the previous chips instead of stacking a second row', () => {
|
||||||
const container = makeElement();
|
const container = makeElement();
|
||||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
|
renderMapStackChips(container, CONTROLLER_STACKS, {
|
||||||
|
activeId: 'photoreal',
|
||||||
|
doc,
|
||||||
|
});
|
||||||
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
|
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
|
||||||
|
|
||||||
assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length);
|
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', () => {
|
test('the active chip is the pressed chip, and exactly one is pressed', () => {
|
||||||
const container = makeElement();
|
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');
|
const pressed = container.children.filter(
|
||||||
assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']);
|
(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(pressed[0].classList.contains('active'));
|
||||||
assert.ok(container.children
|
assert.ok(
|
||||||
|
container.children
|
||||||
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
|
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
|
||||||
.every((chip) => !chip.classList.contains('active')));
|
.every((chip) => !chip.classList.contains('active')),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the lit chip tracks controller state, not the click', () => {
|
test('the lit chip tracks controller state, not the click', () => {
|
||||||
const container = makeElement();
|
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.
|
// A rejected/superseded switch reports the stack that is genuinely active.
|
||||||
syncMapStackChips(container, 'photoreal');
|
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', () => {
|
test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
|
||||||
const container = makeElement();
|
const container = makeElement();
|
||||||
const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
|
const keyless = CONTROLLER_STACKS.map((stack) =>
|
||||||
|
stack.requiresIon
|
||||||
|
? {
|
||||||
...stack,
|
...stack,
|
||||||
available: false,
|
available: false,
|
||||||
unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
unavailableReason:
|
||||||
} : stack));
|
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||||
|
}
|
||||||
|
: stack,
|
||||||
|
);
|
||||||
const selected = [];
|
const selected = [];
|
||||||
renderMapStackChips(container, keyless, {
|
renderMapStackChips(container, keyless, {
|
||||||
activeId: 'photoreal',
|
activeId: 'photoreal',
|
||||||
@@ -164,43 +258,78 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bingAerial = container.children[1];
|
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-disabled'), 'true');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
bingAerial.getAttribute('aria-label'),
|
bingAerial.getAttribute('aria-label'),
|
||||||
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
|
||||||
);
|
);
|
||||||
assert.ok(bingAerial.classList.contains('unavailable'));
|
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');
|
assert.equal(chipText(bingAerial), 'Bing Aerial ION');
|
||||||
|
|
||||||
bingAerial.click();
|
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', () => {
|
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
|
// 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.
|
// photoreal is unavailable for a reason that has nothing to do with ion.
|
||||||
const container = makeElement();
|
const container = makeElement();
|
||||||
const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
|
const tilesFailed = CONTROLLER_STACKS.map((stack) =>
|
||||||
|
stack.id === 'photoreal'
|
||||||
|
? {
|
||||||
...stack,
|
...stack,
|
||||||
available: false,
|
available: false,
|
||||||
unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
|
unavailableReason:
|
||||||
} : stack));
|
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
|
||||||
|
}
|
||||||
|
: stack,
|
||||||
|
);
|
||||||
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
|
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
|
||||||
|
|
||||||
const google = container.children[0];
|
const google = container.children[0];
|
||||||
assert.equal(google.getAttribute('aria-disabled'), 'true');
|
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(
|
||||||
assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
|
google.getAttribute('aria-label'),
|
||||||
assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
|
'Google 3D unavailable: 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(
|
||||||
|
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', () => {
|
test("models carry the stack's own reason and never invent an active chip", () => {
|
||||||
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{
|
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [
|
||||||
|
{
|
||||||
id: 'osm',
|
id: 'osm',
|
||||||
label: 'OSM',
|
label: 'OSM',
|
||||||
available: true,
|
available: true,
|
||||||
@@ -209,14 +338,30 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
|
|||||||
requirement: '',
|
requirement: '',
|
||||||
unavailableHint: '',
|
unavailableHint: '',
|
||||||
title: 'OSM',
|
title: 'OSM',
|
||||||
}]);
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// A stack list without a controller-supplied reason still explains itself.
|
// A stack list without a controller-supplied reason still explains itself.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[
|
[
|
||||||
mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null),
|
mapStackChipModel(
|
||||||
mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null),
|
{
|
||||||
].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })),
|
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',
|
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'), []);
|
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', () => {
|
test('a missing row or document is inert rather than throwing during boot', () => {
|
||||||
assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []);
|
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'));
|
assert.doesNotThrow(() => syncMapStackChips(null, 'osm'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,8 +394,14 @@ test('the active cyan survives hover', () => {
|
|||||||
const active = css.indexOf('.map-stack-chip.active {');
|
const active = css.indexOf('.map-stack-chip.active {');
|
||||||
const unavailable = css.indexOf('.map-stack-chip.unavailable');
|
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(
|
||||||
assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up');
|
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(
|
assert.doesNotMatch(
|
||||||
css.slice(hover, active),
|
css.slice(hover, active),
|
||||||
/:not\(/,
|
/:not\(/,
|
||||||
@@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
|
|||||||
// chip-state rule sets.
|
// chip-state rule sets.
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
|
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
|
const chipRules = [
|
||||||
.map(([, selector, body], order) => ({
|
...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g),
|
||||||
|
].map(([, selector, body], order) => ({
|
||||||
selector: stripComments(selector).trim(),
|
selector: stripComments(selector).trim(),
|
||||||
body: stripComments(body),
|
body: stripComments(body),
|
||||||
order,
|
order,
|
||||||
}));
|
}));
|
||||||
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
|
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
|
||||||
|
|
||||||
const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible')
|
const ringIndex = chipRules.findIndex(
|
||||||
&& /outline:\s*(?!none)\S/.test(body)
|
({ selector, body }) =>
|
||||||
&& /outline-offset:/.test(body));
|
selector.includes(':focus-visible') &&
|
||||||
assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring');
|
/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
|
// Nothing after it may touch outline again, so the ring cannot be erased by
|
||||||
// .active, :disabled, or anything added later.
|
// .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`,
|
`"${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(
|
assert.ok(
|
||||||
chipRules.some((rule) => rule.selector.includes(selector)),
|
chipRules.some((rule) => rule.selector.includes(selector)),
|
||||||
`expected a ${selector} rule to exist for this check to mean anything`,
|
`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
|
// `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
|
// 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.
|
// 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.ok(base, 'expected the base .map-stack-chip rule');
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
base.body,
|
base.body,
|
||||||
/transition:\s*all\b/,
|
/transition:\s*all\b/,
|
||||||
'transition: all animates outline-width and kills the focus ring on wrapped rows',
|
'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.doesNotMatch(
|
||||||
assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates');
|
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', () => {
|
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 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(
|
assert.match(
|
||||||
html,
|
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>/,
|
/<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.doesNotMatch(
|
||||||
assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/);
|
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(
|
assert.match(
|
||||||
html,
|
html,
|
||||||
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
|
/<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',
|
'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(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(
|
assert.match(
|
||||||
controller,
|
ui,
|
||||||
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
|
/querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/,
|
||||||
'construction failure must resolve truthfully to OSM',
|
);
|
||||||
|
|
||||||
|
const controls = readFileSync(
|
||||||
|
new URL('./ui/mapSourceControls.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
controller,
|
ui,
|
||||||
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
|
/return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||||
'the active chip must follow the effective provider, not the requested stack',
|
'the application action uses the component selection path',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
controller,
|
controls,
|
||||||
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
|
/onSelect: [\s\S]*?select\(id\)/,
|
||||||
'Esri credit must follow the effective provider',
|
'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(
|
assert.match(
|
||||||
controller,
|
controls,
|
||||||
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
|
/await controller\.setStack\(stackId\)/,
|
||||||
'a stale provider error must not replace a newer user selection',
|
'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 { governorRequestRender } from './renderGovernor.js';
|
||||||
import { keySetupRequirement } from './keySetupCore.mjs';
|
export { MAP_STACKS } from './maps/catalog.js';
|
||||||
|
export { photorealUnavailableReason } from './maps/availability.js';
|
||||||
|
|
||||||
/**
|
/** Preserve the standalone entry point; applications can compose the source controller directly. */
|
||||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
export class MapStackController extends MapSourceController {
|
||||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
constructor(viewer, options = {}) {
|
||||||
* key or ion token configured, the tileset failed for another reason —
|
const googleApiKey =
|
||||||
* restrictions, quota, an EEA-billed key, or the network — and telling the
|
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||||
* user to add a key they already added is the wrong advice.
|
const registry = createDefaultMapSources({ ...options, googleApiKey });
|
||||||
* @param {boolean} hasCredentials
|
super(viewer, {
|
||||||
* @returns {string}
|
registry,
|
||||||
*/
|
initialStack: options.googleTileset
|
||||||
export function photorealUnavailableReason(hasCredentials) {
|
? options.initialStack || 'photoreal'
|
||||||
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
|
: registry.defaultId,
|
||||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
...options,
|
||||||
}
|
requestRender: governorRequestRender,
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
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() {
|
_hasPhotorealCredentials() {
|
||||||
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
const googleKey =
|
||||||
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
|
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||||
}
|
return (
|
||||||
|
Boolean(String(googleKey || '').trim()) ||
|
||||||
getStack(id) {
|
Boolean(String(this.cesiumToken || '').trim())
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
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/);
|
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4
-51
@@ -1,51 +1,4 @@
|
|||||||
const clean = (value) => String(value || '').trim();
|
export {
|
||||||
|
selectMapStartupRoute,
|
||||||
/**
|
loadPhotorealisticTileset,
|
||||||
* Decide which map provider can deliver the best startup experience.
|
} from './maps/google3d.js';
|
||||||
* @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 };
|
|
||||||
}
|
|
||||||
+47
-9
@@ -7,25 +7,42 @@ import {
|
|||||||
|
|
||||||
function fakeCesium(outcomes = []) {
|
function fakeCesium(outcomes = []) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
const next = () => {
|
||||||
|
const outcome = outcomes.shift();
|
||||||
|
if (outcome instanceof Error) throw outcome;
|
||||||
|
return outcome;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
Ion: { defaultAccessToken: undefined },
|
Ion: { defaultAccessToken: undefined },
|
||||||
GoogleMaps: { defaultApiKey: undefined },
|
GoogleMaps: { defaultApiKey: undefined },
|
||||||
async createGooglePhotorealistic3DTileset(options) {
|
async createGooglePhotorealistic3DTileset(options) {
|
||||||
|
calls.push({ options, googleKey: options.key });
|
||||||
|
return next();
|
||||||
|
},
|
||||||
|
IonResource: {
|
||||||
|
async fromAssetId(assetId, options) {
|
||||||
|
return { assetId, ...options };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Cesium3DTileset: {
|
||||||
|
async fromUrl(resource, options) {
|
||||||
calls.push({
|
calls.push({
|
||||||
options,
|
options,
|
||||||
googleKey: this.GoogleMaps.defaultApiKey,
|
ionToken: resource.accessToken,
|
||||||
ionToken: this.Ion.defaultAccessToken,
|
assetId: resource.assetId,
|
||||||
});
|
});
|
||||||
const outcome = outcomes.shift();
|
return next();
|
||||||
if (outcome instanceof Error) throw outcome;
|
},
|
||||||
return outcome;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('map startup route reflects the best configured provider', () => {
|
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({ cesiumToken: 'ion' }), 'google-ion');
|
||||||
assert.equal(selectMapStartupRoute(), 'osm');
|
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 () => {
|
test('an ion-only setup loads the hosted Google 3D asset', async () => {
|
||||||
const tileset = { id: 'ion' };
|
const tileset = { id: 'ion' };
|
||||||
const Cesium = fakeCesium([tileset]);
|
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.tileset, tileset);
|
||||||
assert.equal(result.route, 'google-ion');
|
assert.equal(result.route, 'google-ion');
|
||||||
assert.equal(Cesium.calls.length, 1);
|
assert.equal(Cesium.calls.length, 1);
|
||||||
assert.equal(Cesium.calls[0].googleKey, undefined);
|
assert.equal(Cesium.calls[0].googleKey, undefined);
|
||||||
assert.equal(Cesium.calls[0].ionToken, 'ion-secret');
|
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 () => {
|
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 () => {
|
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, {
|
const result = await loadPhotorealisticTileset(Cesium, {
|
||||||
googleApiKey: 'google-secret',
|
googleApiKey: 'google-secret',
|
||||||
cesiumToken: 'ion-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.calls.length, 2);
|
||||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, undefined);
|
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