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
package/src/render.js CHANGED
@@ -5,6 +5,17 @@ import { SphericalMercator } from '@mapbox/sphericalmercator';
5
5
 
6
6
  const mercator = new SphericalMercator();
7
7
 
8
+ // Constants
9
+ const CONSTANTS = {
10
+ DEFAULT_LINE_WIDTH: 1,
11
+ DEFAULT_BORDER_WIDTH_RATIO: 0.1, // 10% of line width
12
+ DEFAULT_FILL_COLOR: 'rgba(255,255,255,0.4)',
13
+ DEFAULT_STROKE_COLOR: 'rgba(0,64,255,0.7)',
14
+ MAX_LINE_WIDTH: 500,
15
+ MAX_BORDER_WIDTH: 250,
16
+ MARKER_LOAD_TIMEOUT: 5000,
17
+ };
18
+
8
19
  /**
9
20
  * Transforms coordinates to pixels.
10
21
  * @param {Array<number>} ll - Longitude/Latitude coordinate pair.
@@ -17,6 +28,73 @@ const precisePx = (ll, zoom) => {
17
28
  return [px[0] * scale, px[1] * scale];
18
29
  };
19
30
 
31
+ /**
32
+ * Validates if a string is a valid color value.
33
+ * @param {string} color - Color string to validate.
34
+ * @returns {boolean} True if valid color.
35
+ */
36
+ const isValidColor = (color) => {
37
+ if (!color || typeof color !== 'string') {
38
+ return false;
39
+ }
40
+
41
+ // Allow 'none' and 'transparent' keywords
42
+ if (color === 'none' || color === 'transparent') {
43
+ return true;
44
+ }
45
+
46
+ // Basic validation for common formats
47
+ const hexPattern = /^#([0-9A-Fa-f]{3}){1,2}$/; // 3 or 6 digits
48
+ const hexAlphaPattern = /^#([0-9A-Fa-f]{8})$/; // 8 digits with alpha
49
+ const rgbPattern = /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/;
50
+ const rgbaPattern = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/;
51
+ const namedColors = [
52
+ 'red',
53
+ 'blue',
54
+ 'green',
55
+ 'yellow',
56
+ 'black',
57
+ 'white',
58
+ 'gray',
59
+ 'grey',
60
+ 'orange',
61
+ 'purple',
62
+ 'pink',
63
+ 'brown',
64
+ 'cyan',
65
+ 'magenta',
66
+ ];
67
+
68
+ return (
69
+ hexPattern.test(color) ||
70
+ hexAlphaPattern.test(color) ||
71
+ rgbPattern.test(color) ||
72
+ rgbaPattern.test(color) ||
73
+ namedColors.includes(color.toLowerCase())
74
+ );
75
+ };
76
+
77
+ /**
78
+ * Safely parses a numeric value with bounds checking.
79
+ * @param {string|number} value - Value to parse.
80
+ * @param {number} defaultValue - Default value if parsing fails.
81
+ * @param {number} min - Minimum allowed value.
82
+ * @param {number} max - Maximum allowed value.
83
+ * @returns {number} Parsed and bounded value.
84
+ */
85
+ const safeParseNumber = (
86
+ value,
87
+ defaultValue,
88
+ min = -Infinity,
89
+ max = Infinity,
90
+ ) => {
91
+ const parsed = Number(value);
92
+ if (isNaN(parsed)) {
93
+ return defaultValue;
94
+ }
95
+ return Math.max(min, Math.min(max, parsed));
96
+ };
97
+
20
98
  /**
21
99
  * Draws a marker in canvas context.
22
100
  * @param {CanvasRenderingContext2D} ctx - Canvas context object.
@@ -25,22 +103,28 @@ const precisePx = (ll, zoom) => {
25
103
  * @returns {Promise<void>} A promise that resolves when the marker is drawn.
26
104
  */
