refactor(maps): separate source factories and scene coordination

This commit is contained in:
Sameh Khamis committed 2026-09-13 18:59:29 -07:00
1 parent 561a781099
commit e59282dc9c
22 files changed
+1807 -798

No files matched your search

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