@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/build/__tests__/config.data.d.ts +2 -0
  3. package/build/__tests__/config.data.js +38 -2
  4. package/build/__tests__/config.data.js.map +1 -1
  5. package/build/__tests__/wmts.capability.test.js +29 -4
  6. package/build/__tests__/wmts.capability.test.js.map +1 -1
  7. package/build/index.js.map +1 -1
  8. package/build/routes/__tests__/tile.style.json.test.js +114 -39
  9. package/build/routes/__tests__/tile.style.json.test.js.map +1 -1
  10. package/build/routes/__tests__/wmts.test.js +6 -3
  11. package/build/routes/__tests__/wmts.test.js.map +1 -1
  12. package/build/routes/__tests__/xyz.test.js +1 -1
  13. package/build/routes/__tests__/xyz.test.js.map +1 -1
  14. package/build/routes/preview.js +4 -14
  15. package/build/routes/preview.js.map +1 -1
  16. package/build/routes/tile.style.json.js +75 -23
  17. package/build/routes/tile.style.json.js.map +1 -1
  18. package/build/routes/tile.xyz.raster.js +7 -12
  19. package/build/routes/tile.xyz.raster.js.map +1 -1
  20. package/build/util/validate.d.ts +13 -2
  21. package/build/util/validate.js +53 -35
  22. package/build/util/validate.js.map +1 -1
  23. package/build/wmts.capability.d.ts +6 -6
  24. package/build/wmts.capability.js +45 -22
  25. package/build/wmts.capability.js.map +1 -1
  26. package/package.json +7 -7
  27. package/src/__tests__/config.data.ts +40 -2
  28. package/src/__tests__/wmts.capability.test.ts +57 -4
  29. package/src/index.ts +1 -0
  30. package/src/routes/__tests__/tile.style.json.test.ts +154 -42
  31. package/src/routes/__tests__/wmts.test.ts +14 -3
  32. package/src/routes/__tests__/xyz.test.ts +1 -1
  33. package/src/routes/preview.ts +4 -16
  34. package/src/routes/tile.style.json.ts +84 -24
  35. package/src/routes/tile.xyz.raster.ts +7 -11
  36. package/src/util/validate.ts +60 -34
  37. package/src/wmts.capability.ts +57 -24
  38. 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 body = Buffer.from(res.body ?? '', 'base64').toString();
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.sources['basemaps_terrain'] = {
137
- type: 'raster-dem',
138
- tiles: [`${host}/elevation/{z}/{x}/{y}.png?pipeline=terrain-rgb&api=${Api.key}`],
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 body = Buffer.from(res.body ?? '', 'base64').toString();
161
- fakeStyle.sources['basemaps_vector'] = {
162
- type: 'vector',
163
- url: `${host}/vector?api=${Api.key}`,
164
- };
165
- fakeStyle.sources['basemaps_raster'] = {
166
- type: 'raster',
167
- tiles: [`${host}/raster?api=${Api.key}`],
168
- };
169
- fakeStyle.sources['basemaps_raster_encode'] = {
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(JSON.parse(body), fakeStyle);
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: 'Background1',
348
- type: 'background',
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 { Imagery2193, Imagery3857, Provider, TileSetAerial, TileSetElevation } from '../../__tests__/config.data.js';
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 resourceUrl = lines.find((f) => f.includes('ResourceURL'))?.trim();
55
+ const resourceUrls = lines.filter((f) => f.includes('ResourceURL'));
46
56
 
47
- assert.ok(resourceUrl);
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
- // JPEG is not lossless
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
  );
@@ -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 pipeline = req.query.get('pipeline');
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
- // Use the prefered output format from the pipeline if its defined
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 [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp'];
177
- if (tileFormat == null) throw new LambdaHttpResponse(400, 'Invalid image format');
178
-
179
- const pipeline = Validate.pipeline(tileSet, tileFormat, req.query.get('pipeline'));
180
- const pipelineName = pipeline?.name === 'rgba' ? undefined : pipeline?.name;
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({ config: configLocation, api: apiKey, pipeline: pipelineName });
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}.${tileFormat}${query}`;
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
- const format = output.format?.[0] ?? 'webp';
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}.${format}${query}`;
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 [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp'];
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 styleName = req.params.styleName;
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
- * Configuration options used for the landing page:
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
- * TODO: (2024-08) this is not a very scalable way of configuring styles, it would be good to provide a styleJSON merge
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 targetStyle = structuredClone(styleSource);
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 { getImageFormat, Tiler } from '@basemaps/tiler';
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 { NotFound, NotModified } from '../util/response.js';
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 tileOutput = Validate.pipeline(tileSet, xyz.tileType, xyz.pipeline);
122
- if (tileOutput == null) return NotFound();
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: tileOutput.pipeline,
135
+ pipeline: output.pipeline,
140
136
  format,
141
- background: tileOutput.background ?? tileSet.background ?? DefaultBackground,
142
- resizeKernel: tileOutput.resizeKernel ?? tileSet.resizeKernel ?? DefaultResizeKernel,
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
  });
@@ -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 | null;
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
- if (pipeline) req.set('pipeline', pipeline);
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: req.query.get('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
- * Lookup the raster configuration pipeline for a output tile type
140
+ * Get the pipeline to use for a imagery set
141
141
  *
142
- * Defaults to standard image format output if no outputs are defined on the tileset
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
- pipeline(tileSet: ConfigTileSetRaster, tileType: string, pipeline?: string | null): ConfigTileSetRasterOutput | null {
145
- // If there is only one pipeline force the use of it
146
- if (tileSet.outputs?.length === 1 && pipeline == null) pipeline = tileSet.outputs[0].name;
147
-
148
- if (pipeline != null && pipeline !== 'rgba') {
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
- // If the tileset has multiple pipelines defined the user MUST specify which one
161
- if (tileSet.outputs) {
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
- // Generate a default RGBA configuration
166
- const img = getImageFormat(tileType ?? 'webp');
167
- if (img == null) return null;
168
- return {
169
- title: `RGBA ${tileType}`,
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
- } as ConfigTileSetRasterOutput;
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
  };