commit 0ef1bd850dc8b6794133d91f541d7c5296fb5450
parent 3d87d9413bc46894922b0a0924fd00ff220d4c2a
Author: Sergej Orlov <wladimirych@gmail.com>
Date:   Wed, 11 Dec 2024 22:59:54 +0100
Coordinates: show elevation under cursor
Fixes #574
Diffstat:
5 files changed, 307 insertions(+), 15 deletions(-)
diff --git a/src/App.js b/src/App.js
@@ -124,7 +124,7 @@ function setUp() { // eslint-disable-line complexity
         .enableHashState('n2');
     L.Control.Panoramas.hashStateUpgrader(panoramas).enableHashState('n');
 
-    new L.Control.Coordinates({position: 'topleft'}).addTo(map);
+    new L.Control.Coordinates(config.elevationTileUrl, {position: 'topleft'}).addTo(map);
 
     const azimuthControl = new L.Control.Azimuth({position: 'topleft'}).addTo(map);
 
diff --git a/src/config.js b/src/config.js
@@ -18,6 +18,7 @@ const config = {
     wikimapiaTilesBaseUrl: 'https://proxy.nakarte.me/wikimapia/',
     mapillaryRasterTilesUrl: 'https://mapillary.nakarte.me/{z}/{x}/{y}',
     urlsBypassCORSProxy: [new RegExp('^https://pkk\\.rosreestr\\.ru/', 'u')],
+    elevationTileUrl: 'https://tiles.nakarte.me/elevation/{z}/{x}/{y}',
     ...secrets,
 };
 
diff --git a/src/lib/leaflet.control.coordinates/index.js b/src/lib/leaflet.control.coordinates/index.js
@@ -6,6 +6,7 @@ import Contextmenu from '~/lib/contextmenu';
 import {makeButtonWithBar} from '~/lib/leaflet.control.commons';
 import safeLocalStorage from '~/lib/safe-localstorage';
 import '~/lib/controls-styles/controls-styles.css';
+import {ElevationLayer} from '~/lib/leaflet.layer.elevation-display';
 import * as formats from './formats';
 
 const DEFAULT_FORMAT = formats.DEGREES;
@@ -19,6 +20,8 @@ L.Control.Coordinates = L.Control.extend({
             position: 'bottomleft'
         },
 
+        includes: L.Mixin.Events,
+
         formats: [
             formats.SIGNED_DEGREES,
             formats.DEGREES,
@@ -26,9 +29,11 @@ L.Control.Coordinates = L.Control.extend({
             formats.DEGREES_AND_MINUTES_AND_SECONDS
         ],
 
-        initialize: function(options) {
+        initialize: function(elevationTilesUrl, options) {
             L.Control.prototype.initialize.call(this, options);
 
+            this.elevationDisplayLayer = new ElevationLayer(elevationTilesUrl);
+
             this.latlng = ko.observable();
             this.format = ko.observable(DEFAULT_FORMAT);
             this.formatCode = ko.pureComputed({
@@ -126,29 +131,58 @@ L.Control.Coordinates = L.Control.extend({
             L.DomUtil[classFunc](this._map._container, 'coordinates-control-active');
             this._map[eventFunc]('mousemove', this.onMouseMove, this);
             this._map[eventFunc]('contextmenu', this.onMapRightClick, this);
+            this._map[enabled ? 'addLayer' : 'removeLayer'](this.elevationDisplayLayer);
             this._isEnabled = Boolean(enabled);
             this.latlng(null);
         },
 
         onMapRightClick: function(e) {
             L.DomEvent.stop(e);
-
-            function createItem(format, options = {}) {
+            function createItem(format, elevation, overrides = {}) {
                 const {lat, lng} = formats.formatLatLng(e.latlng.wrap(), format);
-                const coordinates = `${lat} ${lng}`;
+                let text = `${lat} ${lng}`;
+                if (elevation !== null) {
+                    text += ` H=${elevation} m`;
+                }
 
-                return {text: `${coordinates} <span class="leaflet-coordinates-menu-fmt">${format.label}</span>`,
-                    callback: () => copyToClipboard(coordinates, e.originalEvent),
-                    ...options};
+                return {text: `${text} <span class="leaflet-coordinates-menu-fmt">${format.label}</span>`,
+                    callback: () => copyToClipboard(text, e.originalEvent),
+                    ...overrides};
             }
 
-            const header = createItem(this.format(), {
-                text: '<b>Copy coordinates to clipboard</b>',
-                header: true,
-            });
-            const items = this.formats.map((format) => createItem(format));
-            items.unshift(header, '-');
-
+            const items = [
+                createItem(
+                    this.format(),
+                    null,
+                    {
+                        text: '<b>Copy coordinates to clipboard</b>',
+                        header: true,
+                    },
+                ),
+                ...this.formats.map((format) => createItem(format, null)),
+            ];
+            const elevationResult = this.elevationDisplayLayer.getElevation(e.latlng);
+            if (elevationResult.ready && !elevationResult.error && elevationResult.elevation !== null) {
+                const elevation = elevationResult.elevation;
+                items.push(
+                    '-',
+                    {
+                        text: `Copy elevation to clipboard: ${elevation}`,
+                        header: true,
+                        callback: () => copyToClipboard(elevation, e.originalEvent),
+                    },
+                    '-',
+                    createItem(
+                        this.format(),
+                        elevation,
+                        {
+                            text: '<b>Copy coordinates with elevation to clipboard</b>',
+                            header: true,
+                        },
+                    ),
+                    ...this.formats.map((format) => createItem(format, elevation)),
+                );
+            }
             new Contextmenu(items).show(e);
         },
 
diff --git a/src/lib/leaflet.layer.elevation-display/index.js b/src/lib/leaflet.layer.elevation-display/index.js
@@ -0,0 +1,239 @@
+import L from 'leaflet';
+
+import {fetch} from '~/lib/xhr-promise';
+import './style.css';
+
+class DataTileStatus {
+    static STATUS_LOADING = 'LOADING';
+    static STATUS_ERROR = 'ERROR';
+    static STATUS_NO_DATA = 'ND';
+    static STATUS_OK = 'OK';
+}
+
+function decodeElevations(arrBuf) {
+    const array = new Int16Array(arrBuf);
+    for (let i = 1; i < array.length; i++) {
+        array[i] += array[i - 1];
+    }
+    return array;
+}
+
+function mod(x, n) {
+    return ((x % n) + n) % n;
+}
+
+const ElevationLayer = L.TileLayer.extend({
+    options: {
+        maxNativeZoom: 11,
+        tileSize: 256,
+        noDataValue: -512,
+    },
+
+    initialize: function (url, options) {
+        L.TileLayer.prototype.initialize.call(this, url, options);
+        this.label = L.tooltip(
+            {
+                direction: 'bottom',
+                className: 'elevation-display-label',
+                offset: [0, 6],
+            },
+            null
+        );
+    },
+
+    onAdd: function (map) {
+        L.TileLayer.prototype.onAdd.call(this, map);
+        this.setupElements(true);
+        this.setupEvents(true);
+    },
+
+    onRemove: function (map) {
+        this.setupEvents(false);
+        this.setupElements(false);
+        map.removeLayer(this.label);
+        L.TileLayer.prototype.onRemove.call(this, map);
+    },
+
+    setupElements: function (enable) {
+        const classFunc = enable ? 'addClass' : 'removeClass';
+        L.DomUtil[classFunc](this._container, 'highlight');
+        L.DomUtil[classFunc](this._map._container, 'elevation-display-control-active');
+    },
+
+    setupEvents: function (on) {
+        const eventFunc = on ? 'on' : 'off';
+
+        this[eventFunc](
+            {
+                tileunload: this.onTileUnload,
+                tileload: this.onTileLoad,
+            },
+            this
+        );
+        this._map[eventFunc](
+            {
+                mousemove: this.onMouseMove,
+                mouseover: this.onMouseMove,
+                mouseout: this.onMouseOut,
+                zoomend: this.onZoomEnd,
+            },
+            this
+        );
+    },
+
+    onTileLoad: function () {
+        if (this._mousePos) {
+            this.updateElevationDisplay(this._map.layerPointToLatLng(this._mousePos));
+        }
+    },
+
+    onTileUnload: function (ev) {
+        ev.tile._data.request.abort();
+        delete ev.tile._data;
+    },
+
+    createTile: function (coords, done) {
+        const tile = L.DomUtil.create('div');
+        tile._data = {};
+        tile._data.status = DataTileStatus.STATUS_LOADING;
+        tile._data.request = fetch(this.getTileUrl(coords), {
+            responseType: 'arraybuffer',
+            isResponseSuccess: (xhr) => [200, 404].includes(xhr.status),
+        });
+        tile._data.request.then(
+            (xhr) => this.onDataLoad(xhr, tile, done),
+            (error) => this.onDataLoadError(tile, done, error)
+        );
+        return tile;
+    },
+
+    onDataLoad: function (xhr, tile, done) {
+        if (xhr.status === 200) {
+            tile._data.elevations = decodeElevations(xhr.response);
+            tile._data.status = DataTileStatus.STATUS_OK;
+        } else {
+            tile._data.status = DataTileStatus.STATUS_NO_DATA;
+        }
+        done(null, tile);
+    },
+
+    onDataLoadError: function (tile, done, error) {
+        done(error, done);
+        tile._data.status = DataTileStatus.STATUS_ERROR;
+    },
+
+    getElevationAtLayerPoint: function (layerPoint) {
+        const tileSize = this.getTileSize();
+        const coordsScale = tileSize.x / this.options.tileSize;
+        const tileCoords = {
+            x: Math.floor(layerPoint.x / tileSize.x),
+            y: Math.floor(layerPoint.y / tileSize.y),
+            z: this._map.getZoom(),
+        };
+        const tileKey = this._tileCoordsToKey(tileCoords);
+        const tile = this._tiles[tileKey];
+        if (!tile) {
+            return {ready: false};
+        }
+        const tileData = tile.el._data;
+        if (tileData.status === DataTileStatus.STATUS_LOADING) {
+            return {ready: false};
+        }
+        if (tileData.status === DataTileStatus.STATUS_ERROR) {
+            return {
+                ready: true,
+                error: true,
+            };
+        }
+        if (tileData.status === DataTileStatus.STATUS_NO_DATA) {
+            return {ready: true, elevation: null};
+        }
+        const dataCoords = {
+            x: Math.floor(mod(layerPoint.x, tileSize.x) / coordsScale),
+            y: Math.floor(mod(layerPoint.y, tileSize.y) / coordsScale),
+        };
+        let elevation = tileData.elevations[dataCoords.y * this.options.tileSize + dataCoords.x];
+        if (elevation === this.options.noDataValue) {
+            elevation = null;
+        }
+        return {ready: true, elevation};
+    },
+
+    bilinearInterpolate: function (values, dx, dy) {
+        const [v1, v2, v3, v4] = values;
+        const q1 = v1 * (1 - dx) + v2 * dx;
+        const q2 = v3 * (1 - dx) + v4 * dx;
+        return q1 * (1 - dy) + q2 * dy;
+    },
+
+    getElevation: function (latlng) {
+        const zoom = this._map.getZoom();
+
+        let layerPoint = this._map.latLngToLayerPoint(latlng).add(this._map.getPixelOrigin());
+        if (zoom <= this.options.maxNativeZoom) {
+            return this.getElevationAtLayerPoint(layerPoint);
+        }
+
+        const tileSize = this.getTileSize();
+        const coordsScale = tileSize.x / this.options.tileSize;
+        layerPoint = layerPoint.subtract([coordsScale / 2, coordsScale / 2]);
+        const elevations = [];
+        for (const [dx, dy] of [
+            [0, 0],
+            [1, 0],
+            [0, 1],
+            [1, 1],
+        ]) {
+            const res = this.getElevationAtLayerPoint(layerPoint.add([dx * coordsScale, dy * coordsScale]));
+            if (!res.ready || res.error || res.elevation === null) {
+                return res;
+            }
+            elevations.push(res.elevation);
+        }
+        const dx = (mod(layerPoint.x, tileSize.x) / coordsScale) % 1;
+        const dy = (mod(layerPoint.y, tileSize.y) / coordsScale) % 1;
+        return {
+            ready: true,
+            elevation: Math.round(this.bilinearInterpolate(elevations, dx, dy)),
+        };
+    },
+
+    onMouseMove: function (e) {
+        this._mousePos = this._map.latLngToLayerPoint(e.latlng);
+        this.label.setLatLng(e.latlng);
+        this.label.addTo(this._map);
+        this.updateElevationDisplay(e.latlng);
+    },
+
+    onMouseOut: function () {
+        this._map.removeLayer(this.label);
+    },
+
+    onZoomEnd: function () {
+        if (this._mousePos) {
+            const latlng = this._map.layerPointToLatLng(this._mousePos);
+            this.label.setLatLng(latlng);
+            this.updateElevationDisplay(latlng);
+        }
+    },
+
+    updateElevationDisplay: function (latlng) {
+        setTimeout(() => this.label.setContent(this.getElevationText(latlng)), 0);
+    },
+
+    getElevationText: function (latlng) {
+        const elevationResult = this.getElevation(latlng);
+        if (!elevationResult.ready) {
+            return 'Loading...';
+        }
+        if (elevationResult.error) {
+            return 'Error';
+        }
+        if (elevationResult.elevation === null) {
+            return 'No data';
+        }
+        return elevationResult.elevation.toString();
+    },
+});
+
+export {ElevationLayer};
diff --git a/src/lib/leaflet.layer.elevation-display/style.css b/src/lib/leaflet.layer.elevation-display/style.css
@@ -0,0 +1,18 @@
+.elevation-display-control-active {
+    cursor: crosshair;
+}
+
+.elevation-display-label {
+    border: none;
+    box-shadow: none;
+    border-radius: 3px;
+    background-color: rgba(190, 190, 190, 0.8);
+    padding: 0 4px;
+    color: black;
+    opacity: 1;
+    pointer-events: none;
+}
+
+.elevation-display-label:before {
+    border: none;
+}