Files
gods-eye-view/src/maps/controller.js
T
Sameh Khamis b9fa0eedcc fix(maps): stop all delivery once the controller is destroyed
A listener could destroy the controller and re-subscribe one still pending in
the snapshot, which then ran. Delivery now stops at destruction and a destroyed
controller takes no listeners. Adds ordering tests for adds, duplicates and
re-adds during delivery, and moves the current-state note after the section
intro.
2026-10-14 19:23:46 -07:00

434 lines
15 KiB
JavaScript

import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapCredits } from './credits.js';
import { acquireImageryComparison } from './imageryComparison.js';
/**
* Private `setStack` option marking a switch the controller issues itself
* (a tile-failure fallback); outside callers cannot forge it.
*/
const AUTOMATIC = Symbol('automatic switch');
/** 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;
/** Who chose the current generation's stack: 'manual' | 'automatic'. */
this._switchOrigin = 'manual';
this._isSwitching = false;
this._lastError = null;
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._terrainMode = null;
this._subscribers = new Set();
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`
);
}
/** Return the shown supplied or controller-owned tileset, if any. */
getImageryHostTileset() {
if (this._destroyed) return null;
for (const source of this._sources.values())
if (source.tileset?.show === true) return source.tileset;
for (const tileset of this._ownedTilesets)
if (tileset.show === true) return tileset;
return null;
}
getActiveId() {
return this._activeId;
}
getActiveStack() {
return this.getStack(this._activeId);
}
getSwitchGeneration() {
return this._switchGen;
}
/**
* Who chose the stack of the current switch generation: 'manual' for a
* `setStack` issued from outside (the operator, a scene, a lease),
* 'automatic' when the controller replaced it itself (a construction or
* tile-failure fallback, or a recovery after a failed activation).
* @returns {'manual' | 'automatic'}
*/
getSwitchOrigin() {
return this._switchOrigin;
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
switchOrigin: this._switchOrigin,
...this._registry.state,
};
}
_emitChange(status) {
this._onChange?.(this.getState(status));
}
/**
* Hear every settled activation: a stack switch, a silent switch, a
* fallback after a failure, a recovery. Unlike `onChange` (the UI's
* status feed, muted by `silent`), listeners fire once per `setStack`
* after `_activeId` has settled, so a layer that drapes on the active
* host can rebind. The state's `switchOrigin` says whether the caller
* ('manual') or the controller itself ('automatic') chose the settled
* stack. A listener that throws is logged, never fatal.
* @param {(state: object) => void} listener
* @returns {() => void} Unsubscribe.
*/
subscribe(listener) {
// A destroyed controller never settles again, so it takes no listeners.
if (typeof listener !== 'function' || this._destroyed) return () => {};
this._subscribers.add(listener);
return () => {
this._subscribers.delete(listener);
};
}
_notifySubscribers() {
if (this._destroyed || !this._subscribers.size) return;
const state = this.getState();
for (const listener of [...this._subscribers]) {
if (this._destroyed) return;
if (!this._subscribers.has(listener)) continue;
try {
listener(state);
} catch (error) {
console.warn('[MapSourceController] subscriber failed:', error);
}
}
}
/**
* Lease the map for an imagery comparison. switchPolicy: 'preserve' (never
* switch) | 'esri' (switch to Esri imagery from any other stack) |
* 'esri-if-photoreal' (switch only when Google 3D is active). Throws while
* another owner holds the lease; see acquireImageryComparison().
*/
acquireImageryComparison(options) {
return acquireImageryComparison(this, options);
}
async setStack(id, { silent = false, [AUTOMATIC]: automatic = 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._switchOrigin = automatic ? 'automatic' : 'manual';
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 (this._activeId !== stack.id) this._switchOrigin = 'automatic';
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)
) {
// Whatever settles now is the controller's choice, not the caller's.
this._switchOrigin = 'automatic';
try {
const activation = await this._activate(recovery, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || recovery.id;
} catch (recoveryError) {
if (gen !== this._switchGen) return this.getState();
this._lastError = recoveryError?.message || String(recoveryError);
this._onError?.(this._lastError, recovery);
}
}
if (!silent) this._emitChange('error');
} finally {
if (gen === this._switchGen) {
this._isSwitching = false;
// The active id has settled for this generation (activated, fell
// back, or recovered): every subscriber hears it, silent or not.
this._notifySubscribers();
}
}
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, () =>
source.createTileset({ signal: this._abort.signal }),
);
}
if (gen !== this._switchGen) return;
if (!source.tileset && !this._ownedTilesets.has(tileset)) {
tileset.show = false;
this.viewer.scene.primitives.add(tileset);
this._ownedTilesets.add(tileset);
}
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;
// Scene shots reapply their map stack at every handoff. Keep the live
// layer (and its loaded tiles) when the resolved provider is unchanged;
// rebuilding it exposes the bare globe while imagery loads again.
if (
!this._imageryLayer ||
this._activeImageryProvider !== resolution.provider
) {
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);
// A repeated request still owns a new switch generation. Rebind its
// failure listener so fallback remains live without accumulating listeners.
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = 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, [AUTOMATIC]: 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._subscribers.clear();
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);
}
for (const promise of this._tilesets.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value),
() => {},
);
this._imageryProviders.clear();
this._terrainProviders.clear();
this._tilesets.clear();
this._ownedTilesets.clear();
this._onChange = null;
this._onError = null;
}
}