extended staticmap capabilities

This commit is contained in:
Benedikt Brandtner 2021-05-05 15:50:12 +02:00
parent f8a0ab6d3c
commit 78ddbf5454
5 changed files with 380 additions and 46 deletions

View file

@ -14,6 +14,7 @@ Example:
"root": "", "root": "",
"fonts": "fonts", "fonts": "fonts",
"sprites": "sprites", "sprites": "sprites",
"icons": "icons",
"styles": "styles", "styles": "styles",
"mbtiles": "" "mbtiles": ""
}, },

View file

@ -39,14 +39,38 @@ Static images
* 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``
* ``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`` - line cap of the path stroke
* ``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
* can be provided multiple times
* e.g. ``5.9,45.8|marker-start.svg|scale:0.5|offset:2,-4``
* ``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.

View file

@ -8,6 +8,7 @@ import util from 'util';
import zlib from 'zlib'; import zlib from 'zlib';
import sharp from 'sharp'; // sharp has to be required before node-canvas. see https://github.com/lovell/sharp/issues/371 import sharp from 'sharp'; // sharp has to be required before node-canvas. see https://github.com/lovell/sharp/issues/371
import pkg from 'canvas'; import pkg from 'canvas';
import Image 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';
@ -93,37 +94,337 @@ function createEmptyResponse(format, color, callback) {
}); });
} }
/**
* Parses coordinate pair provided to pair of floats and ensures the resulting
* pair is a longitude/latitude combination depending on lnglat query parameter.
* @param {List} coordinatePair Coordinate pair.
* @param {Object} query Request query parameters.
*/
const parseCoordinatePair = (coordinates, query) => {
const firstCoordinate = parseFloat(coordinates[0]);
const secondCoordinate = parseFloat(coordinates[1]);
// Ensure provided coordinates could be parsed and abort if not
if (isNaN(firstCoordinate) || isNaN(secondCoordinate)) {
return null;
}
// 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;
};
const extractPathFromQuery = (query, transformer) => { const extractPathFromQuery = (query, transformer) => {
const pathParts = (query.path || '').split('|'); const pathParts = (query.path || '').split('|');
const path = []; const path = [];
for (const pair of pathParts) { for (const pair of pathParts) {
const pairParts = pair.split(','); const pairParts = pair.split(',');
if (pairParts.length === 2) { if (pairParts.length === 2) {
let pair; const pair = parseCoordinates(pairParts, query, transformer);
if (query.latlng === '1' || query.latlng === 'true') {
pair = [+(pairParts[1]), +(pairParts[0])]; // Ensure coordinates could be parsed and skip them if not
} else { if (pair === null) {
pair = [+(pairParts[0]), +(pairParts[1])]; continue;
}
if (transformer) {
pair = transformer(pair);
} }
path.push(pair); path.push(pair);
} }
} }
return path; return path;
}; };
const renderOverlay = (z, x, y, bearing, pitch, w, h, scale, /**
path, query) => { * 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://'))) {
iconURI = path.resolve(options.paths.icons, iconURI);
// Ensure icon exists at provided path
if (!fs.existsSync(iconURI)) {
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 scale = Math.pow(2, zoom - 20);
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 parsed by extractPathFromQuery.
* @param {Object} query Request query parameters.
* @param {Number} z Map zoom level.
*/
const drawPath = async (ctx, path, query, z) => {
if (!path || path.length < 2) { if (!path || path.length < 2) {
return null; return null;
} }
const precisePx = (ll, zoom) => {
const px = mercator.px(ll, 20); ctx.beginPath();
const scale = Math.pow(2, zoom - 20);
return [px[0] * scale, px[1] * scale]; // 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 line start/endpoint style
ctx.lineCap = query.linecap || 'butt';
// 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, path, markers, query) => {
if ((!path || path.length < 2) && (!markers || markers.length === 0)) {
return null;
}
const center = precisePx([x, y], z); const center = precisePx([x, y], z);
@ -147,24 +448,11 @@ 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; drawPath(ctx, path, query, z);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)'; // Await drawing of markers before rendering the canvas
ctx.fillStyle = query.fill || 'rgba(255,255,255,0.4)'; await drawMarkers(ctx, markers, z);
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();
}
return canvas.toBuffer(); return canvas.toBuffer();
}; };
@ -396,7 +684,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);
@ -426,12 +714,13 @@ export const serve_rendered = {
} }
const path = extractPathFromQuery(req.query, transformer); const path = extractPathFromQuery(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, path, 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);
@ -465,8 +754,8 @@ export const serve_rendered = {
const pitch = 0; const pitch = 0;
const path = extractPathFromQuery(req.query, transformer); const path = extractPathFromQuery(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, path, 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 +789,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);
@ -517,12 +806,24 @@ export const serve_rendered = {
mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS;
const path = extractPathFromQuery(req.query, transformer); const path = extractPathFromQuery(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(path).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 +835,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, path, 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');
}); });

View file

@ -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,7 @@ export function server(opts) {
checkPath('fonts'); checkPath('fonts');
checkPath('sprites'); checkPath('sprites');
checkPath('mbtiles'); checkPath('mbtiles');
checkPath('icons');
if (options.dataDecorator) { if (options.dataDecorator) {
try { try {

View file

@ -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');
}); });
}); });