tileserver-gl-light 5.5.0-pre.0 → 5.5.0-pre.11

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 (42) hide show
  1. package/CHANGELOG.md +51 -33
  2. package/docs/config.rst +52 -11
  3. package/docs/endpoints.rst +12 -2
  4. package/docs/installation.rst +6 -6
  5. package/docs/usage.rst +35 -1
  6. package/package.json +15 -15
  7. package/public/resources/elevation-control.js +92 -21
  8. package/public/resources/maplibre-gl-inspect.js +2827 -2770
  9. package/public/resources/maplibre-gl-inspect.js.map +1 -1
  10. package/public/resources/maplibre-gl.css +1 -1
  11. package/public/resources/maplibre-gl.js +4 -4
  12. package/public/resources/maplibre-gl.js.map +1 -1
  13. package/src/main.js +31 -20
  14. package/src/pmtiles_adapter.js +104 -45
  15. package/src/promises.js +1 -1
  16. package/src/render.js +270 -93
  17. package/src/serve_data.js +266 -90
  18. package/src/serve_font.js +2 -2
  19. package/src/serve_light.js +2 -4
  20. package/src/serve_rendered.js +445 -236
  21. package/src/serve_style.js +29 -8
  22. package/src/server.js +115 -60
  23. package/src/utils.js +47 -20
  24. package/test/elevation.js +513 -0
  25. package/test/fixtures/visual/encoded-path-auto.png +0 -0
  26. package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
  27. package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
  28. package/test/fixtures/visual/path-auto.png +0 -0
  29. package/test/fixtures/visual/static-bbox.png +0 -0
  30. package/test/fixtures/visual/static-bearing-pitch.png +0 -0
  31. package/test/fixtures/visual/static-bearing.png +0 -0
  32. package/test/fixtures/visual/static-border-global.png +0 -0
  33. package/test/fixtures/visual/static-lat-lng.png +0 -0
  34. package/test/fixtures/visual/static-markers.png +0 -0
  35. package/test/fixtures/visual/static-multiple-paths.png +0 -0
  36. package/test/fixtures/visual/static-path-border-isolated.png +0 -0
  37. package/test/fixtures/visual/static-path-border-stroke.png +0 -0
  38. package/test/fixtures/visual/static-path-latlng.png +0 -0
  39. package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
  40. package/test/static_images.js +241 -0
  41. package/test/tiles_data.js +1 -1
  42. package/test/utils/create_terrain_mbtiles.js +124 -0
package/src/serve_data.js CHANGED
@@ -12,9 +12,9 @@ import { SphericalMercator } from '@mapbox/sphericalmercator';
12
12
  import {
13
13
  fixTileJSONCenter,
14
14
  getTileUrls,
15
- isS3Url,
16
15
  isValidRemoteUrl,
17
16
  fetchTileData,
17
+ lonLatToTilePixel,
18
18
  } from './utils.js';
19
19
  import { getPMtilesInfo, openPMtiles } from './pmtiles_adapter.js';
20
20
  import { gunzipP, gzipP } from './promises.js';
@@ -31,9 +31,9 @@ const packageJson = JSON.parse(
31
31
  );
32
32
 
33
33
  const isLight = packageJson.name.slice(-6) === '-light';
34
- const serve_rendered = (
35
- await import(`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`)
36
- ).serve_rendered;
34
+ const { serve_rendered } = await import(
35
+ `${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`
36
+ );
37
37
 
