From 9c9ba376e4f334c9dc7b765b1d6b27b58105acb8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 15:36:15 -0800 Subject: [PATCH] Give the Cesium renderer the contract's credits port The shared Cesium ports gain credits, on both the conformance renderer and the application's. list() reports what Cesium owes as plain data: its logo, its static credits and the last drawn frame's imagery, terrain and tile credits, read from the credit display through internals.js, with the credits Core adds listed as given where the display shows them. addStatic shows a credit through Cesium's display, as markup built from the plain data with every value escaped and only web links kept, so attribution stays on screen. change fires when Core adds or removes a credit, and after a drawn frame whose Cesium credits differ from the last one's. src/render/creditMarkup.js reads markup credits as plain data. The credits listener check moves out of renderer.listenerRules into its own check, so a renderer without every port can run it; it and the credits check now run on Cesium. --- scripts/package-boundaries.json | 2 + src/render/cesium/appRenderer.js | 5 +- src/render/cesium/appRenderer.test.mjs | 1 - src/render/cesium/credits.js | 265 ++++++++++++++++++++++ src/render/cesium/credits.test.mjs | 291 +++++++++++++++++++++++++ src/render/cesium/harness.js | 2 + src/render/cesium/internals.js | 23 ++ src/render/cesium/internals.test.mjs | 25 +++ src/render/cesium/ports.js | 13 +- src/render/cesium/sceneRenderer.js | 6 +- src/render/conformance/checks/ports.js | 28 ++- src/render/creditMarkup.js | 102 +++++++++ src/render/creditMarkup.test.mjs | 55 +++++ 13 files changed, 803 insertions(+), 15 deletions(-) create mode 100644 src/render/cesium/credits.js create mode 100644 src/render/cesium/credits.test.mjs create mode 100644 src/render/creditMarkup.js create mode 100644 src/render/creditMarkup.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d466ebc3a..7659e2772 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2694,8 +2694,10 @@ "src/app/operations.js", "src/app/scene.js", "src/render/cesium/appRenderer.js", + "src/render/creditMarkup.js", "src/render/rendererOf.js", "src/render/cesium/ports.js", + "src/render/cesium/credits.js", "src/render/cesium/common.js", "src/render/cesium/camera.js", "src/render/cesium/follow.js", diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 88dbc6cec..526f9919f 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -27,8 +27,8 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; * The members a transitional renderer has today. * @typedef {Pick} + * | 'camera' | 'query' | 'input' | 'capture' | 'credits' | 'on' + * | 'resize' | 'destroy'>} * TransitionalRenderer */ @@ -115,6 +115,7 @@ export function createCesiumRendererPlugin({ query: ports.query, input: input.port, capture: ports.capture, + credits: ports.credits, on: ports.on, resize() { viewer.resize(); diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 2c565a710..8198716d4 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -337,7 +337,6 @@ const EXPECTED_MISSING = [ 'draw.clampState', 'draw.on', 'frame.setTime', - 'renderer.credits', 'renderer.effects', 'renderer.globe', 'renderer.quality', diff --git a/src/render/cesium/credits.js b/src/render/cesium/credits.js new file mode 100644 index 000000000..b84fe02e1 --- /dev/null +++ b/src/render/cesium/credits.js @@ -0,0 +1,265 @@ +/** + * @module render/cesium/credits + * @description The Cesium renderer's credits port over a Cesium credit + * display: every credit Cesium owes (its logo, static credits and each + * frame's imagery, terrain and tile credits) and every credit Core adds, as + * the contract's plain data (`src/render/contract/credits.d.ts`). + * + * Until Core renders the credit DOM, a credit Core adds is shown through + * Cesium's own display, so attribution stays on screen: `inline` credits on + * the credit line, the rest in its expandable list. Its markup is built + * here from the plain data, every value escaped. + * + * `change` fires when a Core credit is added or removed, and after a drawn + * frame whose Cesium credits differ from the last one's. + * + * Cesium keeps one static credit per markup and placement: a Core credit + * and another caller's of the same markup share one entry in the display, + * which goes when the one added first is removed. + */ +import * as Cesium from 'cesium'; +import { + LINK_SCHEMES, + LOGO_SCHEMES, + creditFromMarkup, + safeUrl, +} from '../creditMarkup.js'; +import { listenerList } from './common.js'; +import { creditDisplayContents } from './internals.js'; + +const CREDIT_KEYS = new Set(['text', 'href', 'logoUrl', 'inline']); +/** Readings kept per Cesium credit id; past this many, the cache restarts. */ +const READINGS_KEPT = 512; + +const escape = (value) => + String(value).replace( + /[&<>"']/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char], + ); + +/** + * A credit's markup for Cesium's display: the logo, then the text, linked + * when it has a web link. A link or logo of any other scheme is left out. + * @param {import('../contract/credits').Credit} credit + * @returns {string} + */ +export function creditMarkup(credit) { + const logo = credit.logoUrl && safeUrl(credit.logoUrl, LOGO_SCHEMES); + const body = [ + logo && + ``, + credit.text && escape(credit.text), + ] + .filter(Boolean) + .join(' '); + const href = credit.href && safeUrl(credit.href, LINK_SCHEMES); + return href + ? `${body}` + : `${body}`; +} + +/** The credit as given, its known fields only, in their given order. */ +function copyCredit(credit) { + if (typeof credit?.text !== 'string') + throw new TypeError('A credit needs its text'); + const copy = {}; + for (const [key, value] of Object.entries(credit)) + if (CREDIT_KEYS.has(key) && value !== undefined) copy[key] = value; + return copy; +} + +/** + * @param {{display: object|undefined, scene: object, + * report: (error: unknown) => void, requestRender: () => void}} options + * `display`: the widget's Cesium CreditDisplay; without one, only Core's + * credits are listed. `requestRender` draws the display once a Core + * credit comes or goes. + * @returns {{port: import('../contract/credits').CreditsPort, + * dispose: () => void}} + */ +export function createCesiumCredits({ display, scene, report, requestRender }) { + const listeners = listenerList(report); + /** Core's credits, in the order added: each `{credit, markup}`. */ + const added = []; + /** Core's credits as Cesium shows them, by markup: `{credit, count}`. */ + const shown = new Map(); + /** Cesium credit id → its text, link and logo. */ + const readings = new Map(); + + const reading = (credit) => { + let read = readings.get(credit.id); + if (!read) { + if (readings.size >= READINGS_KEPT) readings.clear(); + read = creditFromMarkup(credit.html); + readings.set(credit.id, read); + } + return { ...read, inline: Boolean(credit.showOnScreen) }; + }; + + // The display shows a frame's credits most frequent first, sorting them + // in place as the frame ends, after postRender: the same stable sort, + // so the order is the same before and after. + const byCount = (elements) => [...elements].sort((a, b) => b.count - a.count); + + /** The Cesium credits made for Core's, while shown. */ + const coreCredits = new Set(); + + /** + * Every credit shown now, in display order, each a Cesium credit or + * (`core`) the Cesium credit made for Core's: the logo, then on the + * credit line and then in the expanded list, the static credits in the + * order added and the last frame's own, most frequent first. A second + * credit of the same markup and placement is left out, as the display + * leaves it out. + */ + function shownCredits() { + const { statics, screen, lightbox, logo } = creditDisplayContents(display); + const out = []; + const seen = new Set(); + const take = (credit) => { + if (!credit) return; + const key = `${credit.id}:${credit.showOnScreen ? 1 : 0}`; + if (seen.has(key)) return; + seen.add(key); + out.push(credit); + }; + take(logo); + for (const [inline, elements] of [ + [true, screen], + [false, lightbox], + ]) { + for (const credit of statics) + // The display shows an ion credit as its logo. + if ( + Boolean(credit.showOnScreen) === inline && + !(logo && credit.isIon?.()) + ) + take(credit); + for (const element of byCount(elements)) + // A static credit counts Number.MAX_VALUE; those are listed above, + // and one removed since the frame is not owed any more. + if (element.count !== Number.MAX_VALUE) take(element.credit); + } + return out; + } + + /** Cesium's own credits only, for the per-frame diff. */ + const engineCredits = () => + shownCredits().filter((credit) => !coreCredits.has(credit)); + + /** + * Cesium's credits read as plain data, with Core's listed as Core gave + * them where the display shows them; any the display does not show (no + * display, or one it already had) after. + */ + function list() { + const out = []; + const listed = new Set(); + for (const credit of shownCredits()) { + if (!coreCredits.has(credit)) { + out.push(reading(credit)); + continue; + } + for (const entry of added) + if (shown.get(entry.key)?.credit === credit) { + out.push({ ...entry.credit }); + listed.add(entry); + } + } + for (const entry of added) + if (!listed.has(entry)) out.push({ ...entry.credit }); + return out; + } + const changed = () => { + if (listeners.size > 0) listeners.fire(list()); + }; + + // What Cesium owed after the last frame, as credit ids and placements. + const signature = () => + engineCredits() + .map((credit) => `${credit.id}:${credit.showOnScreen ? 1 : 0}`) + .join(','); + let lastSignature = signature(); + const afterFrame = () => { + const now = signature(); + if (now === lastSignature) return; + lastSignature = now; + changed(); + }; + const offFrame = scene.postRender.addEventListener(afterFrame); + + // Cesium shows one static credit per markup and placement, so Core's + // credits of the same markup and placement share one. + function show(markup, inline) { + const key = `${inline ? 'inline' : 'listed'}:${markup}`; + let entry = shown.get(key); + if (!entry) { + entry = { credit: new Cesium.Credit(markup, inline), count: 0 }; + shown.set(key, entry); + coreCredits.add(entry.credit); + display?.addStaticCredit(entry.credit); + } + entry.count += 1; + return key; + } + + function hide(key) { + const entry = shown.get(key); + if (!entry) return; + entry.count -= 1; + if (entry.count > 0) return; + shown.delete(key); + coreCredits.delete(entry.credit); + display?.removeStaticCredit(entry.credit); + } + + let disposed = false; + const port = { + addStatic(credit) { + const copy = copyCredit(credit); + const entry = { credit: copy, key: null }; + added.push(entry); + if (!disposed) { + entry.key = show(creditMarkup(copy), Boolean(copy.inline)); + requestRender(); + } + changed(); + return () => { + const i = added.indexOf(entry); + if (i < 0) return; + added.splice(i, 1); + if (entry.key !== null && !disposed) { + hide(entry.key); + requestRender(); + } + changed(); + }; + }, + list, + on(event, cb) { + if (event !== 'change') return () => {}; + return listeners.on(cb); + }, + }; + + return { + port, + dispose() { + disposed = true; + offFrame(); + listeners.clear(); + if (!display?.isDestroyed?.()) + for (const { credit } of shown.values()) + display?.removeStaticCredit(credit); + shown.clear(); + coreCredits.clear(); + }, + }; +} diff --git a/src/render/cesium/credits.test.mjs b/src/render/cesium/credits.test.mjs new file mode 100644 index 000000000..1eb252f93 --- /dev/null +++ b/src/render/cesium/credits.test.mjs @@ -0,0 +1,291 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createCesiumCredits, creditMarkup } from './credits.js'; + +/** A credit display as Cesium keeps it: static credits and a frame's. */ +function fakeDisplay() { + const frame = { screen: [], lightbox: [] }; + const display = { + _staticCredits: [], + _currentFrameCredits: { + screenCredits: { values: frame.screen }, + lightboxCredits: { values: frame.lightbox }, + }, + _currentCesiumCredit: undefined, + addStaticCredit(credit) { + if (!display._staticCredits.some((c) => Cesium.Credit.equals(c, credit))) + display._staticCredits.push(credit); + }, + removeStaticCredit(credit) { + const i = display._staticCredits.indexOf(credit); + if (i >= 0) display._staticCredits.splice(i, 1); + }, + /** A drawn frame: the static credits, then `extra`, as Cesium adds them. */ + draw(extra = []) { + frame.screen.length = 0; + frame.lightbox.length = 0; + const put = (credit, count) => + (credit.showOnScreen ? frame.screen : frame.lightbox).push({ + credit, + count, + }); + for (const credit of display._staticCredits) + put(credit, Number.MAX_VALUE); + for (const credit of extra) put(credit, 1); + }, + }; + return display; +} + +function setup({ display = fakeDisplay() } = {}) { + const postRender = new Set(); + const scene = { + postRender: { + addEventListener(cb) { + postRender.add(cb); + return () => postRender.delete(cb); + }, + }, + }; + const errors = []; + let renders = 0; + const part = createCesiumCredits({ + display, + scene, + report: (error) => errors.push(error), + requestRender: () => (renders += 1), + }); + return { + display, + part, + port: part.port, + errors, + renders: () => renders, + listeners: postRender, + /** One drawn frame with `extra` per-frame credits. */ + frame(extra) { + display.draw(extra); + for (const cb of [...postRender]) cb(); + }, + }; +} + +test('Cesium’s own credits are listed as plain data, the logo first', () => { + const t = setup(); + t.display._currentCesiumCredit = new Cesium.Credit( + '', + true, + ); + t.display.addStaticCredit( + new Cesium.Credit( + 'Data © Example & others', + false, + ), + ); + assert.deepEqual(t.port.list(), [ + { + text: 'Cesium ion', + href: 'https://cesium.com/', + logoUrl: '/ion-credit.png', + inline: true, + }, + { + text: 'Data © Example & others', + href: 'https://example.com/a', + inline: false, + }, + ]); +}); + +test('per-frame credits come and go with the frames, with one change each', () => { + const t = setup(); + const lists = []; + t.port.on('change', (list) => lists.push(list.map((c) => c.text))); + const tile = new Cesium.Credit('Imagery © Tiles', true); + t.frame([tile]); + t.frame([tile]); + assert.deepEqual(lists, [['Imagery © Tiles']], 'one change, not per frame'); + assert.deepEqual( + t.port.list().map((c) => c.text), + ['Imagery © Tiles'], + ); + t.frame([]); + assert.deepEqual(lists, [['Imagery © Tiles'], []]); + t.frame([]); + assert.equal(lists.length, 2); +}); + +test('a static credit added outside the port is listed at once and changes on the next frame', () => { + const t = setup(); + const lists = []; + t.port.on('change', (list) => lists.push(list.length)); + const credit = new Cesium.Credit('Layer data', false); + t.display.addStaticCredit(credit); + assert.equal(t.port.list().length, 1); + t.frame(); + t.frame(); + assert.deepEqual(lists, [1]); + t.display.removeStaticCredit(credit); + assert.equal(t.port.list().length, 0, 'not owed once removed'); + t.frame(); + assert.deepEqual(lists, [1, 0]); +}); + +test('addStatic lists the credit as given and shows it through Cesium', () => { + const t = setup(); + const lists = []; + t.port.on('change', (list) => lists.push(list)); + const credit = { + text: 'Roads ', + href: 'https://example.com/roads', + logoUrl: 'https://example.com/logo.png', + inline: true, + }; + const off = t.port.addStatic(credit); + assert.equal(JSON.stringify(t.port.list()), JSON.stringify([credit])); + assert.equal(lists.length, 1, 'change at once'); + assert.notEqual(t.port.list()[0], t.port.list()[0], 'copies'); + const [shown] = t.display._staticCredits; + assert.equal(shown.showOnScreen, true); + assert.equal( + shown.html, + ' Roads <data>', + ); + assert.equal(t.renders(), 1); + t.frame(); + assert.equal(lists.length, 1, 'its frame is not a second change'); + assert.equal(t.port.list().length, 1, 'listed once, as Core gave it'); + off(); + off(); + assert.deepEqual(t.port.list(), []); + assert.deepEqual(t.display._staticCredits, []); + assert.equal(lists.length, 2); + t.frame(); + assert.equal(lists.length, 2, 'the frame after is not a change either'); +}); + +test('credits of the same markup share one Cesium credit until the last goes', () => { + const t = setup(); + const a = t.port.addStatic({ text: 'Same' }); + const b = t.port.addStatic({ text: 'Same' }); + assert.equal(t.port.list().length, 2); + assert.equal(t.display._staticCredits.length, 1); + a(); + assert.equal(t.display._staticCredits.length, 1, 'still shown'); + b(); + assert.equal(t.display._staticCredits.length, 0); +}); + +test('credits of the same markup but another placement are shown apart', () => { + const t = setup(); + const listed = t.port.addStatic({ text: 'Same', inline: false }); + t.port.addStatic({ text: 'Same', inline: true }); + assert.equal(t.display._staticCredits.length, 2); + listed(); + assert.deepEqual( + t.display._staticCredits.map((c) => c.showOnScreen), + [true], + ); +}); + +test('a frame’s credits are listed most frequent first, as Cesium shows them', () => { + const t = setup(); + const a = new Cesium.Credit('A once', true); + const b = new Cesium.Credit('B twice', true); + const lists = []; + t.port.on('change', (list) => lists.push(list.map((c) => c.text))); + t.display.draw([a, b]); + t.display._currentFrameCredits.screenCredits.values[1].count = 2; + for (const cb of [...t.listeners]) cb(); + assert.deepEqual(lists, [['B twice', 'A once']]); + assert.deepEqual( + t.port.list().map((c) => c.text), + ['B twice', 'A once'], + ); +}); + +test('Core’s credits are listed where the display shows them', () => { + const t = setup(); + t.display.addStaticCredit(new Cesium.Credit('Engine listed', false)); + t.port.addStatic({ text: 'Core listed' }); + t.port.addStatic({ text: 'Core inline', inline: true }); + t.display.addStaticCredit(new Cesium.Credit('Engine inline', true)); + t.frame([new Cesium.Credit('Tile', true)]); + assert.deepEqual( + t.port.list().map((c) => c.text), + ['Core inline', 'Engine inline', 'Tile', 'Engine listed', 'Core listed'], + ); +}); + +test('a credit needs its text; unknown fields are dropped', () => { + const t = setup(); + assert.throws(() => t.port.addStatic({ href: 'https://example.com' })); + assert.throws(() => t.port.addStatic(null)); + t.port.addStatic({ text: 'x', extra: 1, href: undefined }); + assert.deepEqual(t.port.list(), [{ text: 'x' }]); +}); + +test('listeners follow the listener rules, and other events are none', () => { + const t = setup(); + t.port.on('change', () => { + throw new Error('listener'); + }); + const seen = []; + const off = t.port.on('change', () => seen.push(1)); + assert.equal(typeof t.port.on('other', () => {}), 'function'); + t.port.addStatic({ text: 'x' }); + assert.equal(t.errors.length, 1); + assert.deepEqual(seen, [1]); + off(); + t.port.addStatic({ text: 'y' }); + assert.deepEqual(seen, [1]); +}); + +test('without a credit display only Core’s credits are listed', () => { + const postRender = { addEventListener: () => () => {} }; + const { port } = createCesiumCredits({ + display: undefined, + scene: { postRender }, + report() {}, + requestRender() {}, + }); + const off = port.addStatic({ text: 'x' }); + assert.deepEqual(port.list(), [{ text: 'x' }]); + off(); + assert.deepEqual(port.list(), []); +}); + +test('dispose detaches from the frame and takes Core’s credits off', () => { + const t = setup(); + const seen = []; + t.port.on('change', () => seen.push(1)); + const off = t.port.addStatic({ text: 'x' }); + t.part.dispose(); + assert.equal(t.listeners.size, 0); + assert.deepEqual(t.display._staticCredits, []); + off(); + t.port.addStatic({ text: 'late' }); + assert.deepEqual(t.display._staticCredits, [], 'nothing shown after'); + assert.deepEqual(seen, [1]); + assert.equal(t.renders(), 1, 'no render asked of a disposed scene'); +}); + +test('credit markup escapes every value and links only web URLs', () => { + assert.equal( + creditMarkup({ text: 'a "b" & ' }), + 'a "b" & <c>', + ); + assert.equal( + creditMarkup({ text: 'x', href: 'javascript:alert(1)' }), + 'x', + ); + assert.equal( + creditMarkup({ text: 'x', logoUrl: 'javascript:alert(1)' }), + 'x', + ); + assert.equal( + creditMarkup({ text: 'x', href: 'https://example.com/?a=1&b="2"' }), + 'x', + ); +}); diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 15bf22588..bb189c7c0 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -29,6 +29,8 @@ export const CESIUM_CHECKS = new Set([ 'camera.viewRectangle', 'camera.fov', 'camera.trace.view and projection matrices', + 'credits', + 'credits.listenerRules', 'draw.drillPick', 'draw.ids', 'draw.models', diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index 1df6d0ea4..3802381b4 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -179,3 +179,26 @@ export function updateCameraChanged(camera) { export function billboardLoadError(billboard) { return billboard.loadError; } + +const NO_CREDITS = Object.freeze([]); + +/** + * What a credit display shows, for the credits port to report: its static + * credits, the credit elements of the frame under way or last drawn (each + * `{credit, count}`, a static one counted `Number.MAX_VALUE`) and the logo + * credit it shows first. Cesium's public API only adds and removes static + * credits; the per-frame ones (imagery, terrain and tile credits) are seen + * nowhere else. + * @param {object} display Cesium CreditDisplay. + * @returns {{statics: object[], screen: object[], lightbox: object[], + * logo: object|undefined}} + */ +export function creditDisplayContents(display) { + const frame = display?._currentFrameCredits; + return { + statics: display?._staticCredits ?? NO_CREDITS, + screen: frame?.screenCredits?.values ?? NO_CREDITS, + lightbox: frame?.lightboxCredits?.values ?? NO_CREDITS, + logo: display?._currentCesiumCredit, + }; +} diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index 6e80b4972..77f8aa18e 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -12,6 +12,7 @@ import { closeRenderLoopLatch, clusterPointCollection, commandExtents, + creditDisplayContents, defaultView, lastRenderTime, lastSeenCamera, @@ -54,6 +55,9 @@ test('every private field Core reaches is still declared by the installed Cesium [ENGINE, 'Scene/Scene.js', '_view'], [ENGINE, 'Scene/View.js', '_cameraClone'], [ENGINE, 'Scene/Scene.js', '_frameState'], + [ENGINE, 'Scene/CreditDisplay.js', '_staticCredits'], + [ENGINE, 'Scene/CreditDisplay.js', '_currentFrameCredits'], + [ENGINE, 'Scene/CreditDisplay.js', '_currentCesiumCredit'], ]; const missing = fields.filter( ([root, file, field]) => @@ -149,6 +153,27 @@ test('the helpers read and write exactly those fields', () => { const fn = () => {}; atEndOfTick({ _frameState: { afterRender } }, fn); assert.deepEqual(afterRender, [fn]); + const logo = {}; + const statics = [{}]; + const screen = [{ credit: {}, count: 1 }]; + const lightbox = []; + assert.deepEqual( + creditDisplayContents({ + _staticCredits: statics, + _currentFrameCredits: { + screenCredits: { values: screen }, + lightboxCredits: { values: lightbox }, + }, + _currentCesiumCredit: logo, + }), + { statics, screen, lightbox, logo }, + ); + assert.deepEqual(creditDisplayContents(undefined), { + statics: [], + screen: [], + lightbox: [], + logo: undefined, + }); const event = () => { const listeners = new Set(); return { diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index d95b9e7f5..392c229ba 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -2,7 +2,8 @@ * @module render/cesium/ports * @description The Cesium renderer's contract ports, built over a Cesium * widget or viewer someone else made and configured: frame phases, the - * draw sets, `setView` and the pose, picks and projection, and capture. + * draw sets, `setView` and the pose, picks and projection, credits and + * capture. * Building them puts the loop on demand (no holds yet, and no redraws for * scene time alone); nothing else in the scene changes until a call. * @@ -15,6 +16,7 @@ import * as Cesium from 'cesium'; import { heightsFor } from '../../queries/heights.js'; import { createCesiumCamera } from './camera.js'; import { listenerList, toCartesian, uncaught } from './common.js'; +import { createCesiumCredits } from './credits.js'; import { atEndOfTick, billboardLoadError, @@ -734,6 +736,13 @@ export function createCesiumPorts(widget, { capabilities }) { const cameraPart = createCesiumCamera({ scene, camera, report, widget }); const cameraPort = cameraPart.port; + const creditsPart = createCesiumCredits({ + display: widget.creditDisplay, + scene, + report, + requestRender: () => frame.requestRender('credits'), + }); + // --- Query ----------------------------------------------------------------- const screen = (x, y) => new Cesium.Cartesian2(x, y); @@ -1075,6 +1084,7 @@ export function createCesiumPorts(widget, { capabilities }) { frame, draw, camera: cameraPort, + credits: creditsPart.port, query, capture, modelSets, @@ -1091,6 +1101,7 @@ export function createCesiumPorts(widget, { capabilities }) { for (const list of Object.values(rendererListeners)) list.clear(); for (const list of Object.values(frameListeners)) list.clear(); cameraPart.dispose(); + creditsPart.dispose(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. for (const record of sets.values()) record.host = null; diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 817c77536..12c37b5d8 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -3,8 +3,9 @@ * @description The part of the Cesium renderer the reference scenes need, * through the contract's own members: a bare globe and sky, icons, points, * lines, shapes and models, `setView`, picks and projection, the frame - * phases, pointer input, settling and capture. The `/dev/conformance/` page - * draws the scenes with it to make and check the visual references. + * phases, pointer input, credits, settling and capture. The + * `/dev/conformance/` page draws the scenes with it to make and check the + * visual references. * * Its ports are `ports.js`'s, which the application's renderer shares; * this factory adds the conformance widget, its fixed settings, the input @@ -280,6 +281,7 @@ export async function createCesiumSceneRenderer({ query: ports.query, input: input.port, capture: ports.capture, + credits: ports.credits, whenSettled, resize: () => widget.resize(), on: ports.on, diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index 88aef2c54..d3c4285db 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -297,7 +297,6 @@ export function portChecks() { 'every subscription list follows the listener rules', async (h, r) => { await overAustin(h, r); - const credits = []; const lists = [ [ 'frame.on(postRender)', @@ -315,13 +314,6 @@ export function portChecks() { () => r.camera.setView({ destination: { ...AUSTIN, height: 2000 } }), ], - [ - 'credits.on(change)', - (cb) => r.credits.on('change', cb), - () => { - credits.push(r.credits.addStatic({ text: 'Conformance data' })); - }, - ], [ 'quality.signals.on(frame)', (cb) => r.quality.signals.on('frame', cb), @@ -352,7 +344,25 @@ export function portChecks() { ]); for (const [where, subscribe, fire] of lists) await listenerRules(r, where, subscribe, fire); - for (const off of credits) off(); + }, + ), + check( + 'credits.listenerRules', + 'credits.on(change) follows the listener rules', + async (h, r) => { + const credits = []; + try { + await listenerRules( + r, + 'credits.on(change)', + (cb) => r.credits.on('change', cb), + () => { + credits.push(r.credits.addStatic({ text: 'Conformance data' })); + }, + ); + } finally { + for (const off of credits) off(); + } }, ), diff --git a/src/render/creditMarkup.js b/src/render/creditMarkup.js new file mode 100644 index 000000000..70a6b9c97 --- /dev/null +++ b/src/render/creditMarkup.js @@ -0,0 +1,102 @@ +/** + * @module render/creditMarkup + * @description A credit written as HTML markup, as engines and older layer + * code carry it, read as the contract's plain credit data + * (`src/render/contract/credits.d.ts`): its text, the first link and the + * first image. Markup with more than one link keeps only the first. + * + * A plain reading, no DOM: it runs in Node and in workers. A link or logo + * of a scheme that could run script is dropped. + */ + +/** The schemes a credit may link to, and load its logo from. */ +export const LINK_SCHEMES = Object.freeze(['https:', 'http:', 'mailto:']); +export const LOGO_SCHEMES = Object.freeze(['https:', 'http:', 'data:']); + +/** + * A URL of one of `schemes`, resolved against the page, or null. + * @param {string} value + * @param {readonly string[]} schemes + * @returns {string|null} + */ +export function safeUrl(value, schemes) { + try { + const base = globalThis.location?.href ?? 'https://invalid.invalid/'; + const url = new URL(value, base); + if (!schemes.includes(url.protocol)) return null; + if (url.protocol === 'data:' && !/^data:image\//i.test(url.href)) + return null; + return url.href; + } catch { + return null; + } +} + +/** A tag's inside, quoted attribute values allowed to hold `>`. */ +const TAG_BODY = `(?:[^>"']|"[^"]*"|'[^']*')*`; +const ANCHOR = new RegExp(``, 'i'); +const IMAGE = new RegExp(``, 'i'); +const ANY_TAG = new RegExp(`<${TAG_BODY}>`, 'g'); + +const ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', + copy: '©', + reg: '®', + trade: '™', + middot: '·', + ndash: '–', + mdash: '—', +}; + +/** Character references decoded; unknown named ones are left as written. */ +function decode(text) { + return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, ref) => { + if (ref[0] !== '#') return ENTITIES[ref.toLowerCase()] ?? whole; + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; + }); +} + +/** An attribute's value in one start tag, decoded. */ +function attribute(tag, name) { + const match = new RegExp( + `\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, + 'i', + ).exec(tag); + return match ? decode(match[1] ?? match[2] ?? match[3]) : undefined; +} + +/** + * @param {string} html + * @returns {{text: string, href?: string, logoUrl?: string}} A logo with no + * text around it is named by its title or alt text. + */ +export function creditFromMarkup(html) { + const markup = String(html ?? ''); + const anchor = ANCHOR.exec(markup)?.[0]; + const image = IMAGE.exec(markup)?.[0]; + let text = decode( + markup + .replace(//g, '') + .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') + .replace(ANY_TAG, ''), + ) + .replace(/\s+/g, ' ') + .trim(); + if (!text && image) + text = attribute(image, 'title') ?? attribute(image, 'alt') ?? ''; + const credit = { text }; + const href = anchor && attribute(anchor, 'href'); + if (href && safeUrl(href, LINK_SCHEMES)) credit.href = href; + const logoUrl = image && attribute(image, 'src'); + if (logoUrl && safeUrl(logoUrl, LOGO_SCHEMES)) credit.logoUrl = logoUrl; + return credit; +} diff --git a/src/render/creditMarkup.test.mjs b/src/render/creditMarkup.test.mjs new file mode 100644 index 000000000..0440b0724 --- /dev/null +++ b/src/render/creditMarkup.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { creditFromMarkup } from './creditMarkup.js'; + +test('text, the first link and the first logo are read from markup', () => { + assert.deepEqual( + creditFromMarkup( + 'Map data © OpenStreetMap (ODbL)', + ), + { + text: 'Map data © OpenStreetMap (ODbL)', + href: 'https://www.openstreetmap.org/copyright', + }, + ); + assert.deepEqual( + creditFromMarkup( + '', + ), + { text: 'Cesium ion', href: 'https://cesium.com/', logoUrl: '/ion.png' }, + ); + assert.deepEqual(creditFromMarkup('Logo'), { + text: 'Logo', + logoUrl: 'x.png', + }); +}); + +test('character references decode; tags, comments and scripts drop', () => { + assert.deepEqual( + creditFromMarkup( + ' A &'B—&unknown; \n C', + ), + { text: "A &'B—&unknown; C" }, + ); + assert.deepEqual(creditFromMarkup('x'), { + text: 'x', + href: 'yes', + }); + assert.deepEqual( + creditFromMarkup( + 'Provider', + ), + { text: 'Provider', href: 'https://example.com/?q=>' }, + ); + assert.deepEqual( + creditFromMarkup( + 'L', + ), + { text: 'L' }, + ); + assert.deepEqual(creditFromMarkup('L'), { + text: 'L', + }); + assert.deepEqual(creditFromMarkup(''), { text: '' }); + assert.deepEqual(creditFromMarkup(undefined), { text: '' }); +});