tileserver-gl-light
Version:
Map tile server for JSON GL styles - serving vector tiles
515 lines (450 loc) • 15.8 kB
JavaScript
;
import { createCanvas, Image } from 'canvas';
import { SphericalMercator } from '@mapbox/sphericalmercator';
const mercator = new SphericalMercator();
// Constants
const CONSTANTS = {
DEFAULT_LINE_WIDTH: 1,
DEFAULT_BORDER_WIDTH_RATIO: 0.1, // 10% of line width
DEFAULT_FILL_COLOR: 'rgba(255,255,255,0.4)',
DEFAULT_STROKE_COLOR: 'rgba(0,64,255,0.7)',
MAX_LINE_WIDTH: 500,
MAX_BORDER_WIDTH: 250,
MARKER_LOAD_TIMEOUT: 5000,
};
/**
* Transforms coordinates to pixels.
* @param {Array<number>} ll - Longitude/Latitude coordinate pair.
* @param {number} zoom - Map zoom level.
* @returns {Array<number>} Pixel coordinates as [x, y].
*/
const precisePx = (ll, zoom) => {
const px = mercator.px(ll, 20);
const scale = Math.pow(2, zoom - 20);
return [px[0] * scale, px[1] * scale];
};
/**
* Validates if a string is a valid color value.
* @param {string} color - Color string to validate.
* @returns {boolean} True if valid color.
*/
const isValidColor = (color) => {
if (!color || typeof color !== 'string') {
return false;
}
// Allow 'none' and 'transparent' keywords
if (color === 'none' || color === 'transparent') {
return true;
}
// Basic validation for common formats
const hexPattern = /^#([0-9A-Fa-f]{3}){1,2}$/; // 3 or 6 digits
const hexAlphaPattern = /^#([0-9A-Fa-f]{8})$/; // 8 digits with alpha
const rgbPattern = /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/;
const rgbaPattern = /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/;
const namedColors = [
'red',
'blue',
'green',
'yellow',
'black',
'white',
'gray',
'grey',
'orange',
'purple',
'pink',
'brown',
'cyan',
'magenta',
];
return (
hexPattern.test(color) ||
hexAlphaPattern.test(color) ||
rgbPattern.test(color) ||
rgbaPattern.test(color) ||
namedColors.includes(color.toLowerCase())
);
};
/**
* Safely parses a numeric value with bounds checking.
* @param {string|number} value - Value to parse.
* @param {number} defaultValue - Default value if parsing fails.
* @param {number} min - Minimum allowed value.
* @param {number} max - Maximum allowed value.
* @returns {number} Parsed and bounded value.
*/
const safeParseNumber = (
value,
defaultValue,
min = -Infinity,
max = Infinity,
) => {
const parsed = Number(value);
if (isNaN(parsed)) {
return defaultValue;
}
return Math.max(min, Math.min(max, parsed));
};
/**
* Draws a marker in canvas context.
* @param {CanvasRenderingContext2D} ctx - Canvas context object.
* @param {object} marker - Marker object parsed by extractMarkersFromQuery.
* @param {number} z - Map zoom level.
* @returns {Promise<void>} A promise that resolves when the marker is drawn.
*/
const drawMarker = (ctx, marker, z) => {
return new Promise((resolve, reject) => {
const img = new Image();
const pixelCoords = precisePx(marker.location, z);
// Add timeout to prevent hanging on slow/failed image loads
const timeout = setTimeout(() => {
reject(new Error(`Marker image load timeout: ${marker.icon}`));
}, CONSTANTS.MARKER_LOAD_TIMEOUT);
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.
// Subtract half of the image's width from the x-coordinate to center
// the image in relation to the provided location
let xCoordinate = pixelCoords[0] - imageWidth / 2;
// Subtract the image's 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 = () => {
clearTimeout(timeout);
try {
// Check if the image should be resized before being 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();
} catch (error) {
reject(new Error(`Failed to draw marker: ${error.message}`));
}
};
img.onload = drawOnCanvas;
img.onerror = () => {
clearTimeout(timeout);
reject(new Error(`Failed to load marker image: ${marker.icon}`));
};
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 asynchronously in canvas js
* even when provided from a local disk.
* @param {CanvasRenderingContext2D} ctx - Canvas context object.
* @param {Array<object>} markers - Marker objects parsed by extractMarkersFromQuery.
* @param {number} z - Map zoom level.
* @returns {Promise<void>} A promise that resolves when all markers are drawn.
*/
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
// Use Promise.allSettled to continue even if some markers fail
const results = await Promise.allSettled(markerPromises);
// Log any failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.warn(`Marker ${index} failed to render:`, result.reason);
}
});
};
/**
* Extracts an option value from a path query string.
* @param {Array<string>} splitPaths - Path string split by pipe character.
* @param {string} optionName - Name of the option to extract.
* @returns {string|undefined} Option value or undefined if not found.
*/
const getInlineOption = (splitPaths, optionName) => {
const found = splitPaths.find((x) => x.startsWith(`${optionName}:`));
return found ? found.replace(`${optionName}:`, '') : undefined;
};
/**
* Draws a list of coordinates onto a canvas and styles the resulting path.
* @param {CanvasRenderingContext2D} ctx - Canvas context object.
* @param {Array<Array<number>>} path - List of coordinate pairs.
* @param {object} query - Request query parameters.
* @param {string} pathQuery - Path query parameter string.
* @param {number} z - Map zoom level.
* @returns {void}
*/
const drawPath = (ctx, path, query, pathQuery, z) => {
if (!path || path.length < 2) {
return;
}
const splitPaths = pathQuery.split('|');
// Start the path - transform coordinates to pixels on canvas and draw lines between points
ctx.beginPath();
for (const [i, pair] of path.entries()) {
const px = precisePx(pair, z);
if (i === 0) {
ctx.moveTo(px[0], px[1]);
} else {
ctx.lineTo(px[0], px[1]);
}
}
// Check if first coordinate matches last coordinate (closed path)
if (
path[0][0] === path[path.length - 1][0] &&
path[0][1] === path[path.length - 1][1]
) {
ctx.closePath();
}
// --- FILL Logic ---
const inlineFill = getInlineOption(splitPaths, 'fill');
const pathHasFill = inlineFill !== undefined;
if (query.fill !== undefined || pathHasFill) {
let fillColor;
if (pathHasFill) {
fillColor = inlineFill;
} else if ('fill' in query) {
fillColor = query.fill || CONSTANTS.DEFAULT_FILL_COLOR;
} else {
fillColor = CONSTANTS.DEFAULT_FILL_COLOR;
}
// Validate color before using
if (isValidColor(fillColor)) {
ctx.fillStyle = fillColor;
ctx.fill();
} else {
console.warn(`Invalid fill color: ${fillColor}, using default`);
ctx.fillStyle = CONSTANTS.DEFAULT_FILL_COLOR;
ctx.fill();
}
}
// --- WIDTH & BORDER Logic ---
const inlineWidth = getInlineOption(splitPaths, 'width');
const pathHasWidth = inlineWidth !== undefined;
const inlineBorder = getInlineOption(splitPaths, 'border');
const inlineBorderWidth = getInlineOption(splitPaths, 'borderwidth');
const pathHasBorder = inlineBorder !== undefined;
// Parse line width with validation
let lineWidth = CONSTANTS.DEFAULT_LINE_WIDTH;
if (pathHasWidth) {
lineWidth = safeParseNumber(
inlineWidth,
CONSTANTS.DEFAULT_LINE_WIDTH,
0,
CONSTANTS.MAX_LINE_WIDTH,
);
} else if ('width' in query) {
lineWidth = safeParseNumber(
query.width,
CONSTANTS.DEFAULT_LINE_WIDTH,
0,
CONSTANTS.MAX_LINE_WIDTH,
);
}
// Get border width with validation
// Default: 10% of line width
let borderWidth = lineWidth * CONSTANTS.DEFAULT_BORDER_WIDTH_RATIO;
if (pathHasBorder && inlineBorderWidth) {
borderWidth = safeParseNumber(
inlineBorderWidth,
borderWidth,
0,
CONSTANTS.MAX_BORDER_WIDTH,
);
} else if (query.borderwidth !== undefined) {
borderWidth = safeParseNumber(
query.borderwidth,
borderWidth,
0,
CONSTANTS.MAX_BORDER_WIDTH,
);
}
// Set rendering style for the start and end points of the path
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap
const validLineCaps = ['butt', 'round', 'square'];
ctx.lineCap = validLineCaps.includes(query.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
const validLineJoins = ['miter', 'round', 'bevel'];
ctx.lineJoin = validLineJoins.includes(query.linejoin)
? query.linejoin
: 'miter';
// The final border color, prioritized by inline over global query
const finalBorder = pathHasBorder ? inlineBorder : query.border;
// In order to simulate a border we draw the path two times with the first
// being the wider border part.
if (finalBorder !== undefined && borderWidth > 0) {
// Validate border color
if (isValidColor(finalBorder)) {
// 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;
ctx.strokeStyle = finalBorder;
ctx.stroke();
} else {
console.warn(`Invalid border color: ${finalBorder}, skipping border`);
}
}
// Set line width for the main stroke
ctx.lineWidth = lineWidth;
// --- STROKE Logic ---
const inlineStroke = getInlineOption(splitPaths, 'stroke');
const pathHasStroke = inlineStroke !== undefined;
let strokeColor;
if (pathHasStroke) {
strokeColor = inlineStroke;
} else if ('stroke' in query) {
strokeColor = query.stroke;
} else {
strokeColor = CONSTANTS.DEFAULT_STROKE_COLOR;
}
// Validate stroke color
if (isValidColor(strokeColor)) {
ctx.strokeStyle = strokeColor;
} else {
console.warn(`Invalid stroke color: ${strokeColor}, using default`);
ctx.strokeStyle = CONSTANTS.DEFAULT_STROKE_COLOR;
}
ctx.stroke();
};
/**
* Renders an overlay with paths and markers on a map tile.
* @param {number} z - Map zoom level.
* @param {number} x - Longitude of center point.
* @param {number} y - Latitude of center point.
* @param {number} bearing - Map bearing in degrees.
* @param {number} pitch - Map pitch in degrees.
* @param {number} w - Width of the canvas.
* @param {number} h - Height of the canvas.
* @param {number} scale - Scale factor for rendering.
* @param {Array<Array<Array<number>>>} paths - Array of path coordinate arrays.
* @param {Array<object>} markers - Array of marker objects.
* @param {object} query - Request query parameters.
* @returns {Promise<Buffer|null>} A promise that resolves with the canvas buffer or null if no overlay is needed.
*/
export 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 mapHeight = 512 * (1 << z);
const maxEdge = center[1] + h / 2;
const minEdge = center[1] - h / 2;
if (maxEdge > mapHeight) {
center[1] -= maxEdge - mapHeight;
} else if (minEdge < 0) {
center[1] -= minEdge;
}
const canvas = createCanvas(scale * w, scale * h);
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
if (bearing) {
ctx.translate(w / 2, h / 2);
ctx.rotate((-bearing / 180) * Math.PI);
ctx.translate(-center[0], -center[1]);
} else {
// Optimized path
ctx.translate(-center[0] + w / 2, -center[1] + h / 2);
}
// Draw provided paths if any
paths.forEach((path, i) => {
const pathQuery = Array.isArray(query.path) ? query.path.at(i) : query.path;
try {
drawPath(ctx, path, query, pathQuery, z);
} catch (error) {
console.error(`Error drawing path ${i}:`, error);
}
});
// Await drawing of markers before rendering the canvas
try {
await drawMarkers(ctx, markers, z);
} catch (error) {
console.error('Error drawing markers:', error);
}
return canvas.toBuffer();
};
/**
* Renders a watermark on a canvas.
* @param {number} width - Width of the canvas.
* @param {number} height - Height of the canvas.
* @param {number} scale - Scale factor for rendering.
* @param {string} text - Watermark text to render.
* @returns {object} The canvas with the rendered attribution.
*/
export const renderWatermark = (width, height, scale, text) => {
const canvas = createCanvas(scale * width, scale * height);
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.font = '10px sans-serif';
ctx.strokeWidth = '1px';
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.strokeText(text, 5, height - 5);
ctx.fillStyle = 'rgba(0,0,0,.4)';
ctx.fillText(text, 5, height - 5);
return canvas;
};
/**
* Renders an attribution box on a canvas.
* @param {number} width - Width of the canvas.
* @param {number} height - Height of the canvas.
* @param {number} scale - Scale factor for rendering.
* @param {string} text - Attribution text to render.
* @returns {object} The canvas with the rendered attribution.
*/
export const renderAttribution = (width, height, scale, text) => {
const canvas = createCanvas(scale * width, scale * height);
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.font = '10px sans-serif';
const textMetrics = ctx.measureText(text);
const textWidth = textMetrics.width;
const textHeight = 14;
const padding = 6;
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillRect(
width - textWidth - padding,
height - textHeight - padding,
textWidth + padding,
textHeight + padding,
);
ctx.fillStyle = 'rgba(0,0,0,.8)';
ctx.fillText(text, width - textWidth - padding / 2, height - textHeight + 8);
return canvas;
};