nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

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:
Msrc/App.js | 2+-
Msrc/config.js | 1+
Msrc/lib/leaflet.control.coordinates/index.js | 62++++++++++++++++++++++++++++++++++++++++++++++++--------------
Asrc/lib/leaflet.layer.elevation-display/index.js | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.layer.elevation-display/style.css | 18++++++++++++++++++
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; +}