Merge 53e3564e6d
into cd613e2fb5
This commit is contained in:
commit
b2969d5e2b
6 changed files with 416 additions and 2149 deletions
2158
package-lock.json
generated
2158
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -48,6 +48,7 @@
|
||||||
"http-shutdown": "1.2.2",
|
"http-shutdown": "1.2.2",
|
||||||
"leaflet": "1.9.4",
|
"leaflet": "1.9.4",
|
||||||
"leaflet-hash": "0.2.1",
|
"leaflet-hash": "0.2.1",
|
||||||
|
"maplibre-contour": "^0.1.0",
|
||||||
"maplibre-gl": "4.7.1",
|
"maplibre-gl": "4.7.1",
|
||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
"pbf": "4.0.1",
|
"pbf": "4.0.1",
|
||||||
|
|
65
public/resources/contour-control.js
Normal file
65
public/resources/contour-control.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
|
@ -9,6 +9,7 @@
|
||||||
<link rel="stylesheet" type="text/css" href="{{public_url}}maplibre-gl-inspect.css{{&key_query}}" />
|
<link rel="stylesheet" type="text/css" href="{{public_url}}maplibre-gl-inspect.css{{&key_query}}" />
|
||||||
<script src="{{public_url}}maplibre-gl.js{{&key_query}}"></script>
|
<script src="{{public_url}}maplibre-gl.js{{&key_query}}"></script>
|
||||||
<script src="{{public_url}}maplibre-gl-inspect.js{{&key_query}}"></script>
|
<script src="{{public_url}}maplibre-gl-inspect.js{{&key_query}}"></script>
|
||||||
|
<script src="{{public_url}}contour-control.js{{&key_query}}"></script>
|
||||||
{{^is_light}}
|
{{^is_light}}
|
||||||
<script src="{{public_url}}elevation-control.js{{&key_query}}"></script>
|
<script src="{{public_url}}elevation-control.js{{&key_query}}"></script>
|
||||||
{{/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;}
|
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 {position:absolute;top:35px;right:0;bottom:0;width:240px;overflow:auto;}
|
||||||
#layerList div div {width:15px;height:15px;display:inline-block;}
|
#layerList div div {width:15px;height:15px;display:inline-block;}
|
||||||
|
.maplibre-ctrl-contour-active button { color: #33b5e5; font-weight: bold; }
|
||||||
{{^is_light}}
|
{{^is_light}}
|
||||||
.maplibre-ctrl-elevation { padding-left: 5px; padding-right: 5px; }
|
.maplibre-ctrl-elevation { padding-left: 5px; padding-right: 5px; }
|
||||||
{{/is_light}}
|
{{/is_light}}
|
||||||
|
@ -76,6 +78,8 @@
|
||||||
{{/is_terrain}}
|
{{/is_terrain}}
|
||||||
{{#is_terrain}}
|
{{#is_terrain}}
|
||||||
|
|
||||||
|
let baseUrl = window.location.origin;
|
||||||
|
|
||||||
var style = {
|
var style = {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
|
@ -88,8 +92,13 @@
|
||||||
"type": "raster-dem",
|
"type": "raster-dem",
|
||||||
"url": "{{public_url}}data/{{id}}.json",
|
"url": "{{public_url}}data/{{id}}.json",
|
||||||
"encoding": "{{terrain_encoding}}"
|
"encoding": "{{terrain_encoding}}"
|
||||||
|
},
|
||||||
|
"contour": {
|
||||||
|
"type": "vector",
|
||||||
|
"tiles": [ baseUrl + "/data/{{id}}/contour/{z}/{x}/{y}" ],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"glyphs": "/fonts/{fontstack}/{range}.pbf",
|
||||||
"terrain": {
|
"terrain": {
|
||||||
"source": "terrain"
|
"source": "terrain"
|
||||||
},
|
},
|
||||||
|
@ -114,6 +123,33 @@
|
||||||
"hillshade-illumination-direction": 315,
|
"hillshade-illumination-direction": 315,
|
||||||
"hillshade-exaggeration": 0.8
|
"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}}
|
{{^is_light}}
|
||||||
map.addControl(
|
map.addControl(
|
||||||
new ElevationInfoControl({
|
new ElevationInfoControl({
|
||||||
|
|
186
src/contour.js
Normal file
186
src/contour.js
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import mlcontour from '../node_modules/maplibre-contour/dist/index.mjs';
|
||||||
|
import { fetchTileData } from './utils.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<any>} - 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 {
|
||||||
|
const fetchTile = await fetchTileData(
|
||||||
|
this.source,
|
||||||
|
this.sourceType,
|
||||||
|
$zxy.z,
|
||||||
|
$zxy.x,
|
||||||
|
$zxy.y,
|
||||||
|
);
|
||||||
|
if (fetchTile == null) return null;
|
||||||
|
let data = fetchTile.data;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import Pbf from 'pbf';
|
||||||
import { VectorTile } from '@mapbox/vector-tile';
|
import { VectorTile } from '@mapbox/vector-tile';
|
||||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
import SphericalMercator from '@mapbox/sphericalmercator';
|
||||||
|
|
||||||
|
import { LocalDemManager } from './contour.js';
|
||||||
import {
|
import {
|
||||||
fixTileJSONCenter,
|
fixTileJSONCenter,
|
||||||
getTileUrls,
|
getTileUrls,
|
||||||
|
@ -165,6 +166,116 @@ export const serve_data = {
|
||||||
return res.status(200).send(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<void>}
|
||||||
|
*/
|
||||||
|
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.
|
* Handles requests for elevation data.
|
||||||
* @param {object} req - Express request object.
|
* @param {object} req - Express request object.
|
||||||
|
|
Loading…
Reference in a new issue