From c124b5ee1f4d33d50aa6beca375500b63ee98cda Mon Sep 17 00:00:00 2001 From: acalcutt Date: Sat, 21 Dec 2024 14:37:17 -0500 Subject: [PATCH] test 1 - not working --- package-lock.json | 6 ++ package.json | 1 + src/contour.js | 205 ++++++++++++++++++++++++++++++++++++++++++++++ src/serve_data.js | 74 +++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 src/contour.js diff --git a/package-lock.json b/package-lock.json index 408b652..699bb8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "express": "4.19.2", "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", @@ -5304,6 +5305,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": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", diff --git a/package.json b/package.json index 14f94ae..d7070cc 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "express": "4.19.2", "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/src/contour.js b/src/contour.js new file mode 100644 index 0000000..c2046f7 --- /dev/null +++ b/src/contour.js @@ -0,0 +1,205 @@ +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} [extractZXYFromUrlTrimFunction] - The function to extract the zxy from the url. + * @param {Function} [GetTileFunction] - the function that returns a tile from the pmtiles object. + */ + constructor( + encoding, + maxzoom, + source, + sourceType, + GetTileFunction, + extractZXYFromUrlTrimFunction, + ) { + this.encoding = encoding; + this.maxzoom = maxzoom; + this.source = source; + this.sourceType = sourceType; + this.getTile = GetTileFunction || this.GetTile.bind(this); + this.extractZXYFromUrlTrim = + extractZXYFromUrlTrimFunction || this.extractZXYFromUrlTrim.bind(this); + + this.manager = new mlcontour.LocalDemManager({ + demUrlPattern: '/{z}/{x}/{y}', + cacheSize: 100, + encoding: this.encoding, + maxzoom: this.maxzoom, + timeoutMs: 10000, + decodeImage: this.getImageData.bind(this), + getTile: this.getTileFunction.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 {Error} If an error occurs during image processing. + */ + async getImageData(blob, abortController) { + try { + if (Boolean(abortController?.signal?.aborted)) return null; // Check for abort signal. + + const buffer = await blob.arrayBuffer(); + const image = sharp(Buffer.from(buffer)); + const metadata = await image.metadata(); + if (Boolean(abortController?.signal?.aborted)) return null; // Check for abort signal. + + const { data, info } = await image + .raw() + .toBuffer({ resolveWithObject: true }); + if (Boolean(abortController?.signal?.aborted)) return null; // Check for abort signal. + + const parsed = mlcontour.decodeParsedImage( + info.width, + info.height, + this.encoding, + data, + ); + if (Boolean(abortController?.signal?.aborted)) return null; // Check for abort signal. + + return parsed; + } catch (error) { + console.error('Error processing image:', error); + throw error; // Rethrow to handle upstream + // return null; // Or handle error gracefully + } + } + + /** + * 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 {Error} 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 $`); + } + if (abortController.signal.aborted) { + return null; // Or throw an error + } + + try { + let data; + if (this.sourceType === 'pmtiles') { + let zxyTile; + if (this.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 cancelled'); + return null; + } + throw error; // Rethrow for handling upstream + } + } + + /** + * 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 + */ + extractZXYFromUrlTrim(url) { + // 1. Find the index of the last `/` + const lastSlashIndex = url.lastIndexOf('/'); + if (lastSlashIndex === -1) { + return null; // URL does not have any slashes + } + + const segments = url.split('/'); + 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); + + // 3. Attempt to parse segments as numbers + const z = parseInt(zSegment, 10); + const x = parseInt(xSegment, 10); + const y = parseInt(cleanedYSegment, 10); + + if (isNaN(z) || isNaN(x) || isNaN(y)) { + return null; // Conversion failed, invalid URL format + } + + return { z, x, y }; + } + + /** + * Get the underlying maplibre-contour LocalDemManager + * @returns {any} the underlying maplibre-contour LocalDemManager + */ + getManager() { + return this.manager; + } +} diff --git a/src/serve_data.js b/src/serve_data.js index 1936da6..f23c38d 100644 --- a/src/serve_data.js +++ b/src/serve_data.js @@ -11,6 +11,7 @@ import SphericalMercator from '@mapbox/sphericalmercator'; import { Image, createCanvas } from 'canvas'; import sharp from 'sharp'; +import { LocalDemManager } from './contour.js'; import { fixTileJSONCenter, getTileUrls, isValidHttpUrl } from './utils.js'; import { getPMtilesInfo, @@ -165,6 +166,79 @@ export const serve_data = { }, ); + app.get( + '^/:id/contour/:z([0-9]+)/:x([-.0-9]+)/:y([-.0-9]+)', + async (req, res, next) => { + try { + 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(); + + const $data = await demManager.fetchContourTile( + z, + x, + y, + { levels: [10] }, + new AbortController(), + ); + + // Set the Content-Type header here + res.setHeader('Content-Type', 'application/x-protobuf'); + res.setHeader('Content-Encoding', 'gzip'); + res.send($data); + } catch (err) { + return res + .status(500) + .header('Content-Type', 'text/plain') + .send(err.message); + } + }, + ); + app.get( '^/:id/elevation/:z([0-9]+)/:x([-.0-9]+)/:y([-.0-9]+)', async (req, res, next) => {