From 83c13f330d1d4d1ac58b2e27aac80714e513c016 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 18:37:02 +0400 Subject: [PATCH] refactor(credits): use the globe engine Map credits (maps/credits.js) are shown through a credits capability (engine.credits.addStatic) instead of the scene's credit display; the shared map scene takes the engine's credits, so the Cesium engine's maps and its credits part share one owner. Data credits (registerDataCredits, registerDynamicCredit, show/hideOsmCredit) take the globe engine and keep accepting a viewer from layers that still hold one; the scene registers the data credits on its engine. The Cesium credits part reads Viewer.creditDisplay, else the scene frame state display it aliases. --- scripts/package-boundaries.json | 2 + src/app/scene.js | 2 +- src/data/dataCredits.js | 101 +++++++++++++++---------- src/data/dataCredits.test.mjs | 39 ++++++++++ src/globe/cesium/createCesiumEngine.js | 4 +- src/globe/cesium/credits.js | 4 +- src/globe/cesium/credits.test.mjs | 15 ++++ src/globe/cesium/maps.js | 7 +- src/maps/credits.js | 22 +++--- src/maps/sceneApply.js | 14 +++- src/maps/sceneApply.test.mjs | 18 +++++ 11 files changed, 169 insertions(+), 59 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 76540e1a2..973e02e2d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1607,6 +1607,7 @@ "./maps/defaults" ], "modules": [ + "src/globe/cesium/credits.js", "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", @@ -3086,6 +3087,7 @@ "application-operations": { "exports": ["./application/operations"], "modules": [ + "src/globe/cesium/credits.js", "src/services/groundSnap.js", "src/globe/contract.js", "src/annotations/annotationResolver.js", diff --git a/src/app/scene.js b/src/app/scene.js index 993efee3c..524e1804e 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -62,7 +62,7 @@ export async function createApplicationScene({ const tilesetRef = { current: null }; const engine = createSceneEngine(viewer, { tilesetRef, defer }); defer(installTrackpadPinchZoom(viewer)); - registerDataCredits(viewer, credits); + registerDataCredits(engine, credits); configureCreditKeyboardAccess(document); loaderStatus.textContent = googleApiKey || googleTokens || cesiumToken diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..9ddb70258 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import { isGlobeEngine } from '../globe/contract.js'; +import { createCesiumCredits } from '../globe/cesium/credits.js'; /** * Per-layer data attribution registered into Cesium's credit display. @@ -35,8 +36,32 @@ export const OPENMAPTILES_CREDIT = { }; const osmDisplays = new WeakMap(); +const viewerCredits = new WeakMap(); -function updateOsmDisplay(viewer, state) { +/** + * Credits of a globe engine, or of a Cesium viewer's credit display for + * callers that still hold a viewer (layers until phase 1c); null without one. + * @returns {{ addStatic: Function, request: () => void } | null} + */ +function creditsOf(target) { + if (isGlobeEngine(target)) + return { + addStatic: (html, options) => target.credits.addStatic(html, options), + request: () => target.render.request('credits'), + }; + if (typeof target?.creditDisplay?.addStaticCredit !== 'function') return null; + let credits = viewerCredits.get(target); + if (!credits) { + credits = { + addStatic: createCesiumCredits(target).credits.addStatic, + request: () => target.scene?.requestRender?.(), + }; + viewerCredits.set(target, credits); + } + return credits; +} + +function updateOsmDisplay(credits, state) { const osm = state.owners.size > 0; let tiles = false; for (const usesTiles of state.owners.values()) tiles ||= usesTiles; @@ -44,50 +69,48 @@ function updateOsmDisplay(viewer, state) { ['osm', osm], ['tiles', tiles], ]) { - if (state[key].visible === visible) continue; - state[key].visible = visible; - viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit']( - state[key].credit, - ); - viewer.scene?.requestRender?.(); + const entry = state[key]; + if ((entry.remove !== null) === visible) continue; + if (visible) + entry.remove = credits.addStatic(entry.html, { onScreen: true }); + else { + entry.remove(); + entry.remove = null; + } + credits.request(); } } /** Keep one short inline credit while an OSM display owner has visible data. */ -export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) { - if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false; - let state = osmDisplays.get(viewer); +export function showOsmCredit(target, owner, { openMapTiles = false } = {}) { + const credits = creditsOf(target); + if (!credits || !owner) return false; + let state = osmDisplays.get(target); if (!state) { state = { owners: new Map(), osm: { - visible: false, - credit: new Cesium.Credit( - '© OpenStreetMap', - true, - ), + remove: null, + html: '© OpenStreetMap', }, tiles: { - visible: false, - credit: new Cesium.Credit( - '© OpenMapTiles', - true, - ), + remove: null, + html: '© OpenMapTiles', }, }; - osmDisplays.set(viewer, state); + osmDisplays.set(target, state); } if (state.owners.get(owner) === openMapTiles) return false; state.owners.set(owner, openMapTiles); - updateOsmDisplay(viewer, state); + updateOsmDisplay(credits, state); return true; } /** Release only this owner's credit; other displayed OSM sources retain theirs. */ -export function hideOsmCredit(viewer, owner) { - const state = osmDisplays.get(viewer); +export function hideOsmCredit(target, owner) { + const state = osmDisplays.get(target); if (!state?.owners.delete(owner)) return false; - updateOsmDisplay(viewer, state); + updateOsmDisplay(creditsOf(target), state); return true; } @@ -440,18 +463,16 @@ const _dynamicCreditKeys = new Set(); * Register a conditional credit at the moment its data source activates. * Idempotent per `credit.key`; lands in the same "Data attribution" popover * as the static credits (showOnScreen=false). - * @param {Cesium.Viewer} viewer — the initialized Cesium viewer + * @param {object} target — the globe engine, or the initialized Cesium viewer * @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT` * @returns {boolean} True when the credit is (now) registered. */ -export function registerDynamicCredit(viewer, credit) { - const creditDisplay = viewer?.creditDisplay; - if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { - return false; - } +export function registerDynamicCredit(target, credit) { + const credits = creditsOf(target); + if (!credits) return false; if (!credit?.key || !credit?.html) return false; if (_dynamicCreditKeys.has(credit.key)) return true; - creditDisplay.addStaticCredit(new Cesium.Credit(credit.html, false)); + credits.addStatic(credit.html, { onScreen: false }); _dynamicCreditKeys.add(credit.key); return true; } @@ -460,16 +481,14 @@ export function registerDynamicCredit(viewer, credit) { * Register every per-layer data credit into the viewer's credit display. * Idempotent: safe to call once at init. Credits are static and always * present in the "Data attribution" popover. - * @param {Cesium.Viewer} viewer — the initialized Cesium viewer + * @param {object} target — the globe engine, or the initialized Cesium viewer */ -export function registerDataCredits(viewer, credits = DATA_CREDITS) { - const creditDisplay = viewer?.creditDisplay; - if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { - return; - } +export function registerDataCredits(target, credits = DATA_CREDITS) { + const display = creditsOf(target); + if (!display) return; for (const { html } of credits) { - // showOnScreen=false → lives in the expandable "Data attribution" popover, + // onScreen false → lives in the expandable "Data attribution" popover, // not the on-globe credit line. - creditDisplay.addStaticCredit(new Cesium.Credit(html, false)); + display.addStatic(html, { onScreen: false }); } } diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 46de48972..fb76a351b 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -120,3 +120,42 @@ test('tile attribution follows source changes and viewers have independent owner assert.equal(a.credits.size, 0); assert.equal(b.credits.size, 1); }); + +test('data credits go through the globe engine credits', async () => { + const { + registerDataCredits, + registerDynamicCredit, + showOsmCredit, + hideOsmCredit, + } = await import('./dataCredits.js'); + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const engine = createFakeEngine(); + registerDataCredits(engine, [{ html: 'A' }, { html: 'B' }]); + assert.deepEqual(engine.fake.credits, [ + { html: 'A', onScreen: false }, + { html: 'B', onScreen: false }, + ]); + assert.equal( + registerDynamicCredit(engine, { key: 'engine-dyn', html: 'D' }), + true, + ); + assert.equal( + registerDynamicCredit(engine, { key: 'engine-dyn', html: 'D' }), + true, + ); + assert.equal( + engine.fake.credits.filter(({ html }) => html === 'D').length, + 1, + ); + engine.fake.resetCalls(); + assert.equal(showOsmCredit(engine, 'alpr', { openMapTiles: true }), true); + const inline = engine.fake.credits.filter(({ onScreen }) => onScreen); + assert.equal(inline.length, 2); + assert.match(inline[0].html, /© OpenStreetMap/); + assert.ok(engine.fake.callsOf('render.request').length > 0); + assert.equal(hideOsmCredit(engine, 'alpr'), true); + assert.equal( + engine.fake.credits.filter(({ onScreen }) => onScreen).length, + 0, + ); +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 0139af1ee..03c348a28 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -34,6 +34,7 @@ export function createCesiumEngine(viewer, options = {}) { // Viewer.canvas is scene.canvas; the engine reads the public alias. const canvas = viewer.canvas ?? viewer.scene.canvas; const render = createCesiumRender(viewer, options); + const credits = createCesiumCredits(viewer); const parts = { camera: createCesiumCamera(viewer), picker: createCesiumPicker(viewer), @@ -42,9 +43,10 @@ export function createCesiumEngine(viewer, options = {}) { render, surface: createCesiumSurface(viewer), input: createCesiumInput(viewer, options), - credits: createCesiumCredits(viewer), + credits, maps: createCesiumMaps(viewer, { ...options, + credits: credits.credits, requestRender: (reason) => render.render.request(reason), }), }; diff --git a/src/globe/cesium/credits.js b/src/globe/cesium/credits.js index 364247b64..651568676 100644 --- a/src/globe/cesium/credits.js +++ b/src/globe/cesium/credits.js @@ -8,7 +8,9 @@ import * as Cesium from 'cesium'; */ export function createCesiumCredits(viewer) { const added = new Set(); - const display = () => viewer.creditDisplay; + // Viewer.creditDisplay is the scene frame state's display. + const display = () => + viewer.creditDisplay ?? viewer.scene?.frameState?.creditDisplay; const remove = (credit) => { if (!added.delete(credit)) return; display()?.removeStaticCredit(credit); diff --git a/src/globe/cesium/credits.test.mjs b/src/globe/cesium/credits.test.mjs index ffb48fdf0..f67aaa5d4 100644 --- a/src/globe/cesium/credits.test.mjs +++ b/src/globe/cesium/credits.test.mjs @@ -31,3 +31,18 @@ test('destroy removes the credits the engine added', () => { assert.equal(viewer.staticCredits.length, 0); assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 2); }); + +test('the display is Viewer.creditDisplay, else the scene frame state one it aliases', () => { + const added = []; + const display = { + addStaticCredit: (credit) => added.push(credit), + removeStaticCredit: (credit) => added.splice(added.indexOf(credit), 1), + }; + const { credits } = createCesiumCredits({ + scene: { frameState: { creditDisplay: display } }, + }); + const remove = credits.addStatic('frame state'); + assert.equal(added[0].html, 'frame state'); + remove(); + assert.deepEqual(added, []); +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index 8cf29b0ba..291d139ab 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -63,6 +63,7 @@ const dispose = (value) => { * tilesetRef?: { current: object | null } | (() => object | null), * requestRender?: (reason: string) => void, * createImageryLayer?: (provider: object) => object, + * credits?: object, * loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>, * }} [options] `tilesetRef` names the startup Google tileset the app added. * @returns {{ maps: object, destroy: () => void }} @@ -73,12 +74,16 @@ export function createCesiumMaps( tilesetRef = null, requestRender = () => viewer.scene.requestRender?.(), createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), + credits, loadPhotorealTileset = (credentials) => loadPhotorealisticTileset(Cesium, credentials), } = {}, ) { const abort = new AbortController(); - const mapScene = createMapScene(viewer, { createImageryLayer }); + const mapScene = createMapScene(viewer, { + createImageryLayer, + ...(credits && { credits }), + }); const providers = new Map(); let ownedTileset = null; let shownTileset = null; diff --git a/src/maps/credits.js b/src/maps/credits.js index 65a008e0c..002f933ea 100644 --- a/src/maps/credits.js +++ b/src/maps/credits.js @@ -1,21 +1,21 @@ -import * as Cesium from 'cesium'; - -/** Static credits follow the source actually on screen, including fallback. */ -export function createMapCredits(viewer) { - let active = null; +/** + * Static credits follow the source actually on screen, including fallback. + * @param {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits + * A globe engine's credits capability. + */ +export function createMapCredits(credits) { + let remove = 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; + remove?.(); + remove = null; + if (html) remove = credits.addStatic(html, { onScreen: true }); markup = html; - if (active) display.addStaticCredit(active); } catch { - /* Switching remains usable on older credit displays. */ + /* Switching remains usable without a credit display. */ } }, destroy() { diff --git a/src/maps/sceneApply.js b/src/maps/sceneApply.js index 7f82a77af..21a773d2d 100644 --- a/src/maps/sceneApply.js +++ b/src/maps/sceneApply.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { createCesiumCredits } from '../globe/cesium/credits.js'; import { createMapCredits } from './credits.js'; /** @@ -7,13 +8,20 @@ import { createMapCredits } from './credits.js'; * Shared by MapSourceController and the Cesium globe engine's `maps`, so * both apply sources the same way. Creating it touches nothing. * @param {Cesium.Viewer} viewer - * @param {{ createImageryLayer?: (provider: object) => object }} [options] + * @param {{ + * createImageryLayer?: (provider: object) => object, + * credits?: import('../globe/contract.js').GlobeEngine['credits'], + * }} [options] `credits`: the globe engine's credits (default: the viewer's + * credit display through the Cesium engine's credits). */ export function createMapScene( viewer, - { createImageryLayer = (provider) => new Cesium.ImageryLayer(provider) } = {}, + { + createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), + credits: engineCredits = createCesiumCredits(viewer).credits, + } = {}, ) { - const credits = createMapCredits(viewer); + const credits = createMapCredits(engineCredits); let layer = null; let provider = null; const scene = { diff --git a/src/maps/sceneApply.test.mjs b/src/maps/sceneApply.test.mjs index 76aec8a5e..61d2df678 100644 --- a/src/maps/sceneApply.test.mjs +++ b/src/maps/sceneApply.test.mjs @@ -89,3 +89,21 @@ test('removeBaseImagery, tilesets, globe and credits', () => { assert.deepEqual(layers, []); assert.deepEqual(credits, []); }); + +test('the map scene shows its credit through a given credits capability', async () => { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const engine = createFakeEngine(); + const { viewer, credits } = fixture(); + const scene = createMapScene(viewer, { + createImageryLayer: (provider) => ({ provider }), + credits: engine.credits, + }); + scene.showCredit('Esri'); + scene.showCredit('Esri'); + assert.deepEqual(engine.fake.credits, [{ html: 'Esri', onScreen: true }]); + scene.showCredit('OSM'); + assert.deepEqual(engine.fake.credits, [{ html: 'OSM', onScreen: true }]); + scene.destroy(); + assert.deepEqual(engine.fake.credits, []); + assert.deepEqual(credits, [], 'nothing on the viewer display'); +});