'use strict'; // SECTION START // // The order of the two imports below is important. // For an unknown reason, if the order is reversed, rendering can crash. // This happens on ARM: // > terminate called after throwing an instance of 'std::runtime_error' // > what(): Cannot read GLX extensions. import 'canvas'; import '@maplibre/maplibre-gl-native'; // // SECTION END import advancedPool from 'advanced-pool'; import fs from 'node:fs'; import path from 'path'; import url from 'url'; import util from 'util'; import zlib from 'zlib'; import sharp from 'sharp'; import clone from 'clone'; import Color from 'color'; import express from 'express'; import sanitize from 'sanitize-filename'; import SphericalMercator from '@mapbox/sphericalmercator'; import mlgl from '@maplibre/maplibre-gl-native'; import MBTiles from '@mapbox/mbtiles'; import polyline from '@mapbox/polyline'; import proj4 from 'proj4'; import axios from 'axios'; import { getFontsPbf, listFonts, getTileUrls, isValidHttpUrl, fixTileJSONCenter, } from './utils.js'; import { openPMtiles, getPMtilesInfo, getPMtilesTile, } from './pmtiles_adapter.js'; import { renderOverlay, renderWatermark, renderAttribution } from './render.js'; const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)'; const PATH_PATTERN = /^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/; const httpTester = /^https?:\/\//i; const mercator = new SphericalMercator(); const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0; mlgl.on('message', (e) => { if (e.severity === 'WARNING' || e.severity === 'ERROR') { console.log('mlgl:', e); } }); /** * Lookup of sharp output formats by file extension. */ const extensionToFormat = { '.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.webp': 'webp', }; /** * Cache of response data by sharp output format and color. Entry for empty * string is for unknown or unsupported formats. */ const cachedEmptyResponses = { '': Buffer.alloc(0), }; /** * Create an appropriate mlgl response for http errors. * @param {string} format The format (a sharp format or 'pbf'). * @param {string} color The background color (or empty string for transparent). * @param {Function} callback The mlgl callback. */ function createEmptyResponse(format, color, callback) { if (!format || format === 'pbf') { callback(null, { data: cachedEmptyResponses[''] }); return; } if (format === 'jpg') { format = 'jpeg'; } if (!color) { color = 'rgba(255,255,255,0)'; } const cacheKey = `${format},${color}`; const data = cachedEmptyResponses[cacheKey]; if (data) { callback(null, { data: data }); return; } // create an "empty" response image color = new Color(color); const array = color.array(); const channels = array.length === 4 && format !== 'jpeg' ? 4 : 3; sharp(Buffer.from(array), { raw: { width: 1, height: 1, channels, }, }) .toFormat(format) .toBuffer((err, buffer, info) => { if (!err) { cachedEmptyResponses[cacheKey] = buffer; } callback(null, { data: buffer }); }); } /** * 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 coordinates * @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; }; /** * 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) => { // Initiate paths array const paths = []; // Return an empty list if no paths have been provided if ('path' in query && !query.path) { return paths; } // Parse paths provided via path query argument if ('path' in query) { const providedPaths = Array.isArray(query.path) ? query.path : [query.path]; // Iterate through paths, parse and validate them for (const providedPath of providedPaths) { // Logic for pushing coords to path when path includes google polyline if (providedPath.includes('enc:') && PATH_PATTERN.test(providedPath)) { // +4 because 'enc:' is 4 characters, everything after 'enc:' is considered to be part of the polyline const encIndex = providedPath.indexOf('enc:') + 4; const coords = polyline .decode(providedPath.substring(encIndex)) .map(([lat, lng]) => [lng, lat]); paths.push(coords); } else { // Iterate through paths, parse and validate them const currentPath = []; // Extract coordinate-list from path const pathParts = (providedPath || '').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 const isRemoteURL = iconURI.startsWith('http://') || iconURI.startsWith('https://'); const isDataURL = iconURI.startsWith('data:'); if (!(isRemoteURL || isDataURL)) { // 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 (isRemoteURL && options.allowRemoteMarkerIcons !== true) { continue; } else if (isDataURL && options.allowInlineMarkerImages !== 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; }; const calcZForBBox = (bbox, w, h, query) => { let z = 25; const padding = query.padding !== undefined ? parseFloat(query.padding) : 0.1; const minCorner = mercator.px([bbox[0], bbox[3]], z); const maxCorner = mercator.px([bbox[2], bbox[1]], z); const w_ = w / (1 + 2 * padding); const h_ = h / (1 + 2 * padding); z -= Math.max( Math.log((maxCorner[0] - minCorner[0]) / w_), Math.log((maxCorner[1] - minCorner[1]) / h_), ) / Math.LN2; z = Math.max(Math.log(Math.max(w, h) / 256) / Math.LN2, Math.min(25, z)); return z; }; const respondImage = ( options, item, z, lon, lat, bearing, pitch, width, height, scale, format, res, overlay = null, mode = 'tile', ) => { if ( Math.abs(lon) > 180 || Math.abs(lat) > 85.06 || lon !== lon || lat !== lat ) { return res.status(400).send('Invalid center'); } if ( Math.min(width, height) <= 0 || Math.max(width, height) * scale > (options.maxSize || 2048) || width !== width || height !== height ) { return res.status(400).send('Invalid size'); } if (format === 'png' || format === 'webp') { } else if (format === 'jpg' || format === 'jpeg') { format = 'jpeg'; } else { return res.status(400).send('Invalid format'); } const tileMargin = Math.max(options.tileMargin || 0, 0); let pool; if (mode === 'tile' && tileMargin === 0) { pool = item.map.renderers[scale]; } else { pool = item.map.renderersStatic[scale]; } pool.acquire((err, renderer) => { // For 512px tiles, use the actual maplibre-native zoom. For 256px tiles, use zoom - 1 let mlglZ; if (width === 512) { mlglZ = Math.max(0, z); } else { mlglZ = Math.max(0, z - 1); } const params = { zoom: mlglZ, center: [lon, lat], bearing, pitch, width, height, }; // HACK(Part 1) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized in Part 2. if (z === 0 && width === 256) { params.width *= 2; params.height *= 2; } // END HACK(Part 1) if (z > 0 && tileMargin > 0) { params.width += tileMargin * 2; params.height += tileMargin * 2; } renderer.render(params, (err, data) => { pool.release(renderer); if (err) { console.error(err); return res.status(500).header('Content-Type', 'text/plain').send(err); } const image = sharp(data, { raw: { premultiplied: true, width: params.width * scale, height: params.height * scale, channels: 4, }, }); if (z > 0 && tileMargin > 0) { const y = mercator.px(params.center, z)[1]; const yoffset = Math.max( Math.min(0, y - 128 - tileMargin), y + 128 + tileMargin - Math.pow(2, z + 8), ); image.extract({ left: tileMargin * scale, top: (tileMargin + yoffset) * scale, width: width * scale, height: height * scale, }); } // HACK(Part 2) 256px tiles are a zoom level lower than maplibre-native default tiles. this hack allows tileserver-gl to support zoom 0 256px tiles, which would actually be zoom -1 in maplibre-native. Since zoom -1 isn't supported, a double sized zoom 0 tile is requested and resized here. if (z === 0 && width === 256) { image.resize(width * scale, height * scale); } // END HACK(Part 2) const composites = []; if (overlay) { composites.push({ input: overlay }); } if (item.watermark) { const canvas = renderWatermark(width, height, scale, item.watermark); composites.push({ input: canvas.toBuffer() }); } if (mode === 'static' && item.staticAttributionText) { const canvas = renderAttribution( width, height, scale, item.staticAttributionText, ); composites.push({ input: canvas.toBuffer() }); } if (composites.length > 0) { image.composite(composites); } const formatQuality = (options.formatQuality || {})[format]; if (format === 'png') { image.png({ adaptiveFiltering: false }); } else if (format === 'jpeg') { image.jpeg({ quality: formatQuality || 80 }); } else if (format === 'webp') { image.webp({ quality: formatQuality || 90 }); } image.toBuffer((err, buffer, info) => { if (!buffer) { return res.status(404).send('Not found'); } res.set({ 'Last-Modified': item.lastModified, 'Content-Type': `image/${format}`, }); return res.status(200).send(buffer); }); }); }); }; const existingFonts = {}; let maxScaleFactor = 2; export const serve_rendered = { init: async (options, repo) => { maxScaleFactor = Math.min(Math.floor(options.maxScaleFactor || 3), 9); let scalePattern = ''; for (let i = 2; i <= maxScaleFactor; i++) { scalePattern += i.toFixed(); } scalePattern = `@[${scalePattern}]x`; const app = express().disable('x-powered-by'); app.get( `/:id/(:tileSize(256|512)/)?:z(\\d+)/:x(\\d+)/:y(\\d+):scale(${scalePattern})?.:format([\\w]+)`, (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const modifiedSince = req.get('if-modified-since'); const cc = req.get('cache-control'); if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) { if (new Date(item.lastModified) <= new Date(modifiedSince)) { return res.sendStatus(304); } } const z = req.params.z | 0; const x = req.params.x | 0; const y = req.params.y | 0; const scale = getScale(req.params.scale); const format = req.params.format; const tileSize = parseInt(req.params.tileSize, 10) || 256; if ( z < 0 || x < 0 || y < 0 || z > 22 || x >= Math.pow(2, z) || y >= Math.pow(2, z) ) { return res.status(404).send('Out of bounds'); } const tileCenter = mercator.ll( [ ((x + 0.5) / (1 << z)) * (256 << z), ((y + 0.5) / (1 << z)) * (256 << z), ], z, ); // prettier-ignore return respondImage( options, item, z, tileCenter[0], tileCenter[1], 0, 0, tileSize, tileSize, scale, format, res, ); }, ); if (options.serveStaticMaps !== false) { const staticPattern = `/:id/static/:raw(raw)?/%s/:width(\\d+)x:height(\\d+):scale(${scalePattern})?.:format([\\w]+)`; const centerPattern = util.format( ':x(%s),:y(%s),:z(%s)(@:bearing(%s)(,:pitch(%s))?)?', FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, ); app.get( util.format(staticPattern, centerPattern), async (req, res, next) => { try { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const raw = req.params.raw; const z = +req.params.z; let x = +req.params.x; let y = +req.params.y; const bearing = +(req.params.bearing || '0'); const pitch = +(req.params.pitch || '0'); const w = req.params.width | 0; const h = req.params.height | 0; const scale = getScale(req.params.scale); const format = req.params.format; if (z < 0) { return res.status(404).send('Invalid zoom'); } const transformer = raw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; if (transformer) { const ll = transformer([x, y]); x = ll[0]; y = ll[1]; } const paths = extractPathsFromQuery(req.query, transformer); const markers = extractMarkersFromQuery( req.query, options, transformer, ); // prettier-ignore const overlay = await renderOverlay( z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query, ); // prettier-ignore return respondImage( options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', ); } catch (e) { next(e); } }, ); const serveBounds = async (req, res, next) => { try { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const raw = req.params.raw; const bbox = [ +req.params.minx, +req.params.miny, +req.params.maxx, +req.params.maxy, ]; let center = [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2]; const transformer = raw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; if (transformer) { const minCorner = transformer(bbox.slice(0, 2)); const maxCorner = transformer(bbox.slice(2)); bbox[0] = minCorner[0]; bbox[1] = minCorner[1]; bbox[2] = maxCorner[0]; bbox[3] = maxCorner[1]; center = transformer(center); } const w = req.params.width | 0; const h = req.params.height | 0; const scale = getScale(req.params.scale); const format = req.params.format; const z = calcZForBBox(bbox, w, h, req.query); const x = center[0]; const y = center[1]; const bearing = 0; const pitch = 0; const paths = extractPathsFromQuery(req.query, transformer); const markers = extractMarkersFromQuery( req.query, options, transformer, ); // prettier-ignore const overlay = await renderOverlay( z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query, ); // prettier-ignore return respondImage( options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', ); } catch (e) { next(e); } }; const boundsPattern = util.format( ':minx(%s),:miny(%s),:maxx(%s),:maxy(%s)', FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, ); app.get(util.format(staticPattern, boundsPattern), serveBounds); app.get('/:id/static/', (req, res, next) => { for (const key in req.query) { req.query[key.toLowerCase()] = req.query[key]; } req.params.raw = true; req.params.format = (req.query.format || 'image/png').split('/').pop(); const bbox = (req.query.bbox || '').split(','); req.params.minx = bbox[0]; req.params.miny = bbox[1]; req.params.maxx = bbox[2]; req.params.maxy = bbox[3]; req.params.width = req.query.width || '256'; req.params.height = req.query.height || '256'; if (req.query.scale) { req.params.width /= req.query.scale; req.params.height /= req.query.scale; req.params.scale = `@${req.query.scale}`; } return serveBounds(req, res, next); }); const autoPattern = 'auto'; app.get( util.format(staticPattern, autoPattern), async (req, res, next) => { try { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const raw = req.params.raw; const w = req.params.width | 0; const h = req.params.height | 0; const bearing = 0; const pitch = 0; const scale = getScale(req.params.scale); const format = req.params.format; const transformer = raw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; const paths = extractPathsFromQuery(req.query, transformer); 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 = [].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]; 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]); bbox[3] = Math.max(bbox[3], pair[1]); } const bbox_ = mercator.convert(bbox, '900913'); const center = mercator.inverse([ (bbox_[0] + bbox_[2]) / 2, (bbox_[1] + bbox_[3]) / 2, ]); // 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]; // prettier-ignore const overlay = await renderOverlay( z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query, ); // prettier-ignore return respondImage( options, item, z, x, y, bearing, pitch, w, h, scale, format, res, overlay, 'static', ); } catch (e) { next(e); } }, ); } app.get('/(:tileSize(256|512)/)?:id.json', (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); } const tileSize = parseInt(req.params.tileSize, 10) || undefined; const info = clone(item.tileJSON); info.tiles = getTileUrls( req, info.tiles, `styles/${req.params.id}`, tileSize, info.format, item.publicUrl, ); return res.send(info); }); const fonts = await listFonts(options.paths.fonts); Object.assign(existingFonts, fonts); return app; }, add: async (options, repo, params, id, publicUrl, dataResolver) => { const map = { renderers: [], renderersStatic: [], sources: {}, sourceTypes: {}, }; let styleJSON; const createPool = (ratio, mode, min, max) => { const createRenderer = (ratio, createCallback) => { const renderer = new mlgl.Map({ mode, ratio, request: async (req, callback) => { const protocol = req.url.split(':')[0]; // console.log('Handling request:', req); if (protocol === 'sprites') { const dir = options.paths[protocol]; const file = unescape(req.url).substring(protocol.length + 3); fs.readFile(path.join(dir, file), (err, data) => { callback(err, { data: data }); }); } else if (protocol === 'fonts') { const parts = req.url.split('/'); const fontstack = unescape(parts[2]); const range = parts[3].split('.')[0]; try { const concatenated = await getFontsPbf( null, options.paths[protocol], fontstack, range, existingFonts, ); callback(null, { data: concatenated }); } catch (err) { callback(err, { data: null }); } } else if (protocol === 'mbtiles' || protocol === 'pmtiles') { const parts = req.url.split('/'); const sourceId = parts[2]; const source = map.sources[sourceId]; const sourceType = map.sourceTypes[sourceId]; const sourceInfo = styleJSON.sources[sourceId]; const z = parts[3] | 0; const x = parts[4] | 0; const y = parts[5].split('.')[0] | 0; const format = parts[5].split('.')[1]; if (sourceType === 'pmtiles') { let tileinfo = await getPMtilesTile(source, z, x, y); let data = tileinfo.data; let headers = tileinfo.header; if (data == undefined) { if (options.verbose) console.log('MBTiles error, serving empty', err); createEmptyResponse( sourceInfo.format, sourceInfo.color, callback, ); return; } else { const response = {}; response.data = data; if (headers['Last-Modified']) { response.modified = new Date(headers['Last-Modified']); } if (format === 'pbf') { if (options.dataDecoratorFunc) { response.data = options.dataDecoratorFunc( sourceId, 'data', response.data, z, x, y, ); } } callback(null, response); } } else if (sourceType === 'mbtiles') { source.getTile(z, x, y, (err, data, headers) => { if (err) { if (options.verbose) console.log('MBTiles error, serving empty', err); createEmptyResponse( sourceInfo.format, sourceInfo.color, callback, ); return; } const response = {}; if (headers['Last-Modified']) { response.modified = new Date(headers['Last-Modified']); } if (format === 'pbf') { try { response.data = zlib.unzipSync(data); } catch (err) { console.log( 'Skipping incorrect header for tile mbtiles://%s/%s/%s/%s.pbf', id, z, x, y, ); } if (options.dataDecoratorFunc) { response.data = options.dataDecoratorFunc( sourceId, 'data', response.data, z, x, y, ); } } else { response.data = data; } callback(null, response); }); } } else if (protocol === 'http' || protocol === 'https') { try { const response = await axios.get(req.url, { responseType: 'arraybuffer', // Get the response as raw buffer // Axios handles gzip by default, so no need for a gzip flag }); const responseHeaders = response.headers; const responseData = response.data; const parsedResponse = {}; if (responseHeaders['last-modified']) { parsedResponse.modified = new Date( responseHeaders['last-modified'], ); } if (responseHeaders.expires) { parsedResponse.expires = new Date(responseHeaders.expires); } if (responseHeaders.etag) { parsedResponse.etag = responseHeaders.etag; } parsedResponse.data = responseData; callback(null, parsedResponse); } catch (error) { const parts = url.parse(req.url); const extension = path.extname(parts.pathname).toLowerCase(); const format = extensionToFormat[extension] || ''; createEmptyResponse(format, '', callback); } } }, }); renderer.load(styleJSON); createCallback(null, renderer); }; return new advancedPool.Pool({ min, max, create: createRenderer.bind(null, ratio), destroy: (renderer) => { renderer.release(); }, }); }; const styleFile = params.style; const styleJSONPath = path.resolve(options.paths.styles, styleFile); try { styleJSON = JSON.parse(fs.readFileSync(styleJSONPath)); } catch (e) { console.log('Error parsing style file'); return false; } if (styleJSON.sprite) { if (!Array.isArray(styleJSON.sprite)) { styleJSON.sprite = [{ id: 'default', url: styleJSON.sprite }]; } styleJSON.sprite.forEach((spriteItem) => { if (!httpTester.test(spriteItem.url)) { spriteItem.url = 'sprites://' + spriteItem.url .replace('{style}', path.basename(styleFile, '.json')) .replace( '{styleJsonFolder}', path.relative( options.paths.sprites, path.dirname(styleJSONPath), ), ); } }); } if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) { styleJSON.glyphs = `fonts://${styleJSON.glyphs}`; } for (const layer of styleJSON.layers || []) { if (layer && layer.paint) { // Remove (flatten) 3D buildings if (layer.paint['fill-extrusion-height']) { layer.paint['fill-extrusion-height'] = 0; } if (layer.paint['fill-extrusion-base']) { layer.paint['fill-extrusion-base'] = 0; } } } const tileJSON = { tilejson: '2.0.0', name: styleJSON.name, attribution: '', minzoom: 0, maxzoom: 20, bounds: [-180, -85.0511, 180, 85.0511], format: 'png', type: 'baselayer', }; const attributionOverride = params.tilejson && params.tilejson.attribution; if (styleJSON.center && styleJSON.zoom) { tileJSON.center = styleJSON.center.concat(Math.round(styleJSON.zoom)); } Object.assign(tileJSON, params.tilejson || {}); tileJSON.tiles = params.domains || options.domains; fixTileJSONCenter(tileJSON); const repoobj = { tileJSON, publicUrl, map, dataProjWGStoInternalWGS: null, lastModified: new Date().toUTCString(), watermark: params.watermark || options.watermark, staticAttributionText: params.staticAttributionText || options.staticAttributionText, }; repo[id] = repoobj; const queue = []; for (const name of Object.keys(styleJSON.sources)) { let sourceType; let source = styleJSON.sources[name]; let url = source.url; if ( url && (url.startsWith('pmtiles://') || url.startsWith('mbtiles://')) ) { // found pmtiles or mbtiles source, replace with info from local file delete source.url; let dataId = url.replace('pmtiles://', '').replace('mbtiles://', ''); if (dataId.startsWith('{') && dataId.endsWith('}')) { dataId = dataId.slice(1, -1); } const mapsTo = (params.mapping || {})[dataId]; if (mapsTo) { dataId = mapsTo; } let inputFile; const dataInfo = dataResolver(dataId); if (dataInfo.inputFile) { inputFile = dataInfo.inputFile; sourceType = dataInfo.fileType; } else { console.error(`ERROR: data "${inputFile}" not found!`); process.exit(1); } if (!isValidHttpUrl(inputFile)) { const inputFileStats = fs.statSync(inputFile); if (!inputFileStats.isFile() || inputFileStats.size === 0) { throw Error(`Not valid PMTiles file: "${inputFile}"`); } } if (sourceType === 'pmtiles') { map.sources[name] = openPMtiles(inputFile); map.sourceTypes[name] = 'pmtiles'; const metadata = await getPMtilesInfo(map.sources[name]); if (!repoobj.dataProjWGStoInternalWGS && metadata.proj4) { // how to do this for multiple sources with different proj4 defs? const to3857 = proj4('EPSG:3857'); const toDataProj = proj4(metadata.proj4); repoobj.dataProjWGStoInternalWGS = (xy) => to3857.inverse(toDataProj.forward(xy)); } const type = source.type; Object.assign(source, metadata); source.type = type; source.tiles = [ // meta url which will be detected when requested `pmtiles://${name}/{z}/{x}/{y}.${metadata.format || 'pbf'}`, ]; delete source.scheme; if ( !attributionOverride && source.attribution && source.attribution.length > 0 ) { if (!tileJSON.attribution.includes(source.attribution)) { if (tileJSON.attribution.length > 0) { tileJSON.attribution += ' | '; } tileJSON.attribution += source.attribution; } } } else { queue.push( new Promise((resolve, reject) => { inputFile = path.resolve(options.paths.mbtiles, inputFile); const inputFileStats = fs.statSync(inputFile); if (!inputFileStats.isFile() || inputFileStats.size === 0) { throw Error(`Not valid MBTiles file: "${inputFile}"`); } map.sources[name] = new MBTiles(inputFile + '?mode=ro', (err) => { map.sources[name].getInfo((err, info) => { if (err) { console.error(err); return; } map.sourceTypes[name] = 'mbtiles'; if (!repoobj.dataProjWGStoInternalWGS && info.proj4) { // how to do this for multiple sources with different proj4 defs? const to3857 = proj4('EPSG:3857'); const toDataProj = proj4(info.proj4); repoobj.dataProjWGStoInternalWGS = (xy) => to3857.inverse(toDataProj.forward(xy)); } const type = source.type; Object.assign(source, info); source.type = type; source.tiles = [ // meta url which will be detected when requested `mbtiles://${name}/{z}/{x}/{y}.${info.format || 'pbf'}`, ]; delete source.scheme; if (options.dataDecoratorFunc) { source = options.dataDecoratorFunc( name, 'tilejson', source, ); } if ( !attributionOverride && source.attribution && source.attribution.length > 0 ) { if (!tileJSON.attribution.includes(source.attribution)) { if (tileJSON.attribution.length > 0) { tileJSON.attribution += ' | '; } tileJSON.attribution += source.attribution; } } resolve(); }); }); }), ); } } } await Promise.all(queue); // standard and @2x tiles are much more usual -> default to larger pools const minPoolSizes = options.minRendererPoolSizes || [8, 4, 2]; const maxPoolSizes = options.maxRendererPoolSizes || [16, 8, 4]; for (let s = 1; s <= maxScaleFactor; s++) { const i = Math.min(minPoolSizes.length - 1, s - 1); const j = Math.min(maxPoolSizes.length - 1, s - 1); const minPoolSize = minPoolSizes[i]; const maxPoolSize = Math.max(minPoolSize, maxPoolSizes[j]); map.renderers[s] = createPool(s, 'tile', minPoolSize, maxPoolSize); map.renderersStatic[s] = createPool( s, 'static', minPoolSize, maxPoolSize, ); } }, remove: (repo, id) => { const item = repo[id]; if (item) { item.map.renderers.forEach((pool) => { pool.close(); }); item.map.renderersStatic.forEach((pool) => { pool.close(); }); } delete repo[id]; }, };