diff --git a/docs/config.rst b/docs/config.rst index 397725a..a8c644a 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -14,6 +14,7 @@ Example: "root": "", "fonts": "fonts", "sprites": "sprites", + "icons": "icons", "styles": "styles", "mbtiles": "" }, diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 57361fd..2411d7c 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -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`` - * ``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``) * ``stroke`` - color of the path 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) * 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. * The static images are not available in the ``tileserver-gl-light`` version. diff --git a/src/serve_rendered.js b/src/serve_rendered.js index 39f15eb..2909c10 100644 --- a/src/serve_rendered.js +++ b/src/serve_rendered.js @@ -8,6 +8,7 @@ import util from 'util'; 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 pkg from 'canvas'; +import Image from 'canvas'; import clone from 'clone'; import Color from 'color'; 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 pathParts = (query.path || '').split('|'); const path = []; for (const pair of pathParts) { const pairParts = pair.split(','); if (pairParts.length === 2) { - let pair; - if (query.latlng === '1' || query.latlng === 'true') { - pair = [+(pairParts[1]), +(pairParts[0])]; - } else { - pair = [+(pairParts[0]), +(pairParts[1])]; - } - if (transformer) { - pair = transformer(pair); + const pair = parseCoordinates(pairParts, query, transformer); + + // Ensure coordinates could be parsed and skip them if not + if (pair === null) { + continue; } + path.push(pair); } } 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) { return null; } - const precisePx = (ll, zoom) => { - const px = mercator.px(ll, 20); - const scale = Math.pow(2, zoom - 20); - return [px[0] * scale, px[1] * scale]; - }; + + 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 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); @@ -147,24 +448,11 @@ const renderOverlay = (z, x, y, bearing, pitch, w, h, scale, // optimized path ctx.translate(-center[0] + w / 2, -center[1] + h / 2); } - const lineWidth = query.width !== undefined ? - parseFloat(query.width) : 1; - ctx.lineWidth = lineWidth; - ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)'; - 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(); - } + + drawPath(ctx, path, query, z); + + // Await drawing of markers before rendering the canvas + await drawMarkers(ctx, markers, z); return canvas.toBuffer(); }; @@ -396,7 +684,7 @@ export const serve_rendered = { 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]; if (!item) { return res.sendStatus(404); @@ -426,12 +714,13 @@ export const serve_rendered = { } 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'); }); - const serveBounds = (req, res, next) => { + const serveBounds = async (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); @@ -465,8 +754,8 @@ export const serve_rendered = { const pitch = 0; 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'); }; @@ -500,7 +789,7 @@ export const serve_rendered = { 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]; if (!item) { return res.sendStatus(404); @@ -517,12 +806,24 @@ export const serve_rendered = { mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; const path = extractPathFromQuery(req.query, transformer); - if (path.length < 2) { - return res.status(400).send('Invalid path'); + const markers = extractMarkersFromQuery(req.query, options, transformer); + + // 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]; - for (const pair of path) { + for (const pair of coords) { bbox[0] = Math.min(bbox[0], pair[0]); bbox[1] = Math.min(bbox[1], pair[1]); 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] ); - 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 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'); }); diff --git a/src/server.js b/src/server.js index c005438..9f4f103 100644 --- a/src/server.js +++ b/src/server.js @@ -79,6 +79,7 @@ export function server(opts) { paths.fonts = path.resolve(paths.root, paths.fonts || ''); paths.sprites = path.resolve(paths.root, paths.sprites || ''); paths.mbtiles = path.resolve(paths.root, paths.mbtiles || ''); + paths.icons = path.resolve(paths.root, paths.icons || ''); const startupPromises = []; @@ -92,6 +93,7 @@ export function server(opts) { checkPath('fonts'); checkPath('sprites'); checkPath('mbtiles'); + checkPath('icons'); if (options.dataDecorator) { try { diff --git a/test/static.js b/test/static.js index 9499034..13e6ca6 100644 --- a/test/static.js +++ b/test/static.js @@ -95,7 +95,7 @@ describe('Static endpoints', function() { describe('invalid requests return 4xx', function() { 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'); }); });