diff --git a/package-lock.json b/package-lock.json index 592bee79f..8738ec494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "express": "5.0.1", "handlebars": "4.7.8", "http-shutdown": "1.2.2", + "maplibre-contour": "^0.1.0", "morgan": "1.10.0", "pbf": "4.0.1", "pmtiles": "3.0.7", @@ -5551,6 +5552,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/maplibre-contour": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.0.tgz", + "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", diff --git a/package.json b/package.json index 22f4d008f..09cdc072c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "express": "5.0.1", "handlebars": "4.7.8", "http-shutdown": "1.2.2", + "maplibre-contour": "^0.1.0", "morgan": "1.10.0", "pbf": "4.0.1", "pmtiles": "3.0.7", diff --git a/public/resources/contour-control.js b/public/resources/contour-control.js new file mode 100644 index 000000000..2549d4986 --- /dev/null +++ b/public/resources/contour-control.js @@ -0,0 +1,65 @@ +class MaplibreContourControl { + constructor(options) { + this.source = options["source"]; + this.confLayers = options["layers"]; + this.visibility = options["visibility"]; + } + + getDefaultPosition() { + const defaultPosition = "top-right"; + return defaultPosition; + } + + onAdd(map) { + this.map = map; + this.controlContainer = document.createElement("div"); + this.controlContainer.classList.add("maplibregl-ctrl"); + this.controlContainer.classList.add("maplibregl-ctrl-group"); + this.contourButton = document.createElement("button"); + this.contourButton.type = "button"; + this.contourButton.textContent = "C"; + + this.map.on("style.load", () => { + this.confLayers.forEach(layer => { + this.map.setLayoutProperty(layer, "visibility", this.visibility ? "visible" : "none"); + if (this.visibility) { + this.controlContainer.classList.add("maplibre-ctrl-contour-active"); + this.contourButton.title = "Disable Contours"; + } else { + this.contourButton.title = "Enable Contours"; + } + }); + }); + + this.contourButton.addEventListener("click", () => { + this.confLayers.forEach(layer => { + var visibility = this.map.getLayoutProperty(layer, "visibility"); + if (visibility === "visible") { + this.map.setLayoutProperty(layer, "visibility", "none"); + this.controlContainer.classList.remove("maplibre-ctrl-contour-active"); + this.contourButton.title = "Disable Contours"; + } else { + this.controlContainer.classList.add("maplibre-ctrl-contour-active"); + this.map.setLayoutProperty(layer, "visibility", "visible"); + this.contourButton.title = "Enable Contours"; + } + }); + }); + this.controlContainer.appendChild(this.contourButton); + return this.controlContainer; + } + + onRemove() { + if ( + !this.controlContainer || + !this.controlContainer.parentNode || + !this.map || + !this.contourButton + ) { + return; + } + this.contourButton.removeEventListener("click"); + this.controlContainer.parentNode.removeChild(this.controlContainer); + this.map = undefined; + } +}; diff --git a/public/templates/data.tmpl b/public/templates/data.tmpl index 12a8d9850..9ecdc3732 100644 --- a/public/templates/data.tmpl +++ b/public/templates/data.tmpl @@ -9,6 +9,7 @@ + {{^is_light}} {{/is_light}} @@ -23,6 +24,7 @@ h1 {position:absolute;top:5px;right:0;width:240px;margin:0;line-height:20px;font-size:20px;} #layerList {position:absolute;top:35px;right:0;bottom:0;width:240px;overflow:auto;} #layerList div div {width:15px;height:15px;display:inline-block;} + .maplibre-ctrl-contour-active button { color: #33b5e5; font-weight: bold; } {{^is_light}} .maplibre-ctrl-elevation { padding-left: 5px; padding-right: 5px; } {{/is_light}} @@ -76,6 +78,8 @@ {{/is_terrain}} {{#is_terrain}} + let baseUrl = window.location.origin; + var style = { version: 8, sources: { @@ -88,8 +92,13 @@ "type": "raster-dem", "url": "{{public_url}}data/{{id}}.json", "encoding": "{{terrain_encoding}}" + }, + "contour": { + "type": "vector", + "tiles": [ baseUrl + "/data/{{id}}/contour/{z}/{x}/{y}" ], } }, + "glyphs": "/fonts/{fontstack}/{range}.pbf", "terrain": { "source": "terrain" }, @@ -114,6 +123,33 @@ "hillshade-illumination-direction": 315, "hillshade-exaggeration": 0.8 } + }, + { + "id": "contours", + "type": "line", + "source": "contour", + "source-layer": "contours", + "paint": { + "line-opacity": 1, + "line-width": ["match", ["get", "level"], 1, 1, 0.5] + } + }, + { + "id": "contour-label", + "type": "symbol", + "source": "contour", + "source-layer": "contours", + "filter": [">", ["get", "ele"], 0 ], + "paint": { + "text-halo-color": "white", + "text-halo-width": 1 + }, + "layout": { + "symbol-placement": "line", + "text-size": 10, + "text-field": "{ele}", + "text-font": ["Noto Sans Bold"] + } } ] }; @@ -139,6 +175,14 @@ }) ); + map.addControl( + new MaplibreContourControl({ + source: "contour", + visibility: false, + layers: [ "contours", "contour-label" ] + }) + ); + {{^is_light}} map.addControl( new ElevationInfoControl({ diff --git a/src/contour.js b/src/contour.js new file mode 100644 index 000000000..fa770dd11 --- /dev/null +++ b/src/contour.js @@ -0,0 +1,218 @@ +import sharp from 'sharp'; +import mlcontour from '../node_modules/maplibre-contour/dist/index.mjs'; +import { getPMtilesTile } from './pmtiles_adapter.js'; + +/** + * Manages local DEM (Digital Elevation Model) data using maplibre-contour. + */ +export class LocalDemManager { + /** + * Creates a new LocalDemManager instance. + * @param {string} encoding - The encoding type for the DEM data. + * @param {number} maxzoom - The maximum zoom level for the DEM data. + * @param {object} source - The source object that contains either pmtiles or mbtiles. + * @param {'pmtiles' | 'mbtiles'} sourceType - The type of data source + * @param {Function} [GetTileFunction] - the function that returns a tile from the pmtiles object. + * @param {Function} [GetImageFunction] - the function that returns a tile from the pmtiles object. + * @param {Function} [extractZXYFromUrlFunction] - The function to extract the zxy from the url. + */ + constructor( + encoding, + maxzoom, + source, + sourceType, + GetTileFunction, + GetImageFunction, + extractZXYFromUrlFunction, + ) { + this.encoding = encoding; + this.maxzoom = maxzoom; + this.source = source; + this.sourceType = sourceType; + this._getTile = GetTileFunction; + this._decodeImage = GetImageFunction; + this._extractZXY = extractZXYFromUrlFunction; + + this.manager = new mlcontour.LocalDemManager({ + demUrlPattern: '/{z}/{x}/{y}', + cacheSize: 100, + encoding: this.encoding, + maxzoom: this.maxzoom, + timeoutMs: 10000, + decodeImage: this.getImageFunction.bind(this), + getTile: this.getTileFunction.bind(this), + }); + } + + get getTileFunction() { + return this._getTile ? this._getTile.bind(this) : this.GetTile.bind(this); + } + + get getImageFunction() { + return this._decodeImage + ? this._decodeImage.bind(this) + : this.getImageData.bind(this); + } + + get extractZXYFromUrlTrim() { + return this._extractZXY + ? this._extractZXY.bind(this) + : this._extractZXYFromUrl.bind(this); + } + + /** + * Processes image data from a blob. + * @param {Blob} blob - The image data as a Blob. + * @param {AbortController} abortController - An AbortController to cancel the image processing. + * @returns {Promise} - A Promise that resolves with the processed image data, or null if aborted. + * @throws If an error occurs during image processing. + */ + async getImageData(blob, abortController) { + try { + if (Boolean(abortController?.signal?.aborted)) return null; + + const buffer = await blob.arrayBuffer(); + const image = sharp(Buffer.from(buffer)); + + if (Boolean(abortController?.signal?.aborted)) return null; + + const { data, info } = await image + .ensureAlpha() // Ensure RGBA output + .raw() + .toBuffer({ resolveWithObject: true }); + + if (Boolean(abortController?.signal?.aborted)) return null; + + const parsed = mlcontour.decodeParsedImage( + info.width, + info.height, + this.encoding, + data, + ); + + if (Boolean(abortController?.signal?.aborted)) return null; + + return parsed; + } catch (error) { + console.error('Error processing image:', error); + throw error; + } + } + + /** + * Fetches a tile using the provided url and abortController + * @param {string} url - The url that should be used to fetch the tile. + * @param {AbortController} abortController - An AbortController to cancel the request. + * @returns {Promise<{data: Blob, expires: undefined, cacheControl: undefined}>} A promise that resolves with the response data. + * @throws If an error occurs fetching or processing the tile. + */ + async GetTile(url, abortController) { + //console.log(url); + const $zxy = this.extractZXYFromUrlTrim(url); + if (!$zxy) { + throw new Error(`Could not extract zxy from $url`); + } + if (abortController.signal.aborted) { + return null; + } + + try { + let data; + if (this.sourceType === 'pmtiles') { + let zxyTile; + if (getPMtilesTile) { + zxyTile = await getPMtilesTile( + this.source, + $zxy.z, + $zxy.x, + $zxy.y, + abortController, + ); + } else { + if (abortController.signal.aborted) { + console.log('pmtiles aborted in default'); + return null; + } + zxyTile = { + data: new Uint8Array([$zxy.z, $zxy.x, $zxy.y]), + }; + } + + if (!zxyTile || !zxyTile.data) { + throw new Error(`No tile returned for $`); + } + data = zxyTile.data; + } else { + data = await new Promise((resolve, reject) => { + this.source.getTile($zxy.z, $zxy.x, $zxy.y, (err, tileData) => { + if (err) { + return /does not exist/.test(err.message) + ? resolve(null) + : reject(err); + } + resolve(tileData); + }); + }); + } + + if (data == null) { + return null; + } + + if (!data) { + throw new Error(`No tile returned for $`); + } + + const blob = new Blob([data]); + return { + data: blob, + expires: undefined, + cacheControl: undefined, + }; + } catch (error) { + if (error.name === 'AbortError') { + console.log('fetch canceled'); + return null; + } + throw error; + } + } + + /** + * Default implementation for extracting z,x,y from a url + * @param {string} url - The url to extract from + * @returns {{z: number, x: number, y:number} | null} Returns the z,x,y of the url, or null if can't extract + */ + _extractZXYFromUrl(url) { + const segments = url.split('/').filter(Boolean); // Split and remove empty segments + if (segments.length < 3) { + return null; + } + + const ySegment = segments[segments.length - 1]; + const xSegment = segments[segments.length - 2]; + const zSegment = segments[segments.length - 3]; + + const lastDotIndex = ySegment.lastIndexOf('.'); + const cleanedYSegment = + lastDotIndex === -1 ? ySegment : ySegment.substring(0, lastDotIndex); + + const z = parseInt(zSegment, 10); + const x = parseInt(xSegment, 10); + const y = parseInt(cleanedYSegment, 10); + + if (isNaN(z) || isNaN(x) || isNaN(y)) { + return null; + } + + return { z, x, y }; + } + + /** + * Get the underlying maplibre-contour LocalDemManager + * @returns {mlcontour.LocalDemManager} the underlying maplibre-contour LocalDemManager + */ + getManager() { + return this.manager; + } +} diff --git a/src/serve_data.js b/src/serve_data.js index 6369aa21d..19eb6feb1 100644 --- a/src/serve_data.js +++ b/src/serve_data.js @@ -9,6 +9,7 @@ import Pbf from 'pbf'; import { VectorTile } from '@mapbox/vector-tile'; import SphericalMercator from '@mapbox/sphericalmercator'; +import { LocalDemManager } from './contour.js'; import { fixTileJSONCenter, getTileUrls, @@ -163,6 +164,116 @@ export const serve_data = { return res.status(200).send(data); }); + /** + * Handles requests for contour data. + * @param {object} req - Express request object. + * @param {object} res - Express response object. + * @param {string} req.params.id - ID of the contour data. + * @param {string} req.params.z - Z coordinate of the tile. + * @param {string} req.params.x - X coordinate of the tile (either integer or float). + * @param {string} req.params.y - Y coordinate of the tile (either integer or float). + * @returns {Promise} + */ + app.get('/:id/contour/:z/:x/:y', async (req, res, next) => { + try { + if (verbose) { + console.log( + `Handling contour request for: /data/%s/contour/%s/%s/%s`, + String(req.params.id).replace(/\n|\r/g, ''), + String(req.params.z).replace(/\n|\r/g, ''), + String(req.params.x).replace(/\n|\r/g, ''), + String(req.params.y).replace(/\n|\r/g, ''), + ); + } + const item = repo?.[req.params.id]; + if (!item) return res.sendStatus(404); + if (!item.source) return res.status(404).send('Missing source'); + if (!item.tileJSON) return res.status(404).send('Missing tileJSON'); + if (!item.sourceType) return res.status(404).send('Missing sourceType'); + + const { source, tileJSON, sourceType } = item; + + if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') { + return res + .status(400) + .send('Invalid sourceType. Must be pmtiles or mbtiles.'); + } + + const encoding = tileJSON?.encoding; + if (encoding == null) { + return res.status(400).send('Missing tileJSON.encoding'); + } else if (encoding !== 'terrarium' && encoding !== 'mapbox') { + return res + .status(400) + .send('Invalid encoding. Must be terrarium or mapbox.'); + } + + const format = tileJSON?.format; + if (format == null) { + return res.status(400).send('Missing tileJSON.format'); + } else if (format !== 'webp' && format !== 'png') { + return res.status(400).send('Invalid format. Must be webp or png.'); + } + + const maxzoom = tileJSON?.maxzoom; + if (maxzoom == null) { + return res.status(400).send('Missing tileJSON.maxzoom'); + } + + const z = parseInt(req.params.z, 10); + const x = parseFloat(req.params.x); + const y = parseFloat(req.params.y); + + const demManagerInit = new LocalDemManager( + encoding, + maxzoom, + source, + sourceType, + ); + const demManager = await demManagerInit.getManager(); + + let levels; + if (z <= 8) { + levels = 1000; + } else if (z <= 10) { + levels = 500; + } else if (z <= 11) { + levels = 250; + } else if (z <= 12) { + levels = 100; + } else if (z <= 13) { + levels = 50; + } else if (z <= 14) { + levels = 25; + } else if (z <= 15) { + levels = 20; + } else if (z <= 17) { + levels = 10; + } else if (z >= 18) { + levels = 5; + } + + const { arrayBuffer } = await demManager.fetchContourTile( + z, + x, + y, + { levels: [levels] }, + new AbortController(), + ); + // Set the Content-Type header here + res.setHeader('Content-Type', 'application/x-protobuf'); + res.setHeader('Content-Encoding', 'gzip'); + let data = Buffer.from(arrayBuffer); + data = await gzipP(data); + res.send(data); + } catch (err) { + return res + .status(500) + .header('Content-Type', 'text/plain') + .send(err.message); + } + }); + /** * Handles requests for elevation data. * @param {object} req - Express request object.