import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; 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`; } 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 = 'Powered by Esri'; // 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), }; }); } /** * 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; } async setStack(id, { silent = false } = {}) { 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} */ 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)); } }