From e702c7e53d28aaa8db9d624acab048f8ec3a2309 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Fri, 25 Oct 2024 09:49:55 +1300 Subject: [PATCH] feat(lambda-tiler): update imagery layer attributions to show licensor details BM-897 (#3357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation As an Imagery Data Maintainer, I want to ensure that imagery is attributed to the correct licensor(s) for all published surveys so that councils and government departments receive appropriate recognition. --- ### Attribution Types || Compact | Extended | | - | - | - | | Template | © `stac_license` `licensor_names` | © `stac_license` `licensor_names` - `tileset_info` | | Example | © CC BY 4.0 Otago Regional Council | © CC BY 4.0 Otago Regional Council - Otago 0.3 Rural Aerial Photos (2017-2019) | --- ### Modifications #### packages/config-loader - Updated the package so that it copies through `providers` metadata when generating config files. #### packages/lambda-tiler - Updated the attribution endpoint to include `providers` metadata as part of collections when returning `AttributionStac` responses. - Updated the style endpoint to include a _compact attribution_ on sources when returning `StyleJson` responses. #### packages/attribution - Updated the attribution class to return an _extended attribution_ for the bottom-right of the landing page. ### Verification #### packages/lambda-tiler 1. Implemented a test suite for the style endpoint to ensure it generates the correct _compact attribution_ for a given tileset. #### packages/attribution 5. Implemented a test suite to verify that the new utility function `createLicensorAttribution()` generates the correct _compact attribution_ for a given list of providers. --- ### Example Layer: Top of the South 0.15m Flood Aerial Photos (2022) > To recreate this example, you will need to locally download the `collection.json` file and at least one of the .TIFF files. You will then need to run them through the `cogify` process and serve them using the `server` package. #### Landing Page Screenshot showing the _extended attribution_ for the bottom-right of the landing page. ![top-of-the-south-flood-2022-0 15m](https://github.com/user-attachments/assets/d90bb27c-0b66-41c1-91b8-402a5e10e2bc) #### Styles Endpoint `/v1/styles/:styleName.json` Excerpt from the JSON response showing the provider metadata: ```json { ... "collections": [ { ... "providers": [ { "name": "Nelson City Council", "roles": [ "licensor" ] }, { "name": "Tasman District Council", "roles": [ "licensor" ] }, { "name": "Waka Kotahi", "roles": [ "licensor" ] }, ... ], ... } ], ... } ``` #### Attribution Endpoint `/v1/tiles/:tileSet/:tileMatrix/attribution.json` Excerpt from the JSON response showing the _compact attribution_ for the layer source: ```json { ... "sources": { "basemaps-top-of-the-south-flood-2022-0.15m": { ... "attribution": "© CC BY 4.0 Nelson City Council, Tasman District Council, Waka Kotahi", ... } }, ... } ``` --- .../attribution/src/__tests__/utils.test.ts | 65 +++++ packages/attribution/src/attribution.ts | 70 +++++- packages/attribution/src/utils.ts | 25 ++ .../config-loader/src/json/tiff.config.ts | 1 + packages/config/src/config/imagery.ts | 33 +++ packages/geo/src/stac/index.ts | 24 +- .../tile.style.json.attribution.test.ts | 224 ++++++++++++++++++ .../lambda-tiler/src/routes/attribution.ts | 55 ++++- .../src/routes/tile.style.json.ts | 19 +- packages/landing/src/attribution.ts | 12 +- 10 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 packages/attribution/src/__tests__/utils.test.ts create mode 100644 packages/attribution/src/utils.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/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); };