From 2fb2339ec1477abb599d0d0e53a7753935715887 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Thu, 17 Oct 2024 09:16:04 +1300 Subject: [PATCH 1/6] Implement support for licensor attributions --- .../config-loader/src/json/tiff.config.ts | 1 + packages/config/src/config/imagery.ts | 11 +++++++ .../lambda-tiler/src/routes/attribution.ts | 3 +- .../src/routes/tile.style.json.ts | 32 ++++++++++++++++--- packages/landing/src/attribution.ts | 13 ++++++-- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/config-loader/src/json/tiff.config.ts b/packages/config-loader/src/json/tiff.config.ts index e92898642..9f3686375 100644 --- a/packages/config-loader/src/json/tiff.config.ts +++ b/packages/config-loader/src/json/tiff.config.ts @@ -384,6 +384,7 @@ export async function initImageryFromTiffUrl( noData: params.noData, files: params.files, collection: stac ?? undefined, + providers: stac?.providers, }; imagery.overviews = await ConfigJson.findImageryOverviews(imagery); log?.info({ title, imageryName, files: imagery.files.length }, 'Tiff:Loaded'); diff --git a/packages/config/src/config/imagery.ts b/packages/config/src/config/imagery.ts index d8d9458ae..a1087c98a 100644 --- a/packages/config/src/config/imagery.ts +++ b/packages/config/src/config/imagery.ts @@ -46,6 +46,12 @@ export const ConfigImageryOverviewParser = z }) .refine((obj) => obj.minZoom < obj.maxZoom); +export const ProvidersParser = z.object({ + name: z.string(), + description: z.string().optional(), + roles: z.array(z.string()).optional(), + 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({ /** @@ -140,6 +146,11 @@ export const ConfigImageryParser = ConfigBase.extend({ * Separate overview cache */ overviews: ConfigImageryOverviewParser.optional(), + + /** + * list of providers and their metadata + */ + providers: z.array(ProvidersParser).optional(), }); export type ConfigImagery = z.infer; diff --git a/packages/lambda-tiler/src/routes/attribution.ts b/packages/lambda-tiler/src/routes/attribution.ts index cd866bcd9..4a2f3a22b 100644 --- a/packages/lambda-tiler/src/routes/attribution.ts +++ b/packages/lambda-tiler/src/routes/attribution.ts @@ -136,13 +136,14 @@ 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: getHost(host), + providers, 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 f7b302eef..1055377d0 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -1,6 +1,7 @@ import { BasemapsConfigProvider, ConfigId, + ConfigImagery, ConfigPrefix, ConfigTileSetRaster, Layer, @@ -9,7 +10,7 @@ import { TileSetType, } from '@basemaps/config'; import { DefaultExaggeration } from '@basemaps/config/build/config/vector.style.js'; -import { GoogleTms, Nztm2000QuadTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; +import { Epsg, GoogleTms, Nztm2000QuadTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; import { Env, toQueryString } from '@basemaps/shared'; import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda'; import { URL } from 'url'; @@ -153,12 +154,13 @@ async function ensureTerrain( * Generate a StyleJSON from a tileset * @returns */ -export function tileSetToStyle( +export async function tileSetToStyle( req: LambdaHttpRequest, + config: BasemapsConfigProvider, tileSet: ConfigTileSetRaster, tileMatrix: TileMatrixSet, apiKey: string, -): StyleJson { +): Promise { // If the style has outputs defined it has a different process for generating the stylejson if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey); @@ -175,12 +177,32 @@ export 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 styleId = `basemaps-${tileSet.name}`; return { id: ConfigId.prefix(ConfigPrefix.Style, tileSet.name), name: tileSet.name, version: 8, - sources: { [styleId]: { type: 'raster', tiles: [tileUrl], tileSize: 256 } }, + sources: { + [styleId]: { + type: 'raster', + tiles: [tileUrl], + tileSize: 256, + attribution: licensor ?? undefined, + }, + }, layers: [{ id: styleId, type: 'raster', source: styleId }], }; } @@ -248,7 +270,7 @@ async function generateStyleFromTileSet( throw new LambdaHttpResponse(400, 'Only raster tile sets can generate style JSON'); } if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey); - else return tileSetToStyle(req, tileSet, tileMatrix, apiKey); + return tileSetToStyle(req, config, tileSet, tileMatrix, apiKey); } export interface StyleGet { diff --git a/packages/landing/src/attribution.ts b/packages/landing/src/attribution.ts index 2f56eb5e2..5228556bb 100644 --- a/packages/landing/src/attribution.ts +++ b/packages/landing/src/attribution.ts @@ -8,7 +8,7 @@ import { Config } from './config.js'; import { mapToBoundingBox } from './tile.matrix.js'; import { MapOptionType } from './url.js'; -const Copyright = `© ${Stac.License} LINZ`; +const Copyright = `© ${Stac.License}`; export class MapAttributionState { /** Cache the loading of attribution */ @@ -168,11 +168,18 @@ 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; + attributionHTML = `${Copyright} ${licensor ?? 'LINZ'}`; } else { - attributionHTML = Copyright + ' - ' + attributionHTML; + attributionHTML = `${Copyright} ${licensor ?? 'LINZ'} - ${attributionHTML}`; } this.setAttribution(attributionHTML); From 540985203d29cd52d0b9e3263c61c9e74ff17357 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Mon, 21 Oct 2024 09:24:13 +1300 Subject: [PATCH 2/6] 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 { From af4ae75b2487d838d71b5fc1b08fdcbfb1bc15b4 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Tue, 22 Oct 2024 06:20:40 +1300 Subject: [PATCH 3/6] refactor(lambda-tiler): relocated licensor attribution functions again --- packages/attribution/src/attribution.ts | 2 +- packages/attribution/src/utils.ts | 25 +++++++ packages/attribution/src/utils/utils.ts | 66 ------------------- .../lambda-tiler/src/routes/attribution.ts | 53 +++++++++++++-- .../src/routes/tile.style.json.ts | 4 +- 5 files changed, 77 insertions(+), 73 deletions(-) create mode 100644 packages/attribution/src/utils.ts delete mode 100644 packages/attribution/src/utils/utils.ts diff --git a/packages/attribution/src/attribution.ts b/packages/attribution/src/attribution.ts index a2543dfb1..86972e3a0 100644 --- a/packages/attribution/src/attribution.ts +++ b/packages/attribution/src/attribution.ts @@ -1,7 +1,7 @@ import { AttributionCollection, AttributionStac } from '@basemaps/geo'; import { BBox, intersection, MultiPolygon, Ring, Wgs84 } from '@linzjs/geojson'; -import { createLicensorAttribution } from './utils/utils.js'; +import { createLicensorAttribution } from './utils.js'; export interface AttributionFilter { extent: BBox; diff --git a/packages/attribution/src/utils.ts b/packages/attribution/src/utils.ts new file mode 100644 index 000000000..b719e9964 --- /dev/null +++ b/packages/attribution/src/utils.ts @@ -0,0 +1,25 @@ +import { Stac, StacProvider } from '@basemaps/geo'; + +export const copyright = `© ${Stac.License}`; + +/** + * 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 == null) 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/attribution/src/utils/utils.ts b/packages/attribution/src/utils/utils.ts deleted file mode 100644 index c5b69ea85..000000000 --- a/packages/attribution/src/utils/utils.ts +++ /dev/null @@ -1,66 +0,0 @@ -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/lambda-tiler/src/routes/attribution.ts b/packages/lambda-tiler/src/routes/attribution.ts index 824b964d4..e6cb4dc93 100644 --- a/packages/lambda-tiler/src/routes/attribution.ts +++ b/packages/lambda-tiler/src/routes/attribution.ts @@ -1,9 +1,11 @@ -import { ConfigProvider, ConfigTileSet, getAllImagery, TileSetType } from '@basemaps/config'; +import { createLicensorAttribution } from '@basemaps/attribution/build/utils.js'; +import { BasemapsConfigProvider, ConfigProvider, ConfigTileSet, getAllImagery, TileSetType } from '@basemaps/config'; import { AttributionCollection, AttributionItem, AttributionStac, Bounds, + Epsg, GoogleTms, NamedBounds, Projection, @@ -93,10 +95,11 @@ async function tileSetAttribution( const config = await ConfigLoader.load(req); const imagery = await getAllImagery(config, tileSet.layers, [tileMatrix.projection]); - const host = await config.Provider.get(config.Provider.id('linz')); - for (const layer of tileSet.layers) { + for (let i = 0; i < tileSet.layers.length; i++) { + const layer = tileSet.layers[i]; + const imgId = layer[proj.epsg.code]; if (imgId == null) continue; @@ -138,6 +141,7 @@ async function tileSetAttribution( 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, @@ -150,10 +154,11 @@ async function tileSetAttribution( summaries: { 'linz:category': im.category, 'linz:zoom': { min: zoomMin, max: zoomMax }, - 'linz:priority': [1000 + tileSet.layers.indexOf(layer)], + 'linz:priority': [1000 + i], }, }); } + return { id: tileSet.id, type: 'FeatureCollection', @@ -205,3 +210,43 @@ export async function tileAttributionGet(req: LambdaHttpRequest { + // ensure the tileset has exactly one layer + if (tileSet.layers.length > 1 || tileSet.layers[0] == null) { + 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 == null) { + return createLicensorAttribution(); + } + + // return a licensor attribution string + return createLicensorAttribution(imagery.providers); +} diff --git a/packages/lambda-tiler/src/routes/tile.style.json.ts b/packages/lambda-tiler/src/routes/tile.style.json.ts index 044ec1690..efa7c7044 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -1,4 +1,3 @@ -import { getTileSetAttribution } from '@basemaps/attribution/build/utils/utils.js'; import { BasemapsConfigProvider, ConfigId, @@ -20,6 +19,7 @@ import { Etag } from '../util/etag.js'; import { convertStyleToNztmStyle } from '../util/nztm.style.js'; import { NotFound, NotModified } from '../util/response.js'; import { Validate } from '../util/validate.js'; +import { createTileSetAttribution } from './attribution.js'; /** * Convert relative URL into a full hostname URL, converting {tileMatrix} into the provided tileMatrix @@ -177,7 +177,7 @@ export async function tileSetToStyle( (Env.get(Env.PublicUrlBase) ?? '') + `/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${tileFormat}${query}`; - const attribution = await getTileSetAttribution(config, tileSet, tileMatrix.projection); + const attribution = await createTileSetAttribution(config, tileSet, tileMatrix.projection); const styleId = `basemaps-${tileSet.name}`; return { From 18af34ba2a2ae5519ea33410ef59df472a921cab Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Tue, 22 Oct 2024 06:22:38 +1300 Subject: [PATCH 4/6] test(lambda-tiler): implemented test suite for attribution functions --- .../attribution/src/__tests__/utils.test.ts | 65 +++++ .../tile.style.json.attribution.test.ts | 224 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 packages/attribution/src/__tests__/utils.test.ts create mode 100644 packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts diff --git a/packages/attribution/src/__tests__/utils.test.ts b/packages/attribution/src/__tests__/utils.test.ts new file mode 100644 index 000000000..533a42ce6 --- /dev/null +++ b/packages/attribution/src/__tests__/utils.test.ts @@ -0,0 +1,65 @@ +import { strictEqual } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { StacProvider } from '@basemaps/geo'; + +import { copyright, createLicensorAttribution } from '../utils.js'; + +const defaultAttribution = `${copyright} LINZ`; + +describe('utils', () => { + const FakeHost: StacProvider = { + name: 'FakeHost', + roles: ['host'], + }; + const FakeLicensor1: StacProvider = { + name: 'FakeLicensor1', + roles: ['licensor'], + }; + const FakeLicensor2: StacProvider = { + name: 'FakeLicensor2', + roles: ['licensor'], + }; + + it('default attribution: no providers', () => { + const providers = undefined; + const attribution = createLicensorAttribution(providers); + + strictEqual(attribution, defaultAttribution); + }); + + it('default attribution: empty providers', () => { + const providers: StacProvider[] = []; + const attribution = createLicensorAttribution(providers); + + strictEqual(attribution, defaultAttribution); + }); + + it('default attribution: one provider, no licensors', () => { + const providers = [FakeHost]; + + const attribution = createLicensorAttribution(providers); + strictEqual(attribution, defaultAttribution); + }); + + it('custom attribution: one provider, one licensor', () => { + const providers = [FakeLicensor1]; + + const attribution = createLicensorAttribution(providers); + strictEqual(attribution, `${copyright} ${FakeLicensor1.name}`); + }); + + it('custom attribution: two providers, one licensor', () => { + const providers = [FakeHost, FakeLicensor1]; + + const attribution = createLicensorAttribution(providers); + strictEqual(attribution, `${copyright} ${FakeLicensor1.name}`); + }); + + it('custom attribution: two providers, two licensors', () => { + const providers = [FakeLicensor1, FakeLicensor2]; + + const attribution = createLicensorAttribution(providers); + strictEqual(attribution, `${copyright} ${FakeLicensor1.name}, ${FakeLicensor2.name}`); + }); +}); diff --git a/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts b/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts new file mode 100644 index 000000000..c07d98457 --- /dev/null +++ b/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts @@ -0,0 +1,224 @@ +import assert, { strictEqual } from 'node:assert'; +import { afterEach, before, describe, it } from 'node:test'; + +import { copyright, createLicensorAttribution } from '@basemaps/attribution/build/utils/utils.js'; +import { ConfigProviderMemory, StyleJson } from '@basemaps/config'; +import { StacProvider } from '@basemaps/geo'; +import { Env } from '@basemaps/shared'; + +import { FakeData, Imagery3857 } from '../../__tests__/config.data.js'; +import { Api, mockRequest } from '../../__tests__/xyz.util.js'; +import { handler } from '../../index.js'; +import { ConfigLoader } from '../../util/config.loader.js'; + +const defaultAttribution = `${copyright} LINZ`; + +describe('/v1/styles', () => { + const host = 'https://tiles.test'; + const config = new ConfigProviderMemory(); + + const FakeTileSetName = 'tileset'; + const FakeLicensor1: StacProvider = { + name: 'L1', + roles: ['licensor'], + }; + const FakeLicensor2: StacProvider = { + name: 'L2', + roles: ['licensor'], + }; + + before(() => { + process.env[Env.PublicUrlBase] = host; + }); + afterEach(() => { + config.objects.clear(); + }); + + // tileset exists, imagery not found + it('default: imagery not found', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + // insert + config.put(FakeData.tileSetRaster(FakeTileSetName)); + + // request + const req = mockRequest(`/v1/styles/${FakeTileSetName}.json`, 'get', Api.header); + const res = await handler.router.handle(req); + strictEqual(res.status, 200); + + // extract + const body = Buffer.from(res.body, 'base64').toString(); + const json = JSON.parse(body) as StyleJson; + + const source = Object.values(json.sources)[0]; + assert(source != null); + assert(source.attribution != null); + + // verify + strictEqual(source.attribution, defaultAttribution); + }); + + // tileset exists, imagery found, more than one layer + it('default: too many layers', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + // insert + const tileset = FakeData.tileSetRaster(FakeTileSetName); + assert(tileset.layers[0] != null); + + tileset.layers.push(tileset.layers[0]); + assert(tileset.layers.length > 1); + + config.put(tileset); + config.put(Imagery3857); + + // request + const req = mockRequest(`/v1/styles/${FakeTileSetName}.json`, 'get', Api.header); + const res = await handler.router.handle(req); + strictEqual(res.status, 200); + + // extract + const body = Buffer.from(res.body, 'base64').toString(); + const json = JSON.parse(body) as StyleJson; + + const source = Object.values(json.sources)[0]; + assert(source != null); + assert(source.attribution != null); + + // verify + strictEqual(source.attribution, defaultAttribution); + }); + + // tileset exists, imagery found, one layer, no providers + it('default: no providers', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + // insert + const tileset = FakeData.tileSetRaster(FakeTileSetName); + assert(tileset.layers[0] != null); + assert(tileset.layers.length === 1); + + const imagery = Imagery3857; + assert(imagery.providers == null); + + config.put(tileset); + config.put(imagery); + + // request + const req = mockRequest(`/v1/styles/${FakeTileSetName}.json`, 'get', Api.header); + const res = await handler.router.handle(req); + strictEqual(res.status, 200); + + // extract + const body = Buffer.from(res.body, 'base64').toString(); + const json = JSON.parse(body) as StyleJson; + + const source = Object.values(json.sources)[0]; + assert(source != null); + assert(source.attribution != null); + + // verify + strictEqual(source.attribution, defaultAttribution); + }); + + // tileset exists, imagery found, one layer, has providers, no licensors + it('default: no licensors', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + // insert + const tileset = FakeData.tileSetRaster(FakeTileSetName); + assert(tileset.layers[0] != null); + assert(tileset.layers.length === 1); + + const imagery = Imagery3857; + imagery.providers = []; + assert(imagery.providers != null); + + config.put(tileset); + config.put(imagery); + + // request + const req = mockRequest(`/v1/styles/${FakeTileSetName}.json`, 'get', Api.header); + const res = await handler.router.handle(req); + strictEqual(res.status, 200); + + // extract + const body = Buffer.from(res.body, 'base64').toString(); + const json = JSON.parse(body) as StyleJson; + + const source = Object.values(json.sources)[0]; + assert(source != null); + assert(source.attribution != null); + + // verify + strictEqual(source.attribution, defaultAttribution); + }); + + // tileset exists, imagery found, one layer, has providers, one licensor + it('custom: one licensor', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + // insert + const tileset = FakeData.tileSetRaster(FakeTileSetName); + assert(tileset.layers[0] != null); + assert(tileset.layers.length === 1); + + const imagery = Imagery3857; + imagery.providers = [FakeLicensor1]; + assert(imagery.providers != null); + + config.put(tileset); + config.put(imagery); + + // request + const req = mockRequest(`/v1/styles/${FakeTileSetName}.json`, 'get', Api.header); + const res = await handler.router.handle(req); + strictEqual(res.status, 200); + + // extract + const body = Buffer.from(res.body, 'base64').toString(); + const json = JSON.parse(body) as StyleJson; + + const source = Object.values(json.sources)[0]; + assert(source != null); + assert(source.attribution != null); + + // verify + strictEqual(source.attribution, `${copyright} ${FakeLicensor1.name}`); + strictEqual(source.attribution, createLicensorAttribution([FakeLicensor1])); + }); + + // tileset exists, imagery found, one layer, has providers, two licensors + it('custom: two licensors', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + // insert + const tileset = FakeData.tileSetRaster(FakeTileSetName); + assert(tileset.layers[0] != null); + assert(tileset.layers.length === 1); + + const imagery = Imagery3857; + imagery.providers = [FakeLicensor1, FakeLicensor2]; + assert(imagery.providers != null); + + config.put(tileset); + config.put(imagery); + + // request + const req = mockRequest(`/v1/styles/${FakeTileSetName}.json`, 'get', Api.header); + const res = await handler.router.handle(req); + strictEqual(res.status, 200); + + // extract + const body = Buffer.from(res.body, 'base64').toString(); + const json = JSON.parse(body) as StyleJson; + + const source = Object.values(json.sources)[0]; + assert(source != null); + assert(source.attribution != null); + + // verify + strictEqual(source.attribution, `${copyright} ${FakeLicensor1.name}, ${FakeLicensor2.name}`); + strictEqual(source.attribution, createLicensorAttribution([FakeLicensor1, FakeLicensor2])); + }); +}); From 8304f9086bde5d6217c24d9063b6a355a89512a2 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Tue, 22 Oct 2024 13:21:42 +1300 Subject: [PATCH 5/6] fix(lambda-tiler): corrected an invalid import statement --- .../src/routes/__tests__/tile.style.json.attribution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts b/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts index c07d98457..d67420fea 100644 --- a/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts +++ b/packages/lambda-tiler/src/routes/__tests__/tile.style.json.attribution.test.ts @@ -1,7 +1,7 @@ import assert, { strictEqual } from 'node:assert'; import { afterEach, before, describe, it } from 'node:test'; -import { copyright, createLicensorAttribution } from '@basemaps/attribution/build/utils/utils.js'; +import { copyright, createLicensorAttribution } from '@basemaps/attribution/build/utils.js'; import { ConfigProviderMemory, StyleJson } from '@basemaps/config'; import { StacProvider } from '@basemaps/geo'; import { Env } from '@basemaps/shared'; From 0cb7768da115d4fc80786222ebabb3a6c05cb636 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Thu, 24 Oct 2024 16:11:04 +1300 Subject: [PATCH 6/6] fix(lambda-tiler): corrected conditional logic --- packages/attribution/src/attribution.ts | 2 +- packages/lambda-tiler/src/routes/attribution.ts | 4 ++-- packages/landing/src/attribution.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/attribution/src/attribution.ts b/packages/attribution/src/attribution.ts index 86972e3a0..9d5a5b52c 100644 --- a/packages/attribution/src/attribution.ts +++ b/packages/attribution/src/attribution.ts @@ -202,7 +202,7 @@ export class Attribution { const attribution = createLicensorAttribution(providers); const list = this.renderList(filtered); - if (list.length) { + if (list.length > 0) { return `${attribution} - ${list}`; } else { return attribution; diff --git a/packages/lambda-tiler/src/routes/attribution.ts b/packages/lambda-tiler/src/routes/attribution.ts index e6cb4dc93..83c565eb0 100644 --- a/packages/lambda-tiler/src/routes/attribution.ts +++ b/packages/lambda-tiler/src/routes/attribution.ts @@ -239,11 +239,11 @@ export async function createTileSetAttribution( // ensure imagery exist for the given projection const imgId = tileSet.layers[0][projection.code]; - if (imgId === undefined) return ''; + if (imgId == null) return ''; // attempt to load the imagery const imagery = await provider.Imagery.get(imgId); - if (imagery == null || imagery.providers == null) { + if (imagery?.providers == null) { return createLicensorAttribution(); } diff --git a/packages/landing/src/attribution.ts b/packages/landing/src/attribution.ts index 4e0c55dcf..2c132f4df 100644 --- a/packages/landing/src/attribution.ts +++ b/packages/landing/src/attribution.ts @@ -167,7 +167,7 @@ export class MapAttribution implements maplibre.IControl { Config.map.emit('visibleLayers', filteredLayerIds); const attributionHTML = attr.renderLicense(filtered); - this.setAttribution(attributionHTML ?? ''); + this.setAttribution(attributionHTML); }; setAttribution(text: string): void {