Skip to content

Commit

Permalink
Coordinates: show elevation under cursor
Browse files Browse the repository at this point in the history
Fixes #574
  • Loading branch information
wladich committed Dec 13, 2024
1 parent 3d87d94 commit c1b0874
Show file tree
Hide file tree
Showing 5 changed files with 307 additions and 15 deletions.
2 changes: 1 addition & 1 deletion src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
62 changes: 48 additions & 14 deletions src/lib/leaflet.control.coordinates/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,16 +20,20 @@ L.Control.Coordinates = L.Control.extend({
position: 'bottomleft'
},

includes: L.Mixin.Events,

formats: [
formats.SIGNED_DEGREES,
formats.DEGREES,
formats.DEGREES_AND_MINUTES,
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({
Expand Down Expand Up @@ -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);
},

Expand Down
239 changes: 239 additions & 0 deletions src/lib/leaflet.layer.elevation-display/index.js
Original file line number Diff line number Diff line change
@@ -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};
18 changes: 18 additions & 0 deletions src/lib/leaflet.layer.elevation-display/style.css
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit c1b0874

Please sign in to comment.