nakarte

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

index.js (8876B)


      1 import L from 'leaflet';
      2 
      3 import {fetch} from '~/lib/xhr-promise';
      4 
      5 import './style.css';
      6 
      7 class DataTileStatus {
      8     static STATUS_LOADING = 'LOADING';
      9     static STATUS_ERROR = 'ERROR';
     10     static STATUS_NO_DATA = 'ND';
     11     static STATUS_OK = 'OK';
     12 }
     13 
     14 const radToDeg = 180 / Math.PI;
     15 const degToRad = 1 / radToDeg;
     16 
     17 function decodeElevations(arrBuf) {
     18     const array = new Int16Array(arrBuf);
     19     for (let i = 1; i < array.length; i++) {
     20         array[i] += array[i - 1];
     21     }
     22     return array;
     23 }
     24 
     25 function mod(x, n) {
     26     return ((x % n) + n) % n;
     27 }
     28 
     29 const ElevationLayer = L.TileLayer.extend({
     30     options: {
     31         maxNativeZoom: 11,
     32         noDataValue: -512,
     33     },
     34 
     35     initialize: function (url, options) {
     36         L.TileLayer.prototype.initialize.call(this, url, options);
     37         this.label = L.tooltip(
     38             {
     39                 direction: 'bottom',
     40                 className: 'elevation-display-label',
     41                 offset: [0, 6],
     42             },
     43             null
     44         );
     45         this.displayElevation = true;
     46     },
     47 
     48     enableElevationDisplay: function (enable) {
     49         this.displayElevation = enable;
     50         if (!this._map) {
     51             return;
     52         }
     53         if (enable) {
     54             this.setupEvents(true);
     55         } else {
     56             this._map.removeLayer(this.label);
     57             this.setupEvents(false);
     58         }
     59     },
     60 
     61     onAdd: function (map) {
     62         L.TileLayer.prototype.onAdd.call(this, map);
     63         this.enableElevationDisplay(this.displayElevation);
     64     },
     65 
     66     onRemove: function (map) {
     67         this.setupEvents(false);
     68         map.removeLayer(this.label);
     69         L.TileLayer.prototype.onRemove.call(this, map);
     70     },
     71 
     72     setupEvents: function (on) {
     73         const eventFunc = on ? 'on' : 'off';
     74 
     75         this[eventFunc](
     76             {
     77                 tileunload: this.onTileUnload,
     78                 tileload: this.onTileLoad,
     79             },
     80             this
     81         );
     82         this._map[eventFunc](
     83             {
     84                 mousemove: this.onMouseMove,
     85                 mouseover: this.onMouseMove,
     86                 mouseout: this.onMouseOut,
     87                 zoomend: this.onZoomEnd,
     88             },
     89             this
     90         );
     91     },
     92 
     93     onTileLoad: function () {
     94         if (this._mousePos) {
     95             this.updateElevationDisplay(this._map.layerPointToLatLng(this._mousePos));
     96         }
     97     },
     98 
     99     onTileUnload: function (ev) {
    100         ev.tile._data.request.abort();
    101         delete ev.tile._data;
    102     },
    103 
    104     createTile: function (coords, done) {
    105         const tile = L.DomUtil.create('div');
    106         tile._data = {};
    107         tile._data.status = DataTileStatus.STATUS_LOADING;
    108         tile._data.request = fetch(this.getTileUrl(coords), {
    109             responseType: 'arraybuffer',
    110             isResponseSuccess: (xhr) => [200, 404].includes(xhr.status),
    111         });
    112         tile._data.request.then(
    113             (xhr) => this.onDataLoad(xhr, tile, done),
    114             (error) => this.onDataLoadError(tile, done, error)
    115         );
    116         return tile;
    117     },
    118 
    119     onDataLoad: function (xhr, tile, done) {
    120         if (xhr.status === 200) {
    121             tile._data.elevations = decodeElevations(xhr.response);
    122             tile._data.status = DataTileStatus.STATUS_OK;
    123         } else {
    124             tile._data.status = DataTileStatus.STATUS_NO_DATA;
    125         }
    126         done(null, tile);
    127     },
    128 
    129     onDataLoadError: function (tile, done, error) {
    130         done(error, done);
    131         tile._data.status = DataTileStatus.STATUS_ERROR;
    132     },
    133 
    134     getElevationAtLayerPoint: function (layerPoint) {
    135         const tileSize = this.getTileSize();
    136         const coordsScale = tileSize.x / this.options.tileSize;
    137         const tileCoords = {
    138             x: Math.floor(layerPoint.x / tileSize.x),
    139             y: Math.floor(layerPoint.y / tileSize.y),
    140             z: this._map.getZoom(),
    141         };
    142         const tileKey = this._tileCoordsToKey(tileCoords);
    143         const tile = this._tiles[tileKey];
    144         if (!tile) {
    145             return {ready: false};
    146         }
    147         const tileData = tile.el._data;
    148         if (tileData.status === DataTileStatus.STATUS_LOADING) {
    149             return {ready: false};
    150         }
    151         if (tileData.status === DataTileStatus.STATUS_ERROR) {
    152             return {
    153                 ready: true,
    154                 error: true,
    155             };
    156         }
    157         if (tileData.status === DataTileStatus.STATUS_NO_DATA) {
    158             return {ready: true, elevation: null};
    159         }
    160         const dataCoords = {
    161             x: Math.floor(mod(layerPoint.x, tileSize.x) / coordsScale),
    162             y: Math.floor(mod(layerPoint.y, tileSize.y) / coordsScale),
    163         };
    164         let elevation = tileData.elevations[dataCoords.y * this.options.tileSize + dataCoords.x];
    165         if (elevation === this.options.noDataValue) {
    166             elevation = null;
    167         }
    168         return {ready: true, elevation};
    169     },
    170 
    171     bilinearInterpolate: function (values, dx, dy) {
    172         const [v1, v2, v3, v4] = values;
    173         const q1 = v1 * (1 - dx) + v2 * dx;
    174         const q2 = v3 * (1 - dx) + v4 * dx;
    175         return q1 * (1 - dy) + q2 * dy;
    176     },
    177 
    178     bilinearGradient: function (values, dx, dy) {
    179         const [v1, v2, v3, v4] = values;
    180         return {
    181             gradX: (v2 - v1) * (1 - dy) + (v4 - v3) * dy,
    182             gradY: (v3 - v1) * (1 - dx) + (v4 - v2) * dx,
    183         };
    184     },
    185 
    186     getElevation: function (latlng) {
    187         const zoom = this._tileZoom;
    188 
    189         let layerPoint = this._map.latLngToLayerPoint(latlng).add(this._map.getPixelOrigin());
    190         if (zoom <= this.options.maxNativeZoom) {
    191             return this.getElevationAtLayerPoint(layerPoint);
    192         }
    193 
    194         const tileSize = this.getTileSize();
    195         const coordsScale = tileSize.x / this.options.tileSize;
    196         layerPoint = layerPoint.subtract([coordsScale / 2, coordsScale / 2]);
    197         const elevations = [];
    198         for (const [dx, dy] of [
    199             [0, 0],
    200             [1, 0],
    201             [0, 1],
    202             [1, 1],
    203         ]) {
    204             const res = this.getElevationAtLayerPoint(layerPoint.add([dx * coordsScale, dy * coordsScale]));
    205             if (!res.ready || res.error || res.elevation === null) {
    206                 return res;
    207             }
    208             elevations.push(res.elevation);
    209         }
    210         const dx = (mod(layerPoint.x, tileSize.x) / coordsScale) % 1;
    211         const dy = (mod(layerPoint.y, tileSize.y) / coordsScale) % 1;
    212         const {gradX, gradY} = this.bilinearGradient(elevations, dx, dy);
    213         const slopeAzimuth = Math.atan2(-gradY, -gradX) * radToDeg + 90;
    214         // prettier-ignore
    215         const cellSizeInMeters = (
    216             2 * Math.PI * L.Projection.SphericalMercator.R / // Earth equator length
    217             (2 ** zoom  * this.options.tileSize / coordsScale) * // number of pixels on actual data zoom level
    218             Math.cos(latlng.lat * degToRad) // account for latitude
    219         );
    220         const slopeGradient = Math.sqrt(gradX * gradX + gradY * gradY) / cellSizeInMeters;
    221         const slopeAngle = Math.atan(slopeGradient) * radToDeg;
    222 
    223         return {
    224             ready: true,
    225             elevation: Math.round(this.bilinearInterpolate(elevations, dx, dy)),
    226             slopeAzimuth,
    227             slopeAngle,
    228         };
    229     },
    230 
    231     onMouseMove: function (e) {
    232         this._mousePos = this._map.latLngToLayerPoint(e.latlng);
    233         this.label.setLatLng(e.latlng);
    234         this.label.addTo(this._map);
    235         this.updateElevationDisplay(e.latlng);
    236     },
    237 
    238     onMouseOut: function () {
    239         this._map.removeLayer(this.label);
    240     },
    241 
    242     onZoomEnd: function () {
    243         if (this._mousePos) {
    244             const latlng = this._map.layerPointToLatLng(this._mousePos);
    245             this.label.setLatLng(latlng);
    246             this.updateElevationDisplay(latlng);
    247         }
    248     },
    249 
    250     updateElevationDisplay: function (latlng) {
    251         setTimeout(() => this.label.setContent(this.getElevationText(latlng)), 0);
    252     },
    253 
    254     getElevationText: function (latlng) {
    255         const elevationResult = this.getElevation(latlng);
    256         if (!elevationResult.ready) {
    257             return 'Loading...';
    258         }
    259         if (elevationResult.error) {
    260             return 'Error';
    261         }
    262         if (elevationResult.elevation === null) {
    263             return 'No data';
    264         }
    265         const {elevation, slopeAzimuth, slopeAngle} = elevationResult;
    266         let text = `${elevation} m`;
    267         // if not null or undefined
    268         // eslint-disable-next-line no-eq-null,eqeqeq
    269         if (slopeAzimuth != null) {
    270             let directionChar = '<div class="arrow">●</div>';
    271             if (slopeAngle > 1) {
    272                 directionChar = `<div class="arrow" style="rotate: ${slopeAzimuth + 180}deg">↧</div>`;
    273             }
    274             text += `<br>${directionChar} &ang; ${Math.round(slopeAngle)}&deg;`;
    275         }
    276         return text;
    277     },
    278 });
    279 
    280 export {ElevationLayer};