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; +}