diff --git a/src/maps/controller.js b/src/maps/controller.js index 470284155..33499272c 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -15,12 +15,18 @@ const AUTOMATIC = Symbol('automatic switch'); * choices. * * With `engine` (the page's globe engine) every scene change goes through - * `engine.maps`: the controller keeps resolving, caching and watching - * providers and tilesets, and hands the engine ready sources. Supplied - * tilesets must then be ones the engine knows (its startup tileset) or has - * shown. On destroy the engine keeps what it shows; owned tilesets are - * released and disposed. Without an engine the controller applies sources - * to the viewer itself (standalone use and tests). + * `engine.maps` as an engine-neutral description of the registry source + * (imagery `kind`/`style`/`accessToken`, terrain `kind`/`accessToken`, + * photoreal credentials, credit). The registry's factories build Cesium + * objects, so only a Cesium engine also gets them prebuilt (`provider`, + * `tileset`, terrain `create`): the controller then keeps resolving, caching + * and watching them as before (supplied tilesets must be ones the engine + * knows, i.e. its startup tileset, or has shown). Any other engine builds + * its own sources from the descriptions; the controller constructs nothing + * for it, falls back by description when a setter rejects, and has no tile + * failure watch. On destroy the engine keeps what it shows; owned tilesets + * are released and disposed. Without an engine the controller applies + * sources to the viewer itself (standalone use and tests). */ export class MapSourceController { constructor( @@ -47,6 +53,8 @@ export class MapSourceController { this._onError = onError; this._requestRender = requestRender; this._engine = engine; + /** Whether the engine takes the registry's prebuilt Cesium objects. */ + this._prebuilt = engine?.kind === 'cesium'; this._scene = engine ? null : createMapScene(viewer, { createImageryLayer }); @@ -246,6 +254,11 @@ export class MapSourceController { } async _activateTileset(source, gen) { + if (this._engine && !this._prebuilt) { + this._removeImageryLayer(); + await this._engine.maps.setPhotorealTiles(this._describeTiles(source)); + return; + } let tileset = source.tileset; if (!tileset) { if (!source.createTileset) @@ -259,10 +272,9 @@ export class MapSourceController { // The engine adds an owned tileset to the scene when it shows it. if (!source.tileset) this._ownedTilesets.add(tileset); this._removeImageryLayer(); - await this._engine.maps.setPhotorealTiles({ - tileset, - credit: source.credit ?? null, - }); + await this._engine.maps.setPhotorealTiles( + this._describeTiles(source, tileset), + ); return; } if (!source.tileset && !this._ownedTilesets.has(tileset)) { @@ -285,6 +297,8 @@ export class MapSourceController { } async _activateGlobeStack(stack, gen) { + if (this._engine && !this._prebuilt) + return this._activateDescribedGlobeStack(stack, gen); const resolution = await this._getImageryProvider(stack); if (gen !== this._switchGen) return; if (this._engine) return this._activateEngineGlobeStack(resolution, gen); @@ -325,11 +339,9 @@ export class MapSourceController { const source = this._sources.get(resolution.effectiveStackId); this._removeImageryErrorListener?.(); this._removeImageryErrorListener = null; - await maps.setBaseImagery({ - descriptor: source?.descriptor, - provider: resolution.provider, - credit: source?.credit ?? null, - }); + await maps.setBaseImagery( + this._describeImagery(source, resolution.provider), + ); if (gen !== this._switchGen) return; this._baseProvider = resolution.provider; this._watchProvider(resolution, gen); @@ -344,13 +356,87 @@ export class MapSourceController { () => terrain.create({ signal: this._abort.signal }), ); if (gen !== this._switchGen) return; - await maps.setTerrain({ id: terrain.id, create: () => result }); + await maps.setTerrain(this._describeTerrain(terrain, result)); if (gen !== this._switchGen) return; this._terrainMode = terrain.id; } return resolution; } + // A non-Cesium engine builds every source from its description. A source it + // cannot build falls back as the construction fallback says. + async _activateDescribedGlobeStack(stack, gen, visited = new Set()) { + const { maps } = this._engine; + const source = this._sources.get(stack.id); + try { + await maps.setBaseImagery(this._describeImagery(source)); + } catch (error) { + const fallback = source.constructionFallback; + if ( + this._destroyed || + !fallback || + visited.has(stack.id) || + !this.isStackAvailable(fallback.id) + ) + throw error; + const resolution = await this._activateDescribedGlobeStack( + this.getStack(fallback.id), + gen, + new Set(visited).add(stack.id), + ); + return resolution && { ...resolution, fallbackMessage: fallback.message }; + } + if (gen !== this._switchGen) return; + await maps.setPhotorealTiles(null); + if (gen !== this._switchGen) return; + this._activeId = stack.id; + if (source.terrain && source.terrain.id !== this._terrainMode) { + await maps.setTerrain(this._describeTerrain(source.terrain)); + if (gen !== this._switchGen) return; + this._terrainMode = source.terrain.id; + } + return { effectiveStackId: stack.id, fallbackMessage: null }; + } + + /** Engine description of an imagery source (+ a prebuilt Cesium provider). */ + _describeImagery(source, provider) { + const { descriptor } = source; + return { + descriptor, + kind: descriptor.kind, + ...(descriptor.style !== undefined && { style: descriptor.style }), + ...(source.accessToken !== undefined && { + accessToken: source.accessToken, + }), + credit: source.credit ?? null, + ...(provider && { provider }), + }; + } + + /** Engine description of a terrain source (+ the cached Cesium result). */ + _describeTerrain(terrain, result) { + return { + id: terrain.id, + kind: terrain.kind ?? terrain.id, + ...(terrain.accessToken !== undefined && { + accessToken: terrain.accessToken, + }), + ...(result && { create: () => result }), + }; + } + + /** Engine description of a photoreal source (+ a prebuilt Cesium tileset). */ + _describeTiles(source, tileset) { + return { + descriptor: source.descriptor, + kind: source.descriptor.kind, + credit: source.credit ?? null, + ...(source.googleApiKey && { googleApiKey: source.googleApiKey }), + ...(source.cesiumToken && { cesiumToken: source.cesiumToken }), + ...(tileset && { tileset }), + }; + } + /** The live base imagery provider, or null. */ _liveBaseProvider() { return this._engine ? this._baseProvider : this._scene.baseProvider; diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 95ecfce63..670b59bc0 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -993,3 +993,75 @@ test('controller and Cesium engine: photoreal to a globe stack restores imagery engine.destroy(); assert.deepEqual(credits(), []); }); + +// --- On a non-Cesium engine: descriptions only, nothing Cesium is built ------ + +function navaraFixture() { + return import('../globe/testing/fakeEngine.js').then( + ({ createFakeEngine }) => { + const engine = createFakeEngine({ kind: 'navara' }); + const registry = createDefaultMapSources({ + googleTileset: { show: true }, + cesiumToken: 'ion-test', + googleApiKey: 'google-test', + }); + const built = []; + for (const source of registry.sources) { + if (source.imagery) + source.imagery = () => built.push(`imagery:${source.descriptor.id}`); + if (source.terrain) + source.terrain = { + ...source.terrain, + create: () => built.push(`terrain:${source.terrain.id}`), + }; + } + const controller = new MapSourceController(null, { registry, engine }); + return { engine, controller, built }; + }, + ); +} + +test('on a non-Cesium engine the controller sends descriptions and builds nothing', async () => { + const { engine, controller, built } = await navaraFixture(); + await controller.setStack('esri-imagery'); + const [imagery] = engine.fake.callsOf('maps.setBaseImagery')[0].args; + assert.equal(imagery.kind, 'esri-imagery'); + assert.equal(imagery.descriptor.id, 'esri-imagery'); + assert.match(imagery.credit, /Esri/); + assert.equal('provider' in imagery, false); + assert.equal(typeof imagery.imagery, 'undefined'); + const [terrain] = engine.fake.callsOf('maps.setTerrain')[0].args; + assert.deepEqual(terrain, { + id: 'world', + kind: 'world', + accessToken: 'ion-test', + }); + await controller.setStack('bing-aerial'); + const [ion] = engine.fake.callsOf('maps.setBaseImagery')[1].args; + assert.equal(ion.kind, 'ion'); + assert.equal(ion.accessToken, 'ion-test'); + assert.ok(ion.style !== undefined); + await controller.setStack('photoreal'); + const [photoreal] = engine.fake.callsOf('maps.setPhotorealTiles').at(-1).args; + assert.equal(photoreal.kind, 'photoreal'); + assert.equal(photoreal.googleApiKey, 'google-test'); + assert.equal(photoreal.cesiumToken, 'ion-test'); + assert.equal('tileset' in photoreal, false); + assert.deepEqual(built, [], 'no Cesium provider, terrain or tileset built'); + assert.equal(controller.getActiveId(), 'photoreal'); + controller.destroy(); +}); + +test('on a non-Cesium engine a source the engine cannot build falls back by description', async () => { + const { engine, controller } = await navaraFixture(); + const setBaseImagery = engine.maps.setBaseImagery; + engine.maps.setBaseImagery = async (source) => { + if (source.kind === 'esri-imagery') throw new Error('no Esri here'); + return setBaseImagery(source); + }; + const state = await controller.setStack('esri-imagery'); + assert.equal(state.activeId, 'osm'); + assert.equal(state.lastError, 'Esri Satellite is unavailable; using OSM'); + assert.equal(engine.fake.maps.imagery.kind, 'osm'); + controller.destroy(); +}); diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js index 4062e7a80..fa6a263ba 100644 --- a/src/maps/defaultSources.js +++ b/src/maps/defaultSources.js @@ -12,8 +12,16 @@ export function createDefaultMapSources({ } = {}) { const ionToken = String(cesiumToken || '').trim(); const hasIon = Boolean(ionToken); - const hasGoogle = Boolean(String(googleApiKey || '').trim()); - const terrain = terrainSourceFor(ionToken); + // Engine-neutral descriptions ride beside the Cesium factories: a terrain + // `kind` (+ token), ion imagery's token and the photoreal credentials, so + // an engine that builds its own sources needs none of the factories. + const terrain = { + ...terrainSourceFor(ionToken), + kind: hasIon ? 'world' : 'keyless', + ...(hasIon && { accessToken: ionToken }), + }; + const googleKey = String(googleApiKey || '').trim(); + const hasGoogle = Boolean(googleKey); return { defaultId: googleTileset ? 'photoreal' : 'esri-imagery', unknownId: 'photoreal', @@ -33,11 +41,14 @@ export function createDefaultMapSources({ available: Boolean(googleTileset), unavailableReason: photorealUnavailableReason(hasIon || hasGoogle), tileset: googleTileset, + ...(googleKey && { googleApiKey: googleKey }), + ...(hasIon && { cesiumToken: ionToken }), }; const imagery = imageryFactoryFor(descriptor, ionToken); return { ...common, imagery, + ...(descriptor.kind === 'ion' && { accessToken: ionToken }), terrain, ...(descriptor.id === 'esri-imagery' ? { diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 5a26d4f83..6aaf1d4fe 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -216,3 +216,26 @@ test('applyTerrain installs a terrain object or a provider', () => { assert.deepEqual(calls, ['terrain']); assert.equal(viewer.terrainProvider, 'provider'); }); + +test('default sources carry engine-neutral descriptions beside their Cesium factories', () => { + const sources = createDefaultMapSources({ + cesiumToken: 'ion-test', + googleApiKey: 'google-test', + googleTileset: { show: true }, + }).sources; + const byId = (id) => sources.find(({ descriptor }) => descriptor.id === id); + assert.equal(byId('bing-aerial').accessToken, 'ion-test'); + assert.equal( + byId('osm').accessToken, + undefined, + 'only ion imagery needs the token', + ); + assert.equal(byId('osm').terrain.kind, 'world'); + assert.equal(byId('osm').terrain.accessToken, 'ion-test'); + assert.equal(byId('photoreal').googleApiKey, 'google-test'); + assert.equal(byId('photoreal').cesiumToken, 'ion-test'); + const keyless = createDefaultMapSources().sources; + const osm = keyless.find(({ descriptor }) => descriptor.id === 'osm'); + assert.equal(osm.terrain.kind, 'keyless'); + assert.equal(osm.terrain.accessToken, undefined); +});