tileserver-gl-light 5.5.0-pre.1 → 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 -34
  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 +26 -0
  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
@@ -24,7 +24,6 @@ import { SphericalMercator } from '@mapbox/sphericalmercator';
24
24
  import mlgl from '@maplibre/maplibre-gl-native';
25
25
  import polyline from '@mapbox/polyline';
26
26
  import proj4 from 'proj4';
27
- import axios from 'axios';
28
27
  import {
29
28
  allowedScales,
30
29
  allowedTileSizes,
@@ -62,8 +61,7 @@ const staticTypeRegex = new RegExp(
62
61
  );
63
62
 
64
63
  const PATH_PATTERN =
65
- // eslint-disable-next-line security/detect-unsafe-regex -- Simple path pattern validation, no nested quantifiers
66
- /^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/;
64
+ /^((fill|stroke|width|border|borderwidth):[^|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/;
67
65
 
68
66
  const mercator = new SphericalMercator();
69
67
 
@@ -165,7 +163,7 @@ function parseCoordinatePair(coordinates, query) {
165
163
  }
166
164
 
167
165
  // Check if coordinates have been provided as lat/lng pair instead of the
168
- // ususal lng/lat pair and ensure resulting pair is lng/lat
166
+ // usual lng/lat pair and ensure resulting pair is lng/lat
169
167
  if (query.latlng === '1' || query.latlng === 'true') {
170
168
  return [secondCoordinate, firstCoordinate];
171
169
  }
@@ -183,9 +181,18 @@ function parseCoordinatePair(coordinates, query) {
183
181
  function parseCoordinates(coordinatePair, query, transformer) {
184
182
  const parsedCoordinates = parseCoordinatePair(coordinatePair, query);
185
183
 
184
+ if (!parsedCoordinates) {
185
+ return null;
186
+ }
187
+
186
188
  // Transform coordinates
187
189
  if (transformer) {
188
- return transformer(parsedCoordinates);
190
+ try {
191
+ return transformer(parsedCoordinates);
192
+ } catch (error) {
193
+ console.error('Error transforming coordinates:', error);
194
+ return null;
195
+ }
189
196
  }
190
197
 
191
198
  return parsedCoordinates;
@@ -209,12 +216,37 @@ function extractPathsFromQuery(query, transformer) {
209
216
  const providedPaths = Array.isArray(query.path) ? query.path : [query.path];
210
217
  // Iterate through paths, parse and validate them
211
218
  for (const providedPath of providedPaths) {
219
+ let geometryString = providedPath;
220
+
221
+ // Logic to strip style options (like stroke:red) from the front
222
+ const parts = providedPath.split('|');
223
+ let firstGeometryIndex = 0;
224
+ for (const [index, part] of parts.entries()) {
225
+ // A part is considered a style option if it contains ':' but is NOT an 'enc:' string or a coordinate
226
+ if (part.includes(':') && !part.startsWith('enc:')) {
227
+ // This is a style option, continue
228
+ continue;
229
+ } else {
230
+ // This is the start of the geometry (enc: or coordinate)
231
+ firstGeometryIndex = index;
232
+ break;
233
+ }
234
+ }
235
+
236
+ // If we found a geometry, set the geometryString to the rest of the path
237
+ if (firstGeometryIndex > 0) {
238
+ geometryString = parts.slice(firstGeometryIndex).join('|');
239
+ }
240
+
212
241
  // Logic for pushing coords to path when path includes google polyline
213
- if (providedPath.includes('enc:') && PATH_PATTERN.test(providedPath)) {
242
+ if (
243
+ geometryString.includes('enc:') &&
244
+ PATH_PATTERN.test(geometryString)
245
+ ) {
214
246
  // +4 because 'enc:' is 4 characters, everything after 'enc:' is considered to be part of the polyline
215
- const encIndex = providedPath.indexOf('enc:') + 4;
247
+ const encIndex = geometryString.indexOf('enc:') + 4;
216
248
  const coords = polyline
217
- .decode(providedPath.substring(encIndex))
249
+ .decode(geometryString.substring(encIndex))
218
250
  .map(([lat, lng]) => [lng, lat]);
219
251
  paths.push(coords);
220
252
  } else {
@@ -222,7 +254,7 @@ function extractPathsFromQuery(query, transformer) {
222
254
  const currentPath = [];
223
255
 
224
256
  // Extract coordinate-list from path
225
- const pathParts = (providedPath || '').split('|');
257
+ const pathParts = (geometryString || '').split('|');
226
258
 
227
259
  // Iterate through coordinate-list, parse the coordinates and validate them
228
260
  for (const pair of pathParts) {
@@ -250,6 +282,7 @@ function extractPathsFromQuery(query, transformer) {
250
282
  }
251
283
  return paths;
252
284
  }
285
+
253
286
  /**
254
287
  * Parses marker options provided via query and sets corresponding attributes
255
288
  * on marker object.
@@ -269,21 +302,38 @@ function parseMarkerOptions(optionsList, marker) {
269
302
 
270
303
  switch (optionParts[0]) {
271
304
  // Scale factor to up- or downscale icon
272
- case 'scale':
273
- // Scale factors must not be negative
274
- marker.scale = Math.abs(parseFloat(optionParts[1]));
305
+ case 'scale': {
306
+ // Scale factors must not be negative and should have reasonable bounds
307
+ const scale = parseFloat(optionParts[1]);
308
+ if (!isNaN(scale) && scale > 0 && scale < 10) {
309
+ marker.scale = scale;
310
+ } else {
311
+ console.warn(`Invalid marker scale: ${optionParts[1]}`);
312
+ }
275
313
  break;
314
+ }
276
315
  // Icon offset as positive or negative pixel value in the following
277
316
  // format [offsetX],[offsetY] where [offsetY] is optional
278
- case 'offset':
317
+ case 'offset': {
279
318
  const providedOffset = optionParts[1].split(',');
319
+ const offsetX = parseFloat(providedOffset[0]);
320
+
280
321
  // Set X-axis offset
281
- marker.offsetX = parseFloat(providedOffset[0]);
322
+ if (!isNaN(offsetX) && Math.abs(offsetX) < 1000) {
323
+ marker.offsetX = offsetX;
324
+ }
325
+
282
326
  // Check if an offset has been provided for Y-axis
283
327
  if (providedOffset.length > 1) {
284
- marker.offsetY = parseFloat(providedOffset[1]);
328
+ const offsetY = parseFloat(providedOffset[1]);
329
+ if (!isNaN(offsetY) && Math.abs(offsetY) < 1000) {
330
+ marker.offsetY = offsetY;
331
+ }
285
332
  }
286
333
  break;
334
+ }
335
+ default:
336
+ console.warn(`Unknown marker option: ${optionParts[0]}`);
287
337
  }
288
338
  }
289
339
  }
@@ -304,25 +354,32 @@ function extractMarkersFromQuery(query, options, transformer) {
304
354
  const markers = [];
305
355
 
306
356
  // Check if multiple markers have been provided and mimic a list if it's a
307
- // single maker.
357
+ // single marker.
308
358
  const providedMarkers = Array.isArray(query.marker)
309
359
  ? query.marker
310
360
  : [query.marker];
311
361
 
312
- // Iterate through provided markers which can have one of the following
313
- // formats
314
- // [location]|[pathToFileTelativeToConfiguredIconPath]
362
+ // Iterate through provided markers which can have one of the following formats:
363
+ // [location]|[pathToFileRelativeToConfiguredIconPath]
315
364
  // [location]|[pathToFile...]|[option]|[option]|...
316
365
  for (const providedMarker of providedMarkers) {
366
+ if (typeof providedMarker !== 'string') {
367
+ continue;
368
+ }
369
+
317
370
  const markerParts = providedMarker.split('|');
371
+
318
372
  // Ensure we got at least a location and an icon uri
319
373
  if (markerParts.length < 2) {
374
+ console.warn('Marker requires at least location and icon path');
320
375
  continue;
321
376
  }
322
377
 
323
378
  const locationParts = markerParts[0].split(',');
379
+
324
380
  // Ensure the locationParts contains two items
325
381
  if (locationParts.length !== 2) {
382
+ console.warn('Marker location must have exactly 2 coordinates');
326
383
  continue;
327
384
  }
328
385
 
@@ -338,6 +395,7 @@ function extractMarkersFromQuery(query, options, transformer) {
338
395
 
339
396
  // If the selected icon is not part of available icons skip it
340
397
  if (!options.paths.availableIcons.includes(iconURI)) {
398
+ console.warn(`Icon not in available icons: ${iconURI}`);
341
399
  continue;
342
400
  }
343
401
 
@@ -345,21 +403,24 @@ function extractMarkersFromQuery(query, options, transformer) {
345
403
 
346
404
  // When we encounter a remote icon check if the configuration explicitly allows them.
347
405
  } else if (isRemoteURL && options.allowRemoteMarkerIcons !== true) {
406
+ console.warn('Remote marker icons not allowed');
348
407
  continue;
349
408
  } else if (isDataURL && options.allowInlineMarkerImages !== true) {
409
+ console.warn('Inline marker images not allowed');
350
410
  continue;
351
411
  }
352
412
 
353
413
  // Ensure marker location could be parsed
354
414
  const location = parseCoordinates(locationParts, query, transformer);
355
415
  if (location === null) {
416
+ console.warn('Failed to parse marker location');
356
417
  continue;
357
418
  }
358
419
 
359
- const marker = {};
360
-
361
- marker.location = location;
362
- marker.icon = iconURI;
420
+ const marker = {
421
+ location,
422
+ icon: iconURI,
423
+ };
363
424
 
364
425
  // Check if options have been provided
365
426
  if (markerParts.length > 2) {
@@ -453,6 +514,7 @@ async function respondImage(
453
514
  }
454
515
 
455
516
  if (format === 'png' || format === 'webp') {
517
+ /* empty */
456
518
  } else if (format === 'jpg' || format === 'jpeg') {
457
519
  format = 'jpeg';
458
520
  } else {
@@ -469,7 +531,45 @@ async function respondImage(
469
531
  pool = item.map.renderersStatic[scale];
470
532
  }
471
533
 
534
+ if (!pool) {
535
+ console.error(`Pool not found for scale ${scale}, mode ${mode}`);
536
+ return res.status(500).send('Renderer pool not configured');
537
+ }
538
+
472
539
  pool.acquire(async (err, renderer) => {
540
+ // Check if pool.acquire failed or returned null/invalid renderer
541
+ if (err) {
542
+ console.error('Failed to acquire renderer from pool:', err);
543
+ if (!res.headersSent) {
544
+ return res.status(503).send('Renderer pool error');
545
+ }
546
+ return;
547
+ }
548
+
549
+ if (!renderer) {
550
+ console.error(
551
+ 'Renderer is null - likely crashed or failed to initialize',
552
+ );
553
+ if (!res.headersSent) {
554
+ return res.status(503).send('Renderer unavailable');
555
+ }
556
+ return;
557
+ }
558
+
559
+ // Validate renderer has required methods (basic health check)
560
+ if (typeof renderer.render !== 'function') {
561
+ console.error('Renderer is invalid - missing render method');
562
+ try {
563
+ pool.removeBadObject(renderer);
564
+ } catch (e) {
565
+ console.error('Error removing bad renderer:', e);
566
+ }
567
+ if (!res.headersSent) {
568
+ return res.status(503).send('Renderer invalid');
569
+ }
570
+ return;
571
+ }
572
+
473
573
  // For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1
474
574
  let mlglZ;
475
575
  if (width === 512) {
@@ -499,111 +599,162 @@ async function respondImage(
499
599
  params.height += tileMargin * 2;
500
600
  }
501
601
 
502
- renderer.render(params, (err, data) => {
503
- pool.release(renderer);
504
- if (err) {
505
- console.error(err);
506
- return res.status(500).header('Content-Type', 'text/plain').send(err);
507
- }
508
-
509
- const image = sharp(data, {
510
- raw: {
511
- premultiplied: true,
512
- width: params.width * scale,
513
- height: params.height * scale,
514
- channels: 4,
515
- },
516
- });
517
-
518
- if (z > 0 && tileMargin > 0) {
519
- const y = mercator.px(params.center, z)[1];
520
- const yoffset = Math.max(
521
- Math.min(0, y - 128 - tileMargin),
522
- y + 128 + tileMargin - Math.pow(2, z + 8),
523
- );
524
- image.extract({
525
- left: tileMargin * scale,
526
- top: (tileMargin + yoffset) * scale,
527
- width: width * scale,
528
- height: height * scale,
529
- });
530
- }
531
- // HACK(Part 2) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized here.
602
+ // Set a timeout for the render operation to detect hung renderers
603
+ const renderTimeout = setTimeout(() => {
604
+ console.error('Renderer timeout - destroying hung renderer');
532
605
 
533
- if (z === 0 && width === 256) {
534
- image.resize(width * scale, height * scale);
606
+ try {
607
+ pool.removeBadObject(renderer);
608
+ } catch (e) {
609
+ console.error('Error removing timed-out renderer:', e);
535
610
  }
536
- // END HACK(Part 2)
537
611
 
538
- const composites = [];
539
- if (overlay) {
540
- composites.push({ input: overlay });
612
+ if (!res.headersSent) {
613
+ res.status(503).send('Renderer timeout');
541
614
  }
542
- if (item.watermark) {
543
- const canvas = renderWatermark(width, height, scale, item.watermark);
615
+ }, 30000); // 30 second timeout
544
616
 
545
- composites.push({ input: canvas.toBuffer() });
546
- }
617
+ try {
618
+ renderer.render(params, (err, data) => {
619
+ clearTimeout(renderTimeout);
547
620
 
548
- if (mode === 'static' && item.staticAttributionText) {
549
- const canvas = renderAttribution(
550
- width,
551
- height,
552
- scale,
553
- item.staticAttributionText,
554
- );
621
+ if (res.headersSent) {
622
+ // Timeout already fired and sent response, don't process
623
+ return;
624
+ }
555
625
 
556
- composites.push({ input: canvas.toBuffer() });
557
- }
626
+ if (err) {
627
+ console.error('Render error:', err);
628
+ try {
629
+ pool.removeBadObject(renderer);
630
+ } catch (e) {
631
+ console.error('Error removing failed renderer:', e);
632
+ }
633
+ if (!res.headersSent) {
634
+ return res
635
+ .status(500)
636
+ .header('Content-Type', 'text/plain')
637
+ .send(err);
638
+ }
639
+ return;
640
+ }
558
641
 
559
- if (composites.length > 0) {
560
- image.composite(composites);
561
- }
642
+ // Only release if render was successful
643
+ pool.release(renderer);
562
644
 
563
- // Legacy formatQuality is deprecated but still works
564
- const formatQualities = options.formatQuality || {};
565
- if (Object.keys(formatQualities).length !== 0) {
566
- console.log(
567
- 'WARNING: The formatQuality option is deprecated and has been replaced with formatOptions. Please see the documentation. The values from formatQuality will be used if a quality setting is not provided via formatOptions.',
568
- );
569
- }
570
- // eslint-disable-next-line security/detect-object-injection -- format is validated above
571
- const formatQuality = formatQualities[format];
572
-
573
- // eslint-disable-next-line security/detect-object-injection -- format is validated above
574
- const formatOptions = (options.formatOptions || {})[format] || {};
575
-
576
- if (format === 'png') {
577
- image.png({
578
- progressive: formatOptions.progressive,
579
- compressionLevel: formatOptions.compressionLevel,
580
- adaptiveFiltering: formatOptions.adaptiveFiltering,
581
- palette: formatOptions.palette,
582
- quality: formatOptions.quality,
583
- effort: formatOptions.effort,
584
- colors: formatOptions.colors,
585
- dither: formatOptions.dither,
586
- });
587
- } else if (format === 'jpeg') {
588
- image.jpeg({
589
- quality: formatOptions.quality || formatQuality || 80,
590
- progressive: formatOptions.progressive,
645
+ const image = sharp(data, {
646
+ raw: {
647
+ premultiplied: true,
648
+ width: params.width * scale,
649
+ height: params.height * scale,
650
+ channels: 4,
651
+ },
591
652
  });
592
- } else if (format === 'webp') {
593
- image.webp({ quality: formatOptions.quality || formatQuality || 90 });
594
- }
595
- image.toBuffer((err, buffer, info) => {
596
- if (!buffer) {
597
- return res.status(404).send('Not found');
653
+
654
+ if (z > 0 && tileMargin > 0) {
655
+ const y = mercator.px(params.center, z)[1];
656
+ const yoffset = Math.max(
657
+ Math.min(0, y - 128 - tileMargin),
658
+ y + 128 + tileMargin - Math.pow(2, z + 8),
659
+ );
660
+ image.extract({
661
+ left: tileMargin * scale,
662
+ top: (tileMargin + yoffset) * scale,
663
+ width: width * scale,
664
+ height: height * scale,
665
+ });
666
+ }
667
+
668
+ // HACK(Part 2) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized here.
669
+ if (z === 0 && width === 256) {
670
+ image.resize(width * scale, height * scale);
598
671
  }
599
672
 
600
- res.set({
601
- 'Last-Modified': item.lastModified,
602
- 'Content-Type': `image/${format}`,
673
+ const composites = [];
674
+ if (overlay) {
675
+ composites.push({ input: overlay });
676
+ }
677
+ if (item.watermark) {
678
+ const canvas = renderWatermark(width, height, scale, item.watermark);
679
+ composites.push({ input: canvas.toBuffer() });
680
+ }
681
+
682
+ if (mode === 'static' && item.staticAttributionText) {
683
+ const canvas = renderAttribution(
684
+ width,
685
+ height,
686
+ scale,
687
+ item.staticAttributionText,
688
+ );
689
+ composites.push({ input: canvas.toBuffer() });
690
+ }
691
+
692
+ if (composites.length > 0) {
693
+ image.composite(composites);
694
+ }
695
+
696
+ // Legacy formatQuality is deprecated but still works
697
+ const formatQualities = options.formatQuality || {};
698
+ if (Object.keys(formatQualities).length !== 0) {
699
+ console.log(
700
+ 'WARNING: The formatQuality option is deprecated and has been replaced with formatOptions. Please see the documentation. The values from formatQuality will be used if a quality setting is not provided via formatOptions.',
701
+ );
702
+ }
703
+ // eslint-disable-next-line security/detect-object-injection -- format is validated above
704
+ const formatQuality = formatQualities[format];
705
+ // eslint-disable-next-line security/detect-object-injection -- format is validated above
706
+ const formatOptions = (options.formatOptions || {})[format] || {};
707
+
708
+ if (format === 'png') {
709
+ image.png({
710
+ progressive: formatOptions.progressive,
711
+ compressionLevel: formatOptions.compressionLevel,
712
+ adaptiveFiltering: formatOptions.adaptiveFiltering,
713
+ palette: formatOptions.palette,
714
+ quality: formatOptions.quality,
715
+ effort: formatOptions.effort,
716
+ colors: formatOptions.colors,
717
+ dither: formatOptions.dither,
718
+ });
719
+ } else if (format === 'jpeg') {
720
+ image.jpeg({
721
+ quality: formatOptions.quality || formatQuality || 80,
722
+ progressive: formatOptions.progressive,
723
+ });
724
+ } else if (format === 'webp') {
725
+ image.webp({ quality: formatOptions.quality || formatQuality || 90 });
726
+ }
727
+
728
+ image.toBuffer((err, buffer, info) => {
729
+ if (err || !buffer) {
730
+ console.error('Sharp error:', err);
731
+ if (!res.headersSent) {
732
+ return res.status(500).send('Image processing failed');
733
+ }
734
+ return;
735
+ }
736
+
737
+ if (!res.headersSent) {
738
+ res.set({
739
+ 'Last-Modified': item.lastModified,
740
+ 'Content-Type': `image/${format}`,
741
+ });
742
+ return res.status(200).send(buffer);
743
+ }
603
744
  });
604
- return res.status(200).send(buffer);
605
745
  });
606
- });
746
+ } catch (error) {
747
+ clearTimeout(renderTimeout);
748
+ console.error('Unexpected error during render:', error);
749
+ try {
750
+ pool.removeBadObject(renderer);
751
+ } catch (e) {
752
+ console.error('Error removing renderer after error:', e);
753
+ }
754
+ if (!res.headersSent) {
755
+ return res.status(500).send('Render failed');
756
+ }
757
+ }
607
758
  });
608
759
  }
609
760
 
@@ -943,7 +1094,7 @@ export const serve_rendered = {
943
1094
  (!p1 && p2 === 'static') || (p1 === 'static' && p2 === 'raw')
944
1095
  ? 'static'
945
1096
  : 'tile';
946
- if (verbose) {
1097
+ if (verbose >= 3) {
947
1098
  console.log(
948
1099
  `Handling rendered %s request for: /styles/%s%s/%s/%s/%s%s.%s`,
949
1100
  requestType,
@@ -998,13 +1149,12 @@ export const serve_rendered = {
998
1149
  * @returns {void}
999
1150
  */
1000
1151
  app.get('{/:tileSize}/:id.json', (req, res, next) => {
1001
- // eslint-disable-next-line security/detect-object-injection -- req.params.id is route parameter, validated by Express
1002
1152
  const item = repo[req.params.id];
1003
1153
  if (!item) {
1004
1154
  return res.sendStatus(404);
1005
1155
  }
1006
1156
  const tileSize = parseInt(req.params.tileSize, 10) || undefined;
1007
- if (verbose) {
1157
+ if (verbose >= 3) {
1008
1158
  console.log(
1009
1159
  `Handling rendered tilejson request for: /styles/%s%s.json`,
1010
1160
  req.params.tileSize
@@ -1055,11 +1205,16 @@ export const serve_rendered = {
1055
1205
  renderersStatic: [],
1056
1206
  sources: {},
1057
1207
  sourceTypes: {},
1208
+ sparseFlags: {},
1058
1209
  };
1059
1210
 
1060
- const { publicUrl, verbose } = programOpts;
1211
+ const { publicUrl, verbose, fetchTimeout } = programOpts;
1061
1212
 
1062
1213
  const styleJSON = clone(style);
1214
+
1215
+ // Global sparse flag for HTTP/remote sources (from config options)
1216
+ const globalSparse = options.sparse ?? true;
1217
+
1063
1218
  /**
1064
1219
  * Creates a pool of renderers.
1065
1220
  * @param {number} ratio Pixel ratio
@@ -1081,7 +1236,7 @@ export const serve_rendered = {
1081
1236
  ratio,
1082
1237
  request: async (req, callback) => {
1083
1238
  const protocol = req.url.split(':')[0];
1084
- if (verbose && verbose >= 3) {
1239
+ if (verbose >= 3) {
1085
1240
  console.log('Handling request:', req);
1086
1241
  }
1087
1242
  if (protocol === 'sprites') {
@@ -1137,22 +1292,18 @@ export const serve_rendered = {
1137
1292
  x,
1138
1293
  y,
1139
1294
  );
1140
- if (fetchTile == null && sourceInfo.sparse == true) {
1141
- if (verbose) {
1142
- console.log(
1143
- 'fetchTile warning on %s, sparse response',
1144
- req.url,
1145
- );
1295
+ if (fetchTile == null) {
1296
+ if (verbose >= 2) {
1297
+ console.log('fetchTile null on %s', req.url);
1146
1298
  }
1147
- callback();
1148
- return;
1149
- } else if (fetchTile == null) {
1150
- if (verbose) {
1151
- console.log(
1152
- 'fetchTile error on %s, serving empty response',
1153
- req.url,
1154
- );
1299
+ // eslint-disable-next-line security/detect-object-injection -- sourceId from internal style source names
1300
+ const sparse = map.sparseFlags[sourceId] ?? true;
1301
+ // sparse=true (default) -> return empty callback so MapLibre can overzoom
1302
+ if (sparse) {
1303
+ callback();
1304
+ return;
1155
1305
  }
1306
+ // sparse=false -> 204 (empty tile, no overzoom) - create blank response
1156
1307
  createEmptyResponse(
1157
1308
  sourceInfo.format,
1158
1309
  sourceInfo.color,
@@ -1191,62 +1342,115 @@ export const serve_rendered = {
1191
1342
 
1192
1343
  callback(null, response);
1193
1344
  } else if (protocol === 'http' || protocol === 'https') {
1345
+ const controller = new AbortController();
1346
+ const timeoutMs = (fetchTimeout && Number(fetchTimeout)) || 15000;
1347
+ let timeoutId;
1348
+
1194
1349
  try {
1195
- const response = await axios.get(req.url, {
1196
- responseType: 'arraybuffer', // Get the response as raw buffer
1197
- // Axios handles gzip by default, so no need for a gzip flag
1350
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1351
+ const response = await fetch(req.url, {
1352
+ signal: controller.signal,
1198
1353
  });
1354
+ clearTimeout(timeoutId);
1199
1355
 
1200
- const responseHeaders = response.headers;
1201
- const responseData = response.data;
1356
+ // HTTP 204 No Content means "empty tile" - generate a blank tile
1357
+ if (response.status === 204) {
1358
+ const parts = url.parse(req.url);
1359
+ const extension = path.extname(parts.pathname).toLowerCase();
1360
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1361
+ const format = extensionToFormat[extension] || '';
1362
+ createEmptyResponse(format, '', callback);
1363
+ return;
1364
+ }
1365
+
1366
+ if (!response.ok) {
1367
+ if (verbose >= 2) {
1368
+ console.log(
1369
+ 'fetchTile HTTP %d on %s, %s',
1370
+ response.status,
1371
+ req.url,
1372
+ globalSparse
1373
+ ? 'allowing overzoom'
1374
+ : 'creating empty tile',
1375
+ );
1376
+ }
1377
+
1378
+ if (globalSparse) {
1379
+ // sparse=true -> allow overzoom
1380
+ callback();
1381
+ return;
1382
+ }
1383
+
1384
+ // sparse=false (default) -> create empty tile
1385
+ const parts = url.parse(req.url);
1386
+ const extension = path.extname(parts.pathname).toLowerCase();
1387
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1388
+ const format = extensionToFormat[extension] || '';
1389
+ createEmptyResponse(format, '', callback);
1390
+ return;
1391
+ }
1202
1392
 
1393
+ const responseHeaders = response.headers;
1394
+ const responseData = await response.arrayBuffer();
1203
1395
  const parsedResponse = {};
1204
- if (responseHeaders['last-modified']) {
1396
+
1397
+ if (responseHeaders.get('last-modified')) {
1205
1398
  parsedResponse.modified = new Date(
1206
- responseHeaders['last-modified'],
1399
+ responseHeaders.get('last-modified'),
1207
1400
  );
1208
1401
  }
1209
- if (responseHeaders.expires) {
1210
- parsedResponse.expires = new Date(responseHeaders.expires);
1402
+ if (responseHeaders.get('expires')) {
1403
+ parsedResponse.expires = new Date(
1404
+ responseHeaders.get('expires'),
1405
+ );
1211
1406
  }
1212
- if (responseHeaders.etag) {
1213
- parsedResponse.etag = responseHeaders.etag;
1407
+ if (responseHeaders.get('etag')) {
1408
+ parsedResponse.etag = responseHeaders.get('etag');
1214
1409
  }
1215
1410
 
1216
- parsedResponse.data = responseData;
1411
+ parsedResponse.data = Buffer.from(responseData);
1217
1412
  callback(null, parsedResponse);
1218
1413
  } catch (error) {
1219
- if (error.response && error.response.status === 410) {
1220
- // This is the 410 "Gone" error, treat as sparse
1221
- if (verbose) {
1222
- console.log(
1223
- 'fetchTile warning on %s, sparse response due to 410 Gone',
1224
- req.url,
1225
- );
1226
- }
1227
- callback();
1228
- } else {
1229
- // For all other errors (e.g., network errors, 404, 500, etc.) return empty content.
1414
+ // Log DNS failures
1415
+ if (error.cause?.code === 'ENOTFOUND') {
1230
1416
  console.error(
1231
- `Error fetching remote URL ${req.url}:`,
1232
- error.message || error,
1233
- error.response
1234
- ? `Status: ${error.response.status}`
1235
- : 'No response received',
1417
+ `DNS RESOLUTION FAILED for ${req.url}. ` +
1418
+ `This domain may be unreachable or misconfigured in your style. ` +
1419
+ `Consider removing it or fixing the DNS.`,
1236
1420
  );
1421
+ }
1237
1422
 
1238
- const parts = url.parse(req.url);
1239
- const extension = path.extname(parts.pathname).toLowerCase();
1240
- // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1241
- const format = extensionToFormat[extension] || '';
1242
- createEmptyResponse(format, '', callback);
1423
+ // Log timeout
1424
+ if (error.name === 'AbortError') {
1425
+ console.error(
1426
+ `FETCH TIMEOUT for ${req.url}. ` +
1427
+ `The request took longer than ${timeoutMs} ms to complete.`,
1428
+ );
1243
1429
  }
1430
+
1431
+ // Log all other errors
1432
+ console.error(
1433
+ `Error fetching remote URL ${req.url}:`,
1434
+ error.message || error,
1435
+ );
1436
+
1437
+ if (globalSparse) {
1438
+ // sparse=true -> allow overzoom
1439
+ callback();
1440
+ return;
1441
+ }
1442
+
1443
+ // sparse=false (default) -> create empty tile
1444
+ const parts = url.parse(req.url);
1445
+ const extension = path.extname(parts.pathname).toLowerCase();
1446
+ // eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
1447
+ const format = extensionToFormat[extension] || '';
1448
+ createEmptyResponse(format, '', callback);
1244
1449
  }
1245
1450
  } else if (protocol === 'file') {
1246
1451
  const name = decodeURI(req.url).substring(protocol.length + 3);
1247
1452
  const file = path.join(options.paths['files'], name);
1248
1453
  if (await existsP(file)) {
1249
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- file path constructed from configured base path and URL-decoded filename
1250
1454
  const inputFileStats = await fsp.stat(file);
1251
1455
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1252
1456
  throw Error(
@@ -1318,7 +1522,7 @@ export const serve_rendered = {
1318
1522
 
1319
1523
  // Remove (flatten) 3D buildings
1320
1524
  if (layer.paint['fill-extrusion-height']) {
1321
- if (verbose) {
1525
+ if (verbose >= 1) {
1322
1526
  console.warn(
1323
1527
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property 'fill-extrusion-height'. ` +
1324
1528
  `3D extrusion may appear distorted or misleading when rendered as a static image due to camera angle limitations. ` +
@@ -1329,7 +1533,7 @@ export const serve_rendered = {
1329
1533
  layer.paint['fill-extrusion-height'] = 0;
1330
1534
  }
1331
1535
  if (layer.paint['fill-extrusion-base']) {
1332
- if (verbose) {
1536
+ if (verbose >= 1) {
1333
1537
  console.warn(
1334
1538
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property 'fill-extrusion-base'. ` +
1335
1539
  `3D extrusion may appear distorted or misleading when rendered as a static image due to camera angle limitations. ` +
@@ -1349,7 +1553,7 @@ export const serve_rendered = {
1349
1553
 
1350
1554
  for (const prop of hillshadePropertiesToRemove) {
1351
1555
  if (prop in layer.paint) {
1352
- if (verbose) {
1556
+ if (verbose >= 1) {
1353
1557
  console.warn(
1354
1558
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property '${prop}'. ` +
1355
1559
  `This property is not supported by MapLibre Native. ` +
@@ -1364,7 +1568,7 @@ export const serve_rendered = {
1364
1568
 
1365
1569
  // --- Remove 'hillshade-shadow-color' if it is an array. It can only be a string in MapLibre Native ---
1366
1570
  if (Array.isArray(layer.paint['hillshade-shadow-color'])) {
1367
- if (verbose) {
1571
+ if (verbose >= 1) {
1368
1572
  console.warn(
1369
1573
  `Warning: Layer '${layerIdForWarning}' in style '${id}' has property 'hillshade-shadow-color'. ` +
1370
1574
  `An array value is not supported by MapLibre Native for this property (expected string/color). ` +
@@ -1410,7 +1614,6 @@ export const serve_rendered = {
1410
1614
 
1411
1615
  for (const name of Object.keys(styleJSON.sources)) {
1412
1616
  let sourceType;
1413
- let sparse;
1414
1617
  // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1415
1618
  let source = styleJSON.sources[name];
1416
1619
  let url = source.url;
@@ -1436,14 +1639,15 @@ export const serve_rendered = {
1436
1639
  let s3Profile;
1437
1640
  let requestPayer;
1438
1641
  let s3Region;
1642
+ let s3UrlFormat;
1439
1643
  const dataInfo = dataResolver(dataId);
1440
1644
  if (dataInfo.inputFile) {
1441
1645
  inputFile = dataInfo.inputFile;
1442
1646
  sourceType = dataInfo.fileType;
1443
- sparse = dataInfo.sparse;
1444
1647
  s3Profile = dataInfo.s3Profile;
1445
1648
  requestPayer = dataInfo.requestPayer;
1446
1649
  s3Region = dataInfo.s3Region;
1650
+ s3UrlFormat = dataInfo.s3UrlFormat;
1447
1651
  } else {
1448
1652
  console.error(`ERROR: data "${inputFile}" not found!`);
1449
1653
  process.exit(1);
@@ -1451,7 +1655,6 @@ export const serve_rendered = {
1451
1655
 
1452
1656
  // PMTiles supports remote URLs (HTTP and S3), skip file check for those
1453
1657
  if (!isValidRemoteUrl(inputFile)) {
1454
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- inputFile is from dataResolver, which validates against config
1455
1658
  const inputFileStats = await fsp.stat(inputFile);
1456
1659
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1457
1660
  throw Error(`Not valid PMTiles file: "${inputFile}"`);
@@ -1465,6 +1668,7 @@ export const serve_rendered = {
1465
1668
  s3Profile,
1466
1669
  requestPayer,
1467
1670
  s3Region,
1671
+ s3UrlFormat,
1468
1672
  verbose,
1469
1673
  );
1470
1674
  // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
@@ -1483,7 +1687,6 @@ export const serve_rendered = {
1483
1687
  const type = source.type;
1484
1688
  Object.assign(source, metadata);
1485
1689
  source.type = type;
1486
- source.sparse = sparse;
1487
1690
  source.tiles = [
1488
1691
  // meta url which will be detected when requested
1489
1692
  `pmtiles://${name}/{z}/{x}/{y}.${metadata.format || 'pbf'}`,
@@ -1502,9 +1705,16 @@ export const serve_rendered = {
1502
1705
  tileJSON.attribution += source.attribution;
1503
1706
  }
1504
1707
  }
1708
+
1709
+ // Set sparse flag: user config overrides format-based default
1710
+ // Vector tiles (pbf) default to false (204), raster tiles default to true (404)
1711
+ const isVector = metadata.format === 'pbf';
1712
+ // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1713
+ map.sparseFlags[name] =
1714
+ dataInfo.sparse ?? options.sparse ?? !isVector;
1505
1715
  } else {
1506
1716
  // MBTiles does not support remote URLs
1507
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- inputFile is from dataResolver, which validates against config
1717
+
1508
1718
  const inputFileStats = await fsp.stat(inputFile);
1509
1719
  if (!inputFileStats.isFile() || inputFileStats.size === 0) {
1510
1720
  throw Error(`Not valid MBTiles file: "${inputFile}"`);
@@ -1527,7 +1737,6 @@ export const serve_rendered = {
1527
1737
  const type = source.type;
1528
1738
  Object.assign(source, info);
1529
1739
  source.type = type;
1530
- source.sparse = sparse;
1531
1740
  source.tiles = [
1532
1741
  // meta url which will be detected when requested
1533
1742
  `mbtiles://${name}/{z}/{x}/{y}.${info.format || 'pbf'}`,
@@ -1550,6 +1759,13 @@ export const serve_rendered = {
1550
1759
  tileJSON.attribution += source.attribution;
1551
1760
  }
1552
1761
  }
1762
+
1763
+ // Set sparse flag: user config overrides format-based default
1764
+ // Vector tiles (pbf) default to false (204), raster tiles default to true (404)
1765
+ const isVector = info.format === 'pbf';
1766
+ // eslint-disable-next-line security/detect-object-injection -- name is from style sources object keys
1767
+ map.sparseFlags[name] =
1768
+ dataInfo.sparse ?? options.sparse ?? !isVector;
1553
1769
  }
1554
1770
  }
1555
1771
  }
@@ -1616,75 +1832,68 @@ export const serve_rendered = {
1616
1832
  delete repo[id];
1617
1833
  });
1618
1834
  },
1835
+
1619
1836
  /**
1620
- * Get the elevation of terrain tile data by rendering it to a canvas image
1621
- * @param {Buffer} data The terrain tile data buffer.
1622
- * @param {object} param Required parameters (coordinates e.g.)
1623
- * @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
1624
1842
  */
1625
- getTerrainElevation: async function (data, param) {
1626
- return await new Promise(async (resolve, reject) => {
1843
+ getBatchElevationsFromTile: async function (data, param, pixels) {
1844
+ return new Promise((resolve, reject) => {
1627
1845
  const image = new Image();
1628
- image.onload = async () => {
1629
- const canvas = createCanvas(param['tile_size'], param['tile_size']);
1630
- const context = canvas.getContext('2d');
1631
- context.drawImage(image, 0, 0);
1632
-
1633
- // calculate pixel coordinate of tile,
1634
- // see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
1635
- let siny = Math.sin((param['lat'] * Math.PI) / 180);
1636
- // Truncating to 0.9999 effectively limits latitude to 89.189. This is
1637
- // about a third of a tile past the edge of the world tile.
1638
- siny = Math.min(Math.max(siny, -0.9999), 0.9999);
1639
- const xWorld = param['tile_size'] * (0.5 + param['long'] / 360);
1640
- const yWorld =
1641
- param['tile_size'] *
1642
- (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
1643
-
1644
- const scale = 1 << param['z'];
1645
-
1646
- const xTile = Math.floor((xWorld * scale) / param['tile_size']);
1647
- const yTile = Math.floor((yWorld * scale) / param['tile_size']);
1648
-
1649
- const xPixel = Math.floor(xWorld * scale) - xTile * param['tile_size'];
1650
- const yPixel = Math.floor(yWorld * scale) - yTile * param['tile_size'];
1651
- if (
1652
- xPixel < 0 ||
1653
- yPixel < 0 ||
1654
- xPixel >= param['tile_size'] ||
1655
- yPixel >= param['tile_size']
1656
- ) {
1657
- return reject('Out of bounds Pixel');
1658
- }
1659
- const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
1660
- const red = imgdata.data[0];
1661
- const green = imgdata.data[1];
1662
- const blue = imgdata.data[2];
1663
- let elevation;
1664
- if (param['encoding'] === 'mapbox') {
1665
- elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
1666
- } else if (param['encoding'] === 'terrarium') {
1667
- elevation = red * 256 + green + blue / 256 - 32768;
1668
- } else {
1669
- elevation = 'invalid encoding';
1846
+ image.onload = () => {
1847
+ try {
1848
+ const canvas = createCanvas(param.tile_size, param.tile_size);
1849
+ const context = canvas.getContext('2d');
1850
+ context.drawImage(image, 0, 0);
1851
+
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 });
1877
+ }
1878
+ resolve(results);
1879
+ } catch (error) {
1880
+ reject(error);
1670
1881
  }
1671
- param['elevation'] = elevation;
1672
- param['red'] = red;
1673
- param['green'] = green;
1674
- param['blue'] = blue;
1675
- resolve(param);
1676
1882
  };
1677
1883
  image.onerror = (err) => reject(err);
1678
- if (param['format'] === 'webp') {
1884
+
1885
+ (async () => {
1679
1886
  try {
1680
- const img = await sharp(data).toFormat('png').toBuffer();
1681
- image.src = img;
1887
+ if (param.format === 'webp') {
1888
+ const img = await sharp(data).toFormat('png').toBuffer();
1889
+ image.src = `data:image/png;base64,${img.toString('base64')}`;
1890
+ } else {
1891
+ image.src = data;
1892
+ }
1682
1893
  } catch (err) {
1683
1894
  reject(err);
1684
1895
  }
1685
- } else {
1686
- image.src = data;
1687
- }
1896
+ })();
1688
1897
  });
1689
1898
  },
1690
1899
  };