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.
This commit is contained in:
German Khokhlov committed 2026-10-15 18:37:02 +04:00
1 parent be56144d75
commit 83c13f330d
11 files changed
+169 -59

No files matched your search

+2
View File
@@ -1607,6 +1607,7 @@
"./maps/defaults" "./maps/defaults"
], ],
"modules": [ "modules": [
"src/globe/cesium/credits.js",
"src/maps/availability.js", "src/maps/availability.js",
"src/maps/catalog.js", "src/maps/catalog.js",
"src/maps/controller.js", "src/maps/controller.js",
@@ -3086,6 +3087,7 @@
"application-operations": { "application-operations": {
"exports": ["./application/operations"], "exports": ["./application/operations"],
"modules": [ "modules": [
"src/globe/cesium/credits.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/annotations/annotationResolver.js", "src/annotations/annotationResolver.js",
+1 -1
View File
@@ -62,7 +62,7 @@ export async function createApplicationScene({
const tilesetRef = { current: null }; const tilesetRef = { current: null };
const engine = createSceneEngine(viewer, { tilesetRef, defer }); const engine = createSceneEngine(viewer, { tilesetRef, defer });
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits); registerDataCredits(engine, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
loaderStatus.textContent = loaderStatus.textContent =
googleApiKey || googleTokens || cesiumToken googleApiKey || googleTokens || cesiumToken
+60 -41
View File
@@ -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. * Per-layer data attribution registered into Cesium's credit display.
@@ -35,8 +36,32 @@ export const OPENMAPTILES_CREDIT = {
}; };
const osmDisplays = new WeakMap(); 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; const osm = state.owners.size > 0;
let tiles = false; let tiles = false;
for (const usesTiles of state.owners.values()) tiles ||= usesTiles; for (const usesTiles of state.owners.values()) tiles ||= usesTiles;
@@ -44,50 +69,48 @@ function updateOsmDisplay(viewer, state) {
['osm', osm], ['osm', osm],
['tiles', tiles], ['tiles', tiles],
]) { ]) {
if (state[key].visible === visible) continue; const entry = state[key];
state[key].visible = visible; if ((entry.remove !== null) === visible) continue;
viewer.creditDisplay[visible ? 'addStaticCredit' : 'removeStaticCredit']( if (visible)
state[key].credit, entry.remove = credits.addStatic(entry.html, { onScreen: true });
); else {
viewer.scene?.requestRender?.(); entry.remove();
entry.remove = null;
}
credits.request();
} }
} }
/** Keep one short inline credit while an OSM display owner has visible data. */ /** Keep one short inline credit while an OSM display owner has visible data. */
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) { export function showOsmCredit(target, owner, { openMapTiles = false } = {}) {
if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false; const credits = creditsOf(target);
let state = osmDisplays.get(viewer); if (!credits || !owner) return false;
let state = osmDisplays.get(target);
if (!state) { if (!state) {
state = { state = {
owners: new Map(), owners: new Map(),
osm: { osm: {
visible: false, remove: null,
credit: new Cesium.Credit( html: '<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>',
true,
),
}, },
tiles: { tiles: {
visible: false, remove: null,
credit: new Cesium.Credit( html: '<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
'<a href="https://openmaptiles.org" target="_blank" rel="noopener">© OpenMapTiles</a>',
true,
),
}, },
}; };
osmDisplays.set(viewer, state); osmDisplays.set(target, state);
} }
if (state.owners.get(owner) === openMapTiles) return false; if (state.owners.get(owner) === openMapTiles) return false;
state.owners.set(owner, openMapTiles); state.owners.set(owner, openMapTiles);
updateOsmDisplay(viewer, state); updateOsmDisplay(credits, state);
return true; return true;
} }
/** Release only this owner's credit; other displayed OSM sources retain theirs. */ /** Release only this owner's credit; other displayed OSM sources retain theirs. */
export function hideOsmCredit(viewer, owner) { export function hideOsmCredit(target, owner) {
const state = osmDisplays.get(viewer); const state = osmDisplays.get(target);
if (!state?.owners.delete(owner)) return false; if (!state?.owners.delete(owner)) return false;
updateOsmDisplay(viewer, state); updateOsmDisplay(creditsOf(target), state);
return true; return true;
} }
@@ -440,18 +463,16 @@ const _dynamicCreditKeys = new Set();
* Register a conditional credit at the moment its data source activates. * Register a conditional credit at the moment its data source activates.
* Idempotent per `credit.key`; lands in the same "Data attribution" popover * Idempotent per `credit.key`; lands in the same "Data attribution" popover
* as the static credits (showOnScreen=false). * 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` * @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
* @returns {boolean} True when the credit is (now) registered. * @returns {boolean} True when the credit is (now) registered.
*/ */
export function registerDynamicCredit(viewer, credit) { export function registerDynamicCredit(target, credit) {
const creditDisplay = viewer?.creditDisplay; const credits = creditsOf(target);
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { if (!credits) return false;
return false;
}
if (!credit?.key || !credit?.html) return false; if (!credit?.key || !credit?.html) return false;
if (_dynamicCreditKeys.has(credit.key)) return true; 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); _dynamicCreditKeys.add(credit.key);
return true; return true;
} }
@@ -460,16 +481,14 @@ export function registerDynamicCredit(viewer, credit) {
* Register every per-layer data credit into the viewer's credit display. * Register every per-layer data credit into the viewer's credit display.
* Idempotent: safe to call once at init. Credits are static and always * Idempotent: safe to call once at init. Credits are static and always
* present in the "Data attribution" popover. * 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) { export function registerDataCredits(target, credits = DATA_CREDITS) {
const creditDisplay = viewer?.creditDisplay; const display = creditsOf(target);
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { if (!display) return;
return;
}
for (const { html } of credits) { 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. // not the on-globe credit line.
creditDisplay.addStaticCredit(new Cesium.Credit(html, false)); display.addStatic(html, { onScreen: false });
} }
} }
+39
View File
@@ -120,3 +120,42 @@ test('tile attribution follows source changes and viewers have independent owner
assert.equal(a.credits.size, 0); assert.equal(a.credits.size, 0);
assert.equal(b.credits.size, 1); 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,
);
});
+3 -1
View File
@@ -34,6 +34,7 @@ export function createCesiumEngine(viewer, options = {}) {
// Viewer.canvas is scene.canvas; the engine reads the public alias. // Viewer.canvas is scene.canvas; the engine reads the public alias.
const canvas = viewer.canvas ?? viewer.scene.canvas; const canvas = viewer.canvas ?? viewer.scene.canvas;
const render = createCesiumRender(viewer, options); const render = createCesiumRender(viewer, options);
const credits = createCesiumCredits(viewer);
const parts = { const parts = {
camera: createCesiumCamera(viewer), camera: createCesiumCamera(viewer),
picker: createCesiumPicker(viewer), picker: createCesiumPicker(viewer),
@@ -42,9 +43,10 @@ export function createCesiumEngine(viewer, options = {}) {
render, render,
surface: createCesiumSurface(viewer), surface: createCesiumSurface(viewer),
input: createCesiumInput(viewer, options), input: createCesiumInput(viewer, options),
credits: createCesiumCredits(viewer), credits,
maps: createCesiumMaps(viewer, { maps: createCesiumMaps(viewer, {
...options, ...options,
credits: credits.credits,
requestRender: (reason) => render.render.request(reason), requestRender: (reason) => render.render.request(reason),
}), }),
}; };
+3 -1
View File
@@ -8,7 +8,9 @@ import * as Cesium from 'cesium';
*/ */
export function createCesiumCredits(viewer) { export function createCesiumCredits(viewer) {
const added = new Set(); 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) => { const remove = (credit) => {
if (!added.delete(credit)) return; if (!added.delete(credit)) return;
display()?.removeStaticCredit(credit); display()?.removeStaticCredit(credit);
+15
View File
@@ -31,3 +31,18 @@ test('destroy removes the credits the engine added', () => {
assert.equal(viewer.staticCredits.length, 0); assert.equal(viewer.staticCredits.length, 0);
assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 2); 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, []);
});
+6 -1
View File
@@ -63,6 +63,7 @@ const dispose = (value) => {
* tilesetRef?: { current: object | null } | (() => object | null), * tilesetRef?: { current: object | null } | (() => object | null),
* requestRender?: (reason: string) => void, * requestRender?: (reason: string) => void,
* createImageryLayer?: (provider: object) => object, * createImageryLayer?: (provider: object) => object,
* credits?: object,
* loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>, * loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>,
* }} [options] `tilesetRef` names the startup Google tileset the app added. * }} [options] `tilesetRef` names the startup Google tileset the app added.
* @returns {{ maps: object, destroy: () => void }} * @returns {{ maps: object, destroy: () => void }}
@@ -73,12 +74,16 @@ export function createCesiumMaps(
tilesetRef = null, tilesetRef = null,
requestRender = () => viewer.scene.requestRender?.(), requestRender = () => viewer.scene.requestRender?.(),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
credits,
loadPhotorealTileset = (credentials) => loadPhotorealTileset = (credentials) =>
loadPhotorealisticTileset(Cesium, credentials), loadPhotorealisticTileset(Cesium, credentials),
} = {}, } = {},
) { ) {
const abort = new AbortController(); const abort = new AbortController();
const mapScene = createMapScene(viewer, { createImageryLayer }); const mapScene = createMapScene(viewer, {
createImageryLayer,
...(credits && { credits }),
});
const providers = new Map(); const providers = new Map();
let ownedTileset = null; let ownedTileset = null;
let shownTileset = null; let shownTileset = null;
+11 -11
View File
@@ -1,21 +1,21 @@
import * as Cesium from 'cesium'; /**
* Static credits follow the source actually on screen, including fallback.
/** Static credits follow the source actually on screen, including fallback. */ * @param {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
export function createMapCredits(viewer) { * A globe engine's credits capability.
let active = null; */
export function createMapCredits(credits) {
let remove = null;
let markup = null; let markup = null;
return { return {
show(html) { show(html) {
if (html === markup) return; if (html === markup) return;
const display = viewer?.scene?.frameState?.creditDisplay;
if (!display) return;
try { try {
if (active) display.removeStaticCredit(active); remove?.();
active = html ? new Cesium.Credit(html, true) : null; remove = null;
if (html) remove = credits.addStatic(html, { onScreen: true });
markup = html; markup = html;
if (active) display.addStaticCredit(active);
} catch { } catch {
/* Switching remains usable on older credit displays. */ /* Switching remains usable without a credit display. */
} }
}, },
destroy() { destroy() {
+11 -3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createCesiumCredits } from '../globe/cesium/credits.js';
import { createMapCredits } from './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 * Shared by MapSourceController and the Cesium globe engine's `maps`, so
* both apply sources the same way. Creating it touches nothing. * both apply sources the same way. Creating it touches nothing.
* @param {Cesium.Viewer} viewer * @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( export function createMapScene(
viewer, 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 layer = null;
let provider = null; let provider = null;
const scene = { const scene = {
+18
View File
@@ -89,3 +89,21 @@ test('removeBaseImagery, tilesets, globe and credits', () => {
assert.deepEqual(layers, []); assert.deepEqual(layers, []);
assert.deepEqual(credits, []); 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');
});