@basemaps/lambda-tiler 8.11.1 → 8.13.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 +60 -0
- package/build/__tests__/config.data.d.ts +2 -0
- package/build/__tests__/config.data.js +38 -2
- package/build/__tests__/config.data.js.map +1 -1
- package/build/__tests__/wmts.capability.test.js +29 -4
- package/build/__tests__/wmts.capability.test.js.map +1 -1
- package/build/index.js.map +1 -1
- package/build/routes/__tests__/tile.style.json.test.js +114 -39
- package/build/routes/__tests__/tile.style.json.test.js.map +1 -1
- package/build/routes/__tests__/wmts.test.js +6 -3
- package/build/routes/__tests__/wmts.test.js.map +1 -1
- package/build/routes/__tests__/xyz.test.js +1 -1
- package/build/routes/__tests__/xyz.test.js.map +1 -1
- package/build/routes/preview.js +4 -14
- package/build/routes/preview.js.map +1 -1
- package/build/routes/tile.style.json.js +75 -23
- package/build/routes/tile.style.json.js.map +1 -1
- package/build/routes/tile.xyz.raster.js +7 -12
- package/build/routes/tile.xyz.raster.js.map +1 -1
- package/build/util/validate.d.ts +13 -2
- package/build/util/validate.js +53 -35
- package/build/util/validate.js.map +1 -1
- package/build/wmts.capability.d.ts +6 -6
- package/build/wmts.capability.js +45 -22
- package/build/wmts.capability.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/config.data.ts +40 -2
- package/src/__tests__/wmts.capability.test.ts +57 -4
- package/src/index.ts +1 -0
- package/src/routes/__tests__/tile.style.json.test.ts +154 -42
- package/src/routes/__tests__/wmts.test.ts +14 -3
- package/src/routes/__tests__/xyz.test.ts +1 -1
- package/src/routes/preview.ts +4 -16
- package/src/routes/tile.style.json.ts +84 -24
- package/src/routes/tile.xyz.raster.ts +7 -11
- package/src/util/validate.ts +60 -34
- package/src/wmts.capability.ts +57 -24
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -20,19 +20,95 @@ describe('/v1/styles', () => {
|
|
|
20
20
|
before(() => {
|
|
21
21
|
process.env[Env.PublicUrlBase] = host;
|
|
22
22
|
});
|
|
23
|
+
|
|
23
24
|
beforeEach(() => {
|
|
24
25
|
sandbox.stub(ConfigLoader, 'getDefaultConfig').resolves(config);
|
|
25
26
|
});
|
|
27
|
+
|
|
26
28
|
afterEach(() => {
|
|
27
29
|
sandbox.restore();
|
|
28
30
|
config.objects.clear();
|
|
29
31
|
});
|
|
32
|
+
|
|
30
33
|
it('should not found style json', async () => {
|
|
31
34
|
const request = mockRequest('/v1/tiles/topographic/Google/style/topographic.json', 'get', Api.header);
|
|
32
35
|
const res = await handler.router.handle(request);
|
|
33
36
|
assert.equal(res.status, 404);
|
|
34
37
|
});
|
|
35
38
|
|
|
39
|
+
it('should select default layers', async () => {
|
|
40
|
+
config.put(TileSetElevation);
|
|
41
|
+
|
|
42
|
+
const request = mockRequest('/v1/styles/elevation.json', 'get', Api.header);
|
|
43
|
+
const res = await handler.router.handle(request);
|
|
44
|
+
// No default set so this should 404
|
|
45
|
+
assert.equal(res.status, 404, res.statusDescription);
|
|
46
|
+
|
|
47
|
+
// Setting a default of color-ramp
|
|
48
|
+
const ts = structuredClone(TileSetElevation);
|
|
49
|
+
ts.outputs![1].default = true;
|
|
50
|
+
config.put(ts);
|
|
51
|
+
|
|
52
|
+
const resB = await handler.router.handle(request);
|
|
53
|
+
assert.equal(resB.status, 200, resB.statusDescription);
|
|
54
|
+
const body = JSON.parse(Buffer.from(resB.body, 'base64').toString()) as StyleJson;
|
|
55
|
+
assert.equal(body.layers[0].source, 'basemaps-elevation-color-ramp');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should select default layers', async () => {
|
|
59
|
+
const request = mockRequest('/v1/styles/elevation.json', 'get', Api.header);
|
|
60
|
+
|
|
61
|
+
// Setting a default of color-ramp
|
|
62
|
+
const ts = structuredClone(TileSetElevation);
|
|
63
|
+
ts.outputs![0].default = true;
|
|
64
|
+
config.put(ts);
|
|
65
|
+
|
|
66
|
+
const res = await handler.router.handle(request);
|
|
67
|
+
assert.equal(res.status, 200, res.statusDescription);
|
|
68
|
+
const body = JSON.parse(Buffer.from(res.body, 'base64').toString()) as StyleJson;
|
|
69
|
+
assert.equal(body.layers[0].source, 'basemaps-elevation-terrain-rgb');
|
|
70
|
+
assert.deepEqual(Object.keys(body.sources), [
|
|
71
|
+
'basemaps-elevation-terrain-rgb',
|
|
72
|
+
'basemaps-elevation-terrain-rgb-dem',
|
|
73
|
+
'basemaps-elevation-color-ramp',
|
|
74
|
+
'LINZ-Terrain',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should request the tile format if possible', async () => {
|
|
79
|
+
const request = mockUrlRequest('/v1/styles/elevation.json', `?tileFormat=avif&api=${Api.key}`);
|
|
80
|
+
|
|
81
|
+
// Setting a default of color-ramp
|
|
82
|
+
const ts = structuredClone(TileSetElevation);
|
|
83
|
+
ts.outputs![1].default = true;
|
|
84
|
+
config.put(ts);
|
|
85
|
+
|
|
86
|
+
const res = await handler.router.handle(request);
|
|
87
|
+
assert.equal(res.status, 200, res.statusDescription);
|
|
88
|
+
const body = JSON.parse(Buffer.from(res.body, 'base64').toString()) as StyleJson;
|
|
89
|
+
const sources = Object.values(body.sources).map((s) =>
|
|
90
|
+
(s as SourceRaster).tiles?.[0].replace(`api=${Api.key}`, ''),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Requested avif from a dataset that has terrain-rgb,
|
|
94
|
+
// terrain-rgb has to be served as png,
|
|
95
|
+
// but color-ramp can be served as avif
|
|
96
|
+
assert.deepEqual(sources, [
|
|
97
|
+
'https://tiles.test/v1/tiles/elevation/WebMercatorQuad/{z}/{x}/{y}.png?&pipeline=terrain-rgb',
|
|
98
|
+
'https://tiles.test/v1/tiles/elevation/WebMercatorQuad/{z}/{x}/{y}.png?&pipeline=terrain-rgb',
|
|
99
|
+
'https://tiles.test/v1/tiles/elevation/WebMercatorQuad/{z}/{x}/{y}.avif?&pipeline=color-ramp',
|
|
100
|
+
'https://tiles.test/v1/tiles/elevation/WebMercatorQuad/{z}/{x}/{y}.png?&pipeline=terrain-rgb',
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// as terrain rgb cannot be output as avif, if it is specifically requested error
|
|
104
|
+
const requestB = mockUrlRequest(
|
|
105
|
+
'/v1/styles/elevation.json',
|
|
106
|
+
`?tileFormat=avif&api=${Api.key}&pipeline=terrain-rgb`,
|
|
107
|
+
);
|
|
108
|
+
const resB = await handler.router.handle(requestB);
|
|
109
|
+
assert.equal(resB.status, 400, res.statusDescription);
|
|
110
|
+
});
|
|
111
|
+
|
|
36
112
|
const fakeStyle: StyleJson = {
|
|
37
113
|
version: 8,
|
|
38
114
|
id: 'test',
|
|
@@ -119,32 +195,16 @@ describe('/v1/styles', () => {
|
|
|
119
195
|
assert.equal(res.header('content-type'), 'application/json');
|
|
120
196
|
assert.equal(res.header('cache-control'), 'no-store');
|
|
121
197
|
|
|
122
|
-
const
|
|
123
|
-
fakeStyle.sources['basemaps_vector'] = {
|
|
124
|
-
type: 'vector',
|
|
125
|
-
url: `${host}/vector?api=${Api.key}`,
|
|
126
|
-
};
|
|
127
|
-
fakeStyle.sources['basemaps_raster'] = {
|
|
128
|
-
type: 'raster',
|
|
129
|
-
tiles: [`${host}/raster?api=${Api.key}`],
|
|
130
|
-
};
|
|
131
|
-
fakeStyle.sources['basemaps_raster_encode'] = {
|
|
132
|
-
type: 'raster',
|
|
133
|
-
tiles: [`${host}/raster/{z}/{x}/{y}.webp?api=${Api.key}`],
|
|
134
|
-
};
|
|
198
|
+
const targetStyle = JSON.parse(Buffer.from(res.body ?? '', 'base64').toString()) as StyleJson;
|
|
135
199
|
|
|
136
|
-
fakeStyle.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
fakeStyle.sprite = `${host}/sprite`;
|
|
142
|
-
fakeStyle.glyphs = `${host}/glyphs`;
|
|
143
|
-
|
|
144
|
-
assert.deepEqual(JSON.parse(body), fakeStyle);
|
|
200
|
+
assert.deepEqual(targetStyle.layers, fakeStyle.layers);
|
|
201
|
+
assert.deepEqual(Object.keys(targetStyle.sources), Object.keys(fakeStyle.sources));
|
|
202
|
+
assert.equal(targetStyle.glyphs, `${host}${fakeStyle.glyphs}`);
|
|
203
|
+
assert.equal(targetStyle.sprite, `${host}${fakeStyle.sprite}`);
|
|
145
204
|
});
|
|
146
205
|
|
|
147
206
|
it('should serve style json with excluded layers', async () => {
|
|
207
|
+
console.log(fakeRecord.style.layers.map((m) => m.id));
|
|
148
208
|
config.put(fakeRecord);
|
|
149
209
|
const request = mockUrlRequest(
|
|
150
210
|
'/v1/tiles/topographic/Google/style/topographic.json',
|
|
@@ -157,25 +217,18 @@ describe('/v1/styles', () => {
|
|
|
157
217
|
assert.equal(res.header('content-type'), 'application/json');
|
|
158
218
|
assert.equal(res.header('cache-control'), 'no-store');
|
|
159
219
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
type: 'raster',
|
|
171
|
-
tiles: [`${host}/raster/{z}/{x}/{y}.webp?api=${Api.key}`],
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
fakeStyle.sprite = `${host}/sprite`;
|
|
175
|
-
fakeStyle.glyphs = `${host}/glyphs`;
|
|
176
|
-
fakeStyle.layers = [fakeStyle.layers[2]];
|
|
220
|
+
const targetStyle = JSON.parse(Buffer.from(res.body ?? '', 'base64').toString()) as StyleJson;
|
|
221
|
+
assert.deepEqual(
|
|
222
|
+
targetStyle.layers.map((m) => m.id),
|
|
223
|
+
['Background3'],
|
|
224
|
+
);
|
|
225
|
+
// original record should be preserved
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
fakeRecord.style.layers.map((m) => m.id),
|
|
228
|
+
['Background1', 'Background2', 'Background3'],
|
|
229
|
+
);
|
|
177
230
|
|
|
178
|
-
assert.deepEqual(
|
|
231
|
+
assert.deepEqual(Object.keys(targetStyle.sources), Object.keys(fakeStyle.sources));
|
|
179
232
|
});
|
|
180
233
|
|
|
181
234
|
it('should create raster styles', async () => {
|
|
@@ -295,6 +348,7 @@ describe('/v1/styles', () => {
|
|
|
295
348
|
},
|
|
296
349
|
id: 'Background1',
|
|
297
350
|
type: 'background',
|
|
351
|
+
source: 'basemaps_vector',
|
|
298
352
|
minzoom: 0,
|
|
299
353
|
},
|
|
300
354
|
],
|
|
@@ -323,6 +377,63 @@ describe('/v1/styles', () => {
|
|
|
323
377
|
]);
|
|
324
378
|
});
|
|
325
379
|
|
|
380
|
+
describe('style.merge', () => {
|
|
381
|
+
it('should merge terrain for all style config', async () => {
|
|
382
|
+
const request = mockUrlRequest('/v1/styles/aerial,topolite.json', '', Api.header);
|
|
383
|
+
config.put(fakeVectorRecord);
|
|
384
|
+
config.put(fakeAerialRecord);
|
|
385
|
+
|
|
386
|
+
const res = await handler.router.handle(request);
|
|
387
|
+
assert.equal(res.status, 200, res.statusDescription);
|
|
388
|
+
|
|
389
|
+
const body = JSON.parse(Buffer.from(res.body, 'base64').toString()) as StyleJson;
|
|
390
|
+
const aerialSource = body.sources['basemaps_raster'] as unknown as SourceRaster;
|
|
391
|
+
|
|
392
|
+
assert.equal(aerialSource.type, 'raster');
|
|
393
|
+
assert.equal(body.layers[0].source, 'basemaps_raster');
|
|
394
|
+
assert.equal(body.layers[1].source, 'basemaps_vector');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should fail merge terrain for duplicate layers', async () => {
|
|
398
|
+
const request = mockUrlRequest('/v1/styles/topolite,topolite.json', '', Api.header);
|
|
399
|
+
config.put(fakeVectorRecord);
|
|
400
|
+
|
|
401
|
+
const res = await handler.router.handle(request);
|
|
402
|
+
|
|
403
|
+
assert.equal(res.status, 400, res.statusDescription);
|
|
404
|
+
assert.equal(res.statusDescription.includes('duplicate layerIds'), true);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should fail merge terrain for large requests', async () => {
|
|
408
|
+
const layers = 'topolite,'.repeat(10).slice(0, -1);
|
|
409
|
+
const request = mockUrlRequest(`/v1/styles/${layers}.json`, '', Api.header);
|
|
410
|
+
config.put(fakeVectorRecord);
|
|
411
|
+
|
|
412
|
+
const res = await handler.router.handle(request);
|
|
413
|
+
assert.equal(res.status, 400, res.statusDescription);
|
|
414
|
+
assert.equal(res.statusDescription.includes('Too many styles'), true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should skip merging empty style names', async () => {
|
|
418
|
+
const layers = ','.repeat(10);
|
|
419
|
+
const request = mockUrlRequest(`/v1/styles/${layers}topolite.json`, '', Api.header);
|
|
420
|
+
config.put(fakeVectorRecord);
|
|
421
|
+
|
|
422
|
+
const res = await handler.router.handle(request);
|
|
423
|
+
assert.equal(res.status, 200, res.statusDescription);
|
|
424
|
+
console.log(request.url);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should fail merge if one layer is missing', async () => {
|
|
428
|
+
const request = mockUrlRequest('/v1/styles/topolite,missing-layer.json', '', Api.header);
|
|
429
|
+
config.put(fakeVectorRecord);
|
|
430
|
+
|
|
431
|
+
const res = await handler.router.handle(request);
|
|
432
|
+
|
|
433
|
+
assert.equal(res.status, 404, res.statusDescription);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
326
437
|
const fakeAerialStyleConfig = {
|
|
327
438
|
id: 'test',
|
|
328
439
|
name: 'test',
|
|
@@ -344,8 +455,9 @@ describe('/v1/styles', () => {
|
|
|
344
455
|
paint: {
|
|
345
456
|
'background-color': 'rgba(206, 229, 242, 1)',
|
|
346
457
|
},
|
|
347
|
-
id: '
|
|
348
|
-
|
|
458
|
+
id: 'aerial',
|
|
459
|
+
source: 'basemaps_raster',
|
|
460
|
+
type: 'raster',
|
|
349
461
|
minzoom: 0,
|
|
350
462
|
},
|
|
351
463
|
],
|
|
@@ -4,7 +4,15 @@ import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
|
4
4
|
import { ConfigProviderMemory, ConfigTileSetRaster } from '@basemaps/config';
|
|
5
5
|
import { Env } from '@basemaps/shared';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
Imagery2193,
|
|
9
|
+
Imagery2193Elevation,
|
|
10
|
+
Imagery3857,
|
|
11
|
+
Imagery3857Elevation,
|
|
12
|
+
Provider,
|
|
13
|
+
TileSetAerial,
|
|
14
|
+
TileSetElevation,
|
|
15
|
+
} from '../../__tests__/config.data.js';
|
|
8
16
|
import { Api, mockUrlRequest } from '../../__tests__/xyz.util.js';
|
|
9
17
|
import { handler } from '../../index.js';
|
|
10
18
|
import { ConfigLoader } from '../../util/config.loader.js';
|
|
@@ -33,6 +41,8 @@ describe('WMTSRouting', () => {
|
|
|
33
41
|
return process.env[arg];
|
|
34
42
|
});
|
|
35
43
|
config.put(TileSetElevation);
|
|
44
|
+
config.put(Imagery2193Elevation);
|
|
45
|
+
config.put(Imagery3857Elevation);
|
|
36
46
|
t.mock.method(ConfigLoader, 'load', () => Promise.resolve(config));
|
|
37
47
|
const req = mockUrlRequest(
|
|
38
48
|
'/v1/tiles/elevation/WebMercatorQuad/WMTSCapabilities.xml',
|
|
@@ -42,9 +52,10 @@ describe('WMTSRouting', () => {
|
|
|
42
52
|
|
|
43
53
|
assert.equal(res.status, 200);
|
|
44
54
|
const lines = Buffer.from(res.body, 'base64').toString().split('\n');
|
|
45
|
-
const
|
|
55
|
+
const resourceUrls = lines.filter((f) => f.includes('ResourceURL'));
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
const resourceUrl = resourceUrls[0].trim();
|
|
58
|
+
assert.equal(resourceUrls.length, 1);
|
|
48
59
|
assert.ok(resourceUrl.includes('amp;pipeline=terrain-rgb'), `includes pipeline=terrain-rgb in ${resourceUrl}`);
|
|
49
60
|
assert.ok(resourceUrl.includes('.png'), `includes .png in ${resourceUrl}`);
|
|
50
61
|
});
|
|
@@ -205,7 +205,7 @@ describe('/v1/tiles', () => {
|
|
|
205
205
|
elevation.outputs = [{ title: 'Terrain RGB', name: 'terrain-rgb' }];
|
|
206
206
|
config.put(elevation);
|
|
207
207
|
|
|
208
|
-
//
|
|
208
|
+
// pbf is not a imagery format
|
|
209
209
|
const resPbf = await handler.router.handle(
|
|
210
210
|
mockUrlRequest('/v1/tiles/elevation/3857/11/2022/1283.pbf', '?pipeline=terrain-rgb', Api.header),
|
|
211
211
|
);
|
package/src/routes/preview.ts
CHANGED
|
@@ -64,23 +64,11 @@ export async function tilePreviewGet(req: LambdaHttpRequest<PreviewGet>): Promis
|
|
|
64
64
|
// Only raster previews are supported
|
|
65
65
|
if (tileSet.type !== TileSetType.Raster) return new LambdaHttpResponse(404, 'Preview invalid tile set type');
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const { output, format } = Validate.pipeline(tileSet, req.params.outputType, req.query.get('pipeline'));
|
|
68
|
+
req.set('extension', format);
|
|
69
|
+
req.set('pipeline', output.name);
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
let defaultFormat = 'webp';
|
|
71
|
-
if (pipeline) {
|
|
72
|
-
const output = tileSet.outputs?.find((f) => f.name === pipeline);
|
|
73
|
-
defaultFormat = output?.format?.[0] ?? defaultFormat;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const outputFormat = req.params.outputType ?? defaultFormat;
|
|
77
|
-
|
|
78
|
-
const tileOutput = Validate.pipeline(tileSet, outputFormat, req.query.get('pipeline'));
|
|
79
|
-
if (tileOutput == null) return new LambdaHttpResponse(404, `Output format: ${outputFormat} not found`);
|
|
80
|
-
req.set('extension', outputFormat);
|
|
81
|
-
req.set('pipeline', tileOutput.name ?? 'rgba');
|
|
82
|
-
|
|
83
|
-
return renderPreview(req, { tileSet, tileMatrix, location, output: tileOutput, z });
|
|
71
|
+
return renderPreview(req, { tileSet, tileMatrix, location, output, z });
|
|
84
72
|
}
|
|
85
73
|
|
|
86
74
|
interface PreviewRenderContext {
|
|
@@ -173,18 +173,22 @@ export async function tileSetToStyle(
|
|
|
173
173
|
// If the style has outputs defined it has a different process for generating the stylejson
|
|
174
174
|
if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey);
|
|
175
175
|
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
const { output, format } = Validate.pipeline(
|
|
177
|
+
tileSet,
|
|
178
|
+
Validate.getRequestedFormats(req)?.[0],
|
|
179
|
+
req.query.get('pipeline'),
|
|
180
|
+
);
|
|
181
181
|
|
|
182
182
|
const configLocation = ConfigLoader.extract(req);
|
|
183
|
-
const query = toQueryString({
|
|
183
|
+
const query = toQueryString({
|
|
184
|
+
config: configLocation,
|
|
185
|
+
api: apiKey,
|
|
186
|
+
pipeline: output.name === 'rgba' ? undefined : '',
|
|
187
|
+
});
|
|
184
188
|
|
|
185
189
|
const tileUrl =
|
|
186
190
|
(Env.get(Env.PublicUrlBase) ?? '') +
|
|
187
|
-
`/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${
|
|
191
|
+
`/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${format}${query}`;
|
|
188
192
|
|
|
189
193
|
const attribution = await createTileSetAttribution(config, tileSet, tileMatrix.projection);
|
|
190
194
|
|
|
@@ -221,12 +225,21 @@ export function tileSetOutputToStyle(
|
|
|
221
225
|
const sources: Sources = {};
|
|
222
226
|
const layers: Layer[] = [];
|
|
223
227
|
|
|
228
|
+
const requestedFormat = Validate.getRequestedFormats(req)?.[0];
|
|
229
|
+
const { output, format } = Validate.pipeline(tileSet, requestedFormat, req.query.get('pipeline'));
|
|
230
|
+
|
|
224
231
|
for (const output of tileSet.outputs) {
|
|
225
|
-
|
|
232
|
+
let imageFormat = requestedFormat ?? 'webp';
|
|
233
|
+
// If a image format is requested, try and use the requested format
|
|
234
|
+
if (output.format) {
|
|
235
|
+
if (output.format.includes(format)) imageFormat = format;
|
|
236
|
+
else imageFormat = output.format[0];
|
|
237
|
+
}
|
|
238
|
+
// const imageFormat = output.format ? output.format?.includes(format) ? format : output.format?.[0]
|
|
226
239
|
const urlBase = Env.get(Env.PublicUrlBase) ?? '';
|
|
227
240
|
const query = toQueryString({ config: configLocation, api: apiKey, pipeline: output.name });
|
|
228
241
|
|
|
229
|
-
const tileUrl = `${urlBase}/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${
|
|
242
|
+
const tileUrl = `${urlBase}/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${imageFormat}${query}`;
|
|
230
243
|
|
|
231
244
|
if (output.name === 'terrain-rgb') {
|
|
232
245
|
// Add both raster source and dem raster source for terrain-rgb output
|
|
@@ -238,11 +251,7 @@ export function tileSetOutputToStyle(
|
|
|
238
251
|
}
|
|
239
252
|
}
|
|
240
253
|
|
|
241
|
-
const
|
|
242
|
-
if (tileFormat == null) throw new LambdaHttpResponse(400, 'Invalid image format');
|
|
243
|
-
|
|
244
|
-
const pipeline = Validate.pipeline(tileSet, tileFormat, req.query.get('pipeline'));
|
|
245
|
-
const pipelineName = pipeline?.name === 'rgba' ? undefined : pipeline?.name;
|
|
254
|
+
const pipelineName = output.name === 'rgba' ? undefined : output.name;
|
|
246
255
|
|
|
247
256
|
if (pipelineName != null) {
|
|
248
257
|
const sourceId = `${styleId}-${pipelineName}`;
|
|
@@ -289,9 +298,51 @@ export interface StyleGet {
|
|
|
289
298
|
};
|
|
290
299
|
}
|
|
291
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Join styles together
|
|
303
|
+
*
|
|
304
|
+
* Returns a new style json with sources and layers merged together
|
|
305
|
+
*
|
|
306
|
+
* @throws if there are any duplicate layerIds between the styles
|
|
307
|
+
* @param styles styles to merge together, the first style in the array will be used as the base style and urls will be merged into this style
|
|
308
|
+
* @returns
|
|
309
|
+
*/
|
|
310
|
+
function mergeStyles(styles: StyleJson[]): StyleJson {
|
|
311
|
+
const target = structuredClone(styles[0]);
|
|
312
|
+
if (styles.length === 1) return target;
|
|
313
|
+
|
|
314
|
+
const layerId = new Map<string, string>();
|
|
315
|
+
for (const l of target.layers) layerId.set(l.id, target.id);
|
|
316
|
+
|
|
317
|
+
for (const st of styles.slice(1)) {
|
|
318
|
+
for (const newLayers of st.layers) {
|
|
319
|
+
if (layerId.has(newLayers.id)) {
|
|
320
|
+
const prev = layerId.get(newLayers.id);
|
|
321
|
+
throw new LambdaHttpResponse(
|
|
322
|
+
400,
|
|
323
|
+
`Cannot merge styles with duplicate layerIds! styles: "${prev}" "${st.id}" layer: ${newLayers.id}`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
layerId.set(newLayers.id, st.id);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (target.glyphs == null) target.glyphs = st.glyphs;
|
|
330
|
+
if (target.sprite == null) target.sprite = st.sprite;
|
|
331
|
+
if (target.sky == null) target.sky = st.sky;
|
|
332
|
+
|
|
333
|
+
Object.assign(target.sources, st.sources);
|
|
334
|
+
target.layers.push(...st.layers);
|
|
335
|
+
target.name = target.name + '_' + st.name;
|
|
336
|
+
target.id = target.id + '_' + st.id;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return target;
|
|
340
|
+
}
|
|
341
|
+
|
|
292
342
|
export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<LambdaHttpResponse> {
|
|
293
343
|
const apiKey = Validate.apiKey(req);
|
|
294
|
-
const
|
|
344
|
+
const styleNames = req.params.styleName.split(',').filter((f) => f.trim().length > 0);
|
|
345
|
+
if (styleNames.length > 9) throw new LambdaHttpResponse(400, 'Too many styles requested, max 10');
|
|
295
346
|
|
|
296
347
|
const tileMatrix = TileMatrixSets.find(req.query.get('tileMatrix') ?? GoogleTms.identifier);
|
|
297
348
|
if (tileMatrix == null) return new LambdaHttpResponse(400, 'Invalid tile matrix');
|
|
@@ -302,23 +353,32 @@ export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<La
|
|
|
302
353
|
if (excluded.size > 0) req.set('excludedLayers', [...excluded]);
|
|
303
354
|
|
|
304
355
|
/**
|
|
305
|
-
*
|
|
306
|
-
* "terrain" - force add a terrain layer
|
|
307
|
-
* "labels" - merge the labels style with the current style
|
|
356
|
+
* Force add a terrain layer
|
|
308
357
|
*
|
|
309
|
-
*
|
|
358
|
+
* @deprecated 2026-02: use "/aerial,terrain-v2.json"
|
|
310
359
|
*/
|
|
311
360
|
const terrain = req.query.get('terrain') ?? undefined;
|
|
361
|
+
/**
|
|
362
|
+
* Merge the labels style with the current style
|
|
363
|
+
*
|
|
364
|
+
* @deprecated 2026-02: use "/aerial,labels-v2.json"
|
|
365
|
+
*/
|
|
312
366
|
const labels = Boolean(req.query.get('labels') ?? false);
|
|
313
|
-
req.set('styleConfig', { terrain, labels });
|
|
367
|
+
req.set('styleConfig', { terrain, labels, styles: styleNames });
|
|
314
368
|
|
|
315
369
|
// Get style Config from db
|
|
316
370
|
const config = await ConfigLoader.load(req);
|
|
317
|
-
const styleConfig = await config.Style.get(styleName);
|
|
318
|
-
const styleSource =
|
|
319
|
-
styleConfig?.style ?? (await generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey));
|
|
320
371
|
|
|
321
|
-
const
|
|
372
|
+
const styles = await Promise.all(
|
|
373
|
+
styleNames.map(async (styleName) => {
|
|
374
|
+
const styleConfig = await config.Style.get(styleName);
|
|
375
|
+
if (styleConfig?.style != null) return styleConfig.style;
|
|
376
|
+
return generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey);
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const targetStyle = mergeStyles(styles);
|
|
381
|
+
|
|
322
382
|
// Ensure elevation for style json config
|
|
323
383
|
// TODO: We should remove this after adding terrain source into style configs. PR-916
|
|
324
384
|
await ensureTerrain(req, tileMatrix, apiKey, targetStyle);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { ConfigTileSetRaster, getAllImagery } from '@basemaps/config';
|
|
2
2
|
import { Bounds, Epsg, TileMatrixSet, TileMatrixSets } from '@basemaps/geo';
|
|
3
3
|
import { Cotar, Env, stringToUrlFolder, Tiff } from '@basemaps/shared';
|
|
4
|
-
import {
|
|
4
|
+
import { Tiler } from '@basemaps/tiler';
|
|
5
5
|
import { TileMakerSharp } from '@basemaps/tiler-sharp';
|
|
6
6
|
import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
|
|
7
7
|
import pLimit from 'p-limit';
|
|
8
8
|
|
|
9
9
|
import { ConfigLoader } from '../util/config.loader.js';
|
|
10
10
|
import { Etag } from '../util/etag.js';
|
|
11
|
-
import {
|
|
11
|
+
import { NotModified } from '../util/response.js';
|
|
12
12
|
import { CoSources } from '../util/source.cache.js';
|
|
13
13
|
import { TileXyz, Validate } from '../util/validate.js';
|
|
14
14
|
|
|
@@ -118,9 +118,8 @@ export const TileXyzRaster = {
|
|
|
118
118
|
},
|
|
119
119
|
|
|
120
120
|
async tile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise<LambdaHttpResponse> {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
req.set('pipeline', tileOutput.name);
|
|
121
|
+
const { output, format } = Validate.pipeline(tileSet, xyz.tileType, xyz.pipeline);
|
|
122
|
+
req.set('pipeline', output.name);
|
|
124
123
|
|
|
125
124
|
const assetPaths = await this.getAssetsForTile(req, tileSet, xyz);
|
|
126
125
|
const cacheKey = Etag.key(assetPaths);
|
|
@@ -131,15 +130,12 @@ export const TileXyzRaster = {
|
|
|
131
130
|
const tiler = new Tiler(xyz.tileMatrix);
|
|
132
131
|
const layers = tiler.tile(assets, xyz.tile.x, xyz.tile.y, xyz.tile.z);
|
|
133
132
|
|
|
134
|
-
const format = getImageFormat(xyz.tileType);
|
|
135
|
-
if (format == null) return new LambdaHttpResponse(400, 'Invalid image format: ' + xyz.tileType);
|
|
136
|
-
|
|
137
133
|
const res = await TileComposer.compose({
|
|
138
134
|
layers,
|
|
139
|
-
pipeline:
|
|
135
|
+
pipeline: output.pipeline,
|
|
140
136
|
format,
|
|
141
|
-
background:
|
|
142
|
-
resizeKernel:
|
|
137
|
+
background: output.background ?? tileSet.background ?? DefaultBackground,
|
|
138
|
+
resizeKernel: output.resizeKernel ?? tileSet.resizeKernel ?? DefaultResizeKernel,
|
|
143
139
|
metrics: req.timer,
|
|
144
140
|
log: req.log,
|
|
145
141
|
});
|
package/src/util/validate.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface TileXyz {
|
|
|
16
16
|
/** Output tile format */
|
|
17
17
|
tileType: string;
|
|
18
18
|
/** Optional processing pipeline to use */
|
|
19
|
-
pipeline?: string
|
|
19
|
+
pipeline?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface TileMatrixRequest {
|
|
@@ -118,15 +118,15 @@ export const Validate = {
|
|
|
118
118
|
if (isNaN(x) || x < 0 || x > zoom.matrixWidth) throw new LambdaHttpResponse(404, `X not found: ${x}`);
|
|
119
119
|
if (isNaN(y) || y < 0 || y > zoom.matrixHeight) throw new LambdaHttpResponse(404, `Y not found: ${y}`);
|
|
120
120
|
|
|
121
|
-
const pipeline = req.query.get('pipeline');
|
|
122
|
-
|
|
121
|
+
const pipeline = req.query.get('pipeline') ?? undefined;
|
|
122
|
+
req.set('pipeline', pipeline);
|
|
123
123
|
|
|
124
124
|
const xyzData = {
|
|
125
125
|
tile: { x, y, z },
|
|
126
126
|
tileSet: req.params.tileSet,
|
|
127
127
|
tileMatrix,
|
|
128
128
|
tileType: req.params.tileType,
|
|
129
|
-
pipeline
|
|
129
|
+
pipeline,
|
|
130
130
|
};
|
|
131
131
|
req.set('xyz', xyzData.tile);
|
|
132
132
|
|
|
@@ -137,42 +137,68 @@ export const Validate = {
|
|
|
137
137
|
},
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
*
|
|
140
|
+
* Get the pipeline to use for a imagery set
|
|
141
141
|
*
|
|
142
|
-
*
|
|
142
|
+
* @param tileSet
|
|
143
|
+
* @param pipeline pipeline parameter if it exists
|
|
144
|
+
* @returns 'rgba' for any pipeline without outputs, otherwise the provided pipeline or default output
|
|
143
145
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (tileSet.outputs == null) throw new LambdaHttpResponse(404, 'TileSet has no pipelines');
|
|
150
|
-
const output = tileSet.outputs.find((f) => f.name === pipeline);
|
|
151
|
-
if (output == null) throw new LambdaHttpResponse(404, `TileSet has no pipeline named "${pipeline}"`);
|
|
152
|
-
|
|
153
|
-
const validFormats = output.format ?? ['webp', 'png', 'jpeg', 'avif'];
|
|
154
|
-
if (!validFormats.includes(tileType as ImageFormat)) {
|
|
155
|
-
throw new LambdaHttpResponse(400, `TileSet pipeline "${pipeline}" cannot be output as ${tileType}`);
|
|
156
|
-
}
|
|
157
|
-
return output;
|
|
158
|
-
}
|
|
146
|
+
pipelineName(tileSet: ConfigTileSetRaster, pipeline?: string | null): ConfigTileSetRasterOutput {
|
|
147
|
+
if (pipeline == null && tileSet.outputs) {
|
|
148
|
+
// If no pipeline is specified find the default pipeline
|
|
149
|
+
const defaultOutput = tileSet.outputs.find((f) => f.default === true);
|
|
150
|
+
if (defaultOutput) return defaultOutput;
|
|
159
151
|
|
|
160
|
-
|
|
161
|
-
|
|
152
|
+
// If there is only one pipeline force the use of it
|
|
153
|
+
if (tileSet.outputs.length === 1) return tileSet.outputs[0];
|
|
154
|
+
|
|
155
|
+
// No default pipeline, and multiple pipelines exist one must be chosen
|
|
162
156
|
throw new LambdaHttpResponse(404, 'TileSet needs pipeline: ' + tileSet.outputs.map((f) => f.name).join(', '));
|
|
163
157
|
}
|
|
164
158
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
name: 'rgba',
|
|
171
|
-
output: {
|
|
172
|
-
type: [img],
|
|
173
|
-
lossless: img === 'png' ? true : false,
|
|
159
|
+
// No pipeline and no outputs default is RGBA
|
|
160
|
+
if (pipeline == null || pipeline === 'rgba') {
|
|
161
|
+
return {
|
|
162
|
+
title: `RGBA`,
|
|
163
|
+
name: 'rgba',
|
|
174
164
|
background: tileSet.background,
|
|
175
|
-
}
|
|
176
|
-
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pipeline defined and pipeline not found
|
|
169
|
+
if (tileSet.outputs == null) throw new LambdaHttpResponse(404, `TileSet has no pipeline named "${pipeline}"`);
|
|
170
|
+
|
|
171
|
+
const output = tileSet.outputs.find((f) => f.name === pipeline);
|
|
172
|
+
if (output == null) throw new LambdaHttpResponse(404, `TileSet has no pipeline named "${pipeline}"`);
|
|
173
|
+
return output;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Lookup the raster configuration pipeline for a output tile type
|
|
178
|
+
*
|
|
179
|
+
* Defaults to standard image format output if no outputs are defined on the tileset
|
|
180
|
+
*/
|
|
181
|
+
pipeline(
|
|
182
|
+
tileSet: ConfigTileSetRaster,
|
|
183
|
+
imageFormat?: string | null,
|
|
184
|
+
pipelineName?: string | null,
|
|
185
|
+
): { output: ConfigTileSetRasterOutput; format: ImageFormat } {
|
|
186
|
+
const output = Validate.pipelineName(tileSet, pipelineName);
|
|
187
|
+
|
|
188
|
+
// Failed to parse the chosen image format
|
|
189
|
+
const chosenFormat = getImageFormat(imageFormat);
|
|
190
|
+
if (imageFormat != null && chosenFormat == null) {
|
|
191
|
+
throw new LambdaHttpResponse(400, `TileSet pipeline "${output.name}" cannot be output as ${imageFormat}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// No requirement on image formats
|
|
195
|
+
if (output.format == null) return { output, format: chosenFormat ?? 'webp' };
|
|
196
|
+
if (chosenFormat == null) return { output, format: output.format[0] };
|
|
197
|
+
|
|
198
|
+
// Validate selected format works as expected
|
|
199
|
+
if (!output.format.includes(chosenFormat)) {
|
|
200
|
+
throw new LambdaHttpResponse(400, `TileSet pipeline "${output.name}" cannot be output as ${imageFormat}`);
|
|
201
|
+
}
|
|
202
|
+
return { output, format: chosenFormat };
|
|
177
203
|
},
|
|
178
204
|
};
|