tileserver-gl-light 5.5.0-pre.7 → 5.5.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/src/serve_data.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  getTileUrls,
15
15
  isValidRemoteUrl,
16
16
  fetchTileData,
17
+ lonLatToTilePixel,
17
18
  } from './utils.js';
18
19
  import { getPMtilesInfo, openPMtiles } from './pmtiles_adapter.js';
19
20
  import { gunzipP, gzipP } from './promises.js';
@@ -45,6 +46,7 @@ export const serve_data = {
45
46
  init: function (options, repo, programOpts) {
46
47
  const { verbose } = programOpts;
47
48
  const app = express().disable('x-powered-by');
49
+ app.use(express.json());
48
50
 
49
51
  /**
50
52
  * Handles requests for tile data, responding with the tile image.
@@ -168,6 +170,164 @@ export const serve_data = {
168
170
  return res.status(200).send(data);
169
171
  });
170
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
+
171
331
  /**
172
332
  * Handles requests for elevation data.
173
333
  * @param {object} req - Express request object.
@@ -190,48 +350,23 @@ export const serve_data = {
190
350
  );
191
351
  }
192
352
 
193
- const item = repo?.[req.params.id];
194
- if (!item) return res.sendStatus(404);
195
- if (!item.source) return res.status(404).send('Missing source');
196
- if (!item.tileJSON) return res.status(404).send('Missing tileJSON');
197
- if (!item.sourceType) return res.status(404).send('Missing sourceType');
198
- const { source, tileJSON, sourceType } = item;
199
- if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') {
200
- return res
201
- .status(400)
202
- .send('Invalid sourceType. Must be pmtiles or mbtiles.');
203
- }
204
- const encoding = tileJSON?.encoding;
205
- if (encoding == null) {
206
- return res.status(400).send('Missing tileJSON.encoding');
207
- } else if (encoding !== 'terrarium' && encoding !== 'mapbox') {
208
- return res
209
- .status(400)
210
- .send('Invalid encoding. Must be terrarium or mapbox.');
211
- }
212
- const format = tileJSON?.format;
213
- if (format == null) {
214
- return res.status(400).send('Missing tileJSON.format');
215
- } else if (format !== 'webp' && format !== 'png') {
216
- return res.status(400).send('Invalid format. Must be webp or png.');
217
- }
353
+ const sourceInfo = validateElevationSource(req.params.id, res);
354
+ if (!sourceInfo) return;
355
+
218
356
  const z = parseInt(req.params.z, 10);
219
357
  const x = parseFloat(req.params.x);
220
358
  const y = parseFloat(req.params.y);
221
- if (tileJSON.minzoom == null || tileJSON.maxzoom == null) {
222
- return res.status(404).send(JSON.stringify(tileJSON));
223
- }
224
- const TILE_SIZE = tileJSON.tileSize || 512;
225
- let bbox;
226
- let xy;
227
- var zoom = z;
359
+
360
+ let lon, lat;
361
+ let zoom = z;
228
362
 
229
363
  if (Number.isInteger(x) && Number.isInteger(y)) {
364
+ // Tile coordinates mode - strict bounds checking
230
365
  const intX = parseInt(req.params.x, 10);
231
366
  const intY = parseInt(req.params.y, 10);
232
367
  if (
233
- zoom < tileJSON.minzoom ||
234
- zoom > tileJSON.maxzoom ||
368
+ zoom < sourceInfo.minzoom ||
369
+ zoom > sourceInfo.maxzoom ||
235
370
  intX < 0 ||
236
371
  intY < 0 ||
237
372
  intX >= Math.pow(2, zoom) ||
@@ -239,49 +374,83 @@ export const serve_data = {
239
374
  ) {
240
375
  return res.status(404).send('Out of bounds');
241
376
  }
242
- xy = [intX, intY];
243
- 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;
244
380
  } else {
245
- //no zoom limit with coordinates
246
- if (zoom < tileJSON.minzoom) {
247
- zoom = tileJSON.minzoom;
248
- }
249
- if (zoom > tileJSON.maxzoom) {
250
- zoom = tileJSON.maxzoom;
251
- }
252
- bbox = [x, y, x + 0.1, y + 0.1];
253
- const { minX, minY } = new SphericalMercator().xyz(bbox, zoom);
254
- xy = [minX, minY];
381
+ // Coordinate mode
382
+ lon = x;
383
+ lat = y;
255
384
  }
256
385
 
257
- const fetchTile = await fetchTileData(
258
- source,
259
- sourceType,
260
- zoom,
261
- xy[0],
262
- 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,
263
398
  );
264
- if (fetchTile == null) {
265
- // sparse=true (default) -> 404 (allows overzoom)
266
- // sparse=false -> 204 (empty tile, no overzoom)
267
- return res.status(item.sparse ? 404 : 204).send();
399
+ const { tileX, tileY, pixelX, pixelY } = lonLatToTilePixel(
400
+ lon,
401
+ lat,
402
+ clampedZoom,
403
+ sourceInfo.tileSize,
404
+ );
405
+
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');
268
442
  }
269
443
 
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
- };
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
+ }
281
451
 
282
- res
283
- .status(200)
284
- .send(await serve_rendered.getTerrainElevation(data, param));
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)
@@ -6,8 +6,7 @@ export const serve_rendered = {
6
6
  add: (options, repo, params, id, programOpts, dataResolver) => {},
7
7
  remove: (repo, id) => {},
8
8
  clear: (repo) => {},
9
- getTerrainElevation: (data, param) => {
10
- param['elevation'] = 'not supported in light';
11
- return param;
9
+ getBatchElevationsFromTile: (data, param, pixels) => {
10
+ return pixels.map(({ index }) => ({ index, elevation: null }));
12
11
  },
13
12
  };
@@ -1832,83 +1832,66 @@ export const serve_rendered = {
1832
1832
  delete repo[id];
1833
1833
  });
1834
1834
  },
1835
+
1835
1836
  /**
1836
- * Get the elevation of terrain tile data by rendering it to a canvas image
1837
- * @param {Buffer} data The terrain tile data buffer.
1838
- * @param {object} param Required parameters (coordinates e.g.)
1839
- * @returns {Promise<object>} Promise resolving to elevation data
1837
+ * Gets multiple elevation values from a single decoded tile image.
1838
+ * @param {Buffer} data - Raw tile image data
1839
+ * @param {object} param - Parameters object containing encoding, format, and tile_size
1840
+ * @param {Array<{pixelX: number, pixelY: number, index: number}>} pixels - Array of pixel coordinates to sample
1841
+ * @returns {Promise<Array<{index: number, elevation: number}>>} Promise resolving to array of elevation results
1840
1842
  */
1841
- getTerrainElevation: async function (data, param) {
1843
+ getBatchElevationsFromTile: async function (data, param, pixels) {
1842
1844
  return new Promise((resolve, reject) => {
1843
- const image = new Image(); // Create a new Image object
1845
+ const image = new Image();
1844
1846
  image.onload = () => {
1845
1847
  try {
1846
- const canvas = createCanvas(param['tile_size'], param['tile_size']);
1848
+ const canvas = createCanvas(param.tile_size, param.tile_size);
1847
1849
  const context = canvas.getContext('2d');
1848
1850
  context.drawImage(image, 0, 0);
1849
1851
 
1850
- // calculate pixel coordinate of tile,
1851
- // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
1852
- let siny = Math.sin((param['lat'] * Math.PI) / 180);
1853
- // Truncating to 0.9999 effectively limits latitude to 89.189. This is
1854
- // about a third of a tile past the edge of the world tile.
1855
- siny = Math.min(Math.max(siny, -0.9999), 0.9999);
1856
- const xWorld = param['tile_size'] * (0.5 + param['long'] / 360);
1857
- const yWorld =
1858
- param['tile_size'] *
1859
- (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
1860
-
1861
- const scale = 1 << param['z'];
1862
-
1863
- const xTile = Math.floor((xWorld * scale) / param['tile_size']);
1864
- const yTile = Math.floor((yWorld * scale) / param['tile_size']);
1865
-
1866
- const xPixel =
1867
- Math.floor(xWorld * scale) - xTile * param['tile_size'];
1868
- const yPixel =
1869
- Math.floor(yWorld * scale) - yTile * param['tile_size'];
1870
- if (
1871
- xPixel < 0 ||
1872
- yPixel < 0 ||
1873
- xPixel >= param['tile_size'] ||
1874
- yPixel >= param['tile_size']
1875
- ) {
1876
- return reject('Out of bounds Pixel');
1877
- }
1878
- const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
1879
- const red = imgdata.data[0];
1880
- const green = imgdata.data[1];
1881
- const blue = imgdata.data[2];
1882
- let elevation;
1883
- if (param['encoding'] === 'mapbox') {
1884
- elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
1885
- } else if (param['encoding'] === 'terrarium') {
1886
- elevation = red * 256 + green + blue / 256 - 32768;
1887
- } else {
1888
- elevation = 'invalid encoding';
1852
+ const results = [];
1853
+ for (const pixel of pixels) {
1854
+ const { pixelX, pixelY, index } = pixel;
1855
+ if (
1856
+ pixelX < 0 ||
1857
+ pixelY < 0 ||
1858
+ pixelX >= param.tile_size ||
1859
+ pixelY >= param.tile_size
1860
+ ) {
1861
+ results.push({ index, elevation: null });
1862
+ continue;
1863
+ }
1864
+ const imgdata = context.getImageData(pixelX, pixelY, 1, 1);
1865
+ const red = imgdata.data[0];
1866
+ const green = imgdata.data[1];
1867
+ const blue = imgdata.data[2];
1868
+ let elevation;
1869
+ if (param.encoding === 'mapbox') {
1870
+ elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
1871
+ } else if (param.encoding === 'terrarium') {
1872
+ elevation = red * 256 + green + blue / 256 - 32768;
1873
+ } else {
1874
+ elevation = null;
1875
+ }
1876
+ results.push({ index, elevation });
1889
1877
  }
1890
- param['elevation'] = elevation;
1891
- param['red'] = red;
1892
- param['green'] = green;
1893
- param['blue'] = blue;
1894
- resolve(param);
1878
+ resolve(results);
1895
1879
  } catch (error) {
1896
- reject(error); // Catch any errors during canvas operations
1880
+ reject(error);
1897
1881
  }
1898
1882
  };
1899
1883
  image.onerror = (err) => reject(err);
1900
1884
 
1901
- // Load the image data - handle the sharp conversion outside the Promise
1902
1885
  (async () => {
1903
1886
  try {
1904
- if (param['format'] === 'webp') {
1887
+ if (param.format === 'webp') {
1905
1888
  const img = await sharp(data).toFormat('png').toBuffer();
1906
- image.src = `data:image/png;base64,${img.toString('base64')}`; // Set data URL
1889
+ image.src = `data:image/png;base64,${img.toString('base64')}`;
1907
1890
  } else {
1908
1891
  image.src = data;
1909
1892
  }
1910
1893
  } catch (err) {
1911
- reject(err); // Reject promise on sharp error
1894
+ reject(err);
1912
1895
  }
1913
1896
  })();
1914
1897
  });
package/src/utils.js CHANGED
@@ -471,6 +471,35 @@ export function isMBTilesProtocol(string) {
471
471
  }
472
472
  }
473
473
 
474
+ /**
475
+ * Converts a longitude/latitude point to tile and pixel coordinates at a given zoom level.
476
+ * @param {number} lon - Longitude in degrees.
477
+ * @param {number} lat - Latitude in degrees.
478
+ * @param {number} zoom - Zoom level.
479
+ * @param {number} tileSize - Size of the tile in pixels (e.g., 256 or 512).
480
+ * @returns {{tileX: number, tileY: number, pixelX: number, pixelY: number}} - Tile and pixel coordinates.
481
+ */
482
+ export function lonLatToTilePixel(lon, lat, zoom, tileSize) {
483
+ let siny = Math.sin((lat * Math.PI) / 180);
484
+ // Truncating to 0.9999 effectively limits latitude to 89.189. This is
485
+ // about a third of a tile past the edge of the world tile.
486
+ siny = Math.min(Math.max(siny, -0.9999), 0.9999);
487
+
488
+ const xWorld = tileSize * (0.5 + lon / 360);
489
+ const yWorld =
490
+ tileSize * (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
491
+
492
+ const scale = 1 << zoom;
493
+
494
+ const tileX = Math.floor((xWorld * scale) / tileSize);
495
+ const tileY = Math.floor((yWorld * scale) / tileSize);
496
+
497
+ const pixelX = Math.floor(xWorld * scale) - tileX * tileSize;
498
+ const pixelY = Math.floor(yWorld * scale) - tileY * tileSize;
499
+
500
+ return { tileX, tileY, pixelX, pixelY };
501
+ }
502
+
474
503
  /**
475
504
  * Fetches tile data from either PMTiles or MBTiles source.
476
505
  * @param {object} source - The source object, which may contain a mbtiles object, or pmtiles object.