From cf915e8c168c9adf8617e3fb64d63042da5c2017 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 16:12:09 -0800 Subject: [PATCH] Add layer and map credits through the renderer's credits port The street-level provider credits, the map source credit, the shared inline OpenStreetMap and OpenMapTiles credits and the ALPR provider credit go through the renderer's credits port instead of Cesium's credit display. Their markup has one link each and is read as the port's plain data; the ALPR credit is built as plain data, which the port escapes. src/maps/credits.js and src/layers/streetLevel/credits.js no longer reach Cesium and leave the allow-list. findCredits finds the port as findCamera finds the camera: a Cesium viewer of a consumer's own, with no renderer bound, gets a credits port over its own credit display, so the public entries keep their attribution. A port built that way is released, not disposed, when a renderer binds later, and a credit let go after its viewer is destroyed leaves the display alone. The per-layer data credits and the transit and event credits stay on Cesium's display: their markup carries several links each, which the contract's credit cannot hold. --- scripts/package-boundaries.json | 46 +++++++++++++++ .../hybridAnnotationRenderer.test.mjs | 5 +- src/annotations/placeOutline.test.mjs | 5 +- src/data/dataCredits.js | 55 ++++++++++-------- src/data/dataCredits.test.mjs | 9 +-- src/data/sharedOsmDisplays.test.mjs | 3 +- src/layers/alpr/index.js | 8 +-- src/layers/alpr/model.js | 22 +++---- src/layers/alpr/presentation.js | 8 ++- src/layers/alpr/source.test.mjs | 31 ++++++---- src/layers/installations/lifecycle.test.mjs | 5 +- src/layers/streetLevel/credits.js | 38 ++++++------ src/layers/streetLevel/index.test.mjs | 10 +++- src/maps/controller.js | 3 + src/maps/controller.test.mjs | 24 ++++---- src/maps/credits.js | 28 ++++----- src/maps/map-subscription-ownership.test.mjs | 3 - src/render/cesium/camera.js | 2 + src/render/cesium/credits.js | 47 +++++++++++++-- src/render/cesium/credits.test.mjs | 51 ++++++++++++++++ src/render/cesiumAllowList.json | 2 - src/render/rendererOf.js | 58 +++++++++++++++---- src/render/rendererOf.test.mjs | 43 ++++++++++++++ src/render/unboundCamera.test.mjs | 9 ++- src/testSupport/fakeCameraPort.mjs | 8 ++- src/testSupport/fakeCreditsPort.mjs | 37 ++++++++++++ 26 files changed, 418 insertions(+), 142 deletions(-) create mode 100644 src/testSupport/fakeCreditsPort.mjs 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; +}