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;
+}