diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2736abc5b..2bb2b53b8 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -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 = /"']|"[^"]*"|'[^']*')*>[\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*(?:[,·—–]|(? 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} 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); } diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 6cdcaa336..81082a64c 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -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) => `${text}`; + assert.deepEqual( + read(`${link('A')} by Hoffleit & 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(/ 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\)