Skip to content

Commit e702c7e

Browse files
feat(lambda-tiler): update imagery layer attributions to show licensor details BM-897 (#3357)
### 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", ... } }, ... } ```
1 parent 4a684f2 commit e702c7e

File tree

10 files changed

+497
-31
lines changed

10 files changed

+497
-31
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { strictEqual } from 'node:assert';
2+
import { describe, it } from 'node:test';
3+
4+
import { StacProvider } from '@basemaps/geo';
5+
6+
import { copyright, createLicensorAttribution } from '../utils.js';
7+
8+
const defaultAttribution = `${copyright} LINZ`;
9+
10+
describe('utils', () => {
11+
const FakeHost: StacProvider = {
12+
name: 'FakeHost',
13+
roles: ['host'],
14+
};
15+
const FakeLicensor1: StacProvider = {
16+
name: 'FakeLicensor1',
17+
roles: ['licensor'],
18+
};
19+
const FakeLicensor2: StacProvider = {
20+
name: 'FakeLicensor2',
21+
roles: ['licensor'],
22+
};
23+
24+
it('default attribution: no providers', () => {
25+
const providers = undefined;
26+
const attribution = createLicensorAttribution(providers);
27+
28+
strictEqual(attribution, defaultAttribution);
29+
});
30+
31+
it('default attribution: empty providers', () => {
32+
const providers: StacProvider[] = [];
33+
const attribution = createLicensorAttribution(providers);
34+
35+
strictEqual(attribution, defaultAttribution);
36+
});
37+
38+
it('default attribution: one provider, no licensors', () => {
39+
const providers = [FakeHost];
40+
41+
const attribution = createLicensorAttribution(providers);
42+
strictEqual(attribution, defaultAttribution);
43+
});
44+
45+
it('custom attribution: one provider, one licensor', () => {
46+
const providers = [FakeLicensor1];
47+
48+
const attribution = createLicensorAttribution(providers);
49+
strictEqual(attribution, `${copyright} ${FakeLicensor1.name}`);
50+
});
51+
52+
it('custom attribution: two providers, one licensor', () => {
53+
const providers = [FakeHost, FakeLicensor1];
54+
55+
const attribution = createLicensorAttribution(providers);
56+
strictEqual(attribution, `${copyright} ${FakeLicensor1.name}`);
57+
});
58+
59+
it('custom attribution: two providers, two licensors', () => {
60+
const providers = [FakeLicensor1, FakeLicensor2];
61+
62+
const attribution = createLicensorAttribution(providers);
63+
strictEqual(attribution, `${copyright} ${FakeLicensor1.name}, ${FakeLicensor2.name}`);
64+
});
65+
});

packages/attribution/src/attribution.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { AttributionCollection, AttributionStac } from '@basemaps/geo';
22
import { BBox, intersection, MultiPolygon, Ring, Wgs84 } from '@linzjs/geojson';
33

4+
import { createLicensorAttribution } from './utils.js';
5+
46
export interface AttributionFilter {
57
extent: BBox;
68
zoom: number;
@@ -181,20 +183,66 @@ export class Attribution {
181183
isIgnored?: (attr: AttributionBounds) => boolean;
182184

183185
/**
184-
* Render the filtered attributions as a simple string suitable to display as attribution
186+
* Parse the filtered list of attributions into a formatted string comprising license information.
187+
*
188+
* @param filtered The filtered list of attributions.
189+
*
190+
* @returns A formatted license string.
191+
*
192+
* @example
193+
* if (filtered[0] contains no providers or licensors):
194+
* return "CC BY 4.0 LINZ - Otago 0.3 Rural Aerial Photos (2017-2019)"
195+
*
196+
* @example
197+
* if (filtered[0] contains licensors):
198+
* return "CC BY 4.0 Otago Regional Council - Otago 0.3 Rural Aerial Photos (2017-2019)"
199+
*/
200+
renderLicense(filtered: AttributionBounds[]): string {
201+
const providers = filtered[0]?.collection.providers;
202+
const attribution = createLicensorAttribution(providers);
203+
const list = this.renderList(filtered);
204+
205+
if (list.length > 0) {
206+
return `${attribution} - ${list}`;
207+
} else {
208+
return attribution;
209+
}
210+
}
211+
212+
/**
213+
* Render the filtered attributions as a simple string suitable to display as attribution.
214+
*
215+
* @param filtered The filtered list of attributions.
216+
*
217+
* @returns {string} An empty string, if the filtered list is empty.
218+
* Otherwise, a formatted string comprising attribution details.
219+
*
220+
* @example
221+
* if (filtered.length === 0):
222+
* return ""
223+
*
224+
* @example
225+
* if (filtered.length === 1):
226+
* return "Ashburton 0.1m Urban Aerial Photos (2023)"
227+
*
228+
* @example
229+
* if (filtered.length === 2):
230+
* return "Wellington 0.3m Rural Aerial Photos (2021) & New Zealand 10m Satellite Imagery (2023-2024)"
185231
*
186-
* @param list the filtered list of attributions
232+
* @example
233+
* if (filtered.length > 2):
234+
* return "Canterbury 0.2 Rural Aerial Photos (2020-2021) & others 2012-2024"
187235
*/
188-
renderList(list: AttributionBounds[]): string {
189-
if (list.length === 0) return '';
190-
let result = escapeHtml(list[0].collection.title);
191-
if (list.length > 1) {
192-
if (list.length === 2) {
193-
result += ` & ${escapeHtml(list[1].collection.title)}`;
236+
renderList(filtered: AttributionBounds[]): string {
237+
if (filtered.length === 0) return '';
238+
let result = escapeHtml(filtered[0].collection.title);
239+
if (filtered.length > 1) {
240+
if (filtered.length === 2) {
241+
result += ` & ${escapeHtml(filtered[1].collection.title)}`;
194242
} else {
195-
let [minYear, maxYear] = getYears(list[1].collection);
196-
for (let i = 1; i < list.length; ++i) {
197-
const [a, b] = getYears(list[i].collection);
243+
let [minYear, maxYear] = getYears(filtered[1].collection);
244+
for (let i = 1; i < filtered.length; ++i) {
245+
const [a, b] = getYears(filtered[i].collection);
198246
if (a !== -1 && (minYear === -1 || a < minYear)) minYear = a;
199247
if (b !== -1 && (maxYear === -1 || b > maxYear)) maxYear = b;
200248
}

packages/attribution/src/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Stac, StacProvider } from '@basemaps/geo';
2+
3+
export const copyright = ${Stac.License}`;
4+
5+
/**
6+
* Create a licensor attribution string.
7+
*
8+
* @param providers The optional list of providers.
9+
*
10+
* @returns A copyright string comprising the names of licensor providers.
11+
*
12+
* @example
13+
* "CC BY 4.0 LINZ"
14+
*
15+
* @example
16+
* "CC BY 4.0 Nelson City Council, Tasman District Council, Waka Kotahi"
17+
*/
18+
export function createLicensorAttribution(providers?: StacProvider[]): string {
19+
if (providers == null) return `${copyright} LINZ`;
20+
21+
const licensors = providers.filter((p) => p.roles?.includes('licensor'));
22+
if (licensors.length === 0) return `${copyright} LINZ`;
23+
24+
return `${copyright} ${licensors.map((l) => l.name).join(', ')}`;
25+
}

packages/config-loader/src/json/tiff.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ export async function initImageryFromTiffUrl(
384384
noData: params.noData,
385385
files: params.files,
386386
collection: stac ?? undefined,
387+
providers: stac?.providers,
387388
};
388389
imagery.overviews = await ConfigJson.findImageryOverviews(imagery);
389390
log?.info({ title, imageryName, files: imagery.files.length }, 'Tiff:Loaded');

packages/config/src/config/imagery.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ export const ConfigImageryOverviewParser = z
4646
})
4747
.refine((obj) => obj.minZoom < obj.maxZoom);
4848

49+
/**
50+
* Provides information about a provider.
51+
*
52+
* @link https://github.com/radiantearth/stac-spec/blob/master/commons/common-metadata.md#provider
53+
*/
54+
export const ProvidersParser = z.object({
55+
/**
56+
* The name of the organization or the individual.
57+
*/
58+
name: z.string(),
59+
60+
/**
61+
* Multi-line description to add further provider information such as processing details
62+
* for processors and producers, hosting details for hosts or basic contact information.
63+
*/
64+
description: z.string().optional(),
65+
66+
/**
67+
* Roles of the provider. Any of `licensor`, `producer`, `processor` or `host`.
68+
*/
69+
roles: z.array(z.string()).optional(),
70+
71+
/**
72+
* Homepage on which the provider describes the dataset and publishes contact information.
73+
*/
74+
url: z.string().optional(),
75+
});
76+
4977
export const BoundingBoxParser = z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() });
5078
export const NamedBoundsParser = z.object({
5179
/**
@@ -140,6 +168,11 @@ export const ConfigImageryParser = ConfigBase.extend({
140168
* Separate overview cache
141169
*/
142170
overviews: ConfigImageryOverviewParser.optional(),
171+
172+
/**
173+
* list of providers and their metadata
174+
*/
175+
providers: z.array(ProvidersParser).optional(),
143176
});
144177

145178
export type ConfigImagery = z.infer<typeof ConfigImageryParser>;

packages/geo/src/stac/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,31 @@ export interface StacAsset {
2121
description?: string;
2222
}
2323

24+
/**
25+
* Provides information about a provider.
26+
*
27+
* @link https://github.com/radiantearth/stac-spec/blob/master/commons/common-metadata.md#provider
28+
*/
2429
export interface StacProvider {
30+
/**
31+
* The name of the organization or the individual.
32+
*/
2533
name: string;
26-
roles: string[];
34+
35+
/**
36+
* Multi-line description to add further provider information such as processing details
37+
* for processors and producers, hosting details for hosts or basic contact information.
38+
*/
39+
description?: string;
40+
41+
/**
42+
* Roles of the provider. Any of `licensor`, `producer`, `processor` or `host`.
43+
*/
44+
roles?: string[];
45+
46+
/**
47+
* Homepage on which the provider describes the dataset and publishes contact information.
48+
*/
2749
url?: string;
2850
}
2951

0 commit comments

Comments
 (0)