Extended Static-Images Endpoint (#619)
* extended staticmap capabilities * added allowRemoteMarkerIcons configuration option and restricted fetching of remote marker icons only when option is set to true; asynchronously load all available icons in a settings object on server startup; replaced fs.existsSync() call in serve_rendered when drawing marker icons with a check against available icons settings object; * updated documentation for linecap parameter in staticmaps endpoint; added linejoin parameter to staticmaps endpoint; * added capability of staticmap endpoint to draw multiple paths
This commit is contained in:
parent
f8a0ab6d3c
commit
f3f63498a8
6 changed files with 485 additions and 60 deletions
|
@ -14,6 +14,7 @@ Example:
|
||||||
"root": "",
|
"root": "",
|
||||||
"fonts": "fonts",
|
"fonts": "fonts",
|
||||||
"sprites": "sprites",
|
"sprites": "sprites",
|
||||||
|
"icons": "icons",
|
||||||
"styles": "styles",
|
"styles": "styles",
|
||||||
"mbtiles": ""
|
"mbtiles": ""
|
||||||
},
|
},
|
||||||
|
@ -31,6 +32,7 @@ Example:
|
||||||
"serveAllFonts": false,
|
"serveAllFonts": false,
|
||||||
"serveAllStyles": false,
|
"serveAllStyles": false,
|
||||||
"serveStaticMaps": true,
|
"serveStaticMaps": true,
|
||||||
|
"allowRemoteMarkerIcons": true,
|
||||||
"tileMargin": 0
|
"tileMargin": 0
|
||||||
},
|
},
|
||||||
"styles": {
|
"styles": {
|
||||||
|
@ -141,6 +143,13 @@ Optional string to be rendered into the raster tiles (and static maps) as waterm
|
||||||
Can be used for hard-coding attributions etc. (can also be specified per-style).
|
Can be used for hard-coding attributions etc. (can also be specified per-style).
|
||||||
Not used by default.
|
Not used by default.
|
||||||
|
|
||||||
|
``allowRemoteMarkerIcons``
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Allows the rendering of marker icons fetched via http(s) hyperlinks.
|
||||||
|
For security reasons only allow this if you can control the origins from where the markers are fetched!
|
||||||
|
Default is to disallow fetching of icons from remote sources.
|
||||||
|
|
||||||
``styles``
|
``styles``
|
||||||
==========
|
==========
|
||||||
|
|
||||||
|
|
|
@ -38,15 +38,41 @@ Static images
|
||||||
* ``path`` - comma-separated ``lng,lat``, pipe-separated pairs
|
* ``path`` - comma-separated ``lng,lat``, pipe-separated pairs
|
||||||
|
|
||||||
* e.g. ``5.9,45.8|5.9,47.8|10.5,47.8|10.5,45.8|5.9,45.8``
|
* e.g. ``5.9,45.8|5.9,47.8|10.5,47.8|10.5,45.8|5.9,45.8``
|
||||||
|
* can be provided multiple times
|
||||||
|
|
||||||
* ``latlng`` - indicates the ``path`` coordinates are in ``lat,lng`` order rather than the usual ``lng,lat``
|
* ``latlng`` - indicates coordinates are in ``lat,lng`` order rather than the usual ``lng,lat``
|
||||||
* ``fill`` - color to use as the fill (e.g. ``red``, ``rgba(255,255,255,0.5)``, ``#0000ff``)
|
* ``fill`` - color to use as the fill (e.g. ``red``, ``rgba(255,255,255,0.5)``, ``#0000ff``)
|
||||||
* ``stroke`` - color of the path stroke
|
* ``stroke`` - color of the path stroke
|
||||||
* ``width`` - width of the stroke
|
* ``width`` - width of the stroke
|
||||||
|
* ``linecap`` - rendering style for the start and end points of the path
|
||||||
|
* ``linejoin`` - rendering style for overlapping segments of the path with differing directions
|
||||||
|
* ``border`` - color of the optional border path stroke
|
||||||
|
* ``borderwidth`` - width of the border stroke (default 10% of width)
|
||||||
|
* ``marker`` - Marker in format ``lng,lat|iconPath|option|option|...``
|
||||||
|
|
||||||
|
* Will be rendered with the bottom center at the provided location
|
||||||
|
* ``lng,lat`` and ``iconPath`` are mandatory and icons won't be rendered without them
|
||||||
|
* ``iconPath`` is either a link to an image served via http(s) or a path to a file relative to the configured icon path
|
||||||
|
* ``option`` must adhere to the format ``optionName:optionValue`` and supports the following names
|
||||||
|
|
||||||
|
* ``scale`` - Factor to scale image by
|
||||||
|
|
||||||
|
* e.g. ``0.5`` - Scales the image to half it's original size
|
||||||
|
|
||||||
|
* ``offset`` - Image offset as positive or negative pixel value in format ``[offsetX],[offsetY]``
|
||||||
|
|
||||||
|
* scales with ``scale`` parameter since image placement is relative to it's size
|
||||||
|
* e.g. ``2,-4`` - Image will be moved 2 pixel to the right and 4 pixel in the upwards direction from the provided location
|
||||||
|
|
||||||
|
* e.g. ``5.9,45.8|marker-start.svg|scale:0.5|offset:2,-4``
|
||||||
|
* can be provided multiple times
|
||||||
|
|
||||||
* ``padding`` - "percentage" padding for fitted endpoints (area-based and path autofit)
|
* ``padding`` - "percentage" padding for fitted endpoints (area-based and path autofit)
|
||||||
|
|
||||||
* value of ``0.1`` means "add 10% size to each side to make sure the area of interest is nicely visible"
|
* value of ``0.1`` means "add 10% size to each side to make sure the area of interest is nicely visible"
|
||||||
|
|
||||||
|
* ``maxzoom`` - Maximum zoom level (only for auto endpoint where zoom level is calculated and not provided)
|
||||||
|
|
||||||
* You can also use (experimental) ``/styles/{id}/static/raw/...`` endpoints with raw spherical mercator coordinates (EPSG:3857) instead of WGS84.
|
* You can also use (experimental) ``/styles/{id}/static/raw/...`` endpoints with raw spherical mercator coordinates (EPSG:3857) instead of WGS84.
|
||||||
|
|
||||||
* The static images are not available in the ``tileserver-gl-light`` version.
|
* The static images are not available in the ``tileserver-gl-light`` version.
|
||||||
|
|
|
@ -39,7 +39,8 @@
|
||||||
"proj4": "2.8.0",
|
"proj4": "2.8.0",
|
||||||
"request": "2.88.2",
|
"request": "2.88.2",
|
||||||
"sharp": "0.31.0",
|
"sharp": "0.31.0",
|
||||||
"tileserver-gl-styles": "2.0.0"
|
"tileserver-gl-styles": "2.0.0",
|
||||||
|
"sanitize-filename": "1.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
|
|
|
@ -11,6 +11,7 @@ import pkg from 'canvas';
|
||||||
import clone from 'clone';
|
import clone from 'clone';
|
||||||
import Color from 'color';
|
import Color from 'color';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
import SphericalMercator from '@mapbox/sphericalmercator';
|
||||||
import mlgl from '@maplibre/maplibre-gl-native';
|
import mlgl from '@maplibre/maplibre-gl-native';
|
||||||
import MBTiles from '@mapbox/mbtiles';
|
import MBTiles from '@mapbox/mbtiles';
|
||||||
|
@ -21,7 +22,7 @@ import {getFontsPbf, getTileUrls, fixTileJSONCenter} from './utils.js';
|
||||||
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+\.?\\d+)';
|
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+\.?\\d+)';
|
||||||
const httpTester = /^(http(s)?:)?\/\//;
|
const httpTester = /^(http(s)?:)?\/\//;
|
||||||
|
|
||||||
const {createCanvas} = pkg;
|
const {createCanvas, Image} = pkg;
|
||||||
const mercator = new SphericalMercator();
|
const mercator = new SphericalMercator();
|
||||||
const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0;
|
const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0;
|
||||||
|
|
||||||
|
@ -93,37 +94,385 @@ function createEmptyResponse(format, color, callback) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractPathFromQuery = (query, transformer) => {
|
/**
|
||||||
const pathParts = (query.path || '').split('|');
|
* Parses coordinate pair provided to pair of floats and ensures the resulting
|
||||||
const path = [];
|
* pair is a longitude/latitude combination depending on lnglat query parameter.
|
||||||
for (const pair of pathParts) {
|
* @param {List} coordinatePair Coordinate pair.
|
||||||
const pairParts = pair.split(',');
|
* @param {Object} query Request query parameters.
|
||||||
if (pairParts.length === 2) {
|
*/
|
||||||
let pair;
|
const parseCoordinatePair = (coordinates, query) => {
|
||||||
if (query.latlng === '1' || query.latlng === 'true') {
|
const firstCoordinate = parseFloat(coordinates[0]);
|
||||||
pair = [+(pairParts[1]), +(pairParts[0])];
|
const secondCoordinate = parseFloat(coordinates[1]);
|
||||||
} else {
|
|
||||||
pair = [+(pairParts[0]), +(pairParts[1])];
|
|
||||||
}
|
|
||||||
if (transformer) {
|
|
||||||
pair = transformer(pair);
|
|
||||||
}
|
|
||||||
path.push(pair);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderOverlay = (z, x, y, bearing, pitch, w, h, scale,
|
// Ensure provided coordinates could be parsed and abort if not
|
||||||
path, query) => {
|
if (isNaN(firstCoordinate) || isNaN(secondCoordinate)) {
|
||||||
if (!path || path.length < 2) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const precisePx = (ll, zoom) => {
|
|
||||||
|
// Check if coordinates have been provided as lat/lng pair instead of the
|
||||||
|
// ususal lng/lat pair and ensure resulting pair is lng/lat
|
||||||
|
if (query.latlng === '1' || query.latlng === 'true') {
|
||||||
|
return [secondCoordinate, firstCoordinate];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [firstCoordinate, secondCoordinate];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a coordinate pair from query arguments and optionally transforms it.
|
||||||
|
* @param {List} coordinatePair Coordinate pair.
|
||||||
|
* @param {Object} query Request query parameters.
|
||||||
|
* @param {Function} transformer Optional transform function.
|
||||||
|
*/
|
||||||
|
const parseCoordinates = (coordinatePair, query, transformer) => {
|
||||||
|
const parsedCoordinates = parseCoordinatePair(coordinatePair, query);
|
||||||
|
|
||||||
|
// Transform coordinates
|
||||||
|
if (transformer) {
|
||||||
|
return transformer(parsedCoordinates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedCoordinates;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses paths provided via query into a list of path objects.
|
||||||
|
* @param {Object} query Request query parameters.
|
||||||
|
* @param {Function} transformer Optional transform function.
|
||||||
|
*/
|
||||||
|
const extractPathsFromQuery = (query, transformer) => {
|
||||||
|
// Return an empty list if no paths have been provided
|
||||||
|
if (!query.path) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = [];
|
||||||
|
|
||||||
|
// Check if multiple paths have been provided and mimic a list if it's a
|
||||||
|
// single path.
|
||||||
|
const providedPaths = Array.isArray(query.path) ? query.path : [query.path];
|
||||||
|
|
||||||
|
// Iterate through paths, parse and validate them
|
||||||
|
for (const provided_path of providedPaths) {
|
||||||
|
const currentPath = [];
|
||||||
|
|
||||||
|
// Extract coordinate-list from path
|
||||||
|
const pathParts = (provided_path || '').split('|');
|
||||||
|
|
||||||
|
// Iterate through coordinate-list, parse the coordinates and validate them
|
||||||
|
for (const pair of pathParts) {
|
||||||
|
// Extract coordinates from coordinate pair
|
||||||
|
const pairParts = pair.split(',');
|
||||||
|
|
||||||
|
// Ensure we have two coordinates
|
||||||
|
if (pairParts.length === 2) {
|
||||||
|
const pair = parseCoordinates(pairParts, query, transformer);
|
||||||
|
|
||||||
|
// Ensure coordinates could be parsed and skip them if not
|
||||||
|
if (pair === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the coordinate-pair to the current path if they are valid
|
||||||
|
currentPath.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend list of paths with current path if it contains coordinates
|
||||||
|
if (currentPath.length) {
|
||||||
|
paths.push(currentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses marker options provided via query and sets corresponding attributes
|
||||||
|
* on marker object.
|
||||||
|
* Options adhere to the following format
|
||||||
|
* [optionName]:[optionValue]
|
||||||
|
* @param {List[String]} optionsList List of option strings.
|
||||||
|
* @param {Object} marker Marker object to configure.
|
||||||
|
*/
|
||||||
|
const parseMarkerOptions = (optionsList, marker) => {
|
||||||
|
for (const options of optionsList) {
|
||||||
|
const optionParts = options.split(':');
|
||||||
|
// Ensure we got an option name and value
|
||||||
|
if (optionParts.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (optionParts[0]) {
|
||||||
|
// Scale factor to up- or downscale icon
|
||||||
|
case 'scale':
|
||||||
|
// Scale factors must not be negative
|
||||||
|
marker.scale = Math.abs(parseFloat(optionParts[1]))
|
||||||
|
break;
|
||||||
|
// Icon offset as positive or negative pixel value in the following
|
||||||
|
// format [offsetX],[offsetY] where [offsetY] is optional
|
||||||
|
case 'offset':
|
||||||
|
const providedOffset = optionParts[1].split(',');
|
||||||
|
// Set X-axis offset
|
||||||
|
marker.offsetX = parseFloat(providedOffset[0]);
|
||||||
|
// Check if an offset has been provided for Y-axis
|
||||||
|
if (providedOffset.length > 1) {
|
||||||
|
marker.offsetY = parseFloat(providedOffset[1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses markers provided via query into a list of marker objects.
|
||||||
|
* @param {Object} query Request query parameters.
|
||||||
|
* @param {Object} options Configuration options.
|
||||||
|
* @param {Function} transformer Optional transform function.
|
||||||
|
*/
|
||||||
|
const extractMarkersFromQuery = (query, options, transformer) => {
|
||||||
|
// Return an empty list if no markers have been provided
|
||||||
|
if (!query.marker) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const markers = [];
|
||||||
|
|
||||||
|
// Check if multiple markers have been provided and mimic a list if it's a
|
||||||
|
// single maker.
|
||||||
|
const providedMarkers = Array.isArray(query.marker) ?
|
||||||
|
query.marker : [query.marker];
|
||||||
|
|
||||||
|
// Iterate through provided markers which can have one of the following
|
||||||
|
// formats
|
||||||
|
// [location]|[pathToFileTelativeToConfiguredIconPath]
|
||||||
|
// [location]|[pathToFile...]|[option]|[option]|...
|
||||||
|
for (const providedMarker of providedMarkers) {
|
||||||
|
const markerParts = providedMarker.split('|');
|
||||||
|
// Ensure we got at least a location and an icon uri
|
||||||
|
if (markerParts.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationParts = markerParts[0].split(',');
|
||||||
|
// Ensure the locationParts contains two items
|
||||||
|
if (locationParts.length !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let iconURI = markerParts[1];
|
||||||
|
// Check if icon is served via http otherwise marker icons are expected to
|
||||||
|
// be provided as filepaths relative to configured icon path
|
||||||
|
if (!(iconURI.startsWith('http://') || iconURI.startsWith('https://'))) {
|
||||||
|
// Sanitize URI with sanitize-filename
|
||||||
|
// https://www.npmjs.com/package/sanitize-filename#details
|
||||||
|
iconURI = sanitize(iconURI)
|
||||||
|
|
||||||
|
// If the selected icon is not part of available icons skip it
|
||||||
|
if (!options.paths.availableIcons.includes(iconURI)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
iconURI = path.resolve(options.paths.icons, iconURI);
|
||||||
|
|
||||||
|
// When we encounter a remote icon check if the configuration explicitly allows them.
|
||||||
|
} else if (options.allowRemoteMarkerIcons !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure marker location could be parsed
|
||||||
|
const location = parseCoordinates(locationParts, query, transformer);
|
||||||
|
if (location === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = {};
|
||||||
|
|
||||||
|
marker.location = location;
|
||||||
|
marker.icon = iconURI;
|
||||||
|
|
||||||
|
// Check if options have been provided
|
||||||
|
if (markerParts.length > 2) {
|
||||||
|
parseMarkerOptions(markerParts.slice(2), marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add marker to list
|
||||||
|
markers.push(marker);
|
||||||
|
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms coordinates to pixels.
|
||||||
|
* @param {List[Number]} ll Longitude/Latitude coordinate pair.
|
||||||
|
* @param {Number} zoom Map zoom level.
|
||||||
|
*/
|
||||||
|
const precisePx = (ll, zoom) => {
|
||||||
const px = mercator.px(ll, 20);
|
const px = mercator.px(ll, 20);
|
||||||
const scale = Math.pow(2, zoom - 20);
|
const scale = Math.pow(2, zoom - 20);
|
||||||
return [px[0] * scale, px[1] * scale];
|
return [px[0] * scale, px[1] * scale];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a marker in cavans context.
|
||||||
|
* @param {Object} ctx Canvas context object.
|
||||||
|
* @param {Object} marker Marker object parsed by extractMarkersFromQuery.
|
||||||
|
* @param {Number} z Map zoom level.
|
||||||
|
*/
|
||||||
|
const drawMarker = (ctx, marker, z) => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
const pixelCoords = precisePx(marker.location, z);
|
||||||
|
|
||||||
|
const getMarkerCoordinates = (imageWidth, imageHeight, scale) => {
|
||||||
|
// Images are placed with their top-left corner at the provided location
|
||||||
|
// within the canvas but we expect icons to be centered and above it.
|
||||||
|
|
||||||
|
// Substract half of the images width from the x-coordinate to center
|
||||||
|
// the image in relation to the provided location
|
||||||
|
let xCoordinate = pixelCoords[0] - imageWidth / 2;
|
||||||
|
// Substract the images height from the y-coordinate to place it above
|
||||||
|
// the provided location
|
||||||
|
let yCoordinate = pixelCoords[1] - imageHeight;
|
||||||
|
|
||||||
|
// Since image placement is dependent on the size offsets have to be
|
||||||
|
// scaled as well. Additionally offsets are provided as either positive or
|
||||||
|
// negative values so we always add them
|
||||||
|
if (marker.offsetX) {
|
||||||
|
xCoordinate = xCoordinate + (marker.offsetX * scale);
|
||||||
|
}
|
||||||
|
if (marker.offsetY) {
|
||||||
|
yCoordinate = yCoordinate + (marker.offsetY * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'x': xCoordinate,
|
||||||
|
'y': yCoordinate
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawOnCanvas = () => {
|
||||||
|
// Check if the images should be resized before beeing drawn
|
||||||
|
const defaultScale = 1;
|
||||||
|
const scale = marker.scale ? marker.scale : defaultScale;
|
||||||
|
|
||||||
|
// Calculate scaled image sizes
|
||||||
|
const imageWidth = img.width * scale;
|
||||||
|
const imageHeight = img.height * scale;
|
||||||
|
|
||||||
|
// Pass the desired sizes to get correlating coordinates
|
||||||
|
const coords = getMarkerCoordinates(imageWidth, imageHeight, scale);
|
||||||
|
|
||||||
|
// Draw the image on canvas
|
||||||
|
if (scale != defaultScale) {
|
||||||
|
ctx.drawImage(img, coords.x, coords.y, imageWidth, imageHeight);
|
||||||
|
} else {
|
||||||
|
ctx.drawImage(img, coords.x, coords.y);
|
||||||
|
}
|
||||||
|
// Resolve the promise when image has been drawn
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onload = drawOnCanvas;
|
||||||
|
img.onerror = err => { throw err };
|
||||||
|
img.src = marker.icon;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a list of markers onto a canvas.
|
||||||
|
* Wraps drawing of markers into list of promises and awaits them.
|
||||||
|
* It's required because images are expected to load asynchronous in canvas js
|
||||||
|
* even when provided from a local disk.
|
||||||
|
* @param {Object} ctx Canvas context object.
|
||||||
|
* @param {List[Object]} markers Marker objects parsed by extractMarkersFromQuery.
|
||||||
|
* @param {Number} z Map zoom level.
|
||||||
|
*/
|
||||||
|
const drawMarkers = async (ctx, markers, z) => {
|
||||||
|
const markerPromises = [];
|
||||||
|
|
||||||
|
for (const marker of markers) {
|
||||||
|
// Begin drawing marker
|
||||||
|
markerPromises.push(drawMarker(ctx, marker, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await marker drawings before continuing
|
||||||
|
await Promise.all(markerPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a list of coordinates onto a canvas and styles the resulting path.
|
||||||
|
* @param {Object} ctx Canvas context object.
|
||||||
|
* @param {List[Number]} path List of coordinates.
|
||||||
|
* @param {Object} query Request query parameters.
|
||||||
|
* @param {Number} z Map zoom level.
|
||||||
|
*/
|
||||||
|
const drawPath = (ctx, path, query, z) => {
|
||||||
|
if (!path || path.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
// Transform coordinates to pixel on canvas and draw lines between points
|
||||||
|
for (const pair of path) {
|
||||||
|
const px = precisePx(pair, z);
|
||||||
|
ctx.lineTo(px[0], px[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first coordinate matches last coordinate
|
||||||
|
if (path[0][0] === path[path.length - 1][0] &&
|
||||||
|
path[0][1] === path[path.length - 1][1]) {
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally fill drawn shape with a rgba color from query
|
||||||
|
if (query.fill !== undefined) {
|
||||||
|
ctx.fillStyle = query.fill;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get line width from query and fall back to 1 if not provided
|
||||||
|
const lineWidth = query.width !== undefined ?
|
||||||
|
parseFloat(query.width) : 1;
|
||||||
|
|
||||||
|
// Ensure line width is valid
|
||||||
|
if (lineWidth > 0) {
|
||||||
|
// Get border width from query and fall back to 10% of line width
|
||||||
|
const borderWidth = query.borderwidth !== undefined ?
|
||||||
|
parseFloat(query.borderwidth) : lineWidth * 0.1;
|
||||||
|
|
||||||
|
// Set rendering style for the start and end points of the path
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap
|
||||||
|
ctx.lineCap = query.linecap || 'butt';
|
||||||
|
|
||||||
|
// Set rendering style for overlapping segments of the path with differing directions
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin
|
||||||
|
ctx.lineJoin = query.linejoin || 'miter';
|
||||||
|
|
||||||
|
// In order to simulate a border we draw the path two times with the first
|
||||||
|
// beeing the wider border part.
|
||||||
|
if (query.border !== undefined && borderWidth > 0) {
|
||||||
|
// We need to double the desired border width and add it to the line width
|
||||||
|
// in order to get the desired border on each side of the line.
|
||||||
|
ctx.lineWidth = lineWidth + (borderWidth * 2);
|
||||||
|
// Set border style as rgba
|
||||||
|
ctx.strokeStyle = query.border;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderOverlay = async (z, x, y, bearing, pitch, w, h, scale, paths, markers, query) => {
|
||||||
|
if ((!paths || paths.length === 0) && (!markers || markers.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const center = precisePx([x, y], z);
|
const center = precisePx([x, y], z);
|
||||||
|
|
||||||
|
@ -147,25 +496,15 @@ const renderOverlay = (z, x, y, bearing, pitch, w, h, scale,
|
||||||
// optimized path
|
// optimized path
|
||||||
ctx.translate(-center[0] + w / 2, -center[1] + h / 2);
|
ctx.translate(-center[0] + w / 2, -center[1] + h / 2);
|
||||||
}
|
}
|
||||||
const lineWidth = query.width !== undefined ?
|
|
||||||
parseFloat(query.width) : 1;
|
// Draw provided paths if any
|
||||||
ctx.lineWidth = lineWidth;
|
for (const path of paths) {
|
||||||
ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)';
|
drawPath(ctx, path, query, z);
|
||||||
ctx.fillStyle = query.fill || 'rgba(255,255,255,0.4)';
|
|
||||||
ctx.beginPath();
|
|
||||||
for (const pair of path) {
|
|
||||||
const px = precisePx(pair, z);
|
|
||||||
ctx.lineTo(px[0], px[1]);
|
|
||||||
}
|
|
||||||
if (path[0][0] === path[path.length - 1][0] &&
|
|
||||||
path[0][1] === path[path.length - 1][1]) {
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
ctx.fill();
|
|
||||||
if (lineWidth > 0) {
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Await drawing of markers before rendering the canvas
|
||||||
|
await drawMarkers(ctx, markers, z);
|
||||||
|
|
||||||
return canvas.toBuffer();
|
return canvas.toBuffer();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -396,7 +735,7 @@ export const serve_rendered = {
|
||||||
FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN,
|
FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN,
|
||||||
FLOAT_PATTERN, FLOAT_PATTERN);
|
FLOAT_PATTERN, FLOAT_PATTERN);
|
||||||
|
|
||||||
app.get(util.format(staticPattern, centerPattern), (req, res, next) => {
|
app.get(util.format(staticPattern, centerPattern), async (req, res, next) => {
|
||||||
const item = repo[req.params.id];
|
const item = repo[req.params.id];
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
|
@ -425,13 +764,14 @@ export const serve_rendered = {
|
||||||
y = ll[1];
|
y = ll[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = extractPathFromQuery(req.query, transformer);
|
const paths = extractPathsFromQuery(req.query, transformer);
|
||||||
const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query);
|
const markers = extractMarkersFromQuery(req.query, options, transformer);
|
||||||
|
const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query);
|
||||||
|
|
||||||
return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
|
return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
|
||||||
});
|
});
|
||||||
|
|
||||||
const serveBounds = (req, res, next) => {
|
const serveBounds = async (req, res, next) => {
|
||||||
const item = repo[req.params.id];
|
const item = repo[req.params.id];
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
|
@ -464,9 +804,9 @@ export const serve_rendered = {
|
||||||
const bearing = 0;
|
const bearing = 0;
|
||||||
const pitch = 0;
|
const pitch = 0;
|
||||||
|
|
||||||
const path = extractPathFromQuery(req.query, transformer);
|
const paths = extractPathsFromQuery(req.query, transformer);
|
||||||
const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query);
|
const markers = extractMarkersFromQuery(req.query, options, transformer);
|
||||||
|
const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query);
|
||||||
return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
|
return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -500,7 +840,7 @@ export const serve_rendered = {
|
||||||
|
|
||||||
const autoPattern = 'auto';
|
const autoPattern = 'auto';
|
||||||
|
|
||||||
app.get(util.format(staticPattern, autoPattern), (req, res, next) => {
|
app.get(util.format(staticPattern, autoPattern), async (req, res, next) => {
|
||||||
const item = repo[req.params.id];
|
const item = repo[req.params.id];
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
|
@ -516,13 +856,25 @@ export const serve_rendered = {
|
||||||
const transformer = raw ?
|
const transformer = raw ?
|
||||||
mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS;
|
mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS;
|
||||||
|
|
||||||
const path = extractPathFromQuery(req.query, transformer);
|
const paths = extractPathsFromQuery(req.query, transformer);
|
||||||
if (path.length < 2) {
|
const markers = extractMarkersFromQuery(req.query, options, transformer);
|
||||||
return res.status(400).send('Invalid path');
|
|
||||||
|
// Extract coordinates from markers
|
||||||
|
const markerCoordinates = [];
|
||||||
|
for (const marker of markers) {
|
||||||
|
markerCoordinates.push(marker.location);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create array with coordinates from markers and path
|
||||||
|
const coords = new Array().concat(paths.flat()).concat(markerCoordinates);
|
||||||
|
|
||||||
|
// Check if we have at least one coordinate to calculate a bounding box
|
||||||
|
if (coords.length < 1) {
|
||||||
|
return res.status(400).send('No coordinates provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
|
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
|
||||||
for (const pair of path) {
|
for (const pair of coords) {
|
||||||
bbox[0] = Math.min(bbox[0], pair[0]);
|
bbox[0] = Math.min(bbox[0], pair[0]);
|
||||||
bbox[1] = Math.min(bbox[1], pair[1]);
|
bbox[1] = Math.min(bbox[1], pair[1]);
|
||||||
bbox[2] = Math.max(bbox[2], pair[0]);
|
bbox[2] = Math.max(bbox[2], pair[0]);
|
||||||
|
@ -534,11 +886,17 @@ export const serve_rendered = {
|
||||||
[(bbox_[0] + bbox_[2]) / 2, (bbox_[1] + bbox_[3]) / 2]
|
[(bbox_[0] + bbox_[2]) / 2, (bbox_[1] + bbox_[3]) / 2]
|
||||||
);
|
);
|
||||||
|
|
||||||
const z = calcZForBBox(bbox, w, h, req.query);
|
// Calculate zoom level
|
||||||
|
const maxZoom = parseFloat(req.query.maxzoom);
|
||||||
|
let z = calcZForBBox(bbox, w, h, req.query);
|
||||||
|
if (maxZoom > 0) {
|
||||||
|
z = Math.min(z, maxZoom);
|
||||||
|
}
|
||||||
|
|
||||||
const x = center[0];
|
const x = center[0];
|
||||||
const y = center[1];
|
const y = center[1];
|
||||||
|
|
||||||
const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query);
|
const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query);
|
||||||
|
|
||||||
return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
|
return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static');
|
||||||
});
|
});
|
||||||
|
|
|
@ -79,6 +79,7 @@ export function server(opts) {
|
||||||
paths.fonts = path.resolve(paths.root, paths.fonts || '');
|
paths.fonts = path.resolve(paths.root, paths.fonts || '');
|
||||||
paths.sprites = path.resolve(paths.root, paths.sprites || '');
|
paths.sprites = path.resolve(paths.root, paths.sprites || '');
|
||||||
paths.mbtiles = path.resolve(paths.root, paths.mbtiles || '');
|
paths.mbtiles = path.resolve(paths.root, paths.mbtiles || '');
|
||||||
|
paths.icons = path.resolve(paths.root, paths.icons || '');
|
||||||
|
|
||||||
const startupPromises = [];
|
const startupPromises = [];
|
||||||
|
|
||||||
|
@ -92,6 +93,36 @@ export function server(opts) {
|
||||||
checkPath('fonts');
|
checkPath('fonts');
|
||||||
checkPath('sprites');
|
checkPath('sprites');
|
||||||
checkPath('mbtiles');
|
checkPath('mbtiles');
|
||||||
|
checkPath('icons');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively get all files within a directory.
|
||||||
|
* Inspired by https://stackoverflow.com/a/45130990/10133863
|
||||||
|
* @param {String} directory Absolute path to a directory to get files from.
|
||||||
|
*/
|
||||||
|
const getFiles = async (directory) => {
|
||||||
|
// Fetch all entries of the directory and attach type information
|
||||||
|
const dirEntries = await fs.promises.readdir(directory, { withFileTypes: true });
|
||||||
|
|
||||||
|
// Iterate through entries and return the relative file-path to the icon directory if it is not a directory
|
||||||
|
// otherwise initiate a recursive call
|
||||||
|
const files = await Promise.all(dirEntries.map((dirEntry) => {
|
||||||
|
const entryPath = path.resolve(directory, dirEntry.name);
|
||||||
|
return dirEntry.isDirectory() ?
|
||||||
|
getFiles(entryPath) : entryPath.replace(paths.icons + path.sep, "");
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Flatten the list of files to a single array
|
||||||
|
return files.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all available icons into a settings object
|
||||||
|
startupPromises.push(new Promise(resolve => {
|
||||||
|
getFiles(paths.icons).then((files) => {
|
||||||
|
paths.availableIcons = files;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
if (options.dataDecorator) {
|
if (options.dataDecorator) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -95,7 +95,7 @@ describe('Static endpoints', function() {
|
||||||
|
|
||||||
describe('invalid requests return 4xx', function() {
|
describe('invalid requests return 4xx', function() {
|
||||||
testStatic(prefix, 'auto/256x256', 'png', 400);
|
testStatic(prefix, 'auto/256x256', 'png', 400);
|
||||||
testStatic(prefix, 'auto/256x256', 'png', 400, undefined, undefined, '?path=10,10');
|
testStatic(prefix, 'auto/256x256', 'png', 400, undefined, undefined, '?path=invalid');
|
||||||
testStatic(prefix, 'auto/2560x2560', 'png', 400, undefined, undefined, '?path=10,10|20,20');
|
testStatic(prefix, 'auto/2560x2560', 'png', 400, undefined, undefined, '?path=10,10|20,20');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue