mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(maps): separate source factories and scene coordination
This commit is contained in:
1 parent
561a781099
commit
e59282dc9c
22 files changed
+1807
-798
No files matched your search
@@ -0,0 +1,15 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
/**
|
||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
||||
* key or ion token configured, the tileset failed for another reason —
|
||||
* restrictions, quota, an EEA-billed key, or the network — and telling the
|
||||
* user to add a key they already added is the wrong advice.
|
||||
* @param {boolean} hasCredentials
|
||||
* @returns {string}
|
||||
*/
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials)
|
||||
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
|
||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as Cesium from 'cesium';
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,335 @@
|
||||
import { indexMapSources } from './registry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMapCredits } from './credits.js';
|
||||
|
||||
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
||||
export class MapSourceController {
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
registry,
|
||||
initialStack,
|
||||
onChange = null,
|
||||
onError = null,
|
||||
requestRender = () => viewer?.scene?.requestRender?.(),
|
||||
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
|
||||
},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this._registry = registry;
|
||||
this._sources = indexMapSources(registry.sources);
|
||||
this._activeId = this.isStackAvailable(initialStack)
|
||||
? initialStack
|
||||
: registry.defaultId;
|
||||
this._onChange = onChange;
|
||||
this._onError = onError;
|
||||
this._requestRender = requestRender;
|
||||
this._createImageryLayer = createImageryLayer;
|
||||
this._credits = createMapCredits(viewer);
|
||||
this._abort = new AbortController();
|
||||
this._imageryProviders = new Map();
|
||||
this._terrainProviders = new Map();
|
||||
this._tilesets = new Map();
|
||||
this._ownedTilesets = new Set();
|
||||
this._disposed = new WeakSet();
|
||||
this._switchGen = 0;
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._terrainMode = null;
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
getStack(id) {
|
||||
return this._sources.get(id)?.descriptor || null;
|
||||
}
|
||||
getStacks() {
|
||||
return [...this._sources.values()].map(({ descriptor }) => {
|
||||
const stack = descriptor;
|
||||
const available = this.isStackAvailable(stack.id);
|
||||
return {
|
||||
...stack,
|
||||
available,
|
||||
unavailableReason: available ? null : this._unavailableReason(stack),
|
||||
};
|
||||
});
|
||||
}
|
||||
isStackAvailable(id) {
|
||||
const source = this._sources.get(id);
|
||||
return Boolean(source && source.available !== false);
|
||||
}
|
||||
_unavailableReason(stack) {
|
||||
return (
|
||||
this._sources.get(stack?.id)?.unavailableReason ||
|
||||
`${stack?.label || 'This map stack'} is unavailable`
|
||||
);
|
||||
}
|
||||
getActiveId() {
|
||||
return this._activeId;
|
||||
}
|
||||
getActiveStack() {
|
||||
return this.getStack(this._activeId);
|
||||
}
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
activeStack: this.getActiveStack(),
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
...this._registry.state,
|
||||
};
|
||||
}
|
||||
_emitChange(status) {
|
||||
this._onChange?.(this.getState(status));
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
|
||||
if (!stack) return null;
|
||||
if (!this.isStackAvailable(stack.id)) {
|
||||
const message = this._unavailableReason(stack);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
return this.getState();
|
||||
}
|
||||
const gen = ++this._switchGen;
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
try {
|
||||
const activation = await this._activate(stack, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
}
|
||||
this._requestRender('map-stack');
|
||||
if (!silent) this._emitChange('ready');
|
||||
} catch (error) {
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
const message = error?.message || String(error);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
const recovery = this.getStack(this._registry.recoveryId);
|
||||
if (
|
||||
recovery &&
|
||||
recovery.id !== stack.id &&
|
||||
this.isStackAvailable(recovery.id)
|
||||
) {
|
||||
await this._activate(recovery, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = recovery.id;
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
_activate(stack, gen) {
|
||||
const source = this._sources.get(stack.id);
|
||||
return source.imagery
|
||||
? this._activateGlobeStack(stack, gen)
|
||||
: this._activateTileset(source, gen);
|
||||
}
|
||||
|
||||
async _activateTileset(source, gen) {
|
||||
let tileset = source.tileset;
|
||||
if (!tileset) {
|
||||
if (!source.createTileset)
|
||||
throw new Error(`Missing 3D source: ${source.descriptor.id}`);
|
||||
tileset = await this._cached(
|
||||
this._tilesets,
|
||||
source.descriptor.id,
|
||||
async () => {
|
||||
const value = await source.createTileset({
|
||||
signal: this._abort.signal,
|
||||
});
|
||||
if (this._destroyed) {
|
||||
this._dispose(value);
|
||||
return value;
|
||||
}
|
||||
value.show = false;
|
||||
this.viewer.scene.primitives.add(value);
|
||||
this._ownedTilesets.add(value);
|
||||
return value;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
this._credits.show(source.credit || null);
|
||||
this._showTileset(tileset);
|
||||
this.viewer.scene.globe.show = false;
|
||||
// Terrain is intentionally untouched while the globe is hidden.
|
||||
}
|
||||
|
||||
_showTileset(active) {
|
||||
for (const source of this._sources.values())
|
||||
if (source.tileset) source.tileset.show = source.tileset === active;
|
||||
for (const tileset of this._ownedTilesets)
|
||||
tileset.show = tileset === active;
|
||||
}
|
||||
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
if (gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
this._imageryLayer = this._createImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
const source = this._sources.get(resolution.effectiveStackId);
|
||||
this._credits.show(source?.credit || null);
|
||||
this._watchProvider(resolution, gen);
|
||||
this._showTileset(null);
|
||||
this.viewer.scene.globe.show = true;
|
||||
this._activeId = resolution.effectiveStackId;
|
||||
if (source?.terrain && source.terrain.id !== this._terrainMode) {
|
||||
const terrain = source.terrain;
|
||||
const result = await this._cached(
|
||||
this._terrainProviders,
|
||||
terrain.id,
|
||||
() => terrain.create({ signal: this._abort.signal }),
|
||||
);
|
||||
if (gen !== this._switchGen) return;
|
||||
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
|
||||
else this.viewer.terrainProvider = result.provider;
|
||||
this._terrainMode = terrain.id;
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
_cached(cache, id, create) {
|
||||
if (cache.has(id)) return cache.get(id);
|
||||
const promise = Promise.resolve()
|
||||
.then(() => {
|
||||
this._abort.signal.throwIfAborted();
|
||||
return create();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cache.get(id) === promise) cache.delete(id);
|
||||
throw error;
|
||||
});
|
||||
cache.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
_getImageryProvider(stack, visited = new Set()) {
|
||||
if (visited.has(stack.id))
|
||||
return Promise.reject(new Error('Map source fallback cycle'));
|
||||
return this._cached(this._imageryProviders, stack.id, async () => {
|
||||
const source = this._sources.get(stack.id);
|
||||
try {
|
||||
const provider = await source.imagery({ signal: this._abort.signal });
|
||||
if (this._destroyed) this._dispose(provider);
|
||||
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
|
||||
} catch (error) {
|
||||
const fallback = source.constructionFallback;
|
||||
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
|
||||
throw error;
|
||||
const next = new Set(visited).add(stack.id);
|
||||
const resolution = await this._getImageryProvider(
|
||||
this.getStack(fallback.id),
|
||||
next,
|
||||
);
|
||||
return { ...resolution, fallbackMessage: fallback.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_watchProvider(resolution, gen) {
|
||||
const fallback = this._sources.get(
|
||||
resolution.effectiveStackId,
|
||||
)?.tileFailureFallback;
|
||||
const errorEvent = resolution.provider?.errorEvent;
|
||||
if (!fallback || !errorEvent?.addEventListener) return;
|
||||
let failures = 0;
|
||||
let pending = false;
|
||||
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
|
||||
if (
|
||||
gen !== this._switchGen ||
|
||||
this._activeImageryProvider !== resolution.provider
|
||||
)
|
||||
return;
|
||||
const retryCount = Number(error?.timesRetried);
|
||||
failures =
|
||||
Number.isInteger(retryCount) && retryCount >= 0
|
||||
? Math.max(failures + 1, retryCount + 1)
|
||||
: failures + 1;
|
||||
if (failures < fallback.threshold || pending) return;
|
||||
pending = true;
|
||||
this._onError?.(
|
||||
fallback.message,
|
||||
this.getStack(resolution.effectiveStackId),
|
||||
);
|
||||
const expectedGen = this._switchGen + 1;
|
||||
void this.setStack(fallback.id, { silent: true })
|
||||
.then((state) => {
|
||||
if (
|
||||
!this._destroyed &&
|
||||
this._switchGen === expectedGen &&
|
||||
state?.activeId === fallback.id
|
||||
) {
|
||||
this._lastError = fallback.message;
|
||||
this._emitChange('error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
pending = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_removeImageryLayer() {
|
||||
this._removeImageryErrorListener?.();
|
||||
this._removeImageryErrorListener = null;
|
||||
if (this._imageryLayer)
|
||||
this.viewer.imageryLayers.remove(this._imageryLayer, true);
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
}
|
||||
_dispose(value) {
|
||||
if (!value || this._disposed.has(value)) return;
|
||||
this._disposed.add(value);
|
||||
if (typeof value.destroy === 'function' && !value.isDestroyed?.())
|
||||
value.destroy();
|
||||
}
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._switchGen++;
|
||||
this._abort.abort();
|
||||
this._isSwitching = false;
|
||||
this._removeImageryLayer();
|
||||
this._credits.destroy();
|
||||
for (const promise of this._imageryProviders.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value.provider),
|
||||
() => {},
|
||||
);
|
||||
for (const promise of this._terrainProviders.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value.provider),
|
||||
() => {},
|
||||
);
|
||||
for (const tileset of this._ownedTilesets) {
|
||||
this.viewer.scene.primitives.remove(tileset);
|
||||
this._dispose(tileset);
|
||||
}
|
||||
this._imageryProviders.clear();
|
||||
this._terrainProviders.clear();
|
||||
this._tilesets.clear();
|
||||
this._ownedTilesets.clear();
|
||||
this._onChange = null;
|
||||
this._onError = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MapSourceController } from './controller.js';
|
||||
import { createDefaultMapSources } from './defaultSources.js';
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
addEventListener(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
raise(value = {}) {
|
||||
for (const fn of [...listeners]) fn(value);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(registry, options = {}) {
|
||||
const imagery = [],
|
||||
credits = new Set(),
|
||||
primitives = [],
|
||||
removed = [],
|
||||
changes = [];
|
||||
const viewer = {
|
||||
scene: {
|
||||
globe: { show: true },
|
||||
requestRender() {},
|
||||
frameState: {
|
||||
creditDisplay: {
|
||||
addStaticCredit(value) {
|
||||
credits.add(value);
|
||||
},
|
||||
removeStaticCredit(value) {
|
||||
credits.delete(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
primitives: {
|
||||
add(value) {
|
||||
primitives.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
primitives.splice(primitives.indexOf(value), 1);
|
||||
value.destroy?.();
|
||||
},
|
||||
},
|
||||
},
|
||||
imageryLayers: {
|
||||
add(value) {
|
||||
imagery.push(value);
|
||||
},
|
||||
remove(value, destroy) {
|
||||
imagery.splice(imagery.indexOf(value), 1);
|
||||
removed.push({ value, destroy });
|
||||
},
|
||||
},
|
||||
};
|
||||
const controller = new MapSourceController(viewer, {
|
||||
registry,
|
||||
createImageryLayer: (provider) => ({ provider }),
|
||||
onChange: (state) => changes.push(state),
|
||||
...options,
|
||||
});
|
||||
return { viewer, controller, imagery, credits, primitives, removed, changes };
|
||||
}
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
|
||||
function publicFixture() {
|
||||
const tileset = { show: true };
|
||||
const registry = createDefaultMapSources({ googleTileset: tileset });
|
||||
const providers = new Map();
|
||||
for (const source of registry.sources) {
|
||||
if (!source.imagery) continue;
|
||||
const provider = { id: source.descriptor.id, errorEvent: event() };
|
||||
providers.set(source.descriptor.id, provider);
|
||||
source.imagery = async () => provider;
|
||||
source.terrain = {
|
||||
id: 'keyless',
|
||||
create: async () => ({ provider: { id: 'terrain' } }),
|
||||
};
|
||||
}
|
||||
return { ...fixture(registry), registry, providers, tileset };
|
||||
}
|
||||
|
||||
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
|
||||
let reads = 0,
|
||||
destroys = 0,
|
||||
terrainReads = 0;
|
||||
const provider = {
|
||||
destroy() {
|
||||
destroys++;
|
||||
},
|
||||
};
|
||||
const terrain = {
|
||||
id: 'custom-floor',
|
||||
create: async () => {
|
||||
terrainReads++;
|
||||
return { provider: { id: 'terrain' } };
|
||||
},
|
||||
};
|
||||
const registry = {
|
||||
defaultId: 'custom',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('custom'),
|
||||
imagery: async () => {
|
||||
reads++;
|
||||
return provider;
|
||||
},
|
||||
terrain,
|
||||
},
|
||||
],
|
||||
};
|
||||
const env = fixture(registry);
|
||||
await env.controller.setStack('custom');
|
||||
await env.controller.setStack('custom');
|
||||
assert.equal(env.controller.getActiveId(), 'custom');
|
||||
assert.equal(env.viewer.terrainProvider.id, 'terrain');
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(terrainReads, 1);
|
||||
assert.equal(env.imagery.length, 1);
|
||||
assert.equal(env.removed[0].destroy, true);
|
||||
env.controller.destroy();
|
||||
env.controller.destroy();
|
||||
await settle();
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
|
||||
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
|
||||
let resolve,
|
||||
signal,
|
||||
destroyed = 0;
|
||||
const env = fixture({
|
||||
defaultId: 'slow',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('slow'),
|
||||
imagery: (request) => {
|
||||
signal = request.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('slow');
|
||||
await settle();
|
||||
env.controller.destroy();
|
||||
assert.equal(signal.aborted, true);
|
||||
resolve({
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
});
|
||||
await loading;
|
||||
await settle();
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.deepEqual(
|
||||
env.changes.map((state) => state.status),
|
||||
['switching'],
|
||||
);
|
||||
});
|
||||
|
||||
test('slow terrain cannot overwrite a newer source and a 3D view never starts unused terrain', async () => {
|
||||
const env = publicFixture();
|
||||
let resolve,
|
||||
reads = 0;
|
||||
const osm = env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'osm',
|
||||
);
|
||||
osm.terrain = {
|
||||
id: 'slow-floor',
|
||||
create: () => {
|
||||
reads++;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
};
|
||||
await env.controller.setStack('photoreal');
|
||||
assert.equal(reads, 0);
|
||||
const loading = env.controller.setStack('osm');
|
||||
await settle();
|
||||
await env.controller.setStack('photoreal');
|
||||
resolve({ provider: { id: 'late-terrain' } });
|
||||
await loading;
|
||||
assert.equal(env.viewer.terrainProvider, undefined);
|
||||
assert.equal(env.viewer.scene.globe.show, false);
|
||||
assert.equal(env.tileset.show, true);
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('Esri construction fallback reports and attributes the source actually rendered', async () => {
|
||||
const env = publicFixture();
|
||||
env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'esri-imagery',
|
||||
).imagery = async () => {
|
||||
throw new Error('unreachable');
|
||||
};
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(
|
||||
env.controller.getState().lastError,
|
||||
'Esri Satellite is unavailable; using OSM',
|
||||
);
|
||||
assert.equal(env.imagery[0].provider, env.providers.get('osm'));
|
||||
assert.equal(env.credits.size, 0);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('one Esri tile failure stays put, two fall back, and stale errors cannot replace a selection', async () => {
|
||||
const env = publicFixture();
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.credits.size, 1);
|
||||
const errorEvent = env.providers.get('esri-imagery').errorEvent;
|
||||
errorEvent.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||
errorEvent.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(env.credits.size, 0);
|
||||
assert.equal(
|
||||
env.controller.getState().lastError,
|
||||
'Esri Satellite tile requests failed; using OSM',
|
||||
);
|
||||
assert.equal(errorEvent.size, 0);
|
||||
await env.controller.setStack('photoreal');
|
||||
errorEvent.raise({ timesRetried: 9 });
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('tooltips and rejected selection share the registry reason, including retired map IDs', async () => {
|
||||
const errors = [],
|
||||
env = publicFixture();
|
||||
env.controller._onError = (message) => errors.push(message);
|
||||
const reason = env.controller
|
||||
.getStacks()
|
||||
.find((stack) => stack.id === 'bing-aerial').unavailableReason;
|
||||
await env.controller.setStack('bing-aerial');
|
||||
assert.deepEqual(errors, [reason]);
|
||||
assert.match(reason, /CESIUM_ION_TOKEN/);
|
||||
await env.controller.setStack('bing-road');
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('a factory-owned 3D tileset is released after late completion and supplied tilesets stay caller-owned', async () => {
|
||||
let resolve,
|
||||
destroyed = 0;
|
||||
const supplied = {
|
||||
show: true,
|
||||
destroy() {
|
||||
assert.fail('caller-owned tileset');
|
||||
},
|
||||
};
|
||||
const env = fixture({
|
||||
defaultId: 'three',
|
||||
sources: [
|
||||
{
|
||||
descriptor: { id: 'three' },
|
||||
createTileset: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
},
|
||||
{ descriptor: { id: 'supplied' }, tileset: supplied },
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('three');
|
||||
await settle();
|
||||
env.controller.destroy();
|
||||
resolve({
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
});
|
||||
await loading;
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(env.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('invalid source graphs fail before constructing or caching a provider', () => {
|
||||
const a = { descriptor: descriptor('a'), constructionFallback: { id: 'b' } };
|
||||
const b = { descriptor: descriptor('b'), constructionFallback: { id: 'a' } };
|
||||
assert.throws(() => fixture({ sources: [a, b] }), /fallback cycle/);
|
||||
assert.throws(() => fixture({ sources: [a, a] }), /unique/);
|
||||
assert.throws(() => fixture({ sources: [a] }), /Unknown map fallback/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Static credits follow the source actually on screen, including fallback. */
|
||||
export function createMapCredits(viewer) {
|
||||
let active = null;
|
||||
let markup = null;
|
||||
return {
|
||||
show(html) {
|
||||
if (html === markup) return;
|
||||
const display = viewer?.scene?.frameState?.creditDisplay;
|
||||
if (!display) return;
|
||||
try {
|
||||
if (active) display.removeStaticCredit(active);
|
||||
active = html ? new Cesium.Credit(html, true) : null;
|
||||
markup = html;
|
||||
if (active) display.addStaticCredit(active);
|
||||
} catch {
|
||||
/* Switching remains usable on older credit displays. */
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
this.show(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MAP_STACKS } from './catalog.js';
|
||||
import { photorealUnavailableReason } from './availability.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
createOsmImagery,
|
||||
createEsriImagery,
|
||||
createIonImagery,
|
||||
ESRI_ATTRIBUTION_HTML,
|
||||
} from './imagery.js';
|
||||
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
|
||||
|
||||
/** Select sources and setup guidance without putting provider branches in the controller. */
|
||||
export function createDefaultMapSources({
|
||||
googleTileset = null,
|
||||
cesiumToken = '',
|
||||
googleApiKey = '',
|
||||
} = {}) {
|
||||
const ionToken = String(cesiumToken || '').trim();
|
||||
const hasIon = Boolean(ionToken);
|
||||
const hasGoogle = Boolean(String(googleApiKey || '').trim());
|
||||
const terrain = {
|
||||
id: hasIon ? 'world' : 'keyless',
|
||||
create: hasIon
|
||||
? (request) => createWorldTerrain(ionToken, request)
|
||||
: createKeylessTerrain,
|
||||
};
|
||||
return {
|
||||
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
|
||||
unknownId: 'photoreal',
|
||||
recoveryId: googleTileset ? 'photoreal' : null,
|
||||
state: { hasCesiumIonToken: hasIon },
|
||||
sources: MAP_STACKS.map((descriptor) => {
|
||||
const common = {
|
||||
descriptor,
|
||||
available: !descriptor.requiresIon || hasIon,
|
||||
unavailableReason: descriptor.requiresIon
|
||||
? keySetupRequirement('cesium-ion')
|
||||
: null,
|
||||
};
|
||||
if (descriptor.kind === 'photoreal')
|
||||
return {
|
||||
...common,
|
||||
available: Boolean(googleTileset),
|
||||
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
|
||||
tileset: googleTileset,
|
||||
};
|
||||
const imagery =
|
||||
descriptor.kind === 'ion'
|
||||
? () => createIonImagery(descriptor.style, ionToken)
|
||||
: descriptor.id === 'osm'
|
||||
? createOsmImagery
|
||||
: createEsriImagery;
|
||||
return {
|
||||
...common,
|
||||
imagery,
|
||||
terrain,
|
||||
...(descriptor.id === 'esri-imagery'
|
||||
? {
|
||||
credit: ESRI_ATTRIBUTION_HTML,
|
||||
constructionFallback: {
|
||||
id: 'osm',
|
||||
message: 'Esri Satellite is unavailable; using OSM',
|
||||
},
|
||||
tileFailureFallback: {
|
||||
id: 'osm',
|
||||
threshold: 2,
|
||||
message: 'Esri Satellite tile requests failed; using OSM',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const clean = (value) => String(value || '').trim();
|
||||
|
||||
/**
|
||||
* Decide which map provider can deliver the best startup experience.
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {'google-direct'|'google-ion'|'osm'}
|
||||
*/
|
||||
export function selectMapStartupRoute({
|
||||
googleApiKey = '',
|
||||
cesiumToken = '',
|
||||
} = {}) {
|
||||
if (clean(googleApiKey)) return 'google-direct';
|
||||
if (clean(cesiumToken)) return 'google-ion';
|
||||
return 'osm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const tileset = attempt.googleKey
|
||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
||||
: await createGoogleIonTileset(Cesium, ionToken);
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
return { tileset: null, route: 'osm', errors };
|
||||
}
|
||||
|
||||
/** Pass credentials to the source instead of changing SDK-wide defaults. */
|
||||
export function createGoogleDirectTileset(Cesium, key) {
|
||||
key = clean(key);
|
||||
if (!key) throw new Error('Google 3D requires an explicit browser key');
|
||||
return Cesium.createGooglePhotorealistic3DTileset({
|
||||
key,
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGoogleIonTileset(
|
||||
Cesium,
|
||||
accessToken,
|
||||
{ signal } = {},
|
||||
) {
|
||||
accessToken = clean(accessToken);
|
||||
if (!accessToken)
|
||||
throw new Error('Google 3D through ion requires an explicit token');
|
||||
signal?.throwIfAborted();
|
||||
const resource = await Cesium.IonResource.fromAssetId(2275207, {
|
||||
accessToken,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
// Match the installed SDK's Google helper rendering/cache defaults.
|
||||
return Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||
cacheBytes: 1536 * 1024 * 1024,
|
||||
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
|
||||
enableCollision: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Attribution and service rights are documented in DATA_SOURCES.md.
|
||||
export const ESRI_ATTRIBUTION_HTML =
|
||||
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
|
||||
|
||||
export function createOsmImagery() {
|
||||
return new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: '© OpenStreetMap contributors',
|
||||
});
|
||||
}
|
||||
|
||||
export function createEsriImagery() {
|
||||
return Cesium.ArcGisMapServerImageryProvider.fromUrl(
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
|
||||
{
|
||||
credit:
|
||||
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
||||
enablePickFeatures: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createIonImagery(style, accessToken) {
|
||||
accessToken = String(accessToken || '').trim();
|
||||
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
|
||||
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Catch invalid source graphs before a cached fallback can wait on itself. */
|
||||
export function indexMapSources(sources) {
|
||||
const index = new Map();
|
||||
for (const source of sources) {
|
||||
const id = source?.descriptor?.id;
|
||||
if (typeof id !== 'string' || !id || index.has(id))
|
||||
throw new TypeError('Map source IDs must be nonempty and unique');
|
||||
index.set(id, source);
|
||||
}
|
||||
for (const id of index.keys()) {
|
||||
const seen = new Set();
|
||||
let next = id;
|
||||
while (next) {
|
||||
if (seen.has(next)) throw new TypeError('Map source fallback cycle');
|
||||
if (!index.has(next)) throw new TypeError('Unknown map fallback source');
|
||||
seen.add(next);
|
||||
next = index.get(next).constructionFallback?.id;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createIonImagery } from './imagery.js';
|
||||
import { createWorldTerrain } from './terrain.js';
|
||||
|
||||
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
|
||||
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const calls = [];
|
||||
const defaultToken = Cesium.Ion.defaultAccessToken;
|
||||
try {
|
||||
Cesium.IonImageryProvider.fromAssetId = async (id, options) => {
|
||||
calls.push({ kind: 'imagery', id, options });
|
||||
return { id };
|
||||
};
|
||||
Cesium.IonResource.fromAssetId = async (id, options) => {
|
||||
calls.push({ kind: 'resource', id, options });
|
||||
return { id };
|
||||
};
|
||||
Cesium.CesiumTerrainProvider.fromUrl = async (resource, options) => {
|
||||
calls.push({ kind: 'terrain', resource, options });
|
||||
return { id: 'terrain' };
|
||||
};
|
||||
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
|
||||
const result = await createWorldTerrain('terrain-token');
|
||||
assert.equal(calls[0].options.accessToken, 'imagery-token');
|
||||
assert.equal(calls[1].options.accessToken, 'terrain-token');
|
||||
assert.equal(calls[1].id, 1);
|
||||
assert.equal(calls[2].options.requestVertexNormals, true);
|
||||
assert.equal(result.provider.id, 'terrain');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
|
||||
} finally {
|
||||
Cesium.IonImageryProvider.fromAssetId = originalImagery;
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation after ion metadata prevents terrain construction', async () => {
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
Cesium.IonResource.fromAssetId = async () => {
|
||||
controller.abort();
|
||||
return {};
|
||||
};
|
||||
Cesium.CesiumTerrainProvider.fromUrl = () =>
|
||||
assert.fail('cancelled terrain construction');
|
||||
await assert.rejects(
|
||||
createWorldTerrain('test-token', { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
} finally {
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
}
|
||||
});
|
||||
|
||||
test('credentialed source factories reject an omitted token instead of consuming an SDK default', async () => {
|
||||
assert.throws(
|
||||
() => createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, ''),
|
||||
/explicit token/,
|
||||
);
|
||||
await assert.rejects(createWorldTerrain(' '), /explicit ion token/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** These factories are lazy: a hidden globe must not trigger terrain loading. */
|
||||
export async function createWorldTerrain(accessToken, { signal } = {}) {
|
||||
accessToken = String(accessToken || '').trim();
|
||||
if (!accessToken)
|
||||
throw new Error('World terrain requires an explicit ion token');
|
||||
signal?.throwIfAborted();
|
||||
const resource = await Cesium.IonResource.fromAssetId(1, { accessToken });
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
provider: await Cesium.CesiumTerrainProvider.fromUrl(resource, {
|
||||
requestVertexNormals: true,
|
||||
requestWaterMask: false,
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createKeylessTerrain() {
|
||||
try {
|
||||
// Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0.
|
||||
return {
|
||||
provider: await Cesium.CesiumTerrainProvider.fromUrl(
|
||||
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
|
||||
error,
|
||||
);
|
||||
return { provider: new Cesium.EllipsoidTerrainProvider() };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user