tileserver-gl-light 5.5.0-pre.1 → 5.5.0-pre.2
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/CHANGELOG.md +4 -2
- package/package.json +9 -9
- package/public/resources/maplibre-gl-inspect.js +2823 -2770
- package/public/resources/maplibre-gl-inspect.js.map +1 -1
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.js +4 -4
- package/public/resources/maplibre-gl.js.map +1 -1
- package/src/main.js +16 -17
- package/src/pmtiles_adapter.js +3 -3
- package/src/promises.js +1 -1
- package/src/render.js +270 -93
- package/src/serve_data.js +6 -8
- package/src/serve_light.js +0 -1
- package/src/serve_rendered.js +372 -205
- package/src/server.js +22 -27
- package/src/utils.js +17 -18
- package/test/fixtures/visual/encoded-path-auto.png +0 -0
- package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
- package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
- package/test/fixtures/visual/path-auto.png +0 -0
- package/test/fixtures/visual/static-bbox.png +0 -0
- package/test/fixtures/visual/static-bearing-pitch.png +0 -0
- package/test/fixtures/visual/static-bearing.png +0 -0
- package/test/fixtures/visual/static-border-global.png +0 -0
- package/test/fixtures/visual/static-lat-lng.png +0 -0
- package/test/fixtures/visual/static-markers.png +0 -0
- package/test/fixtures/visual/static-multiple-paths.png +0 -0
- package/test/fixtures/visual/static-path-border-isolated.png +0 -0
- package/test/fixtures/visual/static-path-border-stroke.png +0 -0
- package/test/fixtures/visual/static-path-latlng.png +0 -0
- package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
- package/test/static_images.js +241 -0
package/src/serve_rendered.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
247
|
+
const encIndex = geometryString.indexOf('enc:') + 4;
|
|
216
248
|
const coords = polyline
|
|
217
|
-
.decode(
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
362
|
-
|
|
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 {
|
|
@@ -470,6 +532,31 @@ async function respondImage(
|
|
|
470
532
|
}
|
|
471
533
|
|
|
472
534
|
pool.acquire(async (err, renderer) => {
|
|
535
|
+
// Check if pool.acquire failed or returned null/invalid renderer
|
|
536
|
+
if (err) {
|
|
537
|
+
console.error('Failed to acquire renderer from pool:', err);
|
|
538
|
+
return res.status(503).send('Renderer pool error');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!renderer) {
|
|
542
|
+
console.error(
|
|
543
|
+
'Renderer is null - likely crashed or failed to initialize',
|
|
544
|
+
);
|
|
545
|
+
return res.status(503).send('Renderer unavailable');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Validate renderer has required methods (basic health check)
|
|
549
|
+
if (typeof renderer.render !== 'function') {
|
|
550
|
+
console.error('Renderer is invalid - missing render method');
|
|
551
|
+
// Destroy the bad renderer and remove from pool
|
|
552
|
+
try {
|
|
553
|
+
pool.destroy(renderer);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
console.error('Error destroying invalid renderer:', e);
|
|
556
|
+
}
|
|
557
|
+
return res.status(503).send('Renderer invalid');
|
|
558
|
+
}
|
|
559
|
+
|
|
473
560
|
// For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1
|
|
474
561
|
let mlglZ;
|
|
475
562
|
if (width === 512) {
|
|
@@ -499,111 +586,154 @@ async function respondImage(
|
|
|
499
586
|
params.height += tileMargin * 2;
|
|
500
587
|
}
|
|
501
588
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
589
|
+
// Set a timeout for the render operation to detect hung renderers
|
|
590
|
+
const renderTimeout = setTimeout(() => {
|
|
591
|
+
console.error('Renderer timeout - destroying potentially hung renderer');
|
|
592
|
+
try {
|
|
593
|
+
// Don't release back to pool, destroy it
|
|
594
|
+
pool.destroy(renderer);
|
|
595
|
+
} catch (e) {
|
|
596
|
+
console.error('Error destroying timed-out renderer:', e);
|
|
507
597
|
}
|
|
598
|
+
if (!res.headersSent) {
|
|
599
|
+
res.status(503).send('Renderer timeout');
|
|
600
|
+
}
|
|
601
|
+
}, 30000); // 30 second timeout
|
|
508
602
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
width: params.width * scale,
|
|
513
|
-
height: params.height * scale,
|
|
514
|
-
channels: 4,
|
|
515
|
-
},
|
|
516
|
-
});
|
|
603
|
+
try {
|
|
604
|
+
renderer.render(params, (err, data) => {
|
|
605
|
+
clearTimeout(renderTimeout);
|
|
517
606
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
607
|
+
if (err) {
|
|
608
|
+
console.error('Render error:', err);
|
|
609
|
+
// Destroy renderer instead of releasing it back to pool since it may be corrupted
|
|
610
|
+
try {
|
|
611
|
+
pool.destroy(renderer);
|
|
612
|
+
} catch (e) {
|
|
613
|
+
console.error('Error destroying failed renderer:', e);
|
|
614
|
+
}
|
|
615
|
+
if (!res.headersSent) {
|
|
616
|
+
return res
|
|
617
|
+
.status(500)
|
|
618
|
+
.header('Content-Type', 'text/plain')
|
|
619
|
+
.send(err);
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Only release if render was successful
|
|
625
|
+
pool.release(renderer);
|
|
626
|
+
|
|
627
|
+
const image = sharp(data, {
|
|
628
|
+
raw: {
|
|
629
|
+
premultiplied: true,
|
|
630
|
+
width: params.width * scale,
|
|
631
|
+
height: params.height * scale,
|
|
632
|
+
channels: 4,
|
|
633
|
+
},
|
|
529
634
|
});
|
|
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.
|
|
532
635
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
636
|
+
if (z > 0 && tileMargin > 0) {
|
|
637
|
+
const y = mercator.px(params.center, z)[1];
|
|
638
|
+
const yoffset = Math.max(
|
|
639
|
+
Math.min(0, y - 128 - tileMargin),
|
|
640
|
+
y + 128 + tileMargin - Math.pow(2, z + 8),
|
|
641
|
+
);
|
|
642
|
+
image.extract({
|
|
643
|
+
left: tileMargin * scale,
|
|
644
|
+
top: (tileMargin + yoffset) * scale,
|
|
645
|
+
width: width * scale,
|
|
646
|
+
height: height * scale,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
// 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.
|
|
537
650
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (item.watermark) {
|
|
543
|
-
const canvas = renderWatermark(width, height, scale, item.watermark);
|
|
651
|
+
if (z === 0 && width === 256) {
|
|
652
|
+
image.resize(width * scale, height * scale);
|
|
653
|
+
}
|
|
654
|
+
// END HACK(Part 2)
|
|
544
655
|
|
|
545
|
-
composites
|
|
546
|
-
|
|
656
|
+
const composites = [];
|
|
657
|
+
if (overlay) {
|
|
658
|
+
composites.push({ input: overlay });
|
|
659
|
+
}
|
|
660
|
+
if (item.watermark) {
|
|
661
|
+
const canvas = renderWatermark(width, height, scale, item.watermark);
|
|
547
662
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
width,
|
|
551
|
-
height,
|
|
552
|
-
scale,
|
|
553
|
-
item.staticAttributionText,
|
|
554
|
-
);
|
|
663
|
+
composites.push({ input: canvas.toBuffer() });
|
|
664
|
+
}
|
|
555
665
|
|
|
556
|
-
|
|
557
|
-
|
|
666
|
+
if (mode === 'static' && item.staticAttributionText) {
|
|
667
|
+
const canvas = renderAttribution(
|
|
668
|
+
width,
|
|
669
|
+
height,
|
|
670
|
+
scale,
|
|
671
|
+
item.staticAttributionText,
|
|
672
|
+
);
|
|
558
673
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
674
|
+
composites.push({ input: canvas.toBuffer() });
|
|
675
|
+
}
|
|
562
676
|
|
|
563
|
-
|
|
564
|
-
|
|
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,
|
|
591
|
-
});
|
|
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');
|
|
677
|
+
if (composites.length > 0) {
|
|
678
|
+
image.composite(composites);
|
|
598
679
|
}
|
|
599
680
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
681
|
+
// Legacy formatQuality is deprecated but still works
|
|
682
|
+
const formatQualities = options.formatQuality || {};
|
|
683
|
+
if (Object.keys(formatQualities).length !== 0) {
|
|
684
|
+
console.log(
|
|
685
|
+
'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.',
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
// eslint-disable-next-line security/detect-object-injection -- format is validated above
|
|
689
|
+
const formatQuality = formatQualities[format];
|
|
690
|
+
|
|
691
|
+
// eslint-disable-next-line security/detect-object-injection -- format is validated above
|
|
692
|
+
const formatOptions = (options.formatOptions || {})[format] || {};
|
|
693
|
+
|
|
694
|
+
if (format === 'png') {
|
|
695
|
+
image.png({
|
|
696
|
+
progressive: formatOptions.progressive,
|
|
697
|
+
compressionLevel: formatOptions.compressionLevel,
|
|
698
|
+
adaptiveFiltering: formatOptions.adaptiveFiltering,
|
|
699
|
+
palette: formatOptions.palette,
|
|
700
|
+
quality: formatOptions.quality,
|
|
701
|
+
effort: formatOptions.effort,
|
|
702
|
+
colors: formatOptions.colors,
|
|
703
|
+
dither: formatOptions.dither,
|
|
704
|
+
});
|
|
705
|
+
} else if (format === 'jpeg') {
|
|
706
|
+
image.jpeg({
|
|
707
|
+
quality: formatOptions.quality || formatQuality || 80,
|
|
708
|
+
progressive: formatOptions.progressive,
|
|
709
|
+
});
|
|
710
|
+
} else if (format === 'webp') {
|
|
711
|
+
image.webp({ quality: formatOptions.quality || formatQuality || 90 });
|
|
712
|
+
}
|
|
713
|
+
image.toBuffer((err, buffer, info) => {
|
|
714
|
+
if (!buffer) {
|
|
715
|
+
return res.status(404).send('Not found');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
res.set({
|
|
719
|
+
'Last-Modified': item.lastModified,
|
|
720
|
+
'Content-Type': `image/${format}`,
|
|
721
|
+
});
|
|
722
|
+
return res.status(200).send(buffer);
|
|
603
723
|
});
|
|
604
|
-
return res.status(200).send(buffer);
|
|
605
724
|
});
|
|
606
|
-
})
|
|
725
|
+
} catch (error) {
|
|
726
|
+
clearTimeout(renderTimeout);
|
|
727
|
+
console.error('Unexpected error during render:', error);
|
|
728
|
+
try {
|
|
729
|
+
pool.destroy(renderer);
|
|
730
|
+
} catch (e) {
|
|
731
|
+
console.error('Error destroying renderer after error:', e);
|
|
732
|
+
}
|
|
733
|
+
if (!res.headersSent) {
|
|
734
|
+
return res.status(500).send('Render failed');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
607
737
|
});
|
|
608
738
|
}
|
|
609
739
|
|
|
@@ -998,7 +1128,6 @@ export const serve_rendered = {
|
|
|
998
1128
|
* @returns {void}
|
|
999
1129
|
*/
|
|
1000
1130
|
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
1131
|
const item = repo[req.params.id];
|
|
1003
1132
|
if (!item) {
|
|
1004
1133
|
return res.sendStatus(404);
|
|
@@ -1192,61 +1321,90 @@ export const serve_rendered = {
|
|
|
1192
1321
|
callback(null, response);
|
|
1193
1322
|
} else if (protocol === 'http' || protocol === 'https') {
|
|
1194
1323
|
try {
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1324
|
+
// Add timeout to prevent hanging on unreachable hosts
|
|
1325
|
+
const controller = new AbortController();
|
|
1326
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
|
1327
|
+
|
|
1328
|
+
const response = await fetch(req.url, {
|
|
1329
|
+
signal: controller.signal,
|
|
1198
1330
|
});
|
|
1199
1331
|
|
|
1332
|
+
clearTimeout(timeoutId);
|
|
1333
|
+
|
|
1334
|
+
// Handle 410 Gone as sparse response
|
|
1335
|
+
if (response.status === 410) {
|
|
1336
|
+
if (verbose) {
|
|
1337
|
+
console.log(
|
|
1338
|
+
'fetchTile warning on %s, sparse response due to 410 Gone',
|
|
1339
|
+
req.url,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
callback();
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Check for other non-ok responses
|
|
1347
|
+
if (!response.ok) {
|
|
1348
|
+
throw new Error(
|
|
1349
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1200
1353
|
const responseHeaders = response.headers;
|
|
1201
|
-
const responseData = response.
|
|
1354
|
+
const responseData = await response.arrayBuffer();
|
|
1202
1355
|
|
|
1203
1356
|
const parsedResponse = {};
|
|
1204
|
-
if (responseHeaders
|
|
1357
|
+
if (responseHeaders.get('last-modified')) {
|
|
1205
1358
|
parsedResponse.modified = new Date(
|
|
1206
|
-
responseHeaders
|
|
1359
|
+
responseHeaders.get('last-modified'),
|
|
1207
1360
|
);
|
|
1208
1361
|
}
|
|
1209
|
-
if (responseHeaders.expires) {
|
|
1210
|
-
parsedResponse.expires = new Date(
|
|
1362
|
+
if (responseHeaders.get('expires')) {
|
|
1363
|
+
parsedResponse.expires = new Date(
|
|
1364
|
+
responseHeaders.get('expires'),
|
|
1365
|
+
);
|
|
1211
1366
|
}
|
|
1212
|
-
if (responseHeaders.etag) {
|
|
1213
|
-
parsedResponse.etag = responseHeaders.etag;
|
|
1367
|
+
if (responseHeaders.get('etag')) {
|
|
1368
|
+
parsedResponse.etag = responseHeaders.get('etag');
|
|
1214
1369
|
}
|
|
1215
1370
|
|
|
1216
|
-
parsedResponse.data = responseData;
|
|
1371
|
+
parsedResponse.data = Buffer.from(responseData);
|
|
1217
1372
|
callback(null, parsedResponse);
|
|
1218
1373
|
} catch (error) {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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.
|
|
1374
|
+
// Log DNS failures more prominently as they often indicate config issues
|
|
1375
|
+
// Native fetch wraps DNS errors in error.cause
|
|
1376
|
+
if (error.cause?.code === 'ENOTFOUND') {
|
|
1230
1377
|
console.error(
|
|
1231
|
-
`
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
? `Status: ${error.response.status}`
|
|
1235
|
-
: 'No response received',
|
|
1378
|
+
`DNS RESOLUTION FAILED for ${req.url}. ` +
|
|
1379
|
+
`This domain may be unreachable or misconfigured in your style. ` +
|
|
1380
|
+
`Consider removing it or fixing the DNS.`,
|
|
1236
1381
|
);
|
|
1382
|
+
}
|
|
1237
1383
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1384
|
+
// Handle AbortController timeout
|
|
1385
|
+
if (error.name === 'AbortError') {
|
|
1386
|
+
console.error(
|
|
1387
|
+
`FETCH TIMEOUT for ${req.url}. ` +
|
|
1388
|
+
`The request took longer than 10 seconds to complete.`,
|
|
1389
|
+
);
|
|
1243
1390
|
}
|
|
1391
|
+
|
|
1392
|
+
// For all other errors (e.g., network errors, 404, 500, etc.) return empty content.
|
|
1393
|
+
console.error(
|
|
1394
|
+
`Error fetching remote URL ${req.url}:`,
|
|
1395
|
+
error.message || error,
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
const parts = url.parse(req.url);
|
|
1399
|
+
const extension = path.extname(parts.pathname).toLowerCase();
|
|
1400
|
+
// eslint-disable-next-line security/detect-object-injection -- extension is from path.extname, limited set
|
|
1401
|
+
const format = extensionToFormat[extension] || '';
|
|
1402
|
+
createEmptyResponse(format, '', callback);
|
|
1244
1403
|
}
|
|
1245
1404
|
} else if (protocol === 'file') {
|
|
1246
1405
|
const name = decodeURI(req.url).substring(protocol.length + 3);
|
|
1247
1406
|
const file = path.join(options.paths['files'], name);
|
|
1248
1407
|
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
1408
|
const inputFileStats = await fsp.stat(file);
|
|
1251
1409
|
if (!inputFileStats.isFile() || inputFileStats.size === 0) {
|
|
1252
1410
|
throw Error(
|
|
@@ -1451,7 +1609,6 @@ export const serve_rendered = {
|
|
|
1451
1609
|
|
|
1452
1610
|
// PMTiles supports remote URLs (HTTP and S3), skip file check for those
|
|
1453
1611
|
if (!isValidRemoteUrl(inputFile)) {
|
|
1454
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename -- inputFile is from dataResolver, which validates against config
|
|
1455
1612
|
const inputFileStats = await fsp.stat(inputFile);
|
|
1456
1613
|
if (!inputFileStats.isFile() || inputFileStats.size === 0) {
|
|
1457
1614
|
throw Error(`Not valid PMTiles file: "${inputFile}"`);
|
|
@@ -1504,7 +1661,7 @@ export const serve_rendered = {
|
|
|
1504
1661
|
}
|
|
1505
1662
|
} else {
|
|
1506
1663
|
// MBTiles does not support remote URLs
|
|
1507
|
-
|
|
1664
|
+
|
|
1508
1665
|
const inputFileStats = await fsp.stat(inputFile);
|
|
1509
1666
|
if (!inputFileStats.isFile() || inputFileStats.size === 0) {
|
|
1510
1667
|
throw Error(`Not valid MBTiles file: "${inputFile}"`);
|
|
@@ -1623,68 +1780,78 @@ export const serve_rendered = {
|
|
|
1623
1780
|
* @returns {Promise<object>} Promise resolving to elevation data
|
|
1624
1781
|
*/
|
|
1625
1782
|
getTerrainElevation: async function (data, param) {
|
|
1626
|
-
return
|
|
1627
|
-
const image = new Image();
|
|
1628
|
-
image.onload =
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1783
|
+
return new Promise((resolve, reject) => {
|
|
1784
|
+
const image = new Image(); // Create a new Image object
|
|
1785
|
+
image.onload = () => {
|
|
1786
|
+
try {
|
|
1787
|
+
const canvas = createCanvas(param['tile_size'], param['tile_size']);
|
|
1788
|
+
const context = canvas.getContext('2d');
|
|
1789
|
+
context.drawImage(image, 0, 0);
|
|
1790
|
+
|
|
1791
|
+
// calculate pixel coordinate of tile,
|
|
1792
|
+
// see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
|
|
1793
|
+
let siny = Math.sin((param['lat'] * Math.PI) / 180);
|
|
1794
|
+
// Truncating to 0.9999 effectively limits latitude to 89.189. This is
|
|
1795
|
+
// about a third of a tile past the edge of the world tile.
|
|
1796
|
+
siny = Math.min(Math.max(siny, -0.9999), 0.9999);
|
|
1797
|
+
const xWorld = param['tile_size'] * (0.5 + param['long'] / 360);
|
|
1798
|
+
const yWorld =
|
|
1799
|
+
param['tile_size'] *
|
|
1800
|
+
(0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
|
|
1801
|
+
|
|
1802
|
+
const scale = 1 << param['z'];
|
|
1803
|
+
|
|
1804
|
+
const xTile = Math.floor((xWorld * scale) / param['tile_size']);
|
|
1805
|
+
const yTile = Math.floor((yWorld * scale) / param['tile_size']);
|
|
1806
|
+
|
|
1807
|
+
const xPixel =
|
|
1808
|
+
Math.floor(xWorld * scale) - xTile * param['tile_size'];
|
|
1809
|
+
const yPixel =
|
|
1810
|
+
Math.floor(yWorld * scale) - yTile * param['tile_size'];
|
|
1811
|
+
if (
|
|
1812
|
+
xPixel < 0 ||
|
|
1813
|
+
yPixel < 0 ||
|
|
1814
|
+
xPixel >= param['tile_size'] ||
|
|
1815
|
+
yPixel >= param['tile_size']
|
|
1816
|
+
) {
|
|
1817
|
+
return reject('Out of bounds Pixel');
|
|
1818
|
+
}
|
|
1819
|
+
const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
|
|
1820
|
+
const red = imgdata.data[0];
|
|
1821
|
+
const green = imgdata.data[1];
|
|
1822
|
+
const blue = imgdata.data[2];
|
|
1823
|
+
let elevation;
|
|
1824
|
+
if (param['encoding'] === 'mapbox') {
|
|
1825
|
+
elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
|
|
1826
|
+
} else if (param['encoding'] === 'terrarium') {
|
|
1827
|
+
elevation = red * 256 + green + blue / 256 - 32768;
|
|
1828
|
+
} else {
|
|
1829
|
+
elevation = 'invalid encoding';
|
|
1830
|
+
}
|
|
1831
|
+
param['elevation'] = elevation;
|
|
1832
|
+
param['red'] = red;
|
|
1833
|
+
param['green'] = green;
|
|
1834
|
+
param['blue'] = blue;
|
|
1835
|
+
resolve(param);
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
reject(error); // Catch any errors during canvas operations
|
|
1670
1838
|
}
|
|
1671
|
-
param['elevation'] = elevation;
|
|
1672
|
-
param['red'] = red;
|
|
1673
|
-
param['green'] = green;
|
|
1674
|
-
param['blue'] = blue;
|
|
1675
|
-
resolve(param);
|
|
1676
1839
|
};
|
|
1677
1840
|
image.onerror = (err) => reject(err);
|
|
1678
|
-
|
|
1841
|
+
|
|
1842
|
+
// Load the image data - handle the sharp conversion outside the Promise
|
|
1843
|
+
(async () => {
|
|
1679
1844
|
try {
|
|
1680
|
-
|
|
1681
|
-
|
|
1845
|
+
if (param['format'] === 'webp') {
|
|
1846
|
+
const img = await sharp(data).toFormat('png').toBuffer();
|
|
1847
|
+
image.src = `data:image/png;base64,${img.toString('base64')}`; // Set data URL
|
|
1848
|
+
} else {
|
|
1849
|
+
image.src = data;
|
|
1850
|
+
}
|
|
1682
1851
|
} catch (err) {
|
|
1683
|
-
reject(err);
|
|
1852
|
+
reject(err); // Reject promise on sharp error
|
|
1684
1853
|
}
|
|
1685
|
-
}
|
|
1686
|
-
image.src = data;
|
|
1687
|
-
}
|
|
1854
|
+
})();
|
|
1688
1855
|
});
|
|
1689
1856
|
},
|
|
1690
1857
|
};
|