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/attribution/src/attribution.ts b/packages/attribution/src/attribution.ts index 2b51119d9..9d5a5b52c 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.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 > 0) { + 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.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/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..93cbbc088 100644 --- a/packages/config/src/config/imagery.ts +++ b/packages/config/src/config/imagery.ts @@ -46,6 +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({ /** @@ -140,6 +168,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/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/__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..d67420fea --- /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.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])); + }); +}); diff --git a/packages/lambda-tiler/src/routes/attribution.ts b/packages/lambda-tiler/src/routes/attribution.ts index cd866bcd9..83c565eb0 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,11 +141,12 @@ 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, id: im.id, - providers: getHost(host), + providers: im.providers ?? getHost(host), title, description: 'No description', extent, @@ -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 == null) return ''; + + // attempt to load the imagery + const imagery = await provider.Imagery.get(imgId); + if (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 f7b302eef..efa7c7044 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -19,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 @@ -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,21 @@ export function tileSetToStyle( (Env.get(Env.PublicUrlBase) ?? '') + `/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${tileFormat}${query}`; + const attribution = await createTileSetAttribution(config, tileSet, tileMatrix.projection); + 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, + }, + }, layers: [{ id: styleId, type: 'raster', source: styleId }], }; } @@ -248,7 +259,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..2c132f4df 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} LINZ`; - export class MapAttributionState { /** Cache the loading of attribution */ attrs: Map> = new Map(); @@ -168,13 +166,7 @@ export class MapAttribution implements maplibre.IControl { const filteredLayerIds = filtered.map((x) => x.collection.id).join('_'); Config.map.emit('visibleLayers', filteredLayerIds); - let attributionHTML = attr.renderList(filtered); - if (attributionHTML === '') { - attributionHTML = Copyright; - } else { - attributionHTML = Copyright + ' - ' + attributionHTML; - } - + const attributionHTML = attr.renderLicense(filtered); this.setAttribution(attributionHTML); };