@basemaps/lambda-tiler 6.38.0 → 6.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/build/__tests__/config.data.d.ts.map +1 -1
- package/build/__tests__/config.data.js +142 -1
- package/build/__tests__/config.data.js.map +1 -1
- package/build/__tests__/wmts.capability.test.js +9 -16
- package/build/__tests__/wmts.capability.test.js.map +1 -1
- package/build/__tests__/xyz.util.d.ts.map +1 -1
- package/build/__tests__/xyz.util.js +6 -2
- package/build/__tests__/xyz.util.js.map +1 -1
- package/build/cli/render.tile.d.ts +2 -0
- package/build/cli/render.tile.d.ts.map +1 -0
- package/build/cli/render.tile.js +33 -0
- package/build/cli/render.tile.js.map +1 -0
- package/build/routes/__tests__/attribution.test.js +63 -3
- package/build/routes/__tests__/attribution.test.js.map +1 -1
- package/build/routes/__tests__/tile.style.json.test.js +91 -43
- package/build/routes/__tests__/tile.style.json.test.js.map +1 -1
- package/build/routes/__tests__/wmts.test.js +50 -8
- package/build/routes/__tests__/wmts.test.js.map +1 -1
- package/build/routes/attribution.d.ts +12 -0
- package/build/routes/attribution.d.ts.map +1 -1
- package/build/routes/attribution.js +29 -27
- package/build/routes/attribution.js.map +1 -1
- package/build/routes/tile.json.d.ts.map +1 -1
- package/build/routes/tile.json.js +2 -1
- package/build/routes/tile.json.js.map +1 -1
- package/build/routes/tile.style.json.d.ts +2 -2
- package/build/routes/tile.style.json.d.ts.map +1 -1
- package/build/routes/tile.style.json.js +7 -4
- package/build/routes/tile.style.json.js.map +1 -1
- package/build/routes/tile.wmts.d.ts.map +1 -1
- package/build/routes/tile.wmts.js +3 -1
- package/build/routes/tile.wmts.js.map +1 -1
- package/build/routes/tile.xyz.raster.d.ts.map +1 -1
- package/build/routes/tile.xyz.raster.js +9 -6
- package/build/routes/tile.xyz.raster.js.map +1 -1
- package/build/util/__test__/filter.test.d.ts +2 -0
- package/build/util/__test__/filter.test.d.ts.map +1 -0
- package/build/util/__test__/filter.test.js +64 -0
- package/build/util/__test__/filter.test.js.map +1 -0
- package/build/util/config.loader.d.ts.map +1 -1
- package/build/util/config.loader.js +2 -3
- package/build/util/config.loader.js.map +1 -1
- package/build/util/filter.d.ts +15 -0
- package/build/util/filter.d.ts.map +1 -0
- package/build/util/filter.js +59 -0
- package/build/util/filter.js.map +1 -0
- package/build/wmts.capability.d.ts +8 -5
- package/build/wmts.capability.d.ts.map +1 -1
- package/build/wmts.capability.js +17 -15
- package/build/wmts.capability.js.map +1 -1
- package/dist/index.js +60 -60
- package/dist/node_modules/.package-lock.json +14 -14
- package/dist/node_modules/minimist/.eslintrc +25 -50
- package/dist/node_modules/minimist/CHANGELOG.md +87 -1
- package/dist/node_modules/minimist/README.md +14 -10
- package/dist/node_modules/minimist/example/parse.js +2 -0
- package/dist/node_modules/minimist/index.js +256 -242
- package/dist/node_modules/minimist/package.json +73 -73
- package/dist/node_modules/minimist/test/all_bool.js +26 -24
- package/dist/node_modules/minimist/test/bool.js +146 -147
- package/dist/node_modules/minimist/test/dash.js +33 -21
- package/dist/node_modules/minimist/test/default_bool.js +26 -24
- package/dist/node_modules/minimist/test/dotted.js +13 -11
- package/dist/node_modules/minimist/test/kv_short.js +26 -10
- package/dist/node_modules/minimist/test/long.js +28 -26
- package/dist/node_modules/minimist/test/num.js +30 -28
- package/dist/node_modules/minimist/test/parse.js +169 -157
- package/dist/node_modules/minimist/test/parse_modified.js +7 -5
- package/dist/node_modules/minimist/test/proto.js +41 -37
- package/dist/node_modules/minimist/test/short.js +57 -55
- package/dist/node_modules/minimist/test/stop_early.js +10 -8
- package/dist/node_modules/minimist/test/unknown.js +83 -81
- package/dist/node_modules/minimist/test/whitespace.js +6 -4
- package/dist/node_modules/node-abi/.circleci/config.yml +7 -25
- package/dist/node_modules/node-abi/.github/CODEOWNERS +1 -0
- package/dist/node_modules/node-abi/.github/workflows/update-abi.yml +5 -4
- package/dist/node_modules/node-abi/CONTRIBUTING.md +1 -1
- package/dist/node_modules/node-abi/README.md +5 -3
- package/dist/node_modules/node-abi/abi_registry.json +14 -0
- package/dist/node_modules/node-abi/package.json +5 -6
- package/dist/node_modules/node-addon-api/README.md +36 -12
- package/dist/node_modules/node-addon-api/index.js +3 -3
- package/dist/node_modules/node-addon-api/napi-inl.deprecated.h +121 -127
- package/dist/node_modules/node-addon-api/napi-inl.h +1166 -1122
- package/dist/node_modules/node-addon-api/napi.h +2786 -2675
- package/dist/node_modules/node-addon-api/package.json +42 -1
- package/dist/node_modules/node-addon-api/tools/check-napi.js +13 -14
- package/dist/node_modules/node-addon-api/tools/conversion.js +161 -169
- package/dist/node_modules/node-addon-api/tools/eslint-format.js +9 -1
- package/dist/node_modules/readable-stream/README.md +1 -1
- package/dist/node_modules/readable-stream/lib/_stream_duplex.js +12 -25
- package/dist/node_modules/readable-stream/lib/_stream_passthrough.js +2 -4
- package/dist/node_modules/readable-stream/lib/_stream_readable.js +176 -273
- package/dist/node_modules/readable-stream/lib/_stream_transform.js +26 -37
- package/dist/node_modules/readable-stream/lib/_stream_writable.js +118 -174
- package/dist/node_modules/readable-stream/lib/internal/streams/async_iterator.js +10 -37
- package/dist/node_modules/readable-stream/lib/internal/streams/buffer_list.js +20 -47
- package/dist/node_modules/readable-stream/lib/internal/streams/destroy.js +8 -17
- package/dist/node_modules/readable-stream/lib/internal/streams/end-of-stream.js +1 -19
- package/dist/node_modules/readable-stream/lib/internal/streams/from.js +12 -24
- package/dist/node_modules/readable-stream/lib/internal/streams/pipeline.js +5 -16
- package/dist/node_modules/readable-stream/lib/internal/streams/state.js +2 -7
- package/dist/node_modules/readable-stream/package.json +1 -1
- package/dist/package-lock.json +15 -346
- package/dist/package.json +1 -1
- package/package.json +8 -8
- package/src/__tests__/config.data.ts +142 -1
- package/src/__tests__/wmts.capability.test.ts +9 -16
- package/src/__tests__/xyz.util.ts +6 -2
- package/src/cli/render.tile.ts +38 -0
- package/src/routes/__tests__/attribution.test.ts +65 -3
- package/src/routes/__tests__/tile.style.json.test.ts +100 -44
- package/src/routes/__tests__/wmts.test.ts +70 -9
- package/src/routes/attribution.ts +24 -28
- package/src/routes/tile.json.ts +2 -1
- package/src/routes/tile.style.json.ts +13 -5
- package/src/routes/tile.wmts.ts +3 -1
- package/src/routes/tile.xyz.raster.ts +10 -7
- package/src/util/__test__/filter.test.ts +80 -0
- package/src/util/config.loader.ts +1 -2
- package/src/util/filter.ts +60 -0
- package/src/wmts.capability.ts +19 -14
- package/tsconfig.tsbuildinfo +1 -1
- package/test-dump.js +0 -6
- package/test-imagery.js +0 -3
|
@@ -9,18 +9,11 @@ import { Api, mockUrlRequest } from '../../__tests__/xyz.util.js';
|
|
|
9
9
|
o.spec('WMTSRouting', () => {
|
|
10
10
|
const sandbox = createSandbox();
|
|
11
11
|
const config = new ConfigProviderMemory();
|
|
12
|
+
const imagery = new Map();
|
|
12
13
|
|
|
13
|
-
o.
|
|
14
|
+
o.beforeEach(() => {
|
|
14
15
|
sandbox.stub(ConfigLoader, 'load').resolves(config);
|
|
15
|
-
});
|
|
16
16
|
|
|
17
|
-
o.afterEach(() => {
|
|
18
|
-
config.objects.clear();
|
|
19
|
-
sandbox.restore();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
o('should default to the aerial layer', async () => {
|
|
23
|
-
const imagery = new Map();
|
|
24
17
|
imagery.set(Imagery3857.id, Imagery3857);
|
|
25
18
|
imagery.set(Imagery2193.id, Imagery2193);
|
|
26
19
|
|
|
@@ -28,7 +21,14 @@ o.spec('WMTSRouting', () => {
|
|
|
28
21
|
config.put(Imagery2193);
|
|
29
22
|
config.put(Imagery3857);
|
|
30
23
|
config.put(Provider);
|
|
24
|
+
});
|
|
31
25
|
|
|
26
|
+
o.afterEach(() => {
|
|
27
|
+
config.objects.clear();
|
|
28
|
+
sandbox.restore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
o('should default to the aerial layer', async () => {
|
|
32
32
|
const req = mockUrlRequest(
|
|
33
33
|
'/v1/tiles/WMTSCapabilities.xml',
|
|
34
34
|
`format=png&api=${Api.key}&config=s3://linz-basemaps/config.json`,
|
|
@@ -53,4 +53,65 @@ o.spec('WMTSRouting', () => {
|
|
|
53
53
|
'<ResourceURL format="image/png" resourceType="tile" template="https://tiles.test/v1/tiles/ōtorohanga-urban-2021-0.1m/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png?api=d01f7w7rnhdzg0p7fyrc9v9ard1&config=Q5pC4UjWdtFLU1CYtLcRSmB49RekgDgMa5EGJnB2M" />',
|
|
54
54
|
]);
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
o('should filter out date[after] by year', async () => {
|
|
58
|
+
const req = mockUrlRequest(
|
|
59
|
+
'/v1/tiles/WMTSCapabilities.xml',
|
|
60
|
+
`format=png&api=${Api.key}&config=s3://linz-basemaps/config.json&date[after]=2022`,
|
|
61
|
+
);
|
|
62
|
+
const res = await handler.router.handle(req);
|
|
63
|
+
|
|
64
|
+
o(res.status).equals(200);
|
|
65
|
+
const lines = Buffer.from(res.body, 'base64').toString().split('\n');
|
|
66
|
+
const titles = lines.filter((f) => f.startsWith(' <ows:Title>')).map((f) => f.trim());
|
|
67
|
+
|
|
68
|
+
o(titles).deepEquals([
|
|
69
|
+
'<ows:Title>Aerial Imagery</ows:Title>',
|
|
70
|
+
'<ows:Title>Google Maps Compatible for the World</ows:Title>',
|
|
71
|
+
'<ows:Title>LINZ NZTM2000 Map Tile Grid V2</ows:Title>',
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
o('should filter out date[before] by year', async () => {
|
|
76
|
+
const req = mockUrlRequest(
|
|
77
|
+
'/v1/tiles/WMTSCapabilities.xml',
|
|
78
|
+
`format=png&api=${Api.key}&config=s3://linz-basemaps/config.json&date[before]=2020`,
|
|
79
|
+
);
|
|
80
|
+
const res = await handler.router.handle(req);
|
|
81
|
+
|
|
82
|
+
o(res.status).equals(200);
|
|
83
|
+
const lines = Buffer.from(res.body, 'base64').toString().split('\n');
|
|
84
|
+
const titles = lines.filter((f) => f.startsWith(' <ows:Title>')).map((f) => f.trim());
|
|
85
|
+
|
|
86
|
+
o(titles).deepEquals([
|
|
87
|
+
'<ows:Title>Aerial Imagery</ows:Title>',
|
|
88
|
+
'<ows:Title>Google Maps Compatible for the World</ows:Title>',
|
|
89
|
+
'<ows:Title>LINZ NZTM2000 Map Tile Grid V2</ows:Title>',
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
o('should filter inclusive date[before] by year', async () => {
|
|
94
|
+
const req = mockUrlRequest(
|
|
95
|
+
'/v1/tiles/WMTSCapabilities.xml',
|
|
96
|
+
`format=png&api=${Api.key}&config=s3://linz-basemaps/config.json&date[before]=2021`,
|
|
97
|
+
);
|
|
98
|
+
const res = await handler.router.handle(req);
|
|
99
|
+
|
|
100
|
+
o(res.status).equals(200);
|
|
101
|
+
const lines = Buffer.from(res.body, 'base64').toString().split('\n');
|
|
102
|
+
const titles = lines.filter((f) => f.startsWith(' <ows:Title>')).map((f) => f.trim());
|
|
103
|
+
|
|
104
|
+
o(titles).deepEquals([
|
|
105
|
+
'<ows:Title>Aerial Imagery</ows:Title>',
|
|
106
|
+
'<ows:Title>Ōtorohanga 0.1m Urban Aerial Photos (2021)</ows:Title>',
|
|
107
|
+
'<ows:Title>Google Maps Compatible for the World</ows:Title>',
|
|
108
|
+
'<ows:Title>LINZ NZTM2000 Map Tile Grid V2</ows:Title>',
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const resourceURLs = lines.filter((f) => f.includes('<ResourceURL')).map((f) => f.trim());
|
|
112
|
+
o(resourceURLs).deepEquals([
|
|
113
|
+
'<ResourceURL format="image/png" resourceType="tile" template="https://tiles.test/v1/tiles/aerial/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png?api=d01f7w7rnhdzg0p7fyrc9v9ard1&config=Q5pC4UjWdtFLU1CYtLcRSmB49RekgDgMa5EGJnB2M&date%5Bbefore%5D=2021" />',
|
|
114
|
+
'<ResourceURL format="image/png" resourceType="tile" template="https://tiles.test/v1/tiles/ōtorohanga-urban-2021-0.1m/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png?api=d01f7w7rnhdzg0p7fyrc9v9ard1&config=Q5pC4UjWdtFLU1CYtLcRSmB49RekgDgMa5EGJnB2M" />',
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
56
117
|
});
|
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
StacProvider,
|
|
12
12
|
TileMatrixSet,
|
|
13
13
|
} from '@basemaps/geo';
|
|
14
|
-
import { extractYearRangeFromName,
|
|
14
|
+
import { extractYearRangeFromName, extractYearRangeFromTitle, Projection } from '@basemaps/shared';
|
|
15
15
|
import { BBox, MultiPolygon, multiPolygonToWgs84, Pair, union, Wgs84 } from '@linzjs/geojson';
|
|
16
16
|
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
|
|
17
17
|
import { ConfigLoader } from '../util/config.loader.js';
|
|
18
18
|
|
|
19
19
|
import { Etag } from '../util/etag.js';
|
|
20
|
+
import { filterLayers, yearRangeToInterval } from '../util/filter.js';
|
|
20
21
|
import { NotFound, NotModified } from '../util/response.js';
|
|
21
22
|
import { Validate } from '../util/validate.js';
|
|
22
23
|
|
|
@@ -44,23 +45,19 @@ function roundPair(p: Pair): Pair {
|
|
|
44
45
|
* @param files in target projection
|
|
45
46
|
* @return MultiPolygon in WGS84
|
|
46
47
|
*/
|
|
47
|
-
function createCoordinates(bbox: BBox, files: NamedBounds[], proj: Projection): MultiPolygon {
|
|
48
|
+
export function createCoordinates(bbox: BBox, files: NamedBounds[], proj: Projection): MultiPolygon {
|
|
48
49
|
if (Wgs84.delta(bbox[0], bbox[2]) <= 0) {
|
|
49
50
|
// This bounds spans more than half the globe which multiPolygonToWgs84 can't handle; just
|
|
50
51
|
// return bbox as polygon
|
|
51
52
|
return Wgs84.bboxToMultiPolygon(bbox);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
const polygons: MultiPolygon = [];
|
|
56
56
|
// merge imagery bounds
|
|
57
|
-
for (const image of files)
|
|
58
|
-
|
|
59
|
-
coordinates = union(coordinates, poly);
|
|
60
|
-
}
|
|
57
|
+
for (const image of files) polygons.push(Bounds.fromJson(image).pad(SmoothPadding).toPolygon());
|
|
58
|
+
const coordinates = union(polygons);
|
|
61
59
|
|
|
62
60
|
const roundToWgs84 = (p: number[]): number[] => roundPair(proj.toWgs84(p) as Pair);
|
|
63
|
-
|
|
64
61
|
return multiPolygonToWgs84(coordinates, roundToWgs84);
|
|
65
62
|
}
|
|
66
63
|
|
|
@@ -86,30 +83,23 @@ async function tileSetAttribution(
|
|
|
86
83
|
|
|
87
84
|
const config = await ConfigLoader.load(req);
|
|
88
85
|
const imagery = await getAllImagery(config, tileSet.layers, [tileMatrix.projection]);
|
|
86
|
+
const filteredLayers = filterLayers(req, tileSet.layers);
|
|
89
87
|
|
|
90
88
|
const host = await config.Provider.get(config.Provider.id('linz'));
|
|
91
89
|
|
|
92
|
-
for (const layer of
|
|
90
|
+
for (const layer of filteredLayers) {
|
|
91
|
+
if (layer.disabled) continue;
|
|
93
92
|
const imgId = layer[proj.epsg.code];
|
|
94
93
|
if (imgId == null) continue;
|
|
95
94
|
const im = imagery.get(imgId);
|
|
96
95
|
if (im == null) continue;
|
|
96
|
+
const title = im.title;
|
|
97
97
|
|
|
98
98
|
const bbox = proj.boundsToWgs84BoundingBox(im.bounds).map(roundNumber) as BBox;
|
|
99
99
|
|
|
100
|
-
const
|
|
101
|
-
if (years[0] === -1) {
|
|
102
|
-
req.log.debug({ imagery: im.name }, 'Attribution:DefaultYear');
|
|
103
|
-
// Put it in the future so people know its a "fake" date
|
|
104
|
-
years[0] = new Date().getUTCFullYear() + 1;
|
|
105
|
-
years[1] = years[0] + 1;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const interval = [years.map((y) => `${y}-01-01T00:00:00Z`) as [string, string]];
|
|
109
|
-
|
|
110
|
-
const extent: StacExtent = { spatial: { bbox: [bbox] }, temporal: { interval } };
|
|
100
|
+
const extent: StacExtent = { spatial: { bbox: [bbox] } };
|
|
111
101
|
|
|
112
|
-
|
|
102
|
+
const item: AttributionItem = {
|
|
113
103
|
type: 'Feature',
|
|
114
104
|
stac_version: Stac.Version,
|
|
115
105
|
id: imgId + '_item',
|
|
@@ -119,13 +109,19 @@ async function tileSetAttribution(
|
|
|
119
109
|
bbox,
|
|
120
110
|
geometry: { type: 'MultiPolygon', coordinates: createCoordinates(bbox, im.files, proj) },
|
|
121
111
|
properties: {
|
|
122
|
-
title
|
|
112
|
+
title,
|
|
123
113
|
category: im.category,
|
|
124
|
-
datetime: null,
|
|
125
|
-
start_datetime: interval[0][0],
|
|
126
|
-
end_datetime: interval[0][1],
|
|
127
114
|
},
|
|
128
|
-
}
|
|
115
|
+
};
|
|
116
|
+
const years = extractYearRangeFromTitle(im.title) ?? extractYearRangeFromName(im.name);
|
|
117
|
+
if (years) {
|
|
118
|
+
const interval = yearRangeToInterval(years);
|
|
119
|
+
extent.temporal = { interval: [[interval[0].toISOString(), interval[1].toISOString()]] };
|
|
120
|
+
item.properties.datetime = null;
|
|
121
|
+
item.properties.start_datetime = interval[0].toISOString();
|
|
122
|
+
item.properties.end_datetime = interval[1].toISOString();
|
|
123
|
+
}
|
|
124
|
+
items.push(item);
|
|
129
125
|
|
|
130
126
|
const zoomMin = TileMatrixSet.convertZoomLevel(layer.minZoom ? layer.minZoom : 0, GoogleTms, tileMatrix, true);
|
|
131
127
|
const zoomMax = TileMatrixSet.convertZoomLevel(layer.maxZoom ? layer.maxZoom : 32, GoogleTms, tileMatrix, true);
|
|
@@ -134,7 +130,7 @@ async function tileSetAttribution(
|
|
|
134
130
|
license: Stac.License,
|
|
135
131
|
id: im.id,
|
|
136
132
|
providers: getHost(host),
|
|
137
|
-
title
|
|
133
|
+
title,
|
|
138
134
|
description: 'No description',
|
|
139
135
|
extent,
|
|
140
136
|
links: [],
|
package/src/routes/tile.json.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { GoogleTms, TileJson, TileMatrixSet } from '@basemaps/geo';
|
|
|
2
2
|
import { Env, toQueryString } from '@basemaps/shared';
|
|
3
3
|
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
|
|
4
4
|
import { ConfigLoader } from '../util/config.loader.js';
|
|
5
|
+
import { getFilters } from '../util/filter.js';
|
|
5
6
|
import { NotFound } from '../util/response.js';
|
|
6
7
|
import { Validate } from '../util/validate.js';
|
|
7
8
|
|
|
@@ -31,7 +32,7 @@ export async function tileJsonGet(req: LambdaHttpRequest<TileJsonGet>): Promise<
|
|
|
31
32
|
|
|
32
33
|
const configLocation = ConfigLoader.extract(req);
|
|
33
34
|
|
|
34
|
-
const query = toQueryString({ api: apiKey, config: configLocation });
|
|
35
|
+
const query = toQueryString({ api: apiKey, config: configLocation, ...getFilters(req) });
|
|
35
36
|
|
|
36
37
|
const tileUrl =
|
|
37
38
|
[host, 'v1', 'tiles', tileSet.name, tileMatrix.identifier, '{z}', '{x}', '{y}'].join('/') + `.${format[0]}${query}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigTileSetRaster, Sources, StyleJson, TileSetType } from '@basemaps/config';
|
|
1
|
+
import { ConfigTileSetRaster, Layer, Sources, StyleJson, TileSetType } from '@basemaps/config';
|
|
2
2
|
import { Env, toQueryString } from '@basemaps/shared';
|
|
3
3
|
import { fsa } from '@chunkd/fs';
|
|
4
4
|
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
|
|
@@ -8,6 +8,7 @@ import { Validate } from '../util/validate.js';
|
|
|
8
8
|
import { Etag } from '../util/etag.js';
|
|
9
9
|
import { ConfigLoader } from '../util/config.loader.js';
|
|
10
10
|
import { GoogleTms, ImageFormat, TileMatrixSets } from '@basemaps/geo';
|
|
11
|
+
import { getFilters } from '../util/filter.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Convert relative URLS into a full hostname url
|
|
@@ -31,7 +32,7 @@ export function convertRelativeUrl(url?: string, apiKey?: string, config?: strin
|
|
|
31
32
|
* @param apiKey api key to inject
|
|
32
33
|
* @returns new stylejson
|
|
33
34
|
*/
|
|
34
|
-
export function convertStyleJson(style: StyleJson, apiKey: string, config: string | null): StyleJson {
|
|
35
|
+
export function convertStyleJson(style: StyleJson, apiKey: string, config: string | null, layers?: Layer[]): StyleJson {
|
|
35
36
|
const sources: Sources = JSON.parse(JSON.stringify(style.sources));
|
|
36
37
|
for (const [key, value] of Object.entries(sources)) {
|
|
37
38
|
if (value.type === 'vector') {
|
|
@@ -49,7 +50,7 @@ export function convertStyleJson(style: StyleJson, apiKey: string, config: strin
|
|
|
49
50
|
id: style.id,
|
|
50
51
|
name: style.name,
|
|
51
52
|
sources,
|
|
52
|
-
layers: style.layers,
|
|
53
|
+
layers: layers ? layers : style.layers,
|
|
53
54
|
metadata: style.metadata ?? {},
|
|
54
55
|
glyphs: convertRelativeUrl(style.glyphs, undefined, config),
|
|
55
56
|
sprite: convertRelativeUrl(style.sprite, undefined, config),
|
|
@@ -73,7 +74,7 @@ export async function tileSetToStyle(
|
|
|
73
74
|
if (tileFormat == null) return new LambdaHttpResponse(400, 'Invalid image format');
|
|
74
75
|
|
|
75
76
|
const configLocation = ConfigLoader.extract(req);
|
|
76
|
-
const query = toQueryString({ config: configLocation, api: apiKey });
|
|
77
|
+
const query = toQueryString({ config: configLocation, api: apiKey, ...getFilters(req) });
|
|
77
78
|
|
|
78
79
|
const tileUrl = fsa.join(
|
|
79
80
|
Env.get(Env.PublicUrlBase) ?? '',
|
|
@@ -101,6 +102,8 @@ export async function tileSetToStyle(
|
|
|
101
102
|
export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<LambdaHttpResponse> {
|
|
102
103
|
const apiKey = Validate.apiKey(req);
|
|
103
104
|
const styleName = req.params.styleName;
|
|
105
|
+
const excludeLayers = req.query.getAll('exclude');
|
|
106
|
+
const excluded = new Set(excludeLayers.map((l) => l.toLowerCase()));
|
|
104
107
|
|
|
105
108
|
// Get style Config from db
|
|
106
109
|
const config = await ConfigLoader.load(req);
|
|
@@ -115,7 +118,12 @@ export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<La
|
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
// Prepare sources and add linz source
|
|
118
|
-
const style = convertStyleJson(
|
|
121
|
+
const style = convertStyleJson(
|
|
122
|
+
styleConfig.style,
|
|
123
|
+
apiKey,
|
|
124
|
+
ConfigLoader.extract(req),
|
|
125
|
+
styleConfig.style.layers.filter((f) => !excluded.has(f.id.toLowerCase())),
|
|
126
|
+
);
|
|
119
127
|
const data = Buffer.from(JSON.stringify(style));
|
|
120
128
|
|
|
121
129
|
const cacheKey = Etag.key(data);
|
package/src/routes/tile.wmts.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { Validate } from '../util/validate.js';
|
|
|
8
8
|
import { WmtsCapabilities } from '../wmts.capability.js';
|
|
9
9
|
import { Etag } from '../util/etag.js';
|
|
10
10
|
import { ConfigLoader } from '../util/config.loader.js';
|
|
11
|
+
import { filterLayers, getFilters } from '../util/filter.js';
|
|
11
12
|
|
|
12
13
|
export interface WmtsCapabilitiesGet {
|
|
13
14
|
Params: {
|
|
@@ -59,11 +60,12 @@ export async function wmtsCapabilitiesGet(req: LambdaHttpRequest<WmtsCapabilitie
|
|
|
59
60
|
provider: provider ?? undefined,
|
|
60
61
|
tileSet,
|
|
61
62
|
tileMatrix,
|
|
62
|
-
isIndividualLayers: req.params.tileMatrix == null,
|
|
63
63
|
imagery,
|
|
64
64
|
apiKey,
|
|
65
65
|
config: ConfigLoader.extract(req),
|
|
66
66
|
formats: Validate.getRequestedFormats(req),
|
|
67
|
+
layers: req.params.tileMatrix == null ? filterLayers(req, tileSet.layers) : null,
|
|
68
|
+
filters: getFilters(req),
|
|
67
69
|
}).toXml();
|
|
68
70
|
if (xml == null) return NotFound();
|
|
69
71
|
|
|
@@ -9,6 +9,7 @@ import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambd
|
|
|
9
9
|
import pLimit from 'p-limit';
|
|
10
10
|
import { ConfigLoader } from '../util/config.loader.js';
|
|
11
11
|
import { Etag } from '../util/etag.js';
|
|
12
|
+
import { filterLayers } from '../util/filter.js';
|
|
12
13
|
import { NotFound, NotModified } from '../util/response.js';
|
|
13
14
|
import { CoSources } from '../util/source.cache.js';
|
|
14
15
|
import { TileXyz } from '../util/validate.js';
|
|
@@ -32,13 +33,15 @@ export const TileXyzRaster = {
|
|
|
32
33
|
async getAssetsForTile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise<string[]> {
|
|
33
34
|
const config = await ConfigLoader.load(req);
|
|
34
35
|
const imagery = await getAllImagery(config, tileSet.layers, [xyz.tileMatrix.projection]);
|
|
36
|
+
const filteredLayers = filterLayers(req, tileSet.layers);
|
|
35
37
|
|
|
36
38
|
const output: string[] = [];
|
|
37
39
|
const tileBounds = xyz.tileMatrix.tileToSourceBounds(xyz.tile);
|
|
38
40
|
|
|
39
41
|
// All zoom level config is stored as Google zoom levels
|
|
40
42
|
const filterZoom = TileMatrixSet.convertZoomLevel(xyz.tile.z, xyz.tileMatrix, TileMatrixSets.get(Epsg.Google));
|
|
41
|
-
for (const layer of
|
|
43
|
+
for (const layer of filteredLayers) {
|
|
44
|
+
if (layer.disabled) continue;
|
|
42
45
|
if (layer.maxZoom != null && filterZoom > layer.maxZoom) continue;
|
|
43
46
|
if (layer.minZoom != null && filterZoom < layer.minZoom) continue;
|
|
44
47
|
|
|
@@ -58,14 +61,14 @@ export const TileXyzRaster = {
|
|
|
58
61
|
}
|
|
59
62
|
if (!tileBounds.intersects(Bounds.fromJson(img.bounds))) continue;
|
|
60
63
|
|
|
61
|
-
// FIXME is this meant to be >= <=
|
|
62
|
-
if (img.overviews && img.overviews.maxZoom >= filterZoom && img.overviews.minZoom <= filterZoom) {
|
|
63
|
-
output.push(fsa.join(img.uri, img.overviews.path));
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
64
|
for (const c of img.files) {
|
|
68
65
|
if (!tileBounds.intersects(Bounds.fromJson(c))) continue;
|
|
66
|
+
|
|
67
|
+
if (img.overviews && img.overviews.maxZoom >= filterZoom && img.overviews.minZoom <= filterZoom) {
|
|
68
|
+
output.push(fsa.join(img.uri, img.overviews.path));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
69
72
|
const tiffPath = fsa.join(img.uri, getTiffName(c.name));
|
|
70
73
|
output.push(tiffPath);
|
|
71
74
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ConfigLayer } from '@basemaps/config';
|
|
2
|
+
import o from 'ospec';
|
|
3
|
+
import { mockUrlRequest } from '../../__tests__/xyz.util.js';
|
|
4
|
+
import { filterLayers } from '../filter.js';
|
|
5
|
+
|
|
6
|
+
o.spec('filterLayers', () => {
|
|
7
|
+
const sourceLayers: ConfigLayer[] = [
|
|
8
|
+
{
|
|
9
|
+
name: 'waikato-0_625m-snc12836-2004',
|
|
10
|
+
title: 'Waikato 0.625m SNC12836 (2004-2008)',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'hawkes-bay--manawat-whanganui-0_75m-snc30001-2002',
|
|
14
|
+
title: 'Hawkes Bay / Manawatū-Whanganui 0.75m SNC30001 (2002)',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'canterbury-0_75m-snc25054-2000-2001',
|
|
18
|
+
title: 'Canterbury 0.75m SNC25054 (2000-2001)',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'otago-0_375m-sn3806-1975',
|
|
22
|
+
title: 'Otago 0.375m SN3806 (1975)',
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
o('should not filter with empty parameters', () => {
|
|
27
|
+
const layers = filterLayers(mockUrlRequest('/foo/bar,js'), sourceLayers);
|
|
28
|
+
o(layers).deepEquals(sourceLayers);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
o('should filter date[after]', () => {
|
|
32
|
+
const dateAfter = '2003-12-31T23:59:59.999';
|
|
33
|
+
const layers = filterLayers(mockUrlRequest('/foo/bar,js', `?date[after]=${dateAfter}`), sourceLayers);
|
|
34
|
+
o(layers).deepEquals([sourceLayers[0]]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
o('should filter date[before]', () => {
|
|
38
|
+
const dateBefore = '2003-01-01T00:00:00.000Z';
|
|
39
|
+
const layers = filterLayers(mockUrlRequest('/foo/bar,js', `?date[before]=${dateBefore}`), sourceLayers);
|
|
40
|
+
o(layers).deepEquals(sourceLayers.slice(1));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
o('should filter date[before] in between years', () => {
|
|
44
|
+
const dateBefore = '2026-01-01T00:00:00.000Z';
|
|
45
|
+
const layer = [{ name: '', title: 'Waikato 0.625m SNC12836 (2020-2028)' }];
|
|
46
|
+
const layers = filterLayers(mockUrlRequest('/foo/bar,js', `?date[before]=${dateBefore}`), layer);
|
|
47
|
+
o(layers).deepEquals(layer);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
o('should filter date[after] in between years', () => {
|
|
51
|
+
const dateAfter = '2026-12-31T23:59:59.999Z';
|
|
52
|
+
const layer = [{ name: '', title: 'Waikato 0.625m SNC12836 (2020-2028)' }];
|
|
53
|
+
const layers = filterLayers(mockUrlRequest('/foo/bar,js', `?date[after]=${dateAfter}`), layer);
|
|
54
|
+
o(layers).deepEquals(layer);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
o('should filter date[after] and date[before] in between years', () => {
|
|
58
|
+
const dateAfter = '2026-12-31T23:59:59.999Z';
|
|
59
|
+
const dateBefore = '2028-01-01T00:00:00.000Z';
|
|
60
|
+
|
|
61
|
+
const layer = [{ name: '', title: 'Waikato 0.625m SNC12836 (2020-2028)' }];
|
|
62
|
+
const layers = filterLayers(
|
|
63
|
+
mockUrlRequest('/foo/bar,js', `?date[after]=${dateAfter}&date[before]=${dateBefore}`),
|
|
64
|
+
layer,
|
|
65
|
+
);
|
|
66
|
+
o(layers).deepEquals(layer);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
o('should filter date[after] and date[before] in between years with single year', () => {
|
|
70
|
+
const dateAfter = '2026-12-31T23:59:59.999Z';
|
|
71
|
+
const dateBefore = '2028-01-01T00:00:00.000Z';
|
|
72
|
+
|
|
73
|
+
const layer = [{ name: '', title: 'Waikato 0.625m SNC12836 (2028)' }];
|
|
74
|
+
const layers = filterLayers(
|
|
75
|
+
mockUrlRequest('/foo/bar,js', `?date[after]=${dateAfter}&date[before]=${dateBefore}`),
|
|
76
|
+
layer,
|
|
77
|
+
);
|
|
78
|
+
o(layers).deepEquals(layer);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -15,8 +15,7 @@ export class ConfigLoader {
|
|
|
15
15
|
const config = getDefaultConfig();
|
|
16
16
|
if (config.assets == null) {
|
|
17
17
|
const cb = await config.ConfigBundle.get(config.ConfigBundle.id('latest'));
|
|
18
|
-
if (cb
|
|
19
|
-
config.assets = cb.assets;
|
|
18
|
+
if (cb) config.assets = cb.assets;
|
|
20
19
|
}
|
|
21
20
|
return config;
|
|
22
21
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ConfigLayer } from '@basemaps/config';
|
|
2
|
+
import { extractYearRangeFromTitle } from '@basemaps/shared';
|
|
3
|
+
import { LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
|
|
4
|
+
|
|
5
|
+
export const FilterNames = {
|
|
6
|
+
DateBefore: 'date[before]',
|
|
7
|
+
DateAfter: 'date[after]',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function getFilters(req: LambdaHttpRequest): Record<string, string | undefined> {
|
|
11
|
+
return {
|
|
12
|
+
[FilterNames.DateBefore]: req.query.get(FilterNames.DateBefore) ?? undefined,
|
|
13
|
+
[FilterNames.DateAfter]: req.query.get(FilterNames.DateAfter) ?? undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert the year range into full ISO date year range
|
|
18
|
+
*
|
|
19
|
+
* Expand to the full year of jan 1st 00:00 -> Dec 31st 23:59
|
|
20
|
+
*/
|
|
21
|
+
export function yearRangeToInterval(x: [number] | [number, number]): [Date, Date] {
|
|
22
|
+
if (x.length === 1) return [new Date(`${x[0]}-01-01T00:00:00.000Z`), new Date(`${x[0]}-12-31T23:59:59.999Z`)];
|
|
23
|
+
return [new Date(`${x[0]}-01-01T00:00:00.000Z`), new Date(`${x[1]}-12-31T23:59:59.999Z`)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseDateAsIso(s: string | null): Date | null {
|
|
27
|
+
if (s == null) return null;
|
|
28
|
+
const date = new Date(s);
|
|
29
|
+
if (isNaN(date.getTime())) throw new LambdaHttpResponse(400, `Invalid date format: "${s}"`);
|
|
30
|
+
return date;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function filterLayers(req: LambdaHttpRequest, layers: ConfigLayer[]): ConfigLayer[] {
|
|
34
|
+
const dateAfterQuery = req.query.get(FilterNames.DateAfter);
|
|
35
|
+
const dateBeforeQuery = req.query.get(FilterNames.DateBefore);
|
|
36
|
+
|
|
37
|
+
if (dateAfterQuery == null && dateBeforeQuery == null) return layers;
|
|
38
|
+
const dateAfter = parseDateAsIso(dateAfterQuery);
|
|
39
|
+
const dateBefore = parseDateAsIso(dateBeforeQuery);
|
|
40
|
+
|
|
41
|
+
const filtered = layers.filter((l) => {
|
|
42
|
+
if (l.title == null) return false;
|
|
43
|
+
const yearRange = extractYearRangeFromTitle(l.title);
|
|
44
|
+
if (yearRange == null) return false;
|
|
45
|
+
|
|
46
|
+
const ranges = yearRangeToInterval(yearRange);
|
|
47
|
+
const startYear = ranges[0];
|
|
48
|
+
const endYear = ranges[1];
|
|
49
|
+
return (dateAfter == null || endYear >= dateAfter) && (dateBefore == null || startYear <= dateBefore);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Trace that layers have been filtered
|
|
53
|
+
req.set('layerFilter', {
|
|
54
|
+
[FilterNames.DateBefore]: dateBefore?.toISOString(),
|
|
55
|
+
[FilterNames.DateAfter]: dateAfter?.toISOString(),
|
|
56
|
+
layerCount: layers.length,
|
|
57
|
+
filterCount: filtered.length,
|
|
58
|
+
});
|
|
59
|
+
return filtered;
|
|
60
|
+
}
|
package/src/wmts.capability.ts
CHANGED
|
@@ -28,8 +28,6 @@ export interface WmtsCapabilitiesParams {
|
|
|
28
28
|
tileSet: ConfigTileSet;
|
|
29
29
|
/** List of tile matrixes to output */
|
|
30
30
|
tileMatrix: TileMatrixSet[];
|
|
31
|
-
/** Should WMTS Layers be created for each imagery set inside this tileSet */
|
|
32
|
-
isIndividualLayers: boolean;
|
|
33
31
|
/** All the imagery used by the tileSet and tileMatrixes */
|
|
34
32
|
imagery: Map<string, ConfigImagery>;
|
|
35
33
|
/** API key to append to all resource urls */
|
|
@@ -38,6 +36,10 @@ export interface WmtsCapabilitiesParams {
|
|
|
38
36
|
formats?: ImageFormat[] | null;
|
|
39
37
|
/** Config location */
|
|
40
38
|
config?: string | null;
|
|
39
|
+
/** Specific layers to add to the WMTS */
|
|
40
|
+
layers?: ConfigLayer[] | null;
|
|
41
|
+
/** Specific DateRange filter for the wmts layers */
|
|
42
|
+
filters?: Record<string, string | undefined>;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/** Number of decimal places to use in lat lng */
|
|
@@ -62,21 +64,23 @@ export class WmtsCapabilities {
|
|
|
62
64
|
tileMatrixSets = new Map<string, TileMatrixSet>();
|
|
63
65
|
imagery: Map<string, ConfigImagery>;
|
|
64
66
|
formats: ImageFormat[];
|
|
65
|
-
|
|
67
|
+
filters?: Record<string, string | undefined>;
|
|
66
68
|
|
|
67
69
|
minZoom = 0;
|
|
68
70
|
maxZoom = 32;
|
|
71
|
+
layers: ConfigLayer[] | null | undefined;
|
|
69
72
|
|
|
70
73
|
constructor(params: WmtsCapabilitiesParams) {
|
|
71
74
|
this.httpBase = params.httpBase;
|
|
72
75
|
this.provider = params.provider;
|
|
73
76
|
this.tileSet = params.tileSet;
|
|
74
77
|
this.config = params.config;
|
|
75
|
-
this.isIndividualLayers = params.isIndividualLayers;
|
|
76
78
|
for (const tms of params.tileMatrix) this.tileMatrixSets.set(tms.identifier, tms);
|
|
77
79
|
this.apiKey = params.apiKey;
|
|
78
80
|
this.formats = params.formats ?? ImageFormatOrder;
|
|
79
81
|
this.imagery = params.imagery;
|
|
82
|
+
this.layers = params.layers;
|
|
83
|
+
this.filters = params.filters;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
buildWgs84BoundingBox(tms: TileMatrixSet, layers: Bounds[]): VNodeElement {
|
|
@@ -159,8 +163,9 @@ export class WmtsCapabilities {
|
|
|
159
163
|
];
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
buildTileUrl(tileSetId: string, suffix: string): string {
|
|
163
|
-
|
|
166
|
+
buildTileUrl(tileSetId: string, suffix: string, addFilter = false): string {
|
|
167
|
+
let query = { api: this.apiKey, config: this.config };
|
|
168
|
+
if (addFilter) query = { api: this.apiKey, config: this.config, ...this.filters };
|
|
164
169
|
|
|
165
170
|
return [
|
|
166
171
|
this.httpBase,
|
|
@@ -170,15 +175,15 @@ export class WmtsCapabilities {
|
|
|
170
175
|
'{TileMatrixSet}',
|
|
171
176
|
'{TileMatrix}',
|
|
172
177
|
'{TileCol}',
|
|
173
|
-
`{TileRow}.${suffix}${query}`,
|
|
178
|
+
`{TileRow}.${suffix}${toQueryString(query)}`,
|
|
174
179
|
].join('/');
|
|
175
180
|
}
|
|
176
181
|
|
|
177
|
-
buildResourceUrl(tileSetId: string, suffix: string): VNodeElement {
|
|
182
|
+
buildResourceUrl(tileSetId: string, suffix: string, addFilter = false): VNodeElement {
|
|
178
183
|
return V('ResourceURL', {
|
|
179
184
|
format: 'image/' + suffix,
|
|
180
185
|
resourceType: 'tile',
|
|
181
|
-
template: this.buildTileUrl(tileSetId, suffix),
|
|
186
|
+
template: this.buildTileUrl(tileSetId, suffix, addFilter),
|
|
182
187
|
});
|
|
183
188
|
}
|
|
184
189
|
|
|
@@ -202,7 +207,7 @@ export class WmtsCapabilities {
|
|
|
202
207
|
if (firstImg == null) return null;
|
|
203
208
|
|
|
204
209
|
return V('Layer', [
|
|
205
|
-
V('ows:Title', layer.title
|
|
210
|
+
V('ows:Title', layer.title),
|
|
206
211
|
V('ows:Abstract', ''),
|
|
207
212
|
V('ows:Identifier', layerNameId),
|
|
208
213
|
this.buildKeywords(firstImg),
|
|
@@ -241,7 +246,7 @@ export class WmtsCapabilities {
|
|
|
241
246
|
}
|
|
242
247
|
|
|
243
248
|
return V('Layer', [
|
|
244
|
-
V('ows:Title', layer.title
|
|
249
|
+
V('ows:Title', layer.title),
|
|
245
250
|
V('ows:Abstract', layer.description ?? ''),
|
|
246
251
|
V('ows:Identifier', layerNameId),
|
|
247
252
|
this.buildKeywords(layer),
|
|
@@ -250,7 +255,7 @@ export class WmtsCapabilities {
|
|
|
250
255
|
this.buildStyle(),
|
|
251
256
|
...this.formats.map((fmt) => V('Format', 'image/' + fmt)),
|
|
252
257
|
...matrixSetNodes,
|
|
253
|
-
...this.formats.map((fmt) => this.buildResourceUrl(layerNameId, fmt)),
|
|
258
|
+
...this.formats.map((fmt) => this.buildResourceUrl(layerNameId, fmt, true)),
|
|
254
259
|
]);
|
|
255
260
|
}
|
|
256
261
|
|
|
@@ -287,10 +292,10 @@ export class WmtsCapabilities {
|
|
|
287
292
|
const layers: (VNodeElement | null)[] = [];
|
|
288
293
|
layers.push(this.buildLayer(this.tileSet));
|
|
289
294
|
|
|
290
|
-
if (this.
|
|
295
|
+
if (this.layers) {
|
|
291
296
|
const layerByName = new Map<string, ConfigLayer>();
|
|
292
297
|
// Dedupe the layers by unique name
|
|
293
|
-
for (const img of this.
|
|
298
|
+
for (const img of this.layers) layerByName.set(standardizeLayerName(img.name), img);
|
|
294
299
|
const orderedLayers = Array.from(layerByName.values()).sort((a, b) =>
|
|
295
300
|
(a.title ?? a.name).localeCompare(b.title ?? b.name),
|
|
296
301
|
);
|