Add data credits through the renderer's credits port

Data attribution no longer builds Cesium credits itself: each credit is
added through the renderer's credits port, in the attribution list as
before. The port's credit carries one link, so a credit whose markup
holds several links becomes one credit per link, shown together in
order, its wording kept. dataCredits.js leaves the Cesium allow-list.
This commit is contained in:
Sameh Khamis committed 2026-11-06 17:59:00 -08:00
1 parent 8f612ba858
commit 9049357916
3 files changed
+167 -25

No files matched your search

+65 -24
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium';
import { creditFromMarkup } from '../render/creditMarkup.js';
import { findCredits } from '../render/rendererOf.js';
/**
@@ -617,43 +617,84 @@ export const BHOTE_KOSHI_LOCATOR_CREDIT = {
'(CC BY-NC 4.0)',
};
/** A link, its whole element. */
const LINK = /<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a\s*>/gi;
/**
* The separators text between two links can break at; it breaks at its
* last. A semicolon ending a character reference is not one. A full stop
* after an abbreviation reads as one, so keep "Dr." and "e.g." out of the
* text between links.
*/
const SEPARATOR = /\s*(?:[,·—–]|(?<!&#?\w+);)\s*|\.\s+|\s+(?=\()/g;
/**
* A credit's markup as the renderer's credits, one link each: the
* contract's credit carries one link, so markup with several becomes one
* credit per link, shown together in order. The text between two links
* breaks at its last separator (a comma, semicolon, dot or dash, a full
* stop, or before an opening bracket); closing brackets right after a link
* stay with it, and a break's comma, semicolon, dot or dash is dropped.
* @param {string} html
* @returns {import('../render/contract/credits').Credit[]}
*/
export function creditsFromMarkup(html) {
const markup = String(html);
const pieces = [''];
let at = 0;
for (const link of markup.matchAll(LINK)) {
let gap = markup.slice(at, link.index);
if (at > 0) {
const close = /^[)\]]*/.exec(gap)[0];
gap = gap.slice(close.length);
const cut = [...gap.matchAll(SEPARATOR)].at(-1);
const before = cut ? gap.slice(0, cut.index) : '';
const stop = cut && cut[0].trimStart().startsWith('.') ? '.' : '';
pieces[pieces.length - 1] += close + before + stop;
pieces.push(cut ? gap.slice(cut.index + cut[0].length) : gap);
} else pieces[0] += gap;
pieces[pieces.length - 1] += link[0];
at = link.index + link[0].length;
}
pieces[pieces.length - 1] += markup.slice(at);
return pieces
.map((piece) => ({ ...creditFromMarkup(piece), inline: false }))
.filter((credit) => credit.text);
}
/** Add a data credit to the attribution list through the renderer. */
function addDataCredit(viewer, credit) {
const credits = findCredits(viewer);
if (!credits) return false;
for (const one of creditsFromMarkup(credit.html)) credits.addStatic(one);
return true;
}
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
const _dynamicCreditKeys = new Set();
/**
* Register a conditional credit at the moment its data source activates.
* Idempotent per `credit.key`; lands in the same "Data attribution" popover
* as the static credits (showOnScreen=false).
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
* Idempotent per `credit.key`; lands in the same "Data attribution" list
* as the static credits, through the renderer's credits port.
* @param {object} viewer — the scene object the renderer is bound to
* @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
* @returns {boolean} True when the credit is (now) registered.
*/
export function registerDynamicCredit(viewer, credit) {
const creditDisplay = viewer?.creditDisplay;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
return false;
}
if (!credit?.key || !credit?.html) return false;
if (_dynamicCreditKeys.has(credit.key)) return true;
creditDisplay.addStaticCredit(new Cesium.Credit(credit.html, false));
if (!viewer || !credit?.key || !credit?.html) return false;
if (_dynamicCreditKeys.has(credit.key)) return Boolean(findCredits(viewer));
if (!addDataCredit(viewer, credit)) return false;
_dynamicCreditKeys.add(credit.key);
return true;
}
/**
* Register every per-layer data credit into the viewer's credit display.
* Idempotent: safe to call once at init. Credits are static and always
* present in the "Data attribution" popover.
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
* Register every per-layer data credit through the renderer's credits
* port. Safe to call once at init. Credits are static and always present
* in the "Data attribution" list, not on the map's credit line.
* @param {object} viewer — the scene object the renderer is bound to
*/
export function registerDataCredits(viewer, credits = DATA_CREDITS) {
const creditDisplay = viewer?.creditDisplay;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
return;
}
for (const { html } of credits) {
// showOnScreen=false → lives in the expandable "Data attribution" popover,
// not the on-globe credit line.
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
}
if (!viewer) return;
for (const credit of credits) addDataCredit(viewer, credit);
}
+102
View File
@@ -142,3 +142,105 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
`${key} has no credit`,
);
});
test('a credit with several links becomes one credit per link, in order', async () => {
const {
creditsFromMarkup,
OSM_CREDIT,
OPENMAPTILES_CREDIT,
WOF_CREDIT,
BHOTE_KOSHI_CREDIT,
} = await import('./dataCredits.js');
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key).html;
const read = (markup) =>
creditsFromMarkup(markup).map(({ text, href }) => [text, href]);
assert.deepEqual(read(OSM_CREDIT.html), [
[
'Map and place data © OpenStreetMap contributors',
'https://www.openstreetmap.org/copyright',
],
['(ODbL)', 'https://opendatacommons.org/licenses/odbl/1-0/'],
]);
assert.deepEqual(read(OPENMAPTILES_CREDIT.html), [
['Vector tiles: OpenFreeMap', 'https://openfreemap.org'],
['© OpenMapTiles', 'https://openmaptiles.org'],
]);
assert.deepEqual(read(html('nasa-gibs'))[1], [
'HLS product page',
'https://lpdaac.usgs.gov/products/hlss30v002/',
]);
assert.deepEqual(read(html('wfigs')), [
[
'Wildfire perimeters: National Interagency Fire Center (WFIGS)',
'https://data-nifc.opendata.arcgis.com/',
],
['Incident information: InciWeb', 'https://inciweb.wildfire.gov/'],
]);
assert.deepEqual(read(html('eccc-aqhi')), [
[
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet (eccc-msc.github.io/open-data)',
'https://eccc-msc.github.io/open-data/',
],
[
'ECCC Data Servers End-use Licence',
'https://eccc-msc.github.io/open-data/licence/readme_en/',
],
]);
assert.deepEqual(
read(BHOTE_KOSHI_CREDIT.html)[1][0].slice(0, 15),
'and GeoPera (CC',
);
assert.deepEqual(
read(WOF_CREDIT.html).map(([text]) => text),
[
"Neighborhood boundaries: Who's On First and contributors, simplified",
'(sources and licences)',
],
);
const link = (text) => `<a href="https://example.org">${text}</a>`;
assert.deepEqual(
read(`${link('A')} by Hoffleit &amp; Warren ${link('B')}`).map(
([text]) => text,
),
['A', 'by Hoffleit & Warren B'],
'a character reference is not a separator',
);
assert.deepEqual(read(html('usgs')), [
['Earthquakes: Data courtesy of the U.S. Geological Survey', undefined],
]);
for (const entry of DATA_CREDITS) {
const credits = creditsFromMarkup(entry.html);
const links = entry.html.match(/<a\b/g)?.length ?? 0;
assert.equal(credits.length, Math.max(links, 1), entry.key);
assert.ok(
credits.every((credit) => credit.inline === false),
`${entry.key} belongs in the attribution list`,
);
}
});
test('data credits go through the renderer to the attribution list', async () => {
const { registerDataCredits, registerDynamicCredit, TOMTOM_CREDIT } =
await import('./dataCredits.js');
const shown = [];
const viewer = bindFakeCesiumCredits({
creditDisplay: {
addStaticCredit: (c) => shown.push(c),
removeStaticCredit() {},
},
});
const credits = DATA_CREDITS.filter((entry) =>
['openstreetmap', 'usgs'].includes(entry.key),
);
registerDataCredits(viewer, credits);
assert.equal(shown.length, 3);
assert.ok(shown.every((credit) => !credit.showOnScreen));
const odbl = shown.find((credit) => credit.html.includes('opendatacommons'));
assert.match(odbl.html, /href="https:\/\/opendatacommons.org/);
assert.match(odbl.html, />\(ODbL\)</);
assert.equal(registerDynamicCredit(viewer, TOMTOM_CREDIT), true);
assert.equal(registerDynamicCredit(viewer, TOMTOM_CREDIT), true);
assert.equal(shown.length, 4, 'a dynamic credit is added once');
assert.match(shown[3].html, /Traffic flow data © TomTom/);
assert.equal(registerDynamicCredit({}, { key: 'none', html: 'x' }), false);
});
-1
View File
@@ -24,7 +24,6 @@
"src/data/cctvGizmo.js",
"src/data/cctvViewshed.js",
"src/data/contactTrailRenderer.js",
"src/data/dataCredits.js",
"src/data/detection.js",
"src/data/focusDeemphasis.js",
"src/data/geoFileImport.js",