27
105
  const drawMarker = (ctx, marker, z) => {
28
- return new Promise((resolve) => {
106
+ return new Promise((resolve, reject) => {
29
107
  const img = new Image();
30
108
  const pixelCoords = precisePx(marker.location, z);
31
109
 
110
+ // Add timeout to prevent hanging on slow/failed image loads
111
+ const timeout = setTimeout(() => {
112
+ reject(new Error(`Marker image load timeout: ${marker.icon}`));
113
+ }, CONSTANTS.MARKER_LOAD_TIMEOUT);
114
+
32
115
  const getMarkerCoordinates = (imageWidth, imageHeight, scale) => {
33
116
  // Images are placed with their top-left corner at the provided location
34
117
  // within the canvas but we expect icons to be centered and above it.
35
118
 
36
- // Substract half of the images width from the x-coordinate to center
119
+ // Subtract half of the image's width from the x-coordinate to center
37
120
  // the image in relation to the provided location
38
121
  let xCoordinate = pixelCoords[0] - imageWidth / 2;
39
- // Substract the images height from the y-coordinate to place it above
122
+
123
+ // Subtract the image's height from the y-coordinate to place it above
40
124
  // the provided location
41
125
  let yCoordinate = pixelCoords[1] - imageHeight;
42
126
 
43
- // Since image placement is dependent on the size offsets have to be
127
+ // Since image placement is dependent on the size, offsets have to be
44
128
  // scaled as well. Additionally offsets are provided as either positive or
45
129
  // negative values so we always add them
46
130
  if (marker.offsetX) {
@@ -57,30 +141,38 @@ const drawMarker = (ctx, marker, z) => {
57
141
  };
58
142
 
59
143
  const drawOnCanvas = () => {
60
- // Check if the images should be resized before beeing drawn
61
- const defaultScale = 1;
62
- const scale = marker.scale ? marker.scale : defaultScale;
63
-
64
- // Calculate scaled image sizes
65
- const imageWidth = img.width * scale;
66
- const imageHeight = img.height * scale;
67
-
68
- // Pass the desired sizes to get correlating coordinates
69
- const coords = getMarkerCoordinates(imageWidth, imageHeight, scale);
70
-
71
- // Draw the image on canvas
72
- if (scale != defaultScale) {
73
- ctx.drawImage(img, coords.x, coords.y, imageWidth, imageHeight);
74
- } else {
75
- ctx.drawImage(img, coords.x, coords.y);
144
+ clearTimeout(timeout);
145
+
146
+ try {
147
+ // Check if the image should be resized before being drawn
148
+ const defaultScale = 1;
149
+ const scale = marker.scale ? marker.scale : defaultScale;
150
+
151
+ // Calculate scaled image sizes
152
+ const imageWidth = img.width * scale;
153
+ const imageHeight = img.height * scale;
154
+
155
+ // Pass the desired sizes to get correlating coordinates
156
+ const coords = getMarkerCoordinates(imageWidth, imageHeight, scale);
157
+
158
+ // Draw the image on canvas
159
+ if (scale !== defaultScale) {
160
+ ctx.drawImage(img, coords.x, coords.y, imageWidth, imageHeight);
161
+ } else {
162
+ ctx.drawImage(img, coords.x, coords.y);
163
+ }
164
+
165
+ // Resolve the promise when image has been drawn
166
+ resolve();
167
+ } catch (error) {
168
+ reject(new Error(`Failed to draw marker: ${error.message}`));
76
169
  }
77
- // Resolve the promise when image has been drawn
78
- resolve();
79
170
  };
80
171
 
81
172
  img.onload = drawOnCanvas;
82
- img.onerror = (err) => {
83
- throw err;
173
+ img.onerror = () => {
174
+ clearTimeout(timeout);
175
+ reject(new Error(`Failed to load marker image: ${marker.icon}`));
84
176
  };
85
177
  img.src = marker.icon;
86
178
  });
@@ -89,7 +181,7 @@ const drawMarker = (ctx, marker, z) => {
89
181
  /**
90
182
  * Draws a list of markers onto a canvas.
91
183
  * Wraps drawing of markers into list of promises and awaits them.
92
- * It's required because images are expected to load asynchronous in canvas js
184
+ * It's required because images are expected to load asynchronously in canvas js
93
185
  * even when provided from a local disk.
94
186
  * @param {CanvasRenderingContext2D} ctx - Canvas context object.
95
187
  * @param {Array<object>} markers - Marker objects parsed by extractMarkersFromQuery.
@@ -105,7 +197,26 @@ const drawMarkers = async (ctx, markers, z) => {
105
197
  }
106
198
 
107
199
  // Await marker drawings before continuing
108
- await Promise.all(markerPromises);
200
+ // Use Promise.allSettled to continue even if some markers fail
201
+ const results = await Promise.allSettled(markerPromises);
202
+
203
+ // Log any failures
204
+ results.forEach((result, index) => {
205
+ if (result.status === 'rejected') {
206
+ console.warn(`Marker ${index} failed to render:`, result.reason);
207
+ }
208
+ });
209
+ };
210
+
211
+ /**
212
+ * Extracts an option value from a path query string.
213
+ * @param {Array<string>} splitPaths - Path string split by pipe character.
214
+ * @param {string} optionName - Name of the option to extract.
215
+ * @returns {string|undefined} Option value or undefined if not found.
216
+ */
217
+ const getInlineOption = (splitPaths, optionName) => {
218
+ const found = splitPaths.find((x) => x.startsWith(`${optionName}:`));
219
+ return found ? found.replace(`${optionName}:`, '') : undefined;
109
220
  };
110
221
 
111
222
  /**
@@ -118,21 +229,25 @@ const drawMarkers = async (ctx, markers, z) => {
118
229
  * @returns {void}
119
230
  */
120
231
  const drawPath = (ctx, path, query, pathQuery, z) => {
121
- const splitPaths = pathQuery.split('|');
122
-
123
232
  if (!path || path.length < 2) {
124
- return null;
233
+ return;
125
234
  }
126
235
 
236
+ const splitPaths = pathQuery.split('|');
237
+
238
+ // Start the path - transform coordinates to pixels on canvas and draw lines between points
127
239
  ctx.beginPath();
128
240
 
129
- // Transform coordinates to pixel on canvas and draw lines between points
130
- for (const pair of path) {
241
+ for (const [i, pair] of path.entries()) {
131
242
  const px = precisePx(pair, z);
132
- ctx.lineTo(px[0], px[1]);
243
+ if (i === 0) {
244
+ ctx.moveTo(px[0], px[1]);
245
+ } else {
246
+ ctx.lineTo(px[0], px[1]);
247
+ }
133
248
  }
134
249
 
135
- // Check if first coordinate matches last coordinate
250
+ // Check if first coordinate matches last coordinate (closed path)
136
251
  if (
137
252
  path[0][0] === path[path.length - 1][0] &&
138
253
  path[0][1] === path[path.length - 1][1]
@@ -140,77 +255,130 @@ const drawPath = (ctx, path, query, pathQuery, z) => {
140
255
  ctx.closePath();
141
256
  }
142
257
 
143
- // Optionally fill drawn shape with a rgba color from query
144
- const pathHasFill = splitPaths.filter((x) => x.startsWith('fill')).length > 0;
258
+ // --- FILL Logic ---
259
+ const inlineFill = getInlineOption(splitPaths, 'fill');
260
+ const pathHasFill = inlineFill !== undefined;
261
+
145
262
  if (query.fill !== undefined || pathHasFill) {
146
- if ('fill' in query) {
147
- ctx.fillStyle = query.fill || 'rgba(255,255,255,0.4)';
148
- }
263
+ let fillColor;
264
+
149
265
  if (pathHasFill) {
150
- ctx.fillStyle = splitPaths
151
- .find((x) => x.startsWith('fill:'))
152
- .replace('fill:', '');
266
+ fillColor = inlineFill;
267
+ } else if ('fill' in query) {
268
+ fillColor = query.fill || CONSTANTS.DEFAULT_FILL_COLOR;
269
+ } else {
270
+ fillColor = CONSTANTS.DEFAULT_FILL_COLOR;
153
271
  }
154
- ctx.fill();
155
- }
156
272
 
157
- // Get line width from query and fall back to 1 if not provided
158
- const pathHasWidth =
159
- splitPaths.filter((x) => x.startsWith('width')).length > 0;
160
- if (query.width !== undefined || pathHasWidth) {
161
- let lineWidth = 1;
162
- // Get line width from query
163
- if ('width' in query) {
164
- lineWidth = Number(query.width);
273
+ // Validate color before using
274
+ if (isValidColor(fillColor)) {
275
+ ctx.fillStyle = fillColor;
276
+ ctx.fill();
277
+ } else {
278
+ console.warn(`Invalid fill color: ${fillColor}, using default`);
279
+ ctx.fillStyle = CONSTANTS.DEFAULT_FILL_COLOR;
280
+ ctx.fill();
165
281
  }
166
- // Get line width from path in query
167
- if (pathHasWidth) {
168
- lineWidth = Number(
169
- splitPaths.find((x) => x.startsWith('width:')).replace('width:', ''),
170
- );
171
- }
172
- // Get border width from query and fall back to 10% of line width
173
- const borderWidth =
174
- query.borderwidth !== undefined
175
- ? parseFloat(query.borderwidth)
176
- : lineWidth * 0.1;
177
-
178
- // Set rendering style for the start and end points of the path
179
- // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap
180
- ctx.lineCap = query.linecap || 'butt';
181
-
182
- // Set rendering style for overlapping segments of the path with differing directions
183
- // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin
184
- ctx.lineJoin = query.linejoin || 'miter';
185
-
186
- // In order to simulate a border we draw the path two times with the first
187
- // beeing the wider border part.
188
- if (query.border !== undefined && borderWidth > 0) {
282
+ }
283
+
284
+ // --- WIDTH & BORDER Logic ---
285
+ const inlineWidth = getInlineOption(splitPaths, 'width');
286
+ const pathHasWidth = inlineWidth !== undefined;
287
+ const inlineBorder = getInlineOption(splitPaths, 'border');
288
+ const inlineBorderWidth = getInlineOption(splitPaths, 'borderwidth');
289
+ const pathHasBorder = inlineBorder !== undefined;
290
+
291
+ // Parse line width with validation
292
+ let lineWidth = CONSTANTS.DEFAULT_LINE_WIDTH;
293
+ if (pathHasWidth) {
294
+ lineWidth = safeParseNumber(
295
+ inlineWidth,
296
+ CONSTANTS.DEFAULT_LINE_WIDTH,
297
+ 0,
298
+ CONSTANTS.MAX_LINE_WIDTH,
299
+ );
300
+ } else if ('width' in query) {
301
+ lineWidth = safeParseNumber(
302
+ query.width,
303
+ CONSTANTS.DEFAULT_LINE_WIDTH,
304
+ 0,
305
+ CONSTANTS.MAX_LINE_WIDTH,
306
+ );
307
+ }
308
+
309
+ // Get border width with validation
310
+ // Default: 10% of line width
311
+ let borderWidth = lineWidth * CONSTANTS.DEFAULT_BORDER_WIDTH_RATIO;
312
+ if (pathHasBorder && inlineBorderWidth) {
313
+ borderWidth = safeParseNumber(
314
+ inlineBorderWidth,
315
+ borderWidth,
316
+ 0,
317
+ CONSTANTS.MAX_BORDER_WIDTH,
318
+ );
319
+ } else if (query.borderwidth !== undefined) {
320
+ borderWidth = safeParseNumber(
321
+ query.borderwidth,
322
+ borderWidth,
323
+ 0,
324
+ CONSTANTS.MAX_BORDER_WIDTH,
325
+ );
326
+ }
327
+
328
+ // Set rendering style for the start and end points of the path
329
+ // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap
330
+ const validLineCaps = ['butt', 'round', 'square'];
331
+ ctx.lineCap = validLineCaps.includes(query.linecap) ? query.linecap : 'butt';
332
+
333
+ // Set rendering style for overlapping segments of the path with differing directions
334
+ // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin
335
+ const validLineJoins = ['miter', 'round', 'bevel'];
336
+ ctx.lineJoin = validLineJoins.includes(query.linejoin)
337
+ ? query.linejoin
338
+ : 'miter';
339
+
340
+ // The final border color, prioritized by inline over global query
341
+ const finalBorder = pathHasBorder ? inlineBorder : query.border;
342
+
343
+ // In order to simulate a border we draw the path two times with the first
344
+ // being the wider border part.
345
+ if (finalBorder !== undefined && borderWidth > 0) {
346
+ // Validate border color
347
+ if (isValidColor(finalBorder)) {
189
348
  // We need to double the desired border width and add it to the line width
190
349
  // in order to get the desired border on each side of the line.
191
350
  ctx.lineWidth = lineWidth + borderWidth * 2;
192
- // Set border style as rgba
193
- ctx.strokeStyle = query.border;
351
+ ctx.strokeStyle = finalBorder;
194
352
  ctx.stroke();
353
+ } else {
354
+ console.warn(`Invalid border color: ${finalBorder}, skipping border`);
195
355
  }
196
- ctx.lineWidth = lineWidth;
197
356
  }
198
357
 
199
- const pathHasStroke =
200
- splitPaths.filter((x) => x.startsWith('stroke')).length > 0;
201
- if (query.stroke !== undefined || pathHasStroke) {
202
- if ('stroke' in query) {
203
- ctx.strokeStyle = query.stroke;
204
- }
205
- // Path Stroke gets higher priority
206
- if (pathHasStroke) {
207
- ctx.strokeStyle = splitPaths
208
- .find((x) => x.startsWith('stroke:'))
209
- .replace('stroke:', '');
210
- }
358
+ // Set line width for the main stroke
359
+ ctx.lineWidth = lineWidth;
360
+
361
+ // --- STROKE Logic ---
362
+ const inlineStroke = getInlineOption(splitPaths, 'stroke');
363
+ const pathHasStroke = inlineStroke !== undefined;
364
+
365
+ let strokeColor;
366
+ if (pathHasStroke) {
367
+ strokeColor = inlineStroke;
368
+ } else if ('stroke' in query) {
369
+ strokeColor = query.stroke;
211
370
  } else {
212
- ctx.strokeStyle = 'rgba(0,64,255,0.7)';
371
+ strokeColor = CONSTANTS.DEFAULT_STROKE_COLOR;
213
372
  }
373
+
374
+ // Validate stroke color
375
+ if (isValidColor(strokeColor)) {
376
+ ctx.strokeStyle = strokeColor;
377
+ } else {
378
+ console.warn(`Invalid stroke color: ${strokeColor}, using default`);
379
+ ctx.strokeStyle = CONSTANTS.DEFAULT_STROKE_COLOR;
380
+ }
381
+
214
382
  ctx.stroke();
215
383
  };
216
384
 
@@ -260,23 +428,32 @@ export const renderOverlay = async (
260
428
  const canvas = createCanvas(scale * w, scale * h);
261
429
  const ctx = canvas.getContext('2d');
262
430
  ctx.scale(scale, scale);
431
+
263
432
  if (bearing) {
264
433
  ctx.translate(w / 2, h / 2);
265
434
  ctx.rotate((-bearing / 180) * Math.PI);
266
435
  ctx.translate(-center[0], -center[1]);
267
436
  } else {
268
- // optimized path
437
+ // Optimized path
269
438
  ctx.translate(-center[0] + w / 2, -center[1] + h / 2);
270
439
  }
271
440
 
272
441
  // Draw provided paths if any
273
442
  paths.forEach((path, i) => {
274
443
  const pathQuery = Array.isArray(query.path) ? query.path.at(i) : query.path;
275
- drawPath(ctx, path, query, pathQuery, z);
444
+ try {
445
+ drawPath(ctx, path, query, pathQuery, z);
446
+ } catch (error) {
447
+ console.error(`Error drawing path ${i}:`, error);
448
+ }
276
449
  });
277
450
 
278
451
  // Await drawing of markers before rendering the canvas
279
- await drawMarkers(ctx, markers, z);
452
+ try {
453
+ await drawMarkers(ctx, markers, z);
454
+ } catch (error) {
455
+ console.error('Error drawing markers:', error);
456
+ }
280
457
 
281
458
  return canvas.toBuffer();
282
459
  };