tileserver-gl-light 4.1.1 → 4.1.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/.dockerignore CHANGED
@@ -1,6 +1,7 @@
1
1
  *
2
2
  !src
3
3
  !public
4
+ !test
4
5
  !package.json
5
6
  !package-lock.json
6
7
  !docker-entrypoint.sh
package/Dockerfile CHANGED
@@ -1,8 +1,25 @@
1
- FROM node:16-bullseye
1
+ FROM ubuntu:focal
2
+
3
+ ENV \
4
+ NODE_ENV="production" \
5
+ CHOKIDAR_USEPOLLING=1 \
6
+ CHOKIDAR_INTERVAL=500
7
+
8
+ RUN set -ex; \
9
+ export DEBIAN_FRONTEND=noninteractive; \
10
+ groupadd -r node; \
11
+ useradd -r -g node node; \
12
+ apt-get -qq update; \
13
+ apt-get -y --no-install-recommends install \
14
+ ca-certificates \
15
+ wget; \
16
+ wget -qO- https://deb.nodesource.com/setup_16.x | bash; \
17
+ apt-get install -y nodejs; \
18
+ apt-get -y remove wget; \
19
+ apt-get -y --purge autoremove; \
20
+ apt-get clean; \
21
+ rm -rf /var/lib/apt/lists/*;
2
22
 
3
- ENV NODE_ENV="production"
4
- ENV CHOKIDAR_USEPOLLING=1
5
- ENV CHOKIDAR_INTERVAL=500
6
23
  EXPOSE 80
7
24
  VOLUME /data
8
25
  WORKDIR /data
@@ -10,5 +27,6 @@ ENTRYPOINT ["/usr/src/app/docker-entrypoint.sh"]
10
27
 
11
28
  RUN mkdir -p /usr/src/app
12
29
  COPY / /usr/src/app
13
- RUN cd /usr/src/app && npm install --production
30
+ RUN cd /usr/src/app && npm install --omit=dev
14
31
  RUN ["chmod", "+x", "/usr/src/app/docker-entrypoint.sh"]
32
+ USER node:node
package/Dockerfile_test CHANGED
@@ -2,34 +2,43 @@
2
2
  # Simply run "docker build -f Dockerfile_test ."
3
3
  # WARNING: sometimes it fails with a core dumped exception
4
4
 
5
- FROM node:16-bullseye
5
+ FROM ubuntu:focal
6
6
 
7
- RUN apt-get -qq update \
8
- && DEBIAN_FRONTEND=noninteractive apt-get -y install \
9
- apt-transport-https \
10
- curl \
11
- unzip \
12
- build-essential \
13
- python \
14
- libcairo2-dev \
15
- libgles2-mesa-dev \
16
- libgbm-dev \
17
- libllvm3.9 \
18
- libprotobuf-dev \
19
- libxxf86vm-dev \
20
- libopengl0 \
21
- xvfb \
22
- && apt-get clean
7
+ ENV NODE_ENV="development"
8
+
9
+ RUN set -ex; \
10
+ export DEBIAN_FRONTEND=noninteractive; \
11
+ apt-get -qq update; \
12
+ apt-get -y --no-install-recommends install \
13
+ unzip \
14
+ build-essential \
15
+ ca-certificates \
16
+ wget \
17
+ pkg-config \
18
+ xvfb \
19
+ libglfw3-dev \
20
+ libuv1-dev \
21
+ libjpeg-turbo8 \
22
+ libicu66 \
23
+ libcairo2-dev \
24
+ libpango1.0-dev \
25
+ libjpeg-dev \
26
+ libgif-dev \
27
+ librsvg2-dev \
28
+ libcurl4-openssl-dev \
29
+ libpixman-1-dev; \
30
+ wget -qO- https://deb.nodesource.com/setup_16.x | bash; \
31
+ apt-get install -y nodejs; \
32
+ apt-get clean;
23
33
 
24
34
  RUN mkdir -p /usr/src/app
25
35
  WORKDIR /usr/src/app
26
36
 
27
- RUN wget -O test_data.zip https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/test_data.zip
28
- RUN unzip -q test_data.zip -d test_data
29
-
30
- ENV NODE_ENV="test"
37
+ RUN wget -O test_data.zip https://github.com/maptiler/tileserver-gl/releases/download/v1.3.0/test_data.zip; \
38
+ unzip -q test_data.zip -d test_data
31
39
 
32
40
  COPY package.json .
33
41
  RUN npm install
34
42
  COPY / .
43
+
35
44
  RUN xvfb-run --server-args="-screen 0 1024x768x24" npm test
package/docs/config.rst CHANGED
@@ -14,6 +14,7 @@ Example:
14
14
  "root": "",
15
15
  "fonts": "fonts",
16
16
  "sprites": "sprites",
17
+ "icons": "icons",
17
18
  "styles": "styles",
18
19
  "mbtiles": ""
19
20
  },
@@ -31,6 +32,7 @@ Example:
31
32
  "serveAllFonts": false,
32
33
  "serveAllStyles": false,
33
34
  "serveStaticMaps": true,
35
+ "allowRemoteMarkerIcons": true,
34
36
  "tileMargin": 0
35
37
  },
36
38
  "styles": {
@@ -141,6 +143,13 @@ Optional string to be rendered into the raster tiles (and static maps) as waterm
141
143
  Can be used for hard-coding attributions etc. (can also be specified per-style).
142
144
  Not used by default.
143
145
 
146
+ ``allowRemoteMarkerIcons``
147
+ --------------
148
+
149
+ Allows the rendering of marker icons fetched via http(s) hyperlinks.
150
+ For security reasons only allow this if you can control the origins from where the markers are fetched!
151
+ Default is to disallow fetching of icons from remote sources.
152
+
144
153
  ``styles``
145
154
  ==========
146
155
 
@@ -38,15 +38,41 @@ Static images
38
38
  * ``path`` - comma-separated ``lng,lat``, pipe-separated pairs
39
39
 
40
40
  * e.g. ``5.9,45.8|5.9,47.8|10.5,47.8|10.5,45.8|5.9,45.8``
41
+ * can be provided multiple times
41
42
 
42
- * ``latlng`` - indicates the ``path`` coordinates are in ``lat,lng`` order rather than the usual ``lng,lat``
43
+ * ``latlng`` - indicates coordinates are in ``lat,lng`` order rather than the usual ``lng,lat``
43
44
  * ``fill`` - color to use as the fill (e.g. ``red``, ``rgba(255,255,255,0.5)``, ``#0000ff``)
44
45
  * ``stroke`` - color of the path stroke
45
46
  * ``width`` - width of the stroke
47
+ * ``linecap`` - rendering style for the start and end points of the path
48
+ * ``linejoin`` - rendering style for overlapping segments of the path with differing directions
49
+ * ``border`` - color of the optional border path stroke
50
+ * ``borderwidth`` - width of the border stroke (default 10% of width)
51
+ * ``marker`` - Marker in format ``lng,lat|iconPath|option|option|...``
52
+
53
+ * Will be rendered with the bottom center at the provided location
54
+ * ``lng,lat`` and ``iconPath`` are mandatory and icons won't be rendered without them
55
+ * ``iconPath`` is either a link to an image served via http(s) or a path to a file relative to the configured icon path
56
+ * ``option`` must adhere to the format ``optionName:optionValue`` and supports the following names
57
+
58
+ * ``scale`` - Factor to scale image by
59
+
60
+ * e.g. ``0.5`` - Scales the image to half it's original size
61
+
62
+ * ``offset`` - Image offset as positive or negative pixel value in format ``[offsetX],[offsetY]``
63
+
64
+ * scales with ``scale`` parameter since image placement is relative to it's size
65
+ * e.g. ``2,-4`` - Image will be moved 2 pixel to the right and 4 pixel in the upwards direction from the provided location
66
+
67
+ * e.g. ``5.9,45.8|marker-start.svg|scale:0.5|offset:2,-4``
68
+ * can be provided multiple times
69
+
46
70
  * ``padding`` - "percentage" padding for fitted endpoints (area-based and path autofit)
47
71
 
48
72
  * value of ``0.1`` means "add 10% size to each side to make sure the area of interest is nicely visible"
49
73
 
74
+ * ``maxzoom`` - Maximum zoom level (only for auto endpoint where zoom level is calculated and not provided)
75
+
50
76
  * You can also use (experimental) ``/styles/{id}/static/raw/...`` endpoints with raw spherical mercator coordinates (EPSG:3857) instead of WGS84.
51
77
 
52
78
  * The static images are not available in the ``tileserver-gl-light`` version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tileserver-gl-light",
3
- "version": "4.1.1",
3
+ "version": "4.1.2",
4
4
  "description": "Map tile server for JSON GL styles - serving vector tiles",
5
5
  "main": "src/main.js",
6
6
  "bin": "src/main.js",
@@ -36,6 +36,7 @@
36
36
  "pbf": "3.2.1",
37
37
  "proj4": "2.8.0",
38
38
  "request": "2.88.2",
39
- "tileserver-gl-styles": "2.0.0"
39
+ "tileserver-gl-styles": "2.0.0",
40
+ "sanitize-filename": "1.6.3"
40
41
  }
41
42
  }
@@ -11,7 +11,7 @@
11
11
  <ows:Operation name="GetCapabilities">
12
12
  <ows:DCP>
13
13
  <ows:HTTP>
14
- <ows:Get xlink:href="{{baseUrl}}/wmts/{{id}}/">
14
+ <ows:Get xlink:href="{{baseUrl}}wmts/{{id}}/">
15
15
  <ows:Constraint name="GetEncoding">
16
16
  <ows:AllowedValues>
17
17
  <ows:Value>RESTful</ows:Value>
@@ -24,7 +24,7 @@
24
24
  <ows:Operation name="GetTile">
25
25
  <ows:DCP>
26
26
  <ows:HTTP>
27
- <ows:Get xlink:href="{{baseUrl}}/styles/">
27
+ <ows:Get xlink:href="{{baseUrl}}styles/">
28
28
  <ows:Constraint name="GetEncoding">
29
29
  <ows:AllowedValues>
30
30
  <ows:Value>RESTful</ows:Value>
@@ -50,7 +50,7 @@
50
50
  <TileMatrixSetLink>
51
51
  <TileMatrixSet>GoogleMapsCompatible</TileMatrixSet>
52
52
  </TileMatrixSetLink>
53
- <ResourceURL format="image/png" resourceType="tile" template="{{baseUrl}}/styles/{{id}}/{TileMatrix}/{TileCol}/{TileRow}.png{{key_query}}"/>
53
+ <ResourceURL format="image/png" resourceType="tile" template="{{baseUrl}}styles/{{id}}/{TileMatrix}/{TileCol}/{TileRow}.png{{key_query}}"/>
54
54
  </Layer><TileMatrixSet>
55
55
  <ows:Title>GoogleMapsCompatible</ows:Title>
56
56
  <ows:Abstract>GoogleMapsCompatible EPSG:3857</ows:Abstract>
@@ -403,5 +403,5 @@
403
403
  <MatrixHeight>262144</MatrixHeight>
404
404
  </TileMatrix></TileMatrixSet>
405
405
  </Contents>
406
- <ServiceMetadataURL xlink:href="{{baseUrl}}/wmts/{{id}}/"/>
406
+ <ServiceMetadataURL xlink:href="{{baseUrl}}wmts/{{id}}/"/>
407
407
  </Capabilities>
@@ -7,10 +7,11 @@ import url from 'url';
7
7
  import util from 'util';
8
8
  import zlib from 'zlib';
9
9
  import sharp from 'sharp'; // sharp has to be required before node-canvas. see https://github.com/lovell/sharp/issues/371
10
- import pkg from 'canvas';
10
+ import {createCanvas, Image} from 'canvas';
11
11
  import clone from 'clone';
12
12
  import Color from 'color';
13
13
  import express from 'express';
14
+ import sanitize from "sanitize-filename";
14
15
  import SphericalMercator from '@mapbox/sphericalmercator';
15
16
  import mlgl from '@maplibre/maplibre-gl-native';
16
17
  import MBTiles from '@mapbox/mbtiles';
@@ -21,7 +22,6 @@ import {getFontsPbf, getTileUrls, fixTileJSONCenter} from './utils.js';
21
22
  const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+\.?\\d+)';
22
23
  const httpTester = /^(http(s)?:)?\/\//;
23
24
 
24
- const {createCanvas} = pkg;
25
25
  const mercator = new SphericalMercator();
26
26
  const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0;
27
27
 
@@ -93,37 +93,385 @@ function createEmptyResponse(format, color, callback) {
93
93
  });
94
94
  }
95
95
 
96
- const extractPathFromQuery = (query, transformer) => {
97
- const pathParts = (query.path || '').split('|');
98
- const path = [];
99
- for (const pair of pathParts) {
100
- const pairParts = pair.split(',');
101
- if (pairParts.length === 2) {
102
- let pair;
103
- if (query.latlng === '1' || query.latlng === 'true') {
104
- pair = [+(pairParts[1]), +(pairParts[0])];
105
- } else {
106
- pair = [+(pairParts[0]), +(pairParts[1])];
96
+ /**
97
+ * Parses coordinate pair provided to pair of floats and ensures the resulting
98
+ * pair is a longitude/latitude combination depending on lnglat query parameter.
99
+ * @param {List} coordinatePair Coordinate pair.
100
+ * @param {Object} query Request query parameters.
101
+ */
102
+ const parseCoordinatePair = (coordinates, query) => {
103
+ const firstCoordinate = parseFloat(coordinates[0]);
104
+ const secondCoordinate = parseFloat(coordinates[1]);
105
+
106
+ // Ensure provided coordinates could be parsed and abort if not
107
+ if (isNaN(firstCoordinate) || isNaN(secondCoordinate)) {
108
+ return null;
109
+ }
110
+
111
+ // Check if coordinates have been provided as lat/lng pair instead of the
112
+ // ususal lng/lat pair and ensure resulting pair is lng/lat
113
+ if (query.latlng === '1' || query.latlng === 'true') {
114
+ return [secondCoordinate, firstCoordinate];
115
+ }
116
+
117
+ return [firstCoordinate, secondCoordinate];
118
+ };
119
+
120
+ /**
121
+ * Parses a coordinate pair from query arguments and optionally transforms it.
122
+ * @param {List} coordinatePair Coordinate pair.
123
+ * @param {Object} query Request query parameters.
124
+ * @param {Function} transformer Optional transform function.
125
+ */
126
+ const parseCoordinates = (coordinatePair, query, transformer) => {
127
+ const parsedCoordinates = parseCoordinatePair(coordinatePair, query);
128
+
129
+ // Transform coordinates
130
+ if (transformer) {
131
+ return transformer(parsedCoordinates);
132
+ }
133
+
134
+ return parsedCoordinates;
135
+ };
136
+
137
+
138
+ /**
139
+ * Parses paths provided via query into a list of path objects.
140
+ * @param {Object} query Request query parameters.
141
+ * @param {Function} transformer Optional transform function.
142
+ */
143
+ const extractPathsFromQuery = (query, transformer) => {
144
+ // Return an empty list if no paths have been provided
145
+ if (!query.path) {
146
+ return [];
147
+ }
148
+
149
+ const paths = [];
150
+
151
+ // Check if multiple paths have been provided and mimic a list if it's a
152
+ // single path.
153
+ const providedPaths = Array.isArray(query.path) ? query.path : [query.path];
154
+
155
+ // Iterate through paths, parse and validate them
156
+ for (const provided_path of providedPaths) {
157
+ const currentPath = [];
158
+
159
+ // Extract coordinate-list from path
160
+ const pathParts = (provided_path || '').split('|');
161
+
162
+ // Iterate through coordinate-list, parse the coordinates and validate them
163
+ for (const pair of pathParts) {
164
+ // Extract coordinates from coordinate pair
165
+ const pairParts = pair.split(',');
166
+
167
+ // Ensure we have two coordinates
168
+ if (pairParts.length === 2) {
169
+ const pair = parseCoordinates(pairParts, query, transformer);
170
+
171
+ // Ensure coordinates could be parsed and skip them if not
172
+ if (pair === null) {
173
+ continue;
174
+ }
175
+
176
+ // Add the coordinate-pair to the current path if they are valid
177
+ currentPath.push(pair);
107
178
  }
108
- if (transformer) {
109
- pair = transformer(pair);
179
+ }
180
+
181
+ // Extend list of paths with current path if it contains coordinates
182
+ if (currentPath.length) {
183
+ paths.push(currentPath)
184
+ }
185
+
186
+ }
187
+ return paths;
188
+ };
189
+
190
+ /**
191
+ * Parses marker options provided via query and sets corresponding attributes
192
+ * on marker object.
193
+ * Options adhere to the following format
194
+ * [optionName]:[optionValue]
195
+ * @param {List[String]} optionsList List of option strings.
196
+ * @param {Object} marker Marker object to configure.
197
+ */
198
+ const parseMarkerOptions = (optionsList, marker) => {
199
+ for (const options of optionsList) {
200
+ const optionParts = options.split(':');
201
+ // Ensure we got an option name and value
202
+ if (optionParts.length < 2) {
203
+ continue;
204
+ }
205
+
206
+ switch (optionParts[0]) {
207
+ // Scale factor to up- or downscale icon
208
+ case 'scale':
209
+ // Scale factors must not be negative
210
+ marker.scale = Math.abs(parseFloat(optionParts[1]))
211
+ break;
212
+ // Icon offset as positive or negative pixel value in the following
213
+ // format [offsetX],[offsetY] where [offsetY] is optional
214
+ case 'offset':
215
+ const providedOffset = optionParts[1].split(',');
216
+ // Set X-axis offset
217
+ marker.offsetX = parseFloat(providedOffset[0]);
218
+ // Check if an offset has been provided for Y-axis
219
+ if (providedOffset.length > 1) {
220
+ marker.offsetY = parseFloat(providedOffset[1]);
221
+ }
222
+ break;
223
+ }
224
+ }
225
+ };
226
+
227
+ /**
228
+ * Parses markers provided via query into a list of marker objects.
229
+ * @param {Object} query Request query parameters.
230
+ * @param {Object} options Configuration options.
231
+ * @param {Function} transformer Optional transform function.
232
+ */
233
+ const extractMarkersFromQuery = (query, options, transformer) => {
234
+ // Return an empty list if no markers have been provided
235
+ if (!query.marker) {
236
+ return [];
237
+ }
238
+
239
+ const markers = [];
240
+
241
+ // Check if multiple markers have been provided and mimic a list if it's a
242
+ // single maker.
243
+ const providedMarkers = Array.isArray(query.marker) ?
244
+ query.marker : [query.marker];
245
+
246
+ // Iterate through provided markers which can have one of the following
247
+ // formats
248
+ // [location]|[pathToFileTelativeToConfiguredIconPath]
249
+ // [location]|[pathToFile...]|[option]|[option]|...
250
+ for (const providedMarker of providedMarkers) {
251
+ const markerParts = providedMarker.split('|');
252
+ // Ensure we got at least a location and an icon uri
253
+ if (markerParts.length < 2) {
254
+ continue;
255
+ }
256
+
257
+ const locationParts = markerParts[0].split(',');
258
+ // Ensure the locationParts contains two items
259
+ if (locationParts.length !== 2) {
260
+ continue;
261
+ }
262
+
263
+ let iconURI = markerParts[1];
264
+ // Check if icon is served via http otherwise marker icons are expected to
265
+ // be provided as filepaths relative to configured icon path
266
+ if (!(iconURI.startsWith('http://') || iconURI.startsWith('https://'))) {
267
+ // Sanitize URI with sanitize-filename
268
+ // https://www.npmjs.com/package/sanitize-filename#details
269
+ iconURI = sanitize(iconURI)
270
+
271
+ // If the selected icon is not part of available icons skip it
272
+ if (!options.paths.availableIcons.includes(iconURI)) {
273
+ continue;
110
274
  }
111
- path.push(pair);
275
+
276
+ iconURI = path.resolve(options.paths.icons, iconURI);
277
+
278
+ // When we encounter a remote icon check if the configuration explicitly allows them.
279
+ } else if (options.allowRemoteMarkerIcons !== true) {
280
+ continue;
112
281
  }
282
+
283
+ // Ensure marker location could be parsed
284
+ const location = parseCoordinates(locationParts, query, transformer);
285
+ if (location === null) {
286
+ continue;
287
+ }
288
+
289
+ const marker = {};
290
+
291
+ marker.location = location;
292
+ marker.icon = iconURI;
293
+
294
+ // Check if options have been provided
295
+ if (markerParts.length > 2) {
296
+ parseMarkerOptions(markerParts.slice(2), marker);
297
+ }
298
+
299
+ // Add marker to list
300
+ markers.push(marker);
301
+
113
302
  }
114
- return path;
303
+ return markers;
304
+ };
305
+
306
+ /**
307
+ * Transforms coordinates to pixels.
308
+ * @param {List[Number]} ll Longitude/Latitude coordinate pair.
309
+ * @param {Number} zoom Map zoom level.
310
+ */
311
+ const precisePx = (ll, zoom) => {
312
+ const px = mercator.px(ll, 20);
313
+ const scale = Math.pow(2, zoom - 20);
314
+ return [px[0] * scale, px[1] * scale];
115
315
  };
116
316
 
117
- const renderOverlay = (z, x, y, bearing, pitch, w, h, scale,
118
- path, query) => {
317
+ /**
318
+ * Draws a marker in cavans context.
319
+ * @param {Object} ctx Canvas context object.
320
+ * @param {Object} marker Marker object parsed by extractMarkersFromQuery.
321
+ * @param {Number} z Map zoom level.
322
+ */
323
+ const drawMarker = (ctx, marker, z) => {
324
+ return new Promise(resolve => {
325
+ const img = new Image();
326
+ const pixelCoords = precisePx(marker.location, z);
327
+
328
+ const getMarkerCoordinates = (imageWidth, imageHeight, scale) => {
329
+ // Images are placed with their top-left corner at the provided location
330
+ // within the canvas but we expect icons to be centered and above it.
331
+
332
+ // Substract half of the images width from the x-coordinate to center
333
+ // the image in relation to the provided location
334
+ let xCoordinate = pixelCoords[0] - imageWidth / 2;
335
+ // Substract the images height from the y-coordinate to place it above
336
+ // the provided location
337
+ let yCoordinate = pixelCoords[1] - imageHeight;
338
+
339
+ // Since image placement is dependent on the size offsets have to be
340
+ // scaled as well. Additionally offsets are provided as either positive or
341
+ // negative values so we always add them
342
+ if (marker.offsetX) {
343
+ xCoordinate = xCoordinate + (marker.offsetX * scale);
344
+ }
345
+ if (marker.offsetY) {
346
+ yCoordinate = yCoordinate + (marker.offsetY * scale);
347
+ }
348
+
349
+ return {
350
+ 'x': xCoordinate,
351
+ 'y': yCoordinate
352
+ };
353
+ };
354
+
355
+ const drawOnCanvas = () => {
356
+ // Check if the images should be resized before beeing drawn
357
+ const defaultScale = 1;
358
+ const scale = marker.scale ? marker.scale : defaultScale;
359
+
360
+ // Calculate scaled image sizes
361
+ const imageWidth = img.width * scale;
362
+ const imageHeight = img.height * scale;
363
+
364
+ // Pass the desired sizes to get correlating coordinates
365
+ const coords = getMarkerCoordinates(imageWidth, imageHeight, scale);
366
+
367
+ // Draw the image on canvas
368
+ if (scale != defaultScale) {
369
+ ctx.drawImage(img, coords.x, coords.y, imageWidth, imageHeight);
370
+ } else {
371
+ ctx.drawImage(img, coords.x, coords.y);
372
+ }
373
+ // Resolve the promise when image has been drawn
374
+ resolve();
375
+ };
376
+
377
+ img.onload = drawOnCanvas;
378
+ img.onerror = err => { throw err };
379
+ img.src = marker.icon;
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Draws a list of markers onto a canvas.
385
+ * Wraps drawing of markers into list of promises and awaits them.
386
+ * It's required because images are expected to load asynchronous in canvas js
387
+ * even when provided from a local disk.
388
+ * @param {Object} ctx Canvas context object.
389
+ * @param {List[Object]} markers Marker objects parsed by extractMarkersFromQuery.
390
+ * @param {Number} z Map zoom level.
391
+ */
392
+ const drawMarkers = async (ctx, markers, z) => {
393
+ const markerPromises = [];
394
+
395
+ for (const marker of markers) {
396
+ // Begin drawing marker
397
+ markerPromises.push(drawMarker(ctx, marker, z));
398
+ }
399
+
400
+ // Await marker drawings before continuing
401
+ await Promise.all(markerPromises);
402
+ }
403
+
404
+ /**
405
+ * Draws a list of coordinates onto a canvas and styles the resulting path.
406
+ * @param {Object} ctx Canvas context object.
407
+ * @param {List[Number]} path List of coordinates.
408
+ * @param {Object} query Request query parameters.
409
+ * @param {Number} z Map zoom level.
410
+ */
411
+ const drawPath = (ctx, path, query, z) => {
119
412
  if (!path || path.length < 2) {
120
413
  return null;
121
414
  }
122
- const precisePx = (ll, zoom) => {
123
- const px = mercator.px(ll, 20);
124
- const scale = Math.pow(2, zoom - 20);
125
- return [px[0] * scale, px[1] * scale];
126
- };
415
+
416
+ ctx.beginPath();
417
+
418
+ // Transform coordinates to pixel on canvas and draw lines between points
419
+ for (const pair of path) {
420
+ const px = precisePx(pair, z);
421
+ ctx.lineTo(px[0], px[1]);
422
+ }
423
+
424
+ // Check if first coordinate matches last coordinate
425
+ if (path[0][0] === path[path.length - 1][0] &&
426
+ path[0][1] === path[path.length - 1][1]) {
427
+ ctx.closePath();
428
+ }
429
+
430
+ // Optionally fill drawn shape with a rgba color from query
431
+ if (query.fill !== undefined) {
432
+ ctx.fillStyle = query.fill;
433
+ ctx.fill();
434
+ }
435
+
436
+ // Get line width from query and fall back to 1 if not provided
437
+ const lineWidth = query.width !== undefined ?
438
+ parseFloat(query.width) : 1;
439
+
440
+ // Ensure line width is valid
441
+ if (lineWidth > 0) {
442
+ // Get border width from query and fall back to 10% of line width
443
+ const borderWidth = query.borderwidth !== undefined ?
444
+ parseFloat(query.borderwidth) : lineWidth * 0.1;
445
+
446
+ // Set rendering style for the start and end points of the path
447
+ // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap
448
+ ctx.lineCap = query.linecap || 'butt';
449
+
450
+ // Set rendering style for overlapping segments of the path with differing directions
451
+ // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin
452
+ ctx.lineJoin = query.linejoin || 'miter';
453
+
454
+ // In order to simulate a border we draw the path two times with the first
455
+ // beeing the wider border part.
456
+ if (query.border !== undefined && borderWidth > 0) {
457
+ // We need to double the desired border width and add it to the line width
458
+ // in order to get the desired border on each side of the line.
459
+ ctx.lineWidth = lineWidth + (borderWidth * 2);
460
+ // Set border style as rgba
461
+ ctx.strokeStyle = query.border;
462
+ ctx.stroke();
463
+ }
464
+
465
+ ctx.lineWidth = lineWidth;
466
+ ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)';
467
+ ctx.stroke();
468
+ }
469
+ }
470
+
471
+ const renderOverlay = async (z, x, y, bearing, pitch, w, h, scale, paths, markers, query) => {
472
+ if ((!paths || paths.length === 0) && (!markers || markers.length === 0)) {
473
+ return null;
474
+ }
127
475
 
128
476
  const center = precisePx([x, y], z);
129
477
 
@@ -147,25 +495,15 @@ const renderOverlay = (z, x, y, bearing, pitch, w, h, scale,
147
495
  // optimized path
148
496
  ctx.translate(-center[0] + w / 2, -center[1] + h / 2);
149
497
  }
150
- const lineWidth = query.width !== undefined ?
151
- parseFloat(query.width) : 1;
152
- ctx.lineWidth = lineWidth;
153
- ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)';
154
- ctx.fillStyle = query.fill || 'rgba(255,255,255,0.4)';
155
- ctx.beginPath();
156
- for (const pair of path) {
157
- const px = precisePx(pair, z);
158
- ctx.lineTo(px[0], px[1]);
159
- }
160
- if (path[0][0] === path[path.length - 1][0] &&
161
- path[0][1] === path[path.length - 1][1]) {
162
- ctx.closePath();
163
- }
164
- ctx.fill();
165
- if (lineWidth > 0) {
166
- ctx.stroke();
498
+
499
+ // Draw provided paths if any
500
+ for (const path of paths) {
501
+ drawPath(ctx, path, query, z);
167
502
  }
168
503
 
504
+ // Await drawing of markers before rendering the canvas
505
+ await drawMarkers(ctx, markers, z);
506
+
169
507
  return canvas.toBuffer();
170
508
  };
171
509
 
@@ -396,7 +734,7 @@ export const serve_rendered = {
396
734
  FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN,
397
735
  FLOAT_PATTERN, FLOAT_PATTERN);
398
736
 
399
- app.get(util.format(staticPattern, centerPattern), (req, res, next) => {
737
+ app.get(util.format(staticPattern, centerPattern), async (req, res, next) => {
400
738
  const item = repo[req.params.id];
401
739
  if (!item) {
402
740
  return res.sendStatus(404);
@@ -425,13 +763,14 @@ export const serve_rendered = {
425
763
  y = ll[1];
426
764
  }
427
765
 
428
- const path = extractPathFromQuery(req.query, transformer);
429
- const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query);
766
+ const paths = extractPathsFromQuery(req.query, transformer);
767
+ const markers = extractMarkersFromQuery(req.query, options, transformer);
768
+ const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query);
430
769
 
431
770
  return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
432
771
  });
433
772
 
434
- const serveBounds = (req, res, next) => {
773
+ const serveBounds = async (req, res, next) => {
435
774
  const item = repo[req.params.id];
436
775
  if (!item) {
437
776
  return res.sendStatus(404);
@@ -464,9 +803,9 @@ export const serve_rendered = {
464
803
  const bearing = 0;
465
804
  const pitch = 0;
466
805
 
467
- const path = extractPathFromQuery(req.query, transformer);
468
- const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query);
469
-
806
+ const paths = extractPathsFromQuery(req.query, transformer);
807
+ const markers = extractMarkersFromQuery(req.query, options, transformer);
808
+ const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query);
470
809
  return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
471
810
  };
472
811
 
@@ -500,7 +839,7 @@ export const serve_rendered = {
500
839
 
501
840
  const autoPattern = 'auto';
502
841
 
503
- app.get(util.format(staticPattern, autoPattern), (req, res, next) => {
842
+ app.get(util.format(staticPattern, autoPattern), async (req, res, next) => {
504
843
  const item = repo[req.params.id];
505
844
  if (!item) {
506
845
  return res.sendStatus(404);
@@ -516,13 +855,25 @@ export const serve_rendered = {
516
855
  const transformer = raw ?
517
856
  mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS;
518
857
 
519
- const path = extractPathFromQuery(req.query, transformer);
520
- if (path.length < 2) {
521
- return res.status(400).send('Invalid path');
858
+ const paths = extractPathsFromQuery(req.query, transformer);
859
+ const markers = extractMarkersFromQuery(req.query, options, transformer);
860
+
861
+ // Extract coordinates from markers
862
+ const markerCoordinates = [];
863
+ for (const marker of markers) {
864
+ markerCoordinates.push(marker.location);
865
+ }
866
+
867
+ // Create array with coordinates from markers and path
868
+ const coords = new Array().concat(paths.flat()).concat(markerCoordinates);
869
+
870
+ // Check if we have at least one coordinate to calculate a bounding box
871
+ if (coords.length < 1) {
872
+ return res.status(400).send('No coordinates provided');
522
873
  }
523
874
 
524
875
  const bbox = [Infinity, Infinity, -Infinity, -Infinity];
525
- for (const pair of path) {
876
+ for (const pair of coords) {
526
877
  bbox[0] = Math.min(bbox[0], pair[0]);
527
878
  bbox[1] = Math.min(bbox[1], pair[1]);
528
879
  bbox[2] = Math.max(bbox[2], pair[0]);
@@ -534,11 +885,17 @@ export const serve_rendered = {
534
885
  [(bbox_[0] + bbox_[2]) / 2, (bbox_[1] + bbox_[3]) / 2]
535
886
  );
536
887
 
537
- const z = calcZForBBox(bbox, w, h, req.query);
888
+ // Calculate zoom level
889
+ const maxZoom = parseFloat(req.query.maxzoom);
890
+ let z = calcZForBBox(bbox, w, h, req.query);
891
+ if (maxZoom > 0) {
892
+ z = Math.min(z, maxZoom);
893
+ }
894
+
538
895
  const x = center[0];
539
896
  const y = center[1];
540
897
 
541
- const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query);
898
+ const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query);
542
899
 
543
900
  return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
544
901
  });
package/src/server.js CHANGED
@@ -30,7 +30,7 @@ const packageJson = JSON.parse(fs.readFileSync(__dirname + '/../package.json', '
30
30
  const isLight = packageJson.name.slice(-6) === '-light';
31
31
  const serve_rendered = (await import(`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`)).serve_rendered;
32
32
 
33
- export function server(opts) {
33
+ function start(opts) {
34
34
  console.log('Starting server');
35
35
 
36
36
  const app = express().disable('x-powered-by');
@@ -79,6 +79,7 @@ export function server(opts) {
79
79
  paths.fonts = path.resolve(paths.root, paths.fonts || '');
80
80
  paths.sprites = path.resolve(paths.root, paths.sprites || '');
81
81
  paths.mbtiles = path.resolve(paths.root, paths.mbtiles || '');
82
+ paths.icons = path.resolve(paths.root, paths.icons || '');
82
83
 
83
84
  const startupPromises = [];
84
85
 
@@ -92,6 +93,36 @@ export function server(opts) {
92
93
  checkPath('fonts');
93
94
  checkPath('sprites');
94
95
  checkPath('mbtiles');
96
+ checkPath('icons');
97
+
98
+ /**
99
+ * Recursively get all files within a directory.
100
+ * Inspired by https://stackoverflow.com/a/45130990/10133863
101
+ * @param {String} directory Absolute path to a directory to get files from.
102
+ */
103
+ const getFiles = async (directory) => {
104
+ // Fetch all entries of the directory and attach type information
105
+ const dirEntries = await fs.promises.readdir(directory, { withFileTypes: true });
106
+
107
+ // Iterate through entries and return the relative file-path to the icon directory if it is not a directory
108
+ // otherwise initiate a recursive call
109
+ const files = await Promise.all(dirEntries.map((dirEntry) => {
110
+ const entryPath = path.resolve(directory, dirEntry.name);
111
+ return dirEntry.isDirectory() ?
112
+ getFiles(entryPath) : entryPath.replace(paths.icons + path.sep, "");
113
+ }));
114
+
115
+ // Flatten the list of files to a single array
116
+ return files.flat();
117
+ }
118
+
119
+ // Load all available icons into a settings object
120
+ startupPromises.push(new Promise(resolve => {
121
+ getFiles(paths.icons).then((files) => {
122
+ paths.availableIcons = files;
123
+ resolve();
124
+ });
125
+ }));
95
126
 
96
127
  if (options.dataDecorator) {
97
128
  try {
@@ -420,7 +451,12 @@ export function server(opts) {
420
451
  }
421
452
  wmts.id = id;
422
453
  wmts.name = (serving.styles[id] || serving.rendered[id]).name;
423
- wmts.baseUrl = `${req.get('X-Forwarded-Protocol') ? req.get('X-Forwarded-Protocol') : req.protocol}://${req.get('host')}`;
454
+ if (opts.publicUrl) {
455
+ wmts.baseUrl = opts.publicUrl;
456
+ }
457
+ else {
458
+ wmts.baseUrl = `${req.get('X-Forwarded-Protocol') ? req.get('X-Forwarded-Protocol') : req.protocol}://${req.get('host')}/`;
459
+ }
424
460
  return wmts;
425
461
  });
426
462
 
@@ -466,7 +502,7 @@ export function server(opts) {
466
502
  };
467
503
  }
468
504
 
469
- export const exports = (opts) => {
505
+ export function server(opts) {
470
506
  const running = start(opts);
471
507
 
472
508
  running.startupPromise.catch((err) => {
@@ -482,10 +518,6 @@ export const exports = (opts) => {
482
518
  console.log('Stopping server and reloading config');
483
519
 
484
520
  running.server.shutdown(() => {
485
- for (const key in require.cache) {
486
- delete require.cache[key];
487
- }
488
-
489
521
  const restarted = start(opts);
490
522
  running.server = restarted.server;
491
523
  running.app = restarted.app;
package/test/static.js CHANGED
@@ -95,7 +95,7 @@ describe('Static endpoints', function() {
95
95
 
96
96
  describe('invalid requests return 4xx', function() {
97
97
  testStatic(prefix, 'auto/256x256', 'png', 400);
98
- testStatic(prefix, 'auto/256x256', 'png', 400, undefined, undefined, '?path=10,10');
98
+ testStatic(prefix, 'auto/256x256', 'png', 400, undefined, undefined, '?path=invalid');
99
99
  testStatic(prefix, 'auto/2560x2560', 'png', 400, undefined, undefined, '?path=10,10|20,20');
100
100
  });
101
101
  });