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} ∠ ${Math.round(slopeAngle)}°`; 275 } 276 return text; 277 }, 278 }); 279 280 export {ElevationLayer};