Skip to content

Commit

Permalink
feat(lambda-tiler): update imagery layer attributions to show licenso…
Browse files Browse the repository at this point in the history
…r 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",
            ...
        }
    },
    ...
}
```
  • Loading branch information
tawera-manaena authored Oct 24, 2024
1 parent 4a684f2 commit e702c7e
Show file tree
Hide file tree
Showing 10 changed files with 497 additions and 31 deletions.
65 changes: 65 additions & 0 deletions packages/attribution/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
70 changes: 59 additions & 11 deletions packages/attribution/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
25 changes: 25 additions & 0 deletions packages/attribution/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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(', ')}`;
}
1 change: 1 addition & 0 deletions packages/config-loader/src/json/tiff.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
33 changes: 33 additions & 0 deletions packages/config/src/config/imagery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
/**
Expand Down Expand Up @@ -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<typeof ConfigImageryParser>;
24 changes: 23 additions & 1 deletion packages/geo/src/stac/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit e702c7e

Please sign in to comment.