38
38
  export const serve_data = {
39
39
  /**
@@ -46,6 +46,7 @@ export const serve_data = {
46
46
  init: function (options, repo, programOpts) {
47
47
  const { verbose } = programOpts;
48
48
  const app = express().disable('x-powered-by');
49
+ app.use(express.json());
49
50
 
50
51
  /**
51
52
  * Handles requests for tile data, responding with the tile image.
@@ -59,7 +60,7 @@ export const serve_data = {
59
60
  * @returns {Promise<void>}
60
61
  */
61
62
  app.get('/:id/:z/:x/:y.:format', async (req, res) => {
62
- if (verbose) {
63
+ if (verbose >= 1) {
63
64
  console.log(
64
65
  `Handling tile request for: /data/%s/%s/%s/%s.%s`,
65
66
  String(req.params.id).replace(/\n|\r/g, ''),
@@ -69,7 +70,7 @@ export const serve_data = {
69
70
  String(req.params.format).replace(/\n|\r/g, ''),
70
71
  );
71
72
  }
72
- // eslint-disable-next-line security/detect-object-injection -- req.params.id is route parameter, validated by Express
73
+
73
74
  const item = repo[req.params.id];
74
75
  if (!item) {
75
76
  return res.sendStatus(404);
@@ -110,10 +111,10 @@ export const serve_data = {
110
111
  x,
111
112
  y,
112
113
  );
113
- if (fetchTile == null && item.tileJSON.sparse) {
114
- return res.status(410).send();
115
- } else if (fetchTile == null) {
116
- return res.status(204).send();
114
+ if (fetchTile == null) {
115
+ // sparse=true (default) -> 404 (allows overzoom)
116
+ // sparse=false -> 204 (empty tile, no overzoom)
117
+ return res.status(item.sparse ? 404 : 204).send();
117
118
  }
118
119
 
119
120
  let data = fetchTile.data;
@@ -122,7 +123,6 @@ export const serve_data = {
122
123
 
123
124
  if (isGzipped) {
124
125
  data = await gunzipP(data);
125
- isGzipped = false;
126
126
  }
127
127
 
128
128
  if (tileJSONFormat === 'pbf') {
@@ -165,13 +165,169 @@ export const serve_data = {
165
165
  headers['Content-Encoding'] = 'gzip';
166
166
  res.set(headers);
167
167
 
168
- if (!isGzipped) {
169
- data = await gzipP(data);
170
- }
168
+ data = await gzipP(data);
171
169
 
172
170
  return res.status(200).send(data);
173
171
  });
174
172
 
173
+ /**
174
+ * Validates elevation data source and returns source info or sends error response.
175
+ * @param {string} id - ID of the data source.
176
+ * @param {object} res - Express response object.
177
+ * @returns {object|null} Source info object or null if validation failed.
178
+ */
179
+ const validateElevationSource = (id, res) => {
180
+ // eslint-disable-next-line security/detect-object-injection -- id is route parameter for data source lookup
181
+ const item = repo?.[id];
182
+ if (!item) {
183
+ res.sendStatus(404);
184
+ return null;
185
+ }
186
+ if (!item.source) {
187
+ res.status(404).send('Missing source');
188
+ return null;
189
+ }
190
+ if (!item.tileJSON) {
191
+ res.status(404).send('Missing tileJSON');
192
+ return null;
193
+ }
194
+ if (!item.sourceType) {
195
+ res.status(404).send('Missing sourceType');
196
+ return null;
197
+ }
198
+ const { source, tileJSON, sourceType } = item;
199
+ if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') {
200
+ res.status(400).send('Invalid sourceType. Must be pmtiles or mbtiles.');
201
+ return null;
202
+ }
203
+ const encoding = tileJSON?.encoding;
204
+ if (encoding == null) {
205
+ res.status(400).send('Missing tileJSON.encoding');
206
+ return null;
207
+ }
208
+ if (encoding !== 'terrarium' && encoding !== 'mapbox') {
209
+ res.status(400).send('Invalid encoding. Must be terrarium or mapbox.');
210
+ return null;
211
+ }
212
+ const format = tileJSON?.format;
213
+ if (format == null) {
214
+ res.status(400).send('Missing tileJSON.format');
215
+ return null;
216
+ }
217
+ if (format !== 'webp' && format !== 'png') {
218
+ res.status(400).send('Invalid format. Must be webp or png.');
219
+ return null;
220
+ }
221
+ if (tileJSON.minzoom == null || tileJSON.maxzoom == null) {
222
+ res.status(400).send('Missing tileJSON zoom bounds');
223
+ return null;
224
+ }
225
+ return {
226
+ source,
227
+ sourceType,
228
+ encoding,
229
+ format,
230
+ tileSize: tileJSON.tileSize || 512,
231
+ minzoom: tileJSON.minzoom,
232
+ maxzoom: tileJSON.maxzoom,
233
+ };
234
+ };
235
+
236
+ /**
237
+ * Validates that a point has valid lon, lat, and z properties.
238
+ * @param {object} point - Point to validate.
239
+ * @param {number} index - Index of the point in the array.
240
+ * @returns {string|null} Error message if invalid, null if valid.
241
+ */
242
+ const validatePoint = (point, index) => {
243
+ if (point == null || typeof point !== 'object') {
244
+ return `Invalid point at index ${index}: point must be an object`;
245
+ }
246
+ if (typeof point.lon !== 'number' || !isFinite(point.lon)) {
247
+ return `Invalid point at index ${index}: lon must be a finite number`;
248
+ }
249
+ if (typeof point.lat !== 'number' || !isFinite(point.lat)) {
250
+ return `Invalid point at index ${index}: lat must be a finite number`;
251
+ }
252
+ if (typeof point.z !== 'number' || !isFinite(point.z)) {
253
+ return `Invalid point at index ${index}: z must be a finite number`;
254
+ }
255
+ return null;
256
+ };
257
+
258
+ /**
259
+ * Gets batch elevations for an array of points.
260
+ * @param {object} sourceInfo - Validated source info from validateElevationSource.
261
+ * @param {Array<{lon: number, lat: number, z: number}>} points - Array of validated points.
262
+ * @returns {Promise<Array<number|null>>} Array of elevations in same order as input.
263
+ */
264
+ const getBatchElevations = async (sourceInfo, points) => {
265
+ const {
266
+ source,
267
+ sourceType,
268
+ encoding,
269
+ format,
270
+ tileSize,
271
+ minzoom,
272
+ maxzoom,
273
+ } = sourceInfo;
274
+
275
+ // Group points by tile (including zoom level in the key)
276
+ const tileGroups = new Map();
277
+ for (let i = 0; i < points.length; i++) {
278
+ // eslint-disable-next-line security/detect-object-injection -- i is loop counter
279
+ const point = points[i];
280
+ let zoom = point.z;
281
+ if (zoom < minzoom) {
282
+ zoom = minzoom;
283
+ }
284
+ if (zoom > maxzoom) {
285
+ zoom = maxzoom;
286
+ }
287
+ const { tileX, tileY, pixelX, pixelY } = lonLatToTilePixel(
288
+ point.lon,
289
+ point.lat,
290
+ zoom,
291
+ tileSize,
292
+ );
293
+ const tileKey = `${zoom},${tileX},${tileY}`;
294
+ if (!tileGroups.has(tileKey)) {
295
+ tileGroups.set(tileKey, { zoom, tileX, tileY, pixels: [] });
296
+ }
297
+ tileGroups.get(tileKey).pixels.push({ pixelX, pixelY, index: i });
298
+ }
299
+
300
+ // Initialize results array with nulls
301
+ const results = new Array(points.length).fill(null);
302
+
303
+ // Process each tile and extract elevations
304
+ for (const [, tileData] of tileGroups) {
305
+ const { zoom, tileX, tileY, pixels } = tileData;
306
+ const fetchTile = await fetchTileData(
307
+ source,
308
+ sourceType,
309
+ zoom,
310
+ tileX,
311
+ tileY,
312
+ );
313
+ if (fetchTile == null) {
314
+ continue;
315
+ }
316
+
317
+ const elevations = await serve_rendered.getBatchElevationsFromTile(
318
+ fetchTile.data,
319
+ { encoding, format, tile_size: tileSize },
320
+ pixels,
321
+ );
322
+ for (const { index, elevation } of elevations) {
323
+ // eslint-disable-next-line security/detect-object-injection -- index is from internal elevation processing
324
+ results[index] = elevation;
325
+ }
326
+ }
327
+
328
+ return results;
329
+ };
330
+
175
331
  /**
176
332
  * Handles requests for elevation data.
177
333
  * @param {object} req - Express request object.
@@ -184,7 +340,7 @@ export const serve_data = {
184
340
  */
185
341
  app.get('/:id/elevation/:z/:x/:y', async (req, res, next) => {
186
342
  try {
187
- if (verbose) {
343
+ if (verbose >= 1) {
188
344
  console.log(
189
345
  `Handling elevation request for: /data/%s/elevation/%s/%s/%s`,
190
346
  String(req.params.id).replace(/\n|\r/g, ''),
@@ -193,49 +349,24 @@ export const serve_data = {
193
349
  String(req.params.y).replace(/\n|\r/g, ''),
194
350
  );
195
351
  }
196
- // eslint-disable-next-line security/detect-object-injection -- req.params.id is route parameter, validated by Express
197
- const item = repo?.[req.params.id];
198
- if (!item) return res.sendStatus(404);
199
- if (!item.source) return res.status(404).send('Missing source');
200
- if (!item.tileJSON) return res.status(404).send('Missing tileJSON');
201
- if (!item.sourceType) return res.status(404).send('Missing sourceType');
202
- const { source, tileJSON, sourceType } = item;
203
- if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') {
204
- return res
205
- .status(400)
206
- .send('Invalid sourceType. Must be pmtiles or mbtiles.');
207
- }
208
- const encoding = tileJSON?.encoding;
209
- if (encoding == null) {
210
- return res.status(400).send('Missing tileJSON.encoding');
211
- } else if (encoding !== 'terrarium' && encoding !== 'mapbox') {
212
- return res
213
- .status(400)
214
- .send('Invalid encoding. Must be terrarium or mapbox.');
215
- }
216
- const format = tileJSON?.format;
217
- if (format == null) {
218
- return res.status(400).send('Missing tileJSON.format');
219
- } else if (format !== 'webp' && format !== 'png') {
220
- return res.status(400).send('Invalid format. Must be webp or png.');
221
- }
352
+
353
+ const sourceInfo = validateElevationSource(req.params.id, res);
354
+ if (!sourceInfo) return;
355
+
222
356
  const z = parseInt(req.params.z, 10);
223
357
  const x = parseFloat(req.params.x);
224
358
  const y = parseFloat(req.params.y);
225
- if (tileJSON.minzoom == null || tileJSON.maxzoom == null) {
226
- return res.status(404).send(JSON.stringify(tileJSON));
227
- }
228
- const TILE_SIZE = tileJSON.tileSize || 512;
229
- let bbox;
230
- let xy;
231
- var zoom = z;
359
+
360
+ let lon, lat;
361
+ let zoom = z;
232
362
 
233
363
  if (Number.isInteger(x) && Number.isInteger(y)) {
364
+ // Tile coordinates mode - strict bounds checking
234
365
  const intX = parseInt(req.params.x, 10);
235
366
  const intY = parseInt(req.params.y, 10);
236
367
  if (
237
- zoom < tileJSON.minzoom ||
238
- zoom > tileJSON.maxzoom ||
368
+ zoom < sourceInfo.minzoom ||
369
+ zoom > sourceInfo.maxzoom ||
239
370
  intX < 0 ||
240
371
  intY < 0 ||
241
372
  intX >= Math.pow(2, zoom) ||
@@ -243,45 +374,83 @@ export const serve_data = {
243
374
  ) {
244
375
  return res.status(404).send('Out of bounds');
245
376
  }
246
- xy = [intX, intY];
247
- bbox = new SphericalMercator().bbox(intX, intY, zoom);
377
+ const bbox = new SphericalMercator().bbox(intX, intY, zoom);
378
+ lon = (bbox[0] + bbox[2]) / 2;
379
+ lat = (bbox[1] + bbox[3]) / 2;
248
380
  } else {
249
- //no zoom limit with coordinates
250
- if (zoom < tileJSON.minzoom) {
251
- zoom = tileJSON.minzoom;
252
- }
253
- if (zoom > tileJSON.maxzoom) {
254
- zoom = tileJSON.maxzoom;
255
- }
256
- bbox = [x, y, x + 0.1, y + 0.1];
257
- const { minX, minY } = new SphericalMercator().xyz(bbox, zoom);
258
- xy = [minX, minY];
381
+ // Coordinate mode
382
+ lon = x;
383
+ lat = y;
259
384
  }
260
385
 
261
- const fetchTile = await fetchTileData(
262
- source,
263
- sourceType,
264
- zoom,
265
- xy[0],
266
- xy[1],
386
+ const results = await getBatchElevations(sourceInfo, [
387
+ { lon, lat, z: zoom },
388
+ ]);
389
+
390
+ if (results[0] == null) {
391
+ return res.status(204).send();
392
+ }
393
+
394
+ // Build response matching original format
395
+ const clampedZoom = Math.min(
396
+ Math.max(zoom, sourceInfo.minzoom),
397
+ sourceInfo.maxzoom,
398
+ );
399
+ const { tileX, tileY, pixelX, pixelY } = lonLatToTilePixel(
400
+ lon,
401
+ lat,
402
+ clampedZoom,
403
+ sourceInfo.tileSize,
267
404
  );
268
- if (fetchTile == null) return res.status(204).send();
269
-
270
- let data = fetchTile.data;
271
- var param = {
272
- long: bbox[0].toFixed(7),
273
- lat: bbox[1].toFixed(7),
274
- encoding,
275
- format,
276
- tile_size: TILE_SIZE,
277
- z: zoom,
278
- x: xy[0],
279
- y: xy[1],
280
- };
281
405
 
282
- res
283
- .status(200)
284
- .send(await serve_rendered.getTerrainElevation(data, param));
406
+ res.status(200).json({
407
+ long: lon,
408
+ lat: lat,
409
+ elevation: results[0],
410
+ z: clampedZoom,
411
+ x: tileX,
412
+ y: tileY,
413
+ pixelX,
414
+ pixelY,
415
+ });
416
+ } catch (err) {
417
+ return res
418
+ .status(500)
419
+ .header('Content-Type', 'text/plain')
420
+ .send(err.message);
421
+ }
422
+ });
423
+
424
+ /**
425
+ * Handles batch elevation requests.
426
+ * Accepts a POST request with JSON body containing:
427
+ * - points: Array of {lon, lat, z} coordinates with zoom level
428
+ * Returns an array of elevations (or null for points with no data) in the same order as input.
429
+ * @param {object} req - Express request object.
430
+ * @param {object} res - Express response object.
431
+ * @param {string} req.params.id - ID of the data source.
432
+ * @returns {Promise<void>}
433
+ */
434
+ app.post('/:id/elevation', async (req, res, next) => {
435
+ try {
436
+ const sourceInfo = validateElevationSource(req.params.id, res);
437
+ if (!sourceInfo) return;
438
+
439
+ const { points } = req.body;
440
+ if (!Array.isArray(points) || points.length === 0) {
441
+ return res.status(400).send('Missing or empty points array');
442
+ }
443
+
444
+ for (let i = 0; i < points.length; i++) {
445
+ // eslint-disable-next-line security/detect-object-injection -- i is loop counter
446
+ const error = validatePoint(points[i], i);
447
+ if (error) {
448
+ return res.status(400).send(error);
449
+ }
450
+ }
451
+
452
+ const results = await getBatchElevations(sourceInfo, points);
453
+ res.status(200).json(results);
285
454
  } catch (err) {
286
455
  return res
287
456
  .status(500)
@@ -298,13 +467,13 @@ export const serve_data = {
298
467
  * @returns {Promise<void>}
299
468
  */
300
469
  app.get('/:id.json', (req, res) => {
301
- if (verbose) {
470
+ if (verbose >= 1) {
302
471
  console.log(
303
472
  `Handling tilejson request for: /data/%s.json`,
304
473
  String(req.params.id).replace(/\n|\r/g, ''),
305
474
  );
306
475
  }
307
- // eslint-disable-next-line security/detect-object-injection -- req.params.id is route parameter, validated by Express
476
+
308
477
  const item = repo[req.params.id];
309
478
  if (!item) {
310
479
  return res.sendStatus(404);
@@ -335,7 +504,7 @@ export const serve_data = {
335
504
  * @param {string} id ID of the data source.
336
505
  * @param {object} programOpts - An object containing the program options
337
506
  * @param {string} programOpts.publicUrl Public URL for the data.
338
- * @param {boolean} programOpts.verbose Whether verbose logging should be used.
507
+ * @param {number} programOpts.verbose Verbosity level (1-3). 1=important, 2=detailed, 3=debug/all requests.
339
508
  * @returns {Promise<void>}
340
509
  */
341
510
  add: async function (options, repo, params, id, programOpts) {
@@ -363,7 +532,7 @@ export const serve_data = {
363
532
  }
364
533
  }
365
534
 
366
- if (verbose && verbose >= 1) {
535
+ if (verbose >= 1) {
367
536
  console.log(`[INFO] Loading data source '${id}' from: ${inputFile}`);
368
537
  }
369
538
 
@@ -373,7 +542,6 @@ export const serve_data = {
373
542
 
374
543
  // Only check file stats for local files, not remote URLs
375
544
  if (!isValidRemoteUrl(inputFile)) {
376
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- inputFile is from config file, validated above
377
545
  const inputFileStats = await fsp.stat(inputFile);
378
546
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
379
547
  throw Error(`Not valid input file: "${inputFile}"`);
@@ -386,7 +554,6 @@ export const serve_data = {
386
554
  tileJSON['format'] = 'pbf';
387
555
  tileJSON['encoding'] = params['encoding'];
388
556
  tileJSON['tileSize'] = params['tileSize'];
389
- tileJSON['sparse'] = params['sparse'];
390
557
 
391
558
  if (inputType === 'pmtiles') {
392
559
  source = openPMtiles(
@@ -394,6 +561,7 @@ export const serve_data = {
394
561
  params.s3Profile,
395
562
  params.requestPayer,
396
563
  params.s3Region,
564
+ params.s3UrlFormat,
397
565
  verbose,
398
566
  );
399
567
  sourceType = 'pmtiles';
@@ -419,12 +587,20 @@ export const serve_data = {
419
587
  tileJSON = options.dataDecoratorFunc(id, 'tilejson', tileJSON);
420
588
  }
421
589
 
590
+ // Determine sparse: per-source overrides global, then format-based default
591
+ // sparse=true -> 404 (allows overzoom)
592
+ // sparse=false -> 204 (empty tile, no overzoom)
593
+ // Default: vector tiles (pbf) -> false, raster tiles -> true
594
+ const isVector = tileJSON.format === 'pbf';
595
+ const sparse = params.sparse ?? options.sparse ?? !isVector;
596
+
422
597
  // eslint-disable-next-line security/detect-object-injection -- id is from config file data source names
423
598
  repo[id] = {
424
599
  tileJSON,
425
600
  publicUrl,
426
601
  source,
427
602
  sourceType,
603
+ sparse,
428
604
  };
429
605
  },
430
606
  };
package/src/serve_font.js CHANGED
@@ -36,7 +36,7 @@ export async function serve_font(options, allowedFonts, programOpts) {
36
36
  '',
37
37
  );
38
38
 
39
- if (verbose) {
39
+ if (verbose >= 1) {
40
40
  console.log(
41
41
  `Handling font request for: /fonts/%s/%s.pbf`,
42
42
  sFontStack,
@@ -86,7 +86,7 @@ export async function serve_font(options, allowedFonts, programOpts) {
86
86
  * @returns {void}
87
87
  */
88
88
  app.get('/fonts.json', (req, res) => {
89
- if (verbose) {
89
+ if (verbose >= 1) {
90
90
  console.log('Handling list font request for /fonts.json');
91
91
  }
92
92
  res.header('Content-type', 'application/json');
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-empty-function */
2
1
  /* eslint-disable @typescript-eslint/no-unused-vars */
3
2
  'use strict';
4
3
 
@@ -7,8 +6,7 @@ export const serve_rendered = {
7
6
  add: (options, repo, params, id, programOpts, dataResolver) => {},
8
7
  remove: (repo, id) => {},
9
8
  clear: (repo) => {},
10
- getTerrainElevation: (data, param) => {
11
- param['elevation'] = 'not supported in light';
12
- return param;
9
+ getBatchElevationsFromTile: (data, param, pixels) => {
10
+ return pixels.map(({ index }) => ({ index, elevation: null }));
13
11
  },
14
12
  };