@basemaps/lambda-tiler 7.9.0 → 7.10.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 (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/build/__tests__/tile.style.json.test.js +13 -7
  3. package/build/__tests__/tile.style.json.test.js.map +1 -1
  4. package/build/routes/__tests__/health.test.js +40 -20
  5. package/build/routes/__tests__/health.test.js.map +1 -1
  6. package/build/routes/__tests__/tile.style.json.test.js +57 -0
  7. package/build/routes/__tests__/tile.style.json.test.js.map +1 -1
  8. package/build/routes/__tests__/xyz.test.js +13 -0
  9. package/build/routes/__tests__/xyz.test.js.map +1 -1
  10. package/build/routes/health.d.ts +17 -0
  11. package/build/routes/health.js +119 -21
  12. package/build/routes/health.js.map +1 -1
  13. package/build/routes/tile.style.json.d.ts +35 -13
  14. package/build/routes/tile.style.json.js +108 -123
  15. package/build/routes/tile.style.json.js.map +1 -1
  16. package/build/routes/tile.xyz.vector.js +9 -9
  17. package/build/routes/tile.xyz.vector.js.map +1 -1
  18. package/build/util/__test__/nztm.style.test.d.ts +1 -0
  19. package/build/util/__test__/nztm.style.test.js +87 -0
  20. package/build/util/__test__/nztm.style.test.js.map +1 -0
  21. package/build/util/nztm.style.d.ts +12 -0
  22. package/build/util/nztm.style.js +45 -0
  23. package/build/util/nztm.style.js.map +1 -0
  24. package/package.json +7 -6
  25. package/src/__tests__/tile.style.json.test.ts +16 -7
  26. package/src/routes/__tests__/health.test.ts +46 -22
  27. package/src/routes/__tests__/tile.style.json.test.ts +60 -0
  28. package/src/routes/__tests__/xyz.test.ts +18 -0
  29. package/src/routes/health.ts +129 -21
  30. package/src/routes/tile.style.json.ts +131 -145
  31. package/src/routes/tile.xyz.vector.ts +10 -6
  32. package/src/util/__test__/nztm.style.test.ts +100 -0
  33. package/src/util/nztm.style.ts +44 -0
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -1,23 +1,67 @@
1
1
  import * as fs from 'node:fs';
2
2
 
3
- import { ConfigTileSetRaster } from '@basemaps/config';
3
+ import { ConfigTileSetRaster, ConfigTileSetVector, TileSetType } from '@basemaps/config';
4
4
  import { GoogleTms, Nztm2000QuadTms } from '@basemaps/geo';
5
5
  import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
6
+ import { VectorTile } from '@mapbox/vector-tile';
7
+ import Protobuf from 'pbf';
6
8
  import PixelMatch from 'pixelmatch';
7
9
  import Sharp from 'sharp';
10
+ import { gunzipSync } from 'zlib';
8
11
 
9
12
  import { ConfigLoader } from '../util/config.loader.js';
13
+ import { isGzip } from '../util/cotar.serve.js';
10
14
  import { TileXyz } from '../util/validate.js';
11
15
  import { TileXyzRaster } from './tile.xyz.raster.js';
16
+ import { tileXyzVector } from './tile.xyz.vector.js';
17
+
18
+ /**
19
+ * Vector feature that need to check existence
20
+ */
21
+ export interface TestFeature {
22
+ layer: string;
23
+ key: string;
24
+ value: string;
25
+ }
12
26
 
13
27
  interface TestTile extends TileXyz {
14
28
  buf?: Buffer;
29
+ testFeatures?: TestFeature[];
15
30
  }
16
31
 
17
32
  export const TestTiles: TestTile[] = [
18
33
  { tileSet: 'health', tileMatrix: GoogleTms, tileType: 'png', tile: { x: 252, y: 156, z: 8 } },
19
34
  { tileSet: 'health', tileMatrix: Nztm2000QuadTms, tileType: 'png', tile: { x: 30, y: 33, z: 6 } },
35
+ {
36
+ tileSet: 'topographic',
37
+ tileMatrix: GoogleTms,
38
+ tileType: 'pbf',
39
+ tile: { x: 1009, y: 641, z: 10 },
40
+ testFeatures: [
41
+ { layer: 'aeroway', key: 'name', value: 'Wellington Airport' },
42
+ { layer: 'place', key: 'name', value: 'Wellington' },
43
+ { layer: 'coastline', key: 'class', value: 'coastline' },
44
+ { layer: 'landcover', key: 'class', value: 'grass' },
45
+ { layer: 'poi', key: 'name', value: 'Seatoun Wharf' },
46
+ { layer: 'transportation', key: 'name', value: 'Mt Victoria Tunnel' },
47
+ ],
48
+ },
49
+ {
50
+ tileSet: 'topographic',
51
+ tileMatrix: GoogleTms,
52
+ tileType: 'pbf',
53
+ tile: { x: 62, y: 40, z: 6 },
54
+ testFeatures: [
55
+ { layer: 'landuse', key: 'name', value: 'Queenstown' },
56
+ { layer: 'place', key: 'name', value: 'Christchurch' },
57
+ { layer: 'water', key: 'name', value: 'Tasman Lake' },
58
+ { layer: 'coastline', key: 'class', value: 'coastline' },
59
+ { layer: 'landcover', key: 'class', value: 'wood' },
60
+ { layer: 'transportation', key: 'name', value: 'STATE HIGHWAY 6' },
61
+ ],
62
+ },
20
63
  ];
64
+
21
65
  const TileSize = 256;
22
66
 
23
67
  export async function getTestBuffer(test: TestTile): Promise<Buffer> {
@@ -40,6 +84,82 @@ export async function updateExpectedTile(test: TestTile, newTileData: Buffer, di
40
84
  await fs.promises.writeFile(`${expectedFileName}.diff.png`, imgPng);
41
85
  }
42
86
 
87
+ /**
88
+ * Compare and validate the raster test tile from server with pixel match
89
+ */
90
+ async function validateRasterTile(tileSet: ConfigTileSetRaster, test: TestTile, req: LambdaHttpRequest): Promise<void> {
91
+ // Get the parse response tile to raw buffer
92
+ const response = await TileXyzRaster.tile(req, tileSet, test);
93
+ if (response.status !== 200) throw new LambdaHttpResponse(500, response.statusDescription);
94
+ if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
95
+ const resImgBuffer = await Sharp(response._body).raw().toBuffer();
96
+
97
+ // Get test tile to compare
98
+ const testBuffer = await getTestBuffer(test);
99
+ test.buf = testBuffer;
100
+ const testImgBuffer = await Sharp(testBuffer).raw().toBuffer();
101
+
102
+ const outputBuffer = Buffer.alloc(testImgBuffer.length);
103
+ const missMatchedPixels = PixelMatch(testImgBuffer, resImgBuffer, outputBuffer, TileSize, TileSize);
104
+ if (missMatchedPixels) {
105
+ /** Uncomment this to overwite the expected files */
106
+ // await updateExpectedTile(test, response._body as Buffer, outputBuffer);
107
+ req.log.error({ missMatchedPixels, projection: test.tileMatrix.identifier, xyz: test.tile }, 'Health:MissMatch');
108
+ throw new LambdaHttpResponse(500, 'TileSet does not match.');
109
+ }
110
+ }
111
+
112
+ function checkFeatureExists(tile: VectorTile, testFeature: TestFeature): boolean {
113
+ const layer = tile.layers[testFeature.layer];
114
+ for (let i = 0; i < layer.length; i++) {
115
+ const feature = layer.feature(i);
116
+ if (feature.properties[testFeature.key] === testFeature.value) return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Fetch vector tile and decode into mapbox VectorTile
123
+ */
124
+ export const VectorTileProvider = {
125
+ async getVectorTile(tileSet: ConfigTileSetVector, test: TestTile, req: LambdaHttpRequest): Promise<VectorTile> {
126
+ // Get the parse response tile to raw buffer
127
+ const response = await tileXyzVector.tile(req, tileSet, test);
128
+ if (response.status !== 200) throw new LambdaHttpResponse(500, response.statusDescription);
129
+ if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
130
+ const buffer = isGzip(response._body) ? gunzipSync(response._body) : response._body;
131
+ return new VectorTile(new Protobuf(buffer));
132
+ },
133
+ };
134
+
135
+ /**
136
+ * Check the existence of a feature property in side the vector tile
137
+ *
138
+ * @throws LambdaHttpResponse if any test feature not found from vector tile
139
+ */
140
+ function featureCheck(tile: VectorTile, testTile: TestTile): void {
141
+ const testTileName = `${testTile.tileSet}-${testTile.tile.x}/${testTile.tile.y}/z${testTile.tile.z}`;
142
+ if (testTile.testFeatures == null) {
143
+ throw new LambdaHttpResponse(500, `No test feature found from testTile: ${testTileName}`);
144
+ }
145
+ for (const testFeature of testTile.testFeatures) {
146
+ if (!checkFeatureExists(tile, testFeature)) {
147
+ throw new LambdaHttpResponse(500, `Failed to validate tile: ${testTileName} for layer: ${testFeature.layer}.`);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Health check the test vector tiles that contains all the expected features.
154
+ *
155
+ * @throws LambdaHttpResponse if test tiles not returned or features not exists
156
+ */
157
+ async function validateVectorTile(tileSet: ConfigTileSetVector, test: TestTile, req: LambdaHttpRequest): Promise<void> {
158
+ // Get the parse response tile to raw buffer
159
+ const tile = await VectorTileProvider.getVectorTile(tileSet, test, req);
160
+ featureCheck(tile, test);
161
+ }
162
+
43
163
  /**
44
164
  * Health request get health TileSets and validate with test TileSets
45
165
  * - Valid response from get heath tile request
@@ -49,27 +169,15 @@ export async function updateExpectedTile(test: TestTile, newTileData: Buffer, di
49
169
  */
50
170
  export async function healthGet(req: LambdaHttpRequest): Promise<LambdaHttpResponse> {
51
171
  const config = await ConfigLoader.load(req);
52
- const tileSet = await config.TileSet.get(config.TileSet.id('health'));
53
- if (tileSet == null) throw new LambdaHttpResponse(500, 'TileSet: "health" not found');
54
172
  for (const test of TestTiles) {
55
- // Get the parse response tile to raw buffer
56
- const response = await TileXyzRaster.tile(req, tileSet as ConfigTileSetRaster, test);
57
- if (response.status !== 200) return new LambdaHttpResponse(500, response.statusDescription);
58
- if (!Buffer.isBuffer(response._body)) throw new LambdaHttpResponse(500, 'Not a Buffer response content.');
59
- const resImgBuffer = await Sharp(response._body).raw().toBuffer();
60
-
61
- // Get test tile to compare
62
- const testBuffer = await getTestBuffer(test);
63
- test.buf = testBuffer;
64
- const testImgBuffer = await Sharp(testBuffer).raw().toBuffer();
65
-
66
- const outputBuffer = Buffer.alloc(testImgBuffer.length);
67
- const missMatchedPixels = PixelMatch(testImgBuffer, resImgBuffer, outputBuffer, TileSize, TileSize);
68
- if (missMatchedPixels) {
69
- /** Uncomment this to overwite the expected files */
70
- // await updateExpectedTile(test, response._body as Buffer, outputBuffer);
71
- req.log.error({ missMatchedPixels, projection: test.tileMatrix.identifier, xyz: test.tile }, 'Health:MissMatch');
72
- return new LambdaHttpResponse(500, 'TileSet does not match.');
173
+ const tileSet = await config.TileSet.get(config.TileSet.id(test.tileSet));
174
+ if (tileSet == null) throw new LambdaHttpResponse(500, `TileSet: ${test.tileSet} not found`);
175
+ if (tileSet.type === TileSetType.Raster) {
176
+ await validateRasterTile(tileSet, test, req);
177
+ } else if (tileSet.type === TileSetType.Vector) {
178
+ await validateVectorTile(tileSet, test, req);
179
+ } else {
180
+ throw new LambdaHttpResponse(500, `Invalid TileSet type for tileSet ${test.tileSet}`);
73
181
  }
74
182
  }
75
183
 
@@ -1,20 +1,41 @@
1
- import { ConfigId, ConfigPrefix, ConfigTileSetRaster, Layer, Sources, StyleJson, TileSetType } from '@basemaps/config';
1
+ import {
2
+ BasemapsConfigProvider,
3
+ ConfigId,
4
+ ConfigPrefix,
5
+ ConfigTileSetRaster,
6
+ Layer,
7
+ Sources,
8
+ StyleJson,
9
+ TileSetType,
10
+ } from '@basemaps/config';
2
11
  import { DefaultExaggeration } from '@basemaps/config/build/config/vector.style.js';
3
- import { GoogleTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo';
12
+ import { GoogleTms, Nztm2000QuadTms, TileMatrixSet, TileMatrixSets } from '@basemaps/geo';
4
13
  import { Env, toQueryString } from '@basemaps/shared';
5
14
  import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
6
15
  import { URL } from 'url';
7
16
 
8
17
  import { ConfigLoader } from '../util/config.loader.js';
9
18
  import { Etag } from '../util/etag.js';
19
+ import { convertStyleToNztmStyle } from '../util/nztm.style.js';
10
20
  import { NotFound, NotModified } from '../util/response.js';
11
21
  import { Validate } from '../util/validate.js';
12
22
 
13
23
  /**
14
- * Convert relative URLS into a full hostname url
24
+ * Convert relative URL into a full hostname URL, converting {tileMatrix} into the provided tileMatrix
25
+ *
26
+ * Will also add query parameters of apiKey and configuration if provided
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * convertRelativeUrl("/v1/tiles/aerial/{tileMatrix}/{z}/{x}/{y}.webp", NZTM2000Quad)
31
+ * "https://basemaps.linz.govt.nz/v1/tiles/aerial/NZTM2000Quad/{z}/{x}/{y}.webp?api=c..."
32
+ * ```
33
+ *
15
34
  * @param url possible url to update
16
35
  * @param apiKey ApiKey to append with ?api= if required
17
- * @returns Updated Url or empty string if url is empty
36
+ * @param tileMatrix replace {tileMatrix} with the tile matrix
37
+ *
38
+ * @returns Updated URL or empty string if url is empty
18
39
  */
19
40
  export function convertRelativeUrl(
20
41
  url?: string,
@@ -33,20 +54,17 @@ export function convertRelativeUrl(
33
54
  }
34
55
 
35
56
  /**
36
- * Create a new style json that has absolute urls to the current host and API Keys where required
57
+ * Update the style JSON to have absolute urls to the current host and API Keys where required
58
+ *
37
59
  * @param style style to update
60
+ * @param tileMatrix convert the tile matrix to the target tile matrix
38
61
  * @param apiKey api key to inject
39
- * @returns new stylejson
62
+ * @param config optional configuration url to use
63
+ * @param layers replace the layers in the style json
64
+ * @returns new style JSON
40
65
  */
41
- export function convertStyleJson(
42
- style: StyleJson,
43
- tileMatrix: TileMatrixSet,
44
- apiKey: string,
45
- config: string | null,
46
- layers?: Layer[],
47
- ): StyleJson {
48
- const sources = JSON.parse(JSON.stringify(style.sources)) as Sources;
49
- for (const [key, value] of Object.entries(sources)) {
66
+ export function setStyleUrls(style: StyleJson, tileMatrix: TileMatrixSet, apiKey: string, config: string | null): void {
67
+ for (const [key, value] of Object.entries(style.sources ?? {})) {
50
68
  if (value.type === 'vector') {
51
69
  value.url = convertRelativeUrl(value.url, tileMatrix, apiKey, config);
52
70
  } else if ((value.type === 'raster' || value.type === 'raster-dem') && Array.isArray(value.tiles)) {
@@ -54,30 +72,11 @@ export function convertStyleJson(
54
72
  value.tiles[i] = convertRelativeUrl(value.tiles[i], tileMatrix, apiKey, config);
55
73
  }
56
74
  }
57
- sources[key] = value;
75
+ style.sources[key] = value;
58
76
  }
59
77
 
60
- const styleJson: StyleJson = {
61
- version: 8,
62
- id: style.id,
63
- name: style.name,
64
- sources,
65
- layers: layers ? layers : style.layers,
66
- };
67
-
68
- if (style.metadata) styleJson.metadata = style.metadata;
69
- if (style.glyphs) styleJson.glyphs = convertRelativeUrl(style.glyphs, undefined, undefined, config);
70
- if (style.sprite) styleJson.sprite = convertRelativeUrl(style.sprite, undefined, undefined, config);
71
- if (style.sky) styleJson.sky = style.sky;
72
- if (style.terrain) styleJson.terrain = style.terrain;
73
-
74
- return styleJson;
75
- }
76
-
77
- export interface StyleGet {
78
- Params: {
79
- styleName: string;
80
- };
78
+ if (style.glyphs) style.glyphs = convertRelativeUrl(style.glyphs, undefined, undefined, config);
79
+ if (style.sprite) style.sprite = convertRelativeUrl(style.sprite, undefined, undefined, config);
81
80
  }
82
81
 
83
82
  export interface StyleConfig {
@@ -87,15 +86,21 @@ export interface StyleConfig {
87
86
  labels: boolean;
88
87
  }
89
88
 
89
+ /**
90
+ * Turn on the terrain setting in the style json
91
+ */
90
92
  function setStyleTerrain(style: StyleJson, terrain: string, tileMatrix: TileMatrixSet): void {
91
93
  const source = Object.keys(style.sources).find((s) => s === terrain);
92
- if (source == null) throw new LambdaHttpResponse(400, `Terrain: ${terrain} is not exists in the style source.`);
94
+ if (source == null) throw new LambdaHttpResponse(400, `Terrain: ${terrain} does not exists in the style source.`);
93
95
  style.terrain = {
94
96
  source,
95
97
  exaggeration: DefaultExaggeration[tileMatrix.identifier] ?? DefaultExaggeration[GoogleTms.identifier],
96
98
  };
97
99
  }
98
100
 
101
+ /**
102
+ * Merge the "labels" layer into the style json
103
+ */
99
104
  async function setStyleLabels(req: LambdaHttpRequest<StyleGet>, style: StyleJson): Promise<void> {
100
105
  const config = await ConfigLoader.load(req);
101
106
  const labels = await config.Style.get('labels');
@@ -122,6 +127,9 @@ async function setStyleLabels(req: LambdaHttpRequest<StyleGet>, style: StyleJson
122
127
  style.layers = style.layers.concat(labels.style.layers);
123
128
  }
124
129
 
130
+ /**
131
+ * Ensure that a "LINZ-Terrain" layer is force added into the output styleJSON source
132
+ */
125
133
  async function ensureTerrain(
126
134
  req: LambdaHttpRequest<StyleGet>,
127
135
  tileMatrix: TileMatrixSet,
@@ -136,20 +144,26 @@ async function ensureTerrain(
136
144
  style.sources['LINZ-Terrain'] = {
137
145
  type: 'raster-dem',
138
146
  tileSize: 256,
139
- maxzoom: 18,
147
+ maxzoom: 18, // TODO: this should be configurable based on the elevation layer
140
148
  tiles: [convertRelativeUrl(`/v1/tiles/elevation/${tileMatrix.identifier}/{z}/{x}/{y}.png${elevationQuery}`)],
141
149
  };
142
150
  }
143
151
 
144
- export async function tileSetToStyle(
152
+ /**
153
+ * Generate a StyleJSON from a tileset
154
+ * @returns
155
+ */
156
+ export function tileSetToStyle(
145
157
  req: LambdaHttpRequest<StyleGet>,
146
158
  tileSet: ConfigTileSetRaster,
147
159
  tileMatrix: TileMatrixSet,
148
160
  apiKey: string,
149
- cfg: StyleConfig,
150
- ): Promise<LambdaHttpResponse> {
161
+ ): StyleJson {
162
+ // If the style has outputs defined it has a different process for generating the stylejson
163
+ if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey);
164
+
151
165
  const [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp'];
152
- if (tileFormat == null) return new LambdaHttpResponse(400, 'Invalid image format');
166
+ if (tileFormat == null) throw new LambdaHttpResponse(400, 'Invalid image format');
153
167
 
154
168
  const pipeline = Validate.pipeline(tileSet, tileFormat, req.query.get('pipeline'));
155
169
  const pipelineName = pipeline?.name === 'rgba' ? undefined : pipeline?.name;
@@ -162,162 +176,134 @@ export async function tileSetToStyle(
162
176
  `/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${tileFormat}${query}`;
163
177
 
164
178
  const styleId = `basemaps-${tileSet.name}`;
165
- const style: StyleJson = {
179
+ return {
166
180
  id: ConfigId.prefix(ConfigPrefix.Style, tileSet.name),
167
181
  name: tileSet.name,
168
182
  version: 8,
169
183
  sources: { [styleId]: { type: 'raster', tiles: [tileUrl], tileSize: 256 } },
170
184
  layers: [{ id: styleId, type: 'raster', source: styleId }],
171
185
  };
172
-
173
- // Ensure elevation for individual tilesets
174
- await ensureTerrain(req, tileMatrix, apiKey, style);
175
-
176
- // Add terrain in style
177
- if (cfg.terrain) setStyleTerrain(style, cfg.terrain, tileMatrix);
178
- if (cfg.labels) await setStyleLabels(req, style);
179
-
180
- const data = Buffer.from(JSON.stringify(convertStyleJson(style, tileMatrix, apiKey, configLocation)));
181
-
182
- const cacheKey = Etag.key(data);
183
- if (Etag.isNotModified(req, cacheKey)) return NotModified();
184
-
185
- const response = new LambdaHttpResponse(200, 'ok');
186
- response.header(HttpHeader.ETag, cacheKey);
187
- response.header(HttpHeader.CacheControl, 'no-store');
188
- response.buffer(data, 'application/json');
189
- req.set('bytes', data.byteLength);
190
- return response;
191
186
  }
192
187
 
193
- export async function tileSetOutputToStyle(
188
+ /**
189
+ * generate a style from a tile set which has a output
190
+ */
191
+ export function tileSetOutputToStyle(
194
192
  req: LambdaHttpRequest<StyleGet>,
195
193
  tileSet: ConfigTileSetRaster,
196
194
  tileMatrix: TileMatrixSet,
197
195
  apiKey: string,
198
- cfg: StyleConfig,
199
- ): Promise<LambdaHttpResponse> {
196
+ ): StyleJson {
197
+ if (tileSet.outputs == null) throw new LambdaHttpResponse(400, 'TileSet does not have any outputs to generate');
200
198
  const configLocation = ConfigLoader.extract(req);
201
- const query = toQueryString({ config: configLocation, api: apiKey });
202
199
 
203
200
  const styleId = `basemaps-${tileSet.name}`;
204
201
  const sources: Sources = {};
205
202
  const layers: Layer[] = [];
206
203
 
207
- if (tileSet.outputs) {
208
- //for loop output.
209
- for (const output of tileSet.outputs) {
210
- const format = output.format?.[0] ?? 'webp';
211
- const urlBase = Env.get(Env.PublicUrlBase) ?? '';
212
- const tileUrl = `${urlBase}/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${format}${query}`;
213
-
214
- if (output.name === 'terrain-rgb') {
215
- // Add both raster source and dem raster source for terrain-rgb output
216
- sources[`${styleId}-${output.name}`] = {
217
- type: 'raster',
218
- tiles: [tileUrl + `&pipeline=${output.name}`],
219
- tileSize: 256,
220
- };
221
- sources[`${styleId}-${output.name}-dem`] = {
222
- type: 'raster-dem',
223
- tiles: [tileUrl + `&pipeline=${output.name}`],
224
- tileSize: 256,
225
- };
226
- } else {
227
- // Add raster source other outputs
228
- sources[`${styleId}-${output.name}`] = {
229
- type: 'raster',
230
- tiles: [tileUrl + `&pipeline=${output.name}`],
231
- tileSize: 256,
232
- };
233
- }
204
+ for (const output of tileSet.outputs) {
205
+ const format = output.format?.[0] ?? 'webp';
206
+ const urlBase = Env.get(Env.PublicUrlBase) ?? '';
207
+ const query = toQueryString({ config: configLocation, api: apiKey, pipeline: output.name });
208
+
209
+ const tileUrl = `${urlBase}/v1/tiles/${tileSet.name}/${tileMatrix.identifier}/{z}/{x}/{y}.${format}${query}`;
210
+
211
+ if (output.name === 'terrain-rgb') {
212
+ // Add both raster source and dem raster source for terrain-rgb output
213
+ sources[`${styleId}-${output.name}`] = { type: 'raster', tiles: [tileUrl], tileSize: 256 };
214
+ sources[`${styleId}-${output.name}-dem`] = { type: 'raster-dem', tiles: [tileUrl], tileSize: 256 };
215
+ } else {
216
+ // Add raster source other outputs
217
+ sources[`${styleId}-${output.name}`] = { type: 'raster', tiles: [tileUrl], tileSize: 256 };
234
218
  }
235
219
  }
236
220
 
237
221
  // Add first raster source as default layer
238
222
  for (const source of Object.keys(sources)) {
239
223
  if (sources[source].type === 'raster') {
240
- layers.push({
241
- id: styleId,
242
- type: 'raster',
243
- source,
244
- });
224
+ layers.push({ id: styleId, type: 'raster', source });
245
225
  break;
246
226
  }
247
227
  }
248
228
 
249
- const style: StyleJson = {
229
+ return {
250
230
  id: ConfigId.prefix(ConfigPrefix.Style, tileSet.name),
251
231
  name: tileSet.name,
252
232
  version: 8,
253
233
  sources,
254
234
  layers,
255
235
  };
236
+ }
256
237
 
257
- // Ensure elevation for style json config
258
- await ensureTerrain(req, tileMatrix, apiKey, style);
259
-
260
- // Add terrain in style
261
- if (cfg.terrain) setStyleTerrain(style, cfg.terrain, tileMatrix);
262
- if (cfg.labels) await setStyleLabels(req, style);
263
-
264
- const data = Buffer.from(JSON.stringify(convertStyleJson(style, tileMatrix, apiKey, configLocation)));
265
-
266
- const cacheKey = Etag.key(data);
267
- if (Etag.isNotModified(req, cacheKey)) return Promise.resolve(NotModified());
238
+ async function generateStyleFromTileSet(
239
+ req: LambdaHttpRequest<StyleGet>,
240
+ config: BasemapsConfigProvider,
241
+ tileSetName: string,
242
+ tileMatrix: TileMatrixSet,
243
+ apiKey: string,
244
+ ): Promise<StyleJson> {
245
+ const tileSet = await config.TileSet.get(tileSetName);
246
+ if (tileSet == null) throw NotFound();
247
+ if (tileSet.type !== TileSetType.Raster) {
248
+ throw new LambdaHttpResponse(400, 'Only raster tile sets can generate style JSON');
249
+ }
250
+ if (tileSet.outputs) return tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey);
251
+ else return tileSetToStyle(req, tileSet, tileMatrix, apiKey);
252
+ }
268
253
 
269
- const response = new LambdaHttpResponse(200, 'ok');
270
- response.header(HttpHeader.ETag, cacheKey);
271
- response.header(HttpHeader.CacheControl, 'no-store');
272
- response.buffer(data, 'application/json');
273
- req.set('bytes', data.byteLength);
274
- return Promise.resolve(response);
254
+ export interface StyleGet {
255
+ Params: {
256
+ styleName: string;
257
+ };
275
258
  }
276
259
 
277
260
  export async function styleJsonGet(req: LambdaHttpRequest<StyleGet>): Promise<LambdaHttpResponse> {
278
261
  const apiKey = Validate.apiKey(req);
279
262
  const styleName = req.params.styleName;
280
- const excludeLayers = req.query.getAll('exclude');
281
- const excluded = new Set(excludeLayers.map((l) => l.toLowerCase()));
263
+
282
264
  const tileMatrix = TileMatrixSets.find(req.query.get('tileMatrix') ?? GoogleTms.identifier);
283
265
  if (tileMatrix == null) return new LambdaHttpResponse(400, 'Invalid tile matrix');
266
+
267
+ // Remove layers from the output style json
268
+ const excludeLayers = req.query.getAll('exclude');
269
+ const excluded = new Set(excludeLayers.map((l) => l.toLowerCase()));
270
+ if (excluded.size > 0) req.set('excludedLayers', [...excluded]);
271
+
272
+ /**
273
+ * Configuration options used for the landing page:
274
+ * "terrain" - force add a terrain layer
275
+ * "labels" - merge the labels style with the current style
276
+ *
277
+ * TODO: (2024-08) this is not a very scalable way of configuring styles, it would be good to provide a styleJSON merge
278
+ */
284
279
  const terrain = req.query.get('terrain') ?? undefined;
285
280
  const labels = Boolean(req.query.get('labels') ?? false);
281
+ req.set('styleConfig', { terrain, labels });
286
282
 
287
283
  // Get style Config from db
288
284
  const config = await ConfigLoader.load(req);
289
- const dbId = config.Style.id(styleName);
290
- const styleConfig = await config.Style.get(dbId);
291
-
292
- req.set('styleConfig', { terrain, labels });
293
-
294
- if (styleConfig == null) {
295
- // Were we given a tileset name instead, generated
296
- const tileSet = await config.TileSet.get(config.TileSet.id(styleName));
297
- if (tileSet == null) return NotFound();
298
- if (tileSet.type !== TileSetType.Raster) return NotFound();
299
- if (tileSet.outputs) return await tileSetOutputToStyle(req, tileSet, tileMatrix, apiKey, { terrain, labels });
300
- else return await tileSetToStyle(req, tileSet, tileMatrix, apiKey, { terrain, labels });
301
- }
302
-
303
- // Prepare sources and add linz source
304
- const style = convertStyleJson(
305
- styleConfig.style,
306
- tileMatrix,
307
- apiKey,
308
- ConfigLoader.extract(req),
309
- styleConfig.style.layers.filter((f) => !excluded.has(f.id.toLowerCase())),
310
- );
285
+ const styleConfig = await config.Style.get(styleName);
286
+ const styleSource =
287
+ styleConfig?.style ?? (await generateStyleFromTileSet(req, config, styleName, tileMatrix, apiKey));
311
288
 
289
+ const targetStyle = structuredClone(styleSource);
312
290
  // Ensure elevation for style json config
313
291
  // TODO: We should remove this after adding terrain source into style configs. PR-916
314
- await ensureTerrain(req, tileMatrix, apiKey, style);
292
+ await ensureTerrain(req, tileMatrix, apiKey, targetStyle);
315
293
 
316
294
  // Add terrain in style
317
- if (terrain) setStyleTerrain(style, terrain, tileMatrix);
318
- if (labels) await setStyleLabels(req, style);
295
+ if (terrain) setStyleTerrain(targetStyle, terrain, tileMatrix);
296
+ if (labels) await setStyleLabels(req, targetStyle);
297
+
298
+ // convert sources to full URLS and convert style between projections
299
+ setStyleUrls(targetStyle, tileMatrix, apiKey, ConfigLoader.extract(req));
300
+
301
+ if (tileMatrix.identifier === Nztm2000QuadTms.identifier) convertStyleToNztmStyle(targetStyle, false);
302
+
303
+ // filter out any excluded layers
304
+ if (excluded.size > 0) targetStyle.layers = targetStyle.layers.filter((f) => !excluded.has(f.id.toLowerCase()));
319
305
 
320
- const data = Buffer.from(JSON.stringify(style));
306
+ const data = Buffer.from(JSON.stringify(targetStyle));
321
307
 
322
308
  const cacheKey = Etag.key(data);
323
309
  if (Etag.isNotModified(req, cacheKey)) return NotModified();
@@ -1,5 +1,4 @@
1
1
  import { ConfigTileSetVector } from '@basemaps/config';
2
- import { GoogleTms } from '@basemaps/geo';
3
2
  import { fsa } from '@basemaps/shared';
4
3
  import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda';
5
4
 
@@ -13,12 +12,17 @@ export const tileXyzVector = {
13
12
  /** Serve a MVT vector tile */
14
13
  async tile(req: LambdaHttpRequest, tileSet: ConfigTileSetVector, xyz: TileXyz): Promise<LambdaHttpResponse> {
15
14
  if (xyz.tileType !== 'pbf') return NotFound();
16
- if (xyz.tileMatrix.identifier !== GoogleTms.identifier) return NotFound();
17
15
 
18
- if (tileSet.layers.length > 1) return new LambdaHttpResponse(500, 'Too many layers in tileset');
19
- const [layer] = tileSet.layers;
20
- const layerId = layer[3857];
21
- if (layerId == null) return new LambdaHttpResponse(500, 'Layer url not found from tileset Config');
16
+ // Vector tiles cannot be merged (yet!)
17
+ if (tileSet.layers.length > 1) {
18
+ return new LambdaHttpResponse(500, `Too many layers in vector tileset ${tileSet.layers.length}`);
19
+ }
20
+
21
+ const epsgCode = xyz.tileMatrix.projection.code;
22
+ const layerId = tileSet.layers[0][epsgCode];
23
+ if (layerId == null) {
24
+ return new LambdaHttpResponse(404, `No data found for tile matrix: ${xyz.tileMatrix.identifier}`);
25
+ }
22
26
 
23
27
  // Flip Y coordinate because MBTiles files are TMS.
24
28
  const y = (1 << xyz.tile.z) - 1 - xyz.tile.y;