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.