diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 7659e2772..5551f7747 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -9,6 +9,8 @@
"src/data/infrastructure.js",
"src/data/localGeojsonCore.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
@@ -867,6 +869,8 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
@@ -995,6 +999,8 @@
"src/ui/applicationShell.js",
"src/data/followed.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
@@ -1260,6 +1266,8 @@
"src/scenes/director.js",
"src/data/followed.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/data/entityRecords.js",
"src/render/cesium/follow.js",
"src/geo/index.js",
@@ -1803,6 +1811,8 @@
"src/layers/launches/controls.js",
"src/layers/launches/index.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/data/entityRecords.js",
"src/render/cesium/follow.js",
"src/layers/launches/ingestion.js",
@@ -1866,6 +1876,8 @@
"src/data/installationNames.js",
"src/layers/installations/controls.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/geo/index.js",
"src/render/rendererOf.js",
@@ -1946,6 +1958,8 @@
"src/layers/awareness/dependencies.js",
"src/layers/awareness/focus.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
@@ -2130,6 +2144,8 @@
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
@@ -2194,8 +2210,10 @@
],
"modules": [
"src/layers/streetLevel/index.js",
+ "src/render/cesium/credits.js",
"src/geo/index.js",
"src/layers/streetLevel/credits.js",
+ "src/render/creditMarkup.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
@@ -2288,6 +2306,8 @@
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
@@ -2360,6 +2380,8 @@
"src/layers/radio/navigation.js",
"src/data/followed.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/data/entityRecords.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
@@ -2417,6 +2439,8 @@
"src/geo/index.js",
"src/layers/submarineCables/interaction.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
@@ -2495,10 +2519,24 @@
"modules": [
"src/i18n/marker.js",
"src/maps/availability.js",
+ "src/geo/index.js",
"src/maps/catalog.js",
"src/maps/controller.js",
+ "src/render/cesium/credits.js",
+ "src/render/cesium/camera.js",
+ "src/render/camera/events.js",
+ "src/geo/celestial.js",
+ "src/geo/quaternion.js",
+ "src/geo/frames.js",
+ "src/geo/ellipsoid.js",
+ "src/geo/vector.js",
"src/maps/imageryComparison.js",
"src/maps/credits.js",
+ "src/render/rendererOf.js",
+ "src/render/cesium/follow.js",
+ "src/render/cesium/internals.js",
+ "src/render/cesium/common.js",
+ "src/render/creditMarkup.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
"src/maps/imagery.js",
@@ -4165,12 +4203,14 @@
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
+ "src/render/cesium/credits.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
+ "src/render/creditMarkup.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
@@ -4319,6 +4359,8 @@
"src/layers/firms/anchors.js",
"src/locations.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/data/entityRecords.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
@@ -4422,6 +4464,8 @@
"src/geo/index.js",
"src/ui/navigationController.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/data/followed.js",
"src/app/stateChannel.js",
"src/data/cameraArrival.js",
@@ -4945,6 +4989,8 @@
"src/cyberSonar.js",
"src/celestialRing.js",
"src/render/cesium/camera.js",
+ "src/render/cesium/credits.js",
+ "src/render/creditMarkup.js",
"src/render/cesium/follow.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
diff --git a/src/annotations/hybridAnnotationRenderer.test.mjs b/src/annotations/hybridAnnotationRenderer.test.mjs
index 01d3ec274..721a5dcd5 100644
--- a/src/annotations/hybridAnnotationRenderer.test.mjs
+++ b/src/annotations/hybridAnnotationRenderer.test.mjs
@@ -5,6 +5,7 @@ import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
+import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
@@ -110,7 +111,7 @@ function fakeViewer(lon, lat) {
return {
dataSources,
frames,
- viewer: {
+ viewer: bindFakeCesiumCredits({
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
@@ -130,7 +131,7 @@ function fakeViewer(lon, lat) {
return index >= 0;
},
},
- },
+ }),
};
}
diff --git a/src/annotations/placeOutline.test.mjs b/src/annotations/placeOutline.test.mjs
index 1de4b6da5..946245d72 100644
--- a/src/annotations/placeOutline.test.mjs
+++ b/src/annotations/placeOutline.test.mjs
@@ -7,6 +7,7 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js';
+import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
function viewerAt(lat, lon, height = 20_000) {
return {
@@ -180,10 +181,10 @@ test('a geocoded city or neighborhood upgrades to the bundled outline', async ()
const credits = [];
const cancelled = await city.resolve(
'Austin',
- {
+ bindFakeCesiumCredits({
...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
- },
+ }),
{ signal: controller.signal },
);
const pending = cancelled.resolveOutline();
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 5ddb385a6..2736abc5b 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { findCredits } from '../render/rendererOf.js';
/**
* Per-layer data attribution registered into Cesium's credit display.
@@ -36,6 +37,20 @@ export const OPENMAPTILES_CREDIT = {
const osmDisplays = new WeakMap();
+/** The short inline OSM credits, as the renderer's credits port takes them. */
+const OSM_INLINE_CREDITS = Object.freeze({
+ osm: Object.freeze({
+ text: '© OpenStreetMap',
+ href: 'https://www.openstreetmap.org/copyright',
+ inline: true,
+ }),
+ tiles: Object.freeze({
+ text: '© OpenMapTiles',
+ href: 'https://openmaptiles.org',
+ inline: true,
+ }),
+});
+
function updateOsmDisplay(viewer, state) {
const osm = state.owners.size > 0;
let tiles = false;
@@ -44,37 +59,27 @@ 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?.();
+ if (Boolean(state.offs[key]) === visible) continue;
+ if (visible) {
+ // Looked up each time: a renderer bound later takes over.
+ const credits = findCredits(viewer);
+ if (credits) state.offs[key] = credits.addStatic(OSM_INLINE_CREDITS[key]);
+ } else {
+ state.offs[key]();
+ state.offs[key] = null;
+ }
}
}
-/** Keep one short inline credit while an OSM display owner has visible data. */
+/**
+ * Keep one short inline credit, through the renderer's credits port, while
+ * an OSM display owner has visible data.
+ */
export function showOsmCredit(viewer, owner, { openMapTiles = false } = {}) {
- if (!viewer?.creditDisplay?.addStaticCredit || !owner) return false;
+ if (!owner || !findCredits(viewer)) return false;
let state = osmDisplays.get(viewer);
if (!state) {
- state = {
- owners: new Map(),
- osm: {
- visible: false,
- credit: new Cesium.Credit(
- '© OpenStreetMap',
- true,
- ),
- },
- tiles: {
- visible: false,
- credit: new Cesium.Credit(
- '© OpenMapTiles',
- true,
- ),
- },
- };
+ state = { owners: new Map(), offs: { osm: null, tiles: null } };
osmDisplays.set(viewer, state);
}
if (state.owners.get(owner) === openMapTiles) return false;
diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs
index dc3a7951f..6cdcaa336 100644
--- a/src/data/dataCredits.test.mjs
+++ b/src/data/dataCredits.test.mjs
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
+import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -60,13 +61,13 @@ test('inline OSM attribution persists until its last display owner leaves', asyn
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
t.mock.timers.enable({ apis: ['setTimeout'] });
const visible = new Set();
- const viewer = {
+ const viewer = bindFakeCesiumCredits({
creditDisplay: {
addStaticCredit: (c) => visible.add(c),
removeStaticCredit: (c) => visible.delete(c),
},
scene: { requestRender() {} },
- };
+ });
assert.equal(showOsmCredit(viewer, 'alpr'), true);
assert.equal(showOsmCredit(viewer, 'alpr'), false);
assert.equal(visible.size, 1);
@@ -100,13 +101,13 @@ test('tile attribution follows source changes and viewers have independent owner
const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js');
const create = () => {
const credits = new Set();
- return {
+ return bindFakeCesiumCredits({
credits,
creditDisplay: {
addStaticCredit: (c) => credits.add(c),
removeStaticCredit: (c) => credits.delete(c),
},
- };
+ });
};
const a = create(),
b = create();
diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs
index 0ae690f05..ef0472443 100644
--- a/src/data/sharedOsmDisplays.test.mjs
+++ b/src/data/sharedOsmDisplays.test.mjs
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { showOsmCredit, hideOsmCredit } from './dataCredits.js';
+import { bindFakeCesiumCredits } from '../testSupport/fakeCreditsPort.mjs';
import { createInfrastructureLayers } from './infrastructure.js';
import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
@@ -31,7 +32,7 @@ function viewer(t) {
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
};
- return v;
+ return bindFakeCesiumCredits(v);
}
function assertCredit(v) {
assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<')));
diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js
index 2b6fde11e..61e6721bf 100644
--- a/src/layers/alpr/index.js
+++ b/src/layers/alpr/index.js
@@ -12,7 +12,7 @@ import {
boxContains,
alprRetryDelayMs,
validateAlprSnapshot,
- alprCreditMarkup,
+ alprCredit,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
import { t, tn, msg } from '../../i18n/index.js';
@@ -44,6 +44,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
credit: null,
creditTimer: null,
creditPresented: false,
+ creditOff: null,
enabled: false,
records: [],
recordById: new Map(),
@@ -297,10 +298,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
if (state.viewer) throw new Error('ALPR layer is already initialized');
state.viewer = viewer;
state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
- const creditMarkup = alprCreditMarkup(source.attribution);
- state.credit = creditMarkup
- ? new Cesium.Credit(creditMarkup, true)
- : null;
+ state.credit = alprCredit(source.attribution);
viewer.dataSources.add(state.dataSource);
state.moveEndRemove =
viewer.camera.moveEnd.addEventListener(scheduleLoad);
diff --git a/src/layers/alpr/model.js b/src/layers/alpr/model.js
index 5966cad01..b9c1f64c2 100644
--- a/src/layers/alpr/model.js
+++ b/src/layers/alpr/model.js
@@ -31,24 +31,16 @@ export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) {
};
}
-/** Build a linked attribution from plain text and an HTTPS URL only. */
-export function alprCreditMarkup(attribution) {
+/**
+ * The on-map credit for the renderer's credits port, from plain text and an
+ * HTTPS URL only; the port escapes it as it shows it.
+ * @returns {{text: string, href: string, inline: true}|null}
+ */
+export function alprCredit(attribution) {
if (!attribution?.text || !attribution?.href) return null;
const href = new URL(attribution.href);
if (href.protocol !== 'https:' || href.username || href.password) {
throw new TypeError('Camera attribution requires a public HTTPS link');
}
- const escape = (value) =>
- String(value).replace(
- /[&<>"']/g,
- (char) =>
- ({
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": ''',
- })[char],
- );
- return `ALPR: ${escape(attribution.text)}`;
+ return { text: `ALPR: ${attribution.text}`, href: href.href, inline: true };
}
diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js
index 88aa880cb..b31ff6e91 100644
--- a/src/layers/alpr/presentation.js
+++ b/src/layers/alpr/presentation.js
@@ -28,6 +28,7 @@ import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
import { alprMarkers } from './markerState.js';
import { heightsFor } from '../../queries/heights.js';
import { cameraOf } from '../../render/cesium/camera.js';
+import { findCredits } from '../../render/rendererOf.js';
export function createAlprPresentation({ state, services, source }) {
const { governorRequestRender } = services.render;
@@ -326,8 +327,8 @@ export function createAlprPresentation({ state, services, source }) {
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
clearTimeout(state.creditTimer);
state.creditTimer = null;
- if (state.credit)
- state.viewer?.creditDisplay?.removeStaticCredit(state.credit);
+ state.creditOff?.();
+ state.creditOff = null;
governorRequestRender('alpr-credit');
}
@@ -342,7 +343,8 @@ export function createAlprPresentation({ state, services, source }) {
}
if (!state.enabled || state.creditPresented || !state.credit) return;
state.creditPresented = true;
- state.viewer.creditDisplay?.addStaticCredit(state.credit);
+ state.creditOff =
+ findCredits(state.viewer)?.addStatic(state.credit) ?? null;
state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
governorRequestRender('alpr-credit');
}
diff --git a/src/layers/alpr/source.test.mjs b/src/layers/alpr/source.test.mjs
index f6793c28f..9ce95f5d6 100644
--- a/src/layers/alpr/source.test.mjs
+++ b/src/layers/alpr/source.test.mjs
@@ -8,7 +8,7 @@ import {
createOverpassAlprSource,
buildOverpassQuery,
} from './source.js';
-import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
+import { validateAlprSnapshot, alprCredit } from './model.js';
const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
test('the source rejects invalid and unbounded queries before fetching', async () => {
let calls = 0;
@@ -130,19 +130,28 @@ test('source snapshots reject malformed coordinates and duplicate identities', (
);
});
-test('provider attribution escapes markup and rejects executable links', () => {
- assert.equal(alprCreditMarkup(null), null);
+test('provider attribution is plain text with a public HTTPS link only', () => {
+ assert.equal(alprCredit(null), null);
assert.throws(
- () => alprCreditMarkup({ text: 'test', href: 'javascript:alert(1)' }),
+ () => alprCredit({ text: 'test', href: 'javascript:alert(1)' }),
/HTTPS/,
);
- const html = alprCreditMarkup({
- text: '
',
- href: 'https://example.org/?a=1&b=2',
- });
- assert.ok(html.includes('<img onerror=alert(1)>'));
- assert.ok(html.includes('?a=1&b=2'));
- assert.ok(!html.includes('
alprCredit({ text: 'test', href: 'https://user:pw@example.org/' }),
+ /HTTPS/,
+ );
+ assert.deepEqual(
+ alprCredit({
+ text: '
',
+ href: 'https://example.org/?a=1&b=2',
+ }),
+ {
+ text: 'ALPR:
',
+ href: 'https://example.org/?a=1&b=2',
+ inline: true,
+ },
+ 'left as text: the credits port escapes it',
+ );
});
test('wide detail view returns zoom guidance before any fetch and can retry a smaller view', async () => {
diff --git a/src/layers/installations/lifecycle.test.mjs b/src/layers/installations/lifecycle.test.mjs
index 09a60cc70..72e4e3aeb 100644
--- a/src/layers/installations/lifecycle.test.mjs
+++ b/src/layers/installations/lifecycle.test.mjs
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import * as credits from '../../data/dataCredits.js';
import { createLifecycle } from './lifecycle.js';
import { LAYER_ID } from './policy.js';
+import { bindFakeCesiumCredits } from '../../testSupport/fakeCreditsPort.mjs';
for (const source of [
{ name: 'OpenStreetMap', id: 'tile:base' },
@@ -13,13 +14,13 @@ for (const source of [
test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => {
const visibleCredits = new Set();
const noop = () => {};
- const viewer = {
+ const viewer = bindFakeCesiumCredits({
camera: {},
creditDisplay: {
addStaticCredit: (credit) => visibleCredits.add(credit),
removeStaticCredit: (credit) => visibleCredits.delete(credit),
},
- };
+ });
const state = {
viewer,
records: source ? [{ id: 'base', sources: [source] }] : [],
diff --git a/src/layers/streetLevel/credits.js b/src/layers/streetLevel/credits.js
index 25f584a39..4ab7d5693 100644
--- a/src/layers/streetLevel/credits.js
+++ b/src/layers/streetLevel/credits.js
@@ -1,33 +1,31 @@
-import * as Cesium from 'cesium';
+import { creditFromMarkup } from '../../render/creditMarkup.js';
+import { findCredits } from '../../render/rendererOf.js';
/**
- * One static Cesium credit per active provider: CC BY-SA imagery needs
- * visible attribution that goes away with the imagery.
+ * One on-map credit per active provider, through the renderer's credits
+ * port: CC BY-SA imagery needs visible attribution that goes away with the
+ * imagery. A provider's credit is markup with one link, read as the
+ * port's plain data.
*/
export function createCredits() {
- /** @type {Map} provider id → Cesium.Credit */
+ /** @type {Map void>} provider id → the credit's Off */
const shown = new Map();
function show(viewer, def) {
- if (shown.has(def.id) || !viewer?.creditDisplay) return;
- try {
- const credit = new Cesium.Credit(def.credit.html, true);
- viewer.creditDisplay.addStaticCredit(credit);
- shown.set(def.id, credit);
- } catch {
- /* credit display unavailable */
- }
+ if (shown.has(def.id)) return;
+ const credits = findCredits(viewer);
+ if (!credits) return;
+ shown.set(
+ def.id,
+ credits.addStatic({ ...creditFromMarkup(def.credit.html), inline: true }),
+ );
}
- function hide(viewer, def) {
- const credit = shown.get(def.id);
- if (!credit) return;
- try {
- viewer?.creditDisplay?.removeStaticCredit?.(credit);
- } catch {
- /* credit display already torn down */
- }
+ function hide(_viewer, def) {
+ const off = shown.get(def.id);
+ if (!off) return;
shown.delete(def.id);
+ off();
}
function hideAll(viewer) {
diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs
index a6ede575c..69c77e429 100644
--- a/src/layers/streetLevel/index.test.mjs
+++ b/src/layers/streetLevel/index.test.mjs
@@ -4,6 +4,7 @@ import * as Cesium from 'cesium';
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
+import { creditFromMarkup } from '../../render/creditMarkup.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import {
@@ -67,13 +68,16 @@ const posingViewer = () =>
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
const { layer, viewer } = await startLayer(t);
- const shown = () => viewer.credits.map((credit) => credit.html);
- assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
+ const shown = () =>
+ viewer.credits.map((credit) => creditFromMarkup(credit.html).text);
+ const mapillary = creditFromMarkup(MAPILLARY_CREDIT_HTML).text;
+ assert.deepEqual(shown(), [mapillary], 'shown on enable');
+ assert.ok(viewer.credits[0].showOnScreen, 'on the map');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setParams({ mapillary: false });
assert.deepEqual(shown(), [], 'hidden with the switch');
layer.setParams({ mapillary: true });
- assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
+ assert.deepEqual(shown(), [mapillary]);
layer.disable();
assert.deepEqual(shown(), [], 'the credit goes with the layer');
});
diff --git a/src/maps/controller.js b/src/maps/controller.js
index 442c18c0b..40a66d6b5 100644
--- a/src/maps/controller.js
+++ b/src/maps/controller.js
@@ -1,6 +1,9 @@
import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapCredits } from './credits.js';
+// A viewer of a consumer's own gets its credits port (and camera) from the
+// factories this loads.
+import '../render/cesium/camera.js';
import { acquireImageryComparison } from './imageryComparison.js';
/**
diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs
index f7e764754..a43f9d8fc 100644
--- a/src/maps/controller.test.mjs
+++ b/src/maps/controller.test.mjs
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { MapSourceController } from './controller.js';
import { createDefaultMapSources } from './defaultSources.js';
+import { bindRenderer } from '../render/rendererOf.js';
function event() {
const listeners = new Set();
@@ -28,16 +29,6 @@ function fixture(registry, options = {}) {
scene: {
globe: { show: true },
requestRender() {},
- frameState: {
- creditDisplay: {
- addStaticCredit(value) {
- credits.add(value);
- },
- removeStaticCredit(value) {
- credits.delete(value);
- },
- },
- },
primitives: {
add(value) {
primitives.push(value);
@@ -58,6 +49,14 @@ function fixture(registry, options = {}) {
},
},
};
+ bindRenderer(viewer, {
+ credits: {
+ addStatic(credit) {
+ credits.add(credit);
+ return () => credits.delete(credit);
+ },
+ },
+ });
const controller = new MapSourceController(viewer, {
registry,
createImageryLayer: (provider) => ({ provider }),
@@ -274,7 +273,10 @@ test('Esri construction fallback reports and attributes the source actually rend
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);
+ assert.deepEqual(
+ [...env.credits],
+ [{ text: 'Powered by Esri', href: 'https://www.esri.com', inline: true }],
+ );
const errorEvent = env.providers.get('esri-imagery').errorEvent;
errorEvent.raise();
await settle();
diff --git a/src/maps/credits.js b/src/maps/credits.js
index 65a008e0c..0d2b31f15 100644
--- a/src/maps/credits.js
+++ b/src/maps/credits.js
@@ -1,22 +1,24 @@
-import * as Cesium from 'cesium';
+import { creditFromMarkup } from '../render/creditMarkup.js';
+import { findCredits } from '../render/rendererOf.js';
-/** Static credits follow the source actually on screen, including fallback. */
+/**
+ * The on-map credit of the source actually on screen, including fallback,
+ * through the renderer's credits port. A source's credit is markup with one
+ * link, read as the port's plain data.
+ */
export function createMapCredits(viewer) {
- let active = null;
+ let off = 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. */
- }
+ const credits = findCredits(viewer);
+ if (!credits) return;
+ off?.();
+ off = html
+ ? credits.addStatic({ ...creditFromMarkup(html), inline: true })
+ : null;
+ markup = html;
},
destroy() {
this.show(null);
diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs
index cf0708c09..bd10cce60 100644
--- a/src/maps/map-subscription-ownership.test.mjs
+++ b/src/maps/map-subscription-ownership.test.mjs
@@ -7,9 +7,6 @@ function fixture() {
scene: {
globe: { show: true },
requestRender() {},
- frameState: {
- creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
- },
primitives: { add() {}, remove() {} },
},
imageryLayers: { add() {}, remove() {} },
diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js
index 75a64debc..5c6974660 100644
--- a/src/render/cesium/camera.js
+++ b/src/render/cesium/camera.js
@@ -10,6 +10,8 @@ import { listenerList, toCartesian, uncaught } from './common.js';
import { cameraFlightActive } from './internals.js';
import { createCesiumFollow } from './follow.js';
import { cameraOf, findCamera, provideUnboundCamera } from '../rendererOf.js';
+// Loads the factory an unbound viewer gets its credits port from.
+import './credits.js';
/**
* `cameraOf` and `findCamera`, for code that already reaches Cesium:
diff --git a/src/render/cesium/credits.js b/src/render/cesium/credits.js
index b84fe02e1..0afc7699b 100644
--- a/src/render/cesium/credits.js
+++ b/src/render/cesium/credits.js
@@ -24,7 +24,8 @@ import {
creditFromMarkup,
safeUrl,
} from '../creditMarkup.js';
-import { listenerList } from './common.js';
+import { provideUnboundCredits } from '../rendererOf.js';
+import { listenerList, uncaught } from './common.js';
import { creditDisplayContents } from './internals.js';
const CREDIT_KEYS = new Set(['text', 'href', 'logoUrl', 'inline']);
@@ -86,6 +87,9 @@ function copyCredit(credit) {
*/
export function createCesiumCredits({ display, scene, report, requestRender }) {
const listeners = listenerList(report);
+ // A consumer may destroy its viewer before the code holding credits lets
+ // them go; a destroyed display throws on every call.
+ const displayLive = () => Boolean(display) && !display.isDestroyed?.();
/** Core's credits, in the order added: each `{credit, markup}`. */
const added = [];
/** Core's credits as Cesium shows them, by markup: `{credit, count}`. */
@@ -120,7 +124,9 @@ export function createCesiumCredits({ display, scene, report, requestRender }) {
* leaves it out.
*/
function shownCredits() {
- const { statics, screen, lightbox, logo } = creditDisplayContents(display);
+ const { statics, screen, lightbox, logo } = creditDisplayContents(
+ displayLive() ? display : undefined,
+ );
const out = [];
const seen = new Set();
const take = (credit) => {
@@ -204,7 +210,7 @@ export function createCesiumCredits({ display, scene, report, requestRender }) {
entry = { credit: new Cesium.Credit(markup, inline), count: 0 };
shown.set(key, entry);
coreCredits.add(entry.credit);
- display?.addStaticCredit(entry.credit);
+ if (displayLive()) display.addStaticCredit(entry.credit);
}
entry.count += 1;
return key;
@@ -217,7 +223,7 @@ export function createCesiumCredits({ display, scene, report, requestRender }) {
if (entry.count > 0) return;
shown.delete(key);
coreCredits.delete(entry.credit);
- display?.removeStaticCredit(entry.credit);
+ if (displayLive()) display.removeStaticCredit(entry.credit);
}
let disposed = false;
@@ -251,15 +257,44 @@ export function createCesiumCredits({ display, scene, report, requestRender }) {
return {
port,
+ /** Detach from the scene and take Core's credits off the display. */
dispose() {
disposed = true;
offFrame();
listeners.clear();
- if (!display?.isDestroyed?.())
+ if (displayLive())
for (const { credit } of shown.values())
- display?.removeStaticCredit(credit);
+ display.removeStaticCredit(credit);
shown.clear();
coreCredits.clear();
},
+ /**
+ * Detach from the scene only: the credits added stay shown until their
+ * own Off, for a port another one supersedes.
+ */
+ release() {
+ offFrame();
+ listeners.clear();
+ },
};
}
+
+// A Cesium viewer of a consumer's own, with no renderer bound to it, gets a
+// credits port over its credit display when code first asks `findCredits`
+// for one. `camera.js` loads this module, so the entries that load the
+// unbound camera load this too.
+provideUnboundCredits((viewer) =>
+ // By shape, not `instanceof`: a consumer's viewer may come from another
+ // copy of Cesium.
+ typeof viewer.creditDisplay?.addStaticCredit === 'function' &&
+ typeof viewer.scene?.postRender?.addEventListener === 'function'
+ ? createCesiumCredits({
+ display: viewer.creditDisplay,
+ scene: viewer.scene,
+ report: uncaught,
+ requestRender: () => {
+ if (!viewer.scene.isDestroyed?.()) viewer.scene.requestRender?.();
+ },
+ })
+ : null,
+);
diff --git a/src/render/cesium/credits.test.mjs b/src/render/cesium/credits.test.mjs
index 1eb252f93..0b196dc09 100644
--- a/src/render/cesium/credits.test.mjs
+++ b/src/render/cesium/credits.test.mjs
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCesiumCredits, creditMarkup } from './credits.js';
+import { findCredits } from '../rendererOf.js';
/** A credit display as Cesium keeps it: static credits and a frame's. */
function fakeDisplay() {
@@ -271,6 +272,31 @@ test('dispose detaches from the frame and takes Core’s credits off', () => {
assert.equal(t.renders(), 1, 'no render asked of a disposed scene');
});
+test('a credit let go after its display is destroyed leaves the display alone', () => {
+ const t = setup();
+ const off = t.port.addStatic({ text: 'x' });
+ t.display.isDestroyed = () => true;
+ t.display.removeStaticCredit = () => {
+ throw new Error('destroyed');
+ };
+ off();
+ assert.deepEqual(t.port.list(), []);
+ t.part.dispose();
+});
+
+test('a released port stops following frames and keeps its credits until their Off', () => {
+ const t = setup();
+ const seen = [];
+ t.port.on('change', () => seen.push(1));
+ const off = t.port.addStatic({ text: 'x' });
+ t.part.release();
+ assert.equal(t.listeners.size, 0);
+ assert.equal(t.display._staticCredits.length, 1, 'still shown');
+ off();
+ assert.equal(t.display._staticCredits.length, 0);
+ assert.deepEqual(seen, [1], 'its listeners are gone');
+});
+
test('credit markup escapes every value and links only web URLs', () => {
assert.equal(
creditMarkup({ text: 'a "b" & ' }),
@@ -289,3 +315,28 @@ test('credit markup escapes every value and links only web URLs', () => {
'x',
);
});
+
+test('a Cesium viewer with no renderer gets a credits port over its display', () => {
+ const display = fakeDisplay();
+ const listeners = new Set();
+ let renders = 0;
+ const viewer = {
+ creditDisplay: display,
+ scene: {
+ postRender: {
+ addEventListener(cb) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ },
+ requestRender: () => (renders += 1),
+ },
+ };
+ const port = findCredits(viewer);
+ assert.equal(findCredits(viewer), port);
+ port.addStatic({ text: 'Unbound' });
+ assert.equal(display._staticCredits.length, 1);
+ assert.equal(renders, 1);
+ assert.equal(listeners.size, 1);
+ assert.equal(findCredits({ creditDisplay: display }), null, 'no scene');
+});
diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json
index 0f6e2aee5..1032b1cfa 100644
--- a/src/render/cesiumAllowList.json
+++ b/src/render/cesiumAllowList.json
@@ -147,7 +147,6 @@
"src/layers/satellites/testing.js",
"src/layers/satellites/tracking.js",
"src/layers/streetLevel/cameraFraming.js",
- "src/layers/streetLevel/credits.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/lineCoverage.js",
@@ -203,7 +202,6 @@
"src/locations.js",
"src/maps/catalog.js",
"src/maps/controller.js",
- "src/maps/credits.js",
"src/maps/google3d.js",
"src/maps/imagery.js",
"src/maps/terrain.js",
diff --git a/src/render/rendererOf.js b/src/render/rendererOf.js
index 58df8c54a..f0f807c46 100644
--- a/src/render/rendererOf.js
+++ b/src/render/rendererOf.js
@@ -15,6 +15,9 @@ const renderers = new WeakMap();
*/
const unboundCameras = new WeakMap();
let buildUnboundCamera = null;
+/** Credits ports built likewise, each `{port, dispose, release}`. */
+const unboundCredits = new WeakMap();
+let buildUnboundCredits = null;
/**
* Provide the factory `cameraOf` builds a camera port with for a scene
@@ -26,6 +29,17 @@ export function provideUnboundCamera(build) {
buildUnboundCamera = build;
}
+/**
+ * Provide the factory `findCredits` builds a credits port with for a scene
+ * object no renderer is bound to, as `provideUnboundCamera` does. `release`
+ * detaches a port a bound renderer supersedes, keeping its credits shown.
+ * @param {(host: object) => ({port: object, dispose: () => void,
+ * release: () => void} | null)} build
+ */
+export function provideUnboundCredits(build) {
+ buildUnboundCredits = build;
+}
+
/**
* Bind a renderer to its scene object. A camera `cameraOf` built for it
* while it had no renderer is disposed: its listeners hear nothing more,
@@ -39,6 +53,11 @@ export function bindRenderer(host, renderer) {
// The renderer's camera supersedes one built while there was none.
unboundCameras.get(host)?.dispose();
unboundCameras.delete(host);
+ // Its credits port does too for credits added from now on; one built
+ // before is released, not disposed: the credits added through it stay
+ // shown until their own Off.
+ unboundCredits.get(host)?.release?.();
+ unboundCredits.delete(host);
renderers.set(host, renderer);
return () => {
if (renderers.get(host) === renderer) renderers.delete(host);
@@ -60,21 +79,40 @@ export const rendererOf = (host) => (host && renderers.get(host)) || null;
* has no renderer and no camera can be built for it, or is destroyed.
*/
export function findCamera(host) {
- const camera = rendererOf(host)?.camera;
- if (camera) return camera;
+ return (
+ rendererOf(host)?.camera ??
+ findUnbound(host, unboundCameras, buildUnboundCamera)
+ );
+}
+
+/**
+ * The credits port of the renderer bound to `host`; with no renderer bound,
+ * one built over `host` by the provided factory and kept with it while
+ * `host` lives, as `findCamera` does.
+ * @param {object|null|undefined} host
+ * @returns {import('./contract/credits').CreditsPort | null}
+ */
+export function findCredits(host) {
+ return (
+ rendererOf(host)?.credits ??
+ findUnbound(host, unboundCredits, buildUnboundCredits)
+ );
+}
+
+function findUnbound(host, built, build) {
if (!host || typeof host !== 'object') return null;
if (host.isDestroyed?.()) {
- // Gone with its scene: let the built camera go with it.
- unboundCameras.get(host)?.dispose();
- unboundCameras.delete(host);
+ // Gone with its scene: let the built port go with it.
+ built.get(host)?.dispose();
+ built.delete(host);
return null;
}
- let built = unboundCameras.get(host);
- if (!built && buildUnboundCamera) {
- built = buildUnboundCamera(host);
- if (built) unboundCameras.set(host, built);
+ let part = built.get(host);
+ if (!part && build) {
+ part = build(host);
+ if (part) built.set(host, part);
}
- return built?.port ?? null;
+ return part?.port ?? null;
}
/**
diff --git a/src/render/rendererOf.test.mjs b/src/render/rendererOf.test.mjs
index 90b08793c..0d16db175 100644
--- a/src/render/rendererOf.test.mjs
+++ b/src/render/rendererOf.test.mjs
@@ -4,7 +4,9 @@ import {
bindRenderer,
cameraOf,
findCamera,
+ findCredits,
provideUnboundCamera,
+ provideUnboundCredits,
rendererOf,
} from './rendererOf.js';
@@ -87,3 +89,44 @@ test('a destroyed scene gets no built camera, and lets go of the one it had', (t
assert.throws(() => cameraOf(viewer), /destroyed/);
assert.equal(disposed, 1, 'once');
});
+
+test('with no renderer bound, findCredits builds one port per scene and keeps it working once one is', (t) => {
+ t.after(() => provideUnboundCredits(null));
+ const built = [];
+ provideUnboundCredits((host) => {
+ if (!host.creditDisplay) return null;
+ const entry = { port: { id: built.length }, disposed: 0, released: 0 };
+ entry.dispose = () => (entry.disposed += 1);
+ entry.release = () => (entry.released += 1);
+ built.push(entry);
+ return entry;
+ });
+ const viewer = { creditDisplay: {} };
+ assert.equal(findCredits(viewer), built[0].port);
+ assert.equal(findCredits(viewer), built[0].port, 'kept, not rebuilt');
+ assert.equal(findCredits({}), null, 'nothing to build over');
+ assert.equal(findCredits(null), null);
+
+ const credits = { list: () => [] };
+ const unbind = bindRenderer(viewer, { credits });
+ assert.equal(findCredits(viewer), credits);
+ assert.equal(
+ built[0].disposed,
+ 0,
+ 'the credits added through the built port stay until their Off',
+ );
+ assert.equal(built[0].released, 1, 'but it stops following the frames');
+ unbind();
+ assert.equal(
+ findCredits(viewer),
+ built[1].port,
+ 'unbound again: a fresh one',
+ );
+
+ let destroyed = false;
+ const gone = { creditDisplay: {}, isDestroyed: () => destroyed };
+ const port = findCredits(gone);
+ destroyed = true;
+ assert.equal(findCredits(gone), null);
+ assert.equal(built.find((entry) => entry.port === port).disposed, 1);
+});
diff --git a/src/render/unboundCamera.test.mjs b/src/render/unboundCamera.test.mjs
index ace7be1de..f2d346a1c 100644
--- a/src/render/unboundCamera.test.mjs
+++ b/src/render/unboundCamera.test.mjs
@@ -3,7 +3,8 @@
* `cameraOf` a viewer of the consumer's own, with no renderer bound. The
* bridge builds a camera port for it with the factory the Cesium adapter
* provides as `render/cesium/camera.js` loads, so every such entry must load
- * that module too.
+ * that module too. It loads `render/cesium/credits.js`, whose factory
+ * builds `findCredits`' port for such a viewer the same way.
*
* The graph is every static import, re-export and literal dynamic import of
* the entry's module and those it reaches, read by the parsers the Cesium
@@ -129,6 +130,12 @@ function reachable(entry, { eager = false } = {}) {
return seen;
}
+test('the unbound camera factory loads the unbound credits factory', () => {
+ assert.ok(
+ reachable(FACTORY, { eager: true }).has('src/render/cesium/credits.js'),
+ );
+});
+
test('every public entry that reaches the renderer bridge loads the unbound camera factory', () => {
const missing = [];
let bridged = 0;
diff --git a/src/testSupport/fakeCameraPort.mjs b/src/testSupport/fakeCameraPort.mjs
index 2524a698a..83f538d39 100644
--- a/src/testSupport/fakeCameraPort.mjs
+++ b/src/testSupport/fakeCameraPort.mjs
@@ -6,6 +6,7 @@ import { createCameraEvents } from '../render/camera/events.js';
import { toDegrees } from '../geo/index.js';
import { bindRenderer, rendererOf } from '../render/rendererOf.js';
import { createCesiumCamera } from '../render/cesium/camera.js';
+import { fakeCesiumCredits } from './fakeCreditsPort.mjs';
const DEFAULT_POSE = Object.freeze({
lon: 0,
@@ -72,8 +73,9 @@ export function fakeCameraPort({ pose = {}, viewRectangle = null } = {}) {
* from what the fake has (`positionCartographic`, `heading`, `pitch`,
* `roll`) and events come from the fake's own `changed`, `moveStart` and
* `moveEnd`. A fake with `trackedEntityChanged` and `entities` follows
- * through the port, as the renderer does. Binding a viewer twice keeps the
- * first. Returns the viewer.
+ * through the port, as the renderer does. Its credits are the renderer's
+ * credits port over the fake's `creditDisplay`, if it has one. Binding a
+ * viewer twice keeps the first. Returns the viewer.
* @template {{camera: object}} V
* @param {V} viewer
* @returns {V}
@@ -164,6 +166,6 @@ export function bindFakeCesiumCamera(viewer) {
},
},
});
- bindRenderer(viewer, { camera: port });
+ bindRenderer(viewer, { camera: port, credits: fakeCesiumCredits(viewer) });
return viewer;
}
diff --git a/src/testSupport/fakeCreditsPort.mjs b/src/testSupport/fakeCreditsPort.mjs
new file mode 100644
index 000000000..334e054d4
--- /dev/null
+++ b/src/testSupport/fakeCreditsPort.mjs
@@ -0,0 +1,37 @@
+/**
+ * The Cesium renderer's own credits port over a fake viewer's
+ * `creditDisplay`, for tests of code that adds credits through the port
+ * and checks what Cesium's display was given.
+ */
+import { bindRenderer, rendererOf } from '../render/rendererOf.js';
+import { createCesiumCredits } from '../render/cesium/credits.js';
+
+const noEvent = { addEventListener: () => () => {} };
+
+/**
+ * @param {{creditDisplay?: object, scene?: object}} viewer
+ * @returns {import('../render/contract/credits').CreditsPort}
+ */
+export function fakeCesiumCredits(viewer) {
+ return createCesiumCredits({
+ display: viewer.creditDisplay,
+ scene: { postRender: noEvent },
+ report: (error) => {
+ throw error;
+ },
+ requestRender: () => viewer.scene?.requestRender?.(),
+ }).port;
+}
+
+/**
+ * Bind a renderer with just that port to `viewer`; a viewer already bound
+ * keeps its renderer. Returns the viewer.
+ * @template V
+ * @param {V} viewer
+ * @returns {V}
+ */
+export function bindFakeCesiumCredits(viewer) {
+ if (!rendererOf(viewer))
+ bindRenderer(viewer, { credits: fakeCesiumCredits(viewer) });
+ return viewer;
+}