From 540985203d29cd52d0b9e3263c61c9e74ff17357 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Mon, 21 Oct 2024 09:24:13 +1300 Subject: [PATCH] feat(lambda-tiler): relocated and revised licensor attribution functions --- packages/attribution/src/attribution.ts | 70 ++++++++++++++++--- packages/attribution/src/utils/utils.ts | 66 +++++++++++++++++ packages/config/src/config/imagery.ts | 22 ++++++ packages/geo/src/stac/index.ts | 24 ++++++- .../lambda-tiler/src/routes/attribution.ts | 3 +- .../src/routes/tile.style.json.ts | 19 ++--- packages/landing/src/attribution.ts | 21 +----- 7 files changed, 178 insertions(+), 47 deletions(-) create mode 100644 packages/attribution/src/utils/utils.ts diff --git a/packages/attribution/src/attribution.ts b/packages/attribution/src/attribution.ts index 2b51119d9..a2543dfb1 100644 --- a/packages/attribution/src/attribution.ts +++ b/packages/attribution/src/attribution.ts @@ -1,6 +1,8 @@ import { AttributionCollection, AttributionStac } from '@basemaps/geo'; import { BBox, intersection, MultiPolygon, Ring, Wgs84 } from '@linzjs/geojson'; +import { createLicensorAttribution } from './utils/utils.js'; + export interface AttributionFilter { extent: BBox; zoom: number; @@ -181,20 +183,66 @@ export class Attribution { isIgnored?: (attr: AttributionBounds) => boolean; /** - * Render the filtered attributions as a simple string suitable to display as attribution + * Parse the filtered list of attributions into a formatted string comprising license information. + * + * @param filtered The filtered list of attributions. + * + * @returns A formatted license string. + * + * @example + * if (filtered[0] contains no providers or licensors): + * return "CC BY 4.0 LINZ - Otago 0.3 Rural Aerial Photos (2017-2019)" + * + * @example + * if (filtered[0] contains licensors): + * return "CC BY 4.0 Otago Regional Council - Otago 0.3 Rural Aerial Photos (2017-2019)" + */ + renderLicense(filtered: AttributionBounds[]): string { + const providers = filtered[0]?.collection.providers; + const attribution = createLicensorAttribution(providers); + const list = this.renderList(filtered); + + if (list.length) { + return `${attribution} - ${list}`; + } else { + return attribution; + } + } + + /** + * Render the filtered attributions as a simple string suitable to display as attribution. + * + * @param filtered The filtered list of attributions. + * + * @returns {string} An empty string, if the filtered list is empty. + * Otherwise, a formatted string comprising attribution details. + * + * @example + * if (filtered.length === 0): + * return "" + * + * @example + * if (filtered.length === 1): + * return "Ashburton 0.1m Urban Aerial Photos (2023)" + * + * @example + * if (filtered.length === 2): + * return "Wellington 0.3m Rural Aerial Photos (2021) & New Zealand 10m Satellite Imagery (2023-2024)" * - * @param list the filtered list of attributions + * @example + * if (filtered.length > 2): + * return "Canterbury 0.2 Rural Aerial Photos (2020-2021) & others 2012-2024" */ - renderList(list: AttributionBounds[]): string { - if (list.length === 0) return ''; - let result = escapeHtml(list[0].collection.title); - if (list.length > 1) { - if (list.length === 2) { - result += ` & ${escapeHtml(list[1].collection.title)}`; + renderList(filtered: AttributionBounds[]): string { + if (filtered.length === 0) return ''; + let result = escapeHtml(filtered[0].collection.title); + if (filtered.length > 1) { + if (filtered.length === 2) { + result += ` & ${escapeHtml(filtered[1].collection.title)}`; } else { - let [minYear, maxYear] = getYears(list[1].collection); - for (let i = 1; i < list.length; ++i) { - const [a, b] = getYears(list[i].collection); + let [minYear, maxYear] = getYears(filtered[1].collection); + for (let i = 1; i < filtered.length; ++i) { + const [a, b] = getYears(filtered[i].collection); if (a !== -1 && (minYear === -1 || a < minYear)) minYear = a; if (b !== -1 && (maxYear === -1 || b > maxYear)) maxYear = b; } diff --git a/packages/attribution/src/utils/utils.ts b/packages/attribution/src/utils/utils.ts new file mode 100644 index 000000000..c5b69ea85 --- /dev/null +++ b/packages/attribution/src/utils/utils.ts @@ -0,0 +1,66 @@ +import { BasemapsConfigProvider, ConfigTileSet } from '@basemaps/config'; +import { Epsg, Stac, StacProvider } from '@basemaps/geo'; + +export const copyright = `© ${Stac.License}`; + +/** + * Construct a licensor attribution for a given tileSet. + * + * @param provider The BasemapsConfigProvider object. + * @param tileSet The tileset from which to build the attribution. + * @param projection The projection to consider. + * + * @returns A default attribution, if the tileset has more than one layer or no such imagery for the given projection exists. + * Otherwise, a copyright string comprising the names of the tileset's licensors. + * + * @example + * "CC BY 4.0 LINZ" + * + * @example + * "CC BY 4.0 Nelson City Council, Tasman District Council, Waka Kotahi" + */ +export async function getTileSetAttribution( + provider: BasemapsConfigProvider, + tileSet: ConfigTileSet, + projection: Epsg, +): Promise { + // ensure the tileset has exactly one layer + if (tileSet.layers.length > 1 || tileSet.layers[0] === undefined) { + return createLicensorAttribution(); + } + + // ensure imagery exist for the given projection + const imgId = tileSet.layers[0][projection.code]; + if (imgId === undefined) return ''; + + // attempt to load the imagery + const imagery = await provider.Imagery.get(imgId); + if (imagery == null || imagery.providers === undefined) { + return createLicensorAttribution(); + } + + // return a licensor attribution string + return createLicensorAttribution(imagery.providers); +} + +/** + * Create a licensor attribution string. + * + * @param providers The optional list of providers. + * + * @returns A copyright string comprising the names of licensor providers. + * + * @example + * "CC BY 4.0 LINZ" + * + * @example + * "CC BY 4.0 Nelson City Council, Tasman District Council, Waka Kotahi" + */ +export function createLicensorAttribution(providers?: StacProvider[]): string { + if (providers === undefined) return `${copyright} LINZ`; + + const licensors = providers.filter((p) => p.roles?.includes('licensor')); + if (licensors.length === 0) return `${copyright} LINZ`; + + return `${copyright} ${licensors.map((l) => l.name).join(', ')}`; +} diff --git a/packages/config/src/config/imagery.ts b/packages/config/src/config/imagery.ts index a1087c98a..93cbbc088 100644 --- a/packages/config/src/config/imagery.ts +++ b/packages/config/src/config/imagery.ts @@ -46,12 +46,34 @@ export const ConfigImageryOverviewParser = z }) .refine((obj) => obj.minZoom < obj.maxZoom); +/** + * Provides information about a provider. + * + * @link https://github.com/radiantearth/stac-spec/blob/master/commons/common-metadata.md#provider + */ export const ProvidersParser = z.object({ + /** + * The name of the organization or the individual. + */ name: z.string(), + + /** + * Multi-line description to add further provider information such as processing details + * for processors and producers, hosting details for hosts or basic contact information. + */ description: z.string().optional(), + + /** + * Roles of the provider. Any of `licensor`, `producer`, `processor` or `host`. + */ roles: z.array(z.string()).optional(), + + /** + * Homepage on which the provider describes the dataset and publishes contact information. + */ url: z.string().optional(), }); + export const BoundingBoxParser = z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }); export const NamedBoundsParser = z.object({ /** diff --git a/packages/geo/src/stac/index.ts b/packages/geo/src/stac/index.ts index 542128c55..3f73dac8a 100644 --- a/packages/geo/src/stac/index.ts +++ b/packages/geo/src/stac/index.ts @@ -21,9 +21,31 @@ export interface StacAsset { description?: string; } +/** + * Provides information about a provider. + * + * @link https://github.com/radiantearth/stac-spec/blob/master/commons/common-metadata.md#provider + */ export interface StacProvider { + /** + * The name of the organization or the individual. + */ name: string; - roles: string[]; + + /** + * Multi-line description to add further provider information such as processing details + * for processors and producers, hosting details for hosts or basic contact information. + */ + description?: string; + + /** + * Roles of the provider. Any of `licensor`, `producer`, `processor` or `host`. + */ + roles?: string[]; + + /** + * Homepage on which the provider describes the dataset and publishes contact information. + */ url?: string; } diff --git a/packages/lambda-tiler/src/routes/attribution.ts b/packages/lambda-tiler/src/routes/attribution.ts index 4a2f3a22b..824b964d4 100644 --- a/packages/lambda-tiler/src/routes/attribution.ts +++ b/packages/lambda-tiler/src/routes/attribution.ts @@ -136,14 +136,13 @@ async function tileSetAttribution( items.push(item); - const providers = im.providers?.map((p) => ({ ...p, roles: p.roles ?? [] })) ?? getHost(host); const zoomMin = TileMatrixSet.convertZoomLevel(layer.minZoom ? layer.minZoom : 0, GoogleTms, tileMatrix, true); const zoomMax = TileMatrixSet.convertZoomLevel(layer.maxZoom ? layer.maxZoom : 32, GoogleTms, tileMatrix, true); cols.push({ stac_version: Stac.Version, license: Stac.License, id: im.id, - providers, + providers: im.providers ?? getHost(host), title, description: 'No description', extent, diff --git a/packages/lambda-tiler/src/routes/tile.style.json.ts b/packages/lambda-tiler/src/routes/tile.style.json.ts index 1055377d0..044ec1690 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -1,7 +1,7 @@ +import { getTileSetAttribution } from '@basemaps/attribution/build/utils/utils.js'; import { BasemapsConfigProvider, ConfigId, - ConfigImagery, ConfigPrefix, ConfigTileSetRaster, Layer, @@ -10,7 +10,7 @@ import { TileSetType, } from '@basemaps/config'; import { DefaultExaggeration } from '@basemaps/config/build/config/vector.style.js'; -import { Epsg, GoogleTms, Nztm2000QuadTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; +import { GoogleTms, Nztm2000QuadTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; import { Env, toQueryString } from '@basemaps/shared'; import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda'; import { URL } from 'url'; @@ -177,18 +177,7 @@ export async function tileSetToStyle( (Env.get(Env.PublicUrlBase) ?? '') + `/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${tileFormat}${query}`; - // attempt to load the tileset's imagery - const imagery = await (function (): Promise { - if (tileSet.layers.length !== 1) return Promise.resolve(null); - - const imageryId = tileSet.layers[0][Epsg.Nztm2000.code]; - if (imageryId === undefined) return Promise.resolve(null); - - return config.Imagery.get(imageryId); - })(); - - // attempt to extract a licensor from the imagery's providers - const licensor = imagery?.providers?.find((p) => p?.roles?.includes('licensor'))?.name; + const attribution = await getTileSetAttribution(config, tileSet, tileMatrix.projection); const styleId = `basemaps-${tileSet.name}`; return { @@ -200,7 +189,7 @@ export async function tileSetToStyle( type: 'raster', tiles: [tileUrl], tileSize: 256, - attribution: licensor ?? undefined, + attribution, }, }, layers: [{ id: styleId, type: 'raster', source: styleId }], diff --git a/packages/landing/src/attribution.ts b/packages/landing/src/attribution.ts index 5228556bb..4e0c55dcf 100644 --- a/packages/landing/src/attribution.ts +++ b/packages/landing/src/attribution.ts @@ -1,6 +1,6 @@ import { Attribution } from '@basemaps/attribution'; import { AttributionBounds } from '@basemaps/attribution/build/attribution.js'; -import { GoogleTms, Stac } from '@basemaps/geo'; +import { GoogleTms } from '@basemaps/geo'; import * as maplibre from 'maplibre-gl'; import { onMapLoaded } from './components/map.js'; @@ -8,8 +8,6 @@ import { Config } from './config.js'; import { mapToBoundingBox } from './tile.matrix.js'; import { MapOptionType } from './url.js'; -const Copyright = `© ${Stac.License}`; - export class MapAttributionState { /** Cache the loading of attribution */ attrs: Map> = new Map(); @@ -168,21 +166,8 @@ export class MapAttribution implements maplibre.IControl { const filteredLayerIds = filtered.map((x) => x.collection.id).join('_'); Config.map.emit('visibleLayers', filteredLayerIds); - const licensor = (function (): string | null { - const providers = filtered[0].collection.providers; - if (providers === undefined) return null; - - return providers.find((p) => p.roles.some((r) => r === 'licensor'))?.name ?? null; - })(); - - let attributionHTML = attr.renderList(filtered); - if (attributionHTML === '') { - attributionHTML = `${Copyright} ${licensor ?? 'LINZ'}`; - } else { - attributionHTML = `${Copyright} ${licensor ?? 'LINZ'} - ${attributionHTML}`; - } - - this.setAttribution(attributionHTML); + const attributionHTML = attr.renderLicense(filtered); + this.setAttribution(attributionHTML ?? ''); }; setAttribution(text: string): void {