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.
- package/CHANGELOG.md +51 -34
- package/docs/config.rst +52 -11
- package/docs/endpoints.rst +12 -2
- package/docs/installation.rst +6 -6
- package/docs/usage.rst +26 -0
- package/package.json +15 -15
- package/public/resources/elevation-control.js +92 -21
- package/public/resources/maplibre-gl-inspect.js +2827 -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 +31 -20
- package/src/pmtiles_adapter.js +104 -45
- package/src/promises.js +1 -1
- package/src/render.js +270 -93
- package/src/serve_data.js +266 -90
- package/src/serve_font.js +2 -2
- package/src/serve_light.js +2 -4
- package/src/serve_rendered.js +445 -236
- package/src/serve_style.js +29 -8
- package/src/server.js +115 -60
- package/src/utils.js +47 -20
- package/test/elevation.js +513 -0
- 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/test/tiles_data.js +1 -1
- package/test/utils/create_terrain_mbtiles.js +124 -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 {
|
|
@@ -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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
composites.push({ input: overlay });
|
|
612
|
+
if (!res.headersSent) {
|
|
613
|
+
res.status(503).send('Renderer timeout');
|
|
541
614
|
}
|
|
542
|
-
|
|
543
|
-
const canvas = renderWatermark(width, height, scale, item.watermark);
|
|
615
|
+
}, 30000); // 30 second timeout
|
|
544
616
|
|
|
545
|
-
|
|
546
|
-
|
|
617
|
+
try {
|
|
618
|
+
renderer.render(params, (err, data) => {
|
|
619
|
+
clearTimeout(renderTimeout);
|
|
547
620
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
642
|
+
// Only release if render was successful
|
|
643
|
+
pool.release(renderer);
|
|
562
644
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
if (
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1396
|
+
|
|
1397
|
+
if (responseHeaders.get('last-modified')) {
|
|
1205
1398
|
parsedResponse.modified = new Date(
|
|
1206
|
-
responseHeaders
|
|
1399
|
+
responseHeaders.get('last-modified'),
|
|
1207
1400
|
);
|
|
1208
1401
|
}
|
|
1209
|
-
if (responseHeaders.expires) {
|
|
1210
|
-
parsedResponse.expires = new Date(
|
|
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
|
-
|
|
1220
|
-
|
|
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
|
-
`
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1621
|
-
* @param {Buffer} data
|
|
1622
|
-
* @param {object} param
|
|
1623
|
-
* @
|
|
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
|
-
|
|
1626
|
-
return
|
|
1843
|
+
getBatchElevationsFromTile: async function (data, param, pixels) {
|
|
1844
|
+
return new Promise((resolve, reject) => {
|
|
1627
1845
|
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
|
-
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
|
-
|
|
1884
|
+
|
|
1885
|
+
(async () => {
|
|
1679
1886
|
try {
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
}
|
|
1686
|
-
image.src = data;
|
|
1687
|
-
}
|
|
1896
|
+
})();
|
|
1688
1897
|
});
|
|
1689
1898
|
},
|
|
1690
1899
|
};
|