index.js (16386B)
1 import L from 'leaflet'; 2 import {makeButton} from '~/lib/leaflet.control.commons'; 3 import './style.css'; 4 import localStorage from '~/lib/safe-localstorage'; 5 6 const STATE_DISABLED = 'disabled'; 7 const STATE_LOCATING = 'locating'; 8 const STATE_ENABLED = 'enabled'; 9 const STATE_ENABLED_FOLLOWING = 'enabled_following'; 10 const STATE_MOVING_TO_FOLLOWING = 'moving_to_following'; 11 const STATE_MOVING_TO_FOLLOWING_FIRST = 'moving_to_following_first'; 12 const STATE_UPDATING_FOLLOWING = 'updating_following'; 13 14 const EVENT_INIT = 'init'; 15 const EVENT_BUTTON_CLICK = 'button_click'; 16 const EVENT_LOCATION_RECEIVED = 'location_received'; 17 const EVENT_LOCATION_ERROR = 'location_error'; 18 const EVENT_MAP_MOVE = 'user_move'; 19 const EVENT_MAP_MOVE_END = 'map_move_end'; 20 21 const LOCALSTORAGE_POSITION = 'leaflet_locate_position'; 22 23 const PositionMarker = L.LayerGroup.extend({ 24 initialize: function(options) { 25 L.LayerGroup.prototype.initialize.call(this, options); 26 this._locationSet = false; 27 this._elements = { 28 accuracyCircle: L.circle([0, 0], { 29 radius: 1, 30 interactive: false, 31 fillColor: '#4271a8', 32 color: '#2ba3f7', 33 weight: 2 34 }), 35 markerCircle: L.circleMarker([0, 0], { 36 interactive: false, 37 radius: 10, 38 color: '#2ba3f7', 39 weight: 2.5, 40 fill: null, 41 opacity: 0.8 42 }), 43 markerPoint: L.circleMarker([0, 0], { 44 interactive: false, 45 radius: 2, 46 weight: 0, 47 color: '#2ba3f7', 48 fillOpacity: 0.8 49 }), 50 }; 51 this.addLayer(this._elements.accuracyCircle); 52 }, 53 54 onAdd: function(map) { 55 L.LayerGroup.prototype.onAdd.call(this, map); 56 map.on('zoom', this._onZoom, this); 57 this._updatePrecisionState(); 58 }, 59 60 onRemove: function(map) { 61 map.off('zoom', this._onZoom, this); 62 L.LayerGroup.prototype.onRemove.call(this, map); 63 }, 64 65 _updatePrecisionState: function() { 66 if (!this._map || !this._locationSet) { 67 return; 68 } 69 const precise = this._elements.accuracyCircle._radius <= this._elements.markerCircle.options.radius * 0.8; 70 if (precise !== this._precise) { 71 if (precise) { 72 this._elements.accuracyCircle.setStyle({opacity: 0, fillOpacity: 0}); 73 this.addLayer(this._elements.markerPoint); 74 this.addLayer(this._elements.markerCircle); 75 } else { 76 this._elements.accuracyCircle.setStyle({opacity: 0.8, fillOpacity: 0.4}); 77 this.removeLayer(this._elements.markerPoint); 78 this.removeLayer(this._elements.markerCircle); 79 } 80 this._precise = precise; 81 } 82 }, 83 84 setLocation: function(latlng, accuracy) { 85 this._elements.accuracyCircle.setLatLng(latlng); 86 this._elements.accuracyCircle.setRadius(accuracy); 87 this._elements.markerCircle.setLatLng(latlng); 88 this._elements.markerPoint.setLatLng(latlng); 89 this._locationSet = true; 90 this._updatePrecisionState(); 91 }, 92 93 _onZoom: function() { 94 this._updatePrecisionState(); 95 } 96 97 }); 98 99 const LocateControl = L.Control.extend({ 100 // button click behavior: 101 // if button turned off -- turn on, maps follows marker 102 // if button turned on 103 // if map is following marker -- turn off 104 // if map not following marker -- center map at marker, start following 105 106 options: { 107 locationAcquireTimeoutMS: Infinity, 108 showError: ({message}) => { 109 alert(message); 110 }, 111 maxAutoZoom: 17, 112 minAutoZoomDeltaForAuto: 4, 113 minDistForAutoZoom: 2 // in average screen sizes 114 }, 115 116 initialize: function(options) { 117 L.Control.prototype.initialize.call(this, options); 118 this._events = []; 119 }, 120 121 onAdd: function(map) { 122 this._map = map; 123 const {container, link} = makeButton('leaflet-control-locate', 'Where am I?', 'icon-position'); 124 this._container = container; 125 L.DomEvent.on(link, 'click', () => this._handleEvent(EVENT_BUTTON_CLICK)); 126 this._marker = new PositionMarker(); 127 this._handleEvent(EVENT_INIT); 128 return container; 129 }, 130 131 moveMapToCurrentLocation: function(zoom) { 132 let storedPosition = null; 133 try { 134 storedPosition = JSON.parse(localStorage.getItem(LOCALSTORAGE_POSITION)); 135 let {lat, lon} = storedPosition; 136 if (lat && lon) { 137 storedPosition = L.latLng(lat, lon); 138 } else { 139 storedPosition = null; 140 } 141 } catch (e) { 142 // ignore invalid data from localstorage 143 } 144 145 if (storedPosition) { 146 this._map.setView(storedPosition, zoom, {animate: false}); 147 if (!('geolocation' in navigator)) { 148 return; 149 } 150 navigator.geolocation.getCurrentPosition( 151 (pos) => { 152 this._storePositionToLocalStorage(pos); 153 this._map.setView(L.latLng(pos.coords.latitude, pos.coords.longitude), zoom, { 154 animate: false, 155 }); 156 }, 157 (e) => { 158 if (e.code === 1) { 159 localStorage.removeItem(LOCALSTORAGE_POSITION); 160 } 161 }, { 162 enableHighAccuracy: false, 163 timeout: 500, 164 maximumAge: 0 165 }); 166 } 167 }, 168 169 _startLocating: function() { 170 if (!('geolocation' in navigator) || !('watchPosition' in navigator.geolocation)) { 171 const error = {code: 0, message: 'Geolocation not supported'}; 172 setTimeout(() => { 173 this._onLocationError(error); 174 }, 0 175 ); 176 } 177 this._watchID = navigator.geolocation.watchPosition( 178 this._onLocationSuccess.bind(this), this._onLocationError.bind(this), 179 { 180 enableHighAccuracy: true, 181 timeout: this.options.locationAcquireTimeoutMS, 182 } 183 ); 184 }, 185 186 _storePositionToLocalStorage: function(pos) { 187 const coords = {lat: pos.coords.latitude, lon: pos.coords.longitude}; 188 localStorage.setItem(LOCALSTORAGE_POSITION, JSON.stringify(coords)); 189 }, 190 191 _onLocationSuccess: function(pos) { 192 this._handleEvent(EVENT_LOCATION_RECEIVED, pos); 193 this._storePositionToLocalStorage(pos); 194 }, 195 196 _onLocationError: function(e) { 197 this._handleEvent(EVENT_LOCATION_ERROR, e); 198 if (e.code === 1) { 199 localStorage.removeItem(LOCALSTORAGE_POSITION); 200 } 201 }, 202 203 _stopLocating: function() { 204 if (this._watchID && navigator.geolocation) { 205 navigator.geolocation.clearWatch(this._watchID); 206 } 207 }, 208 209 _storeLocation: function(position) { 210 this._latlng = L.latLng(position.coords.latitude, position.coords.longitude); 211 this._accuracy = position.coords.accuracy; 212 }, 213 214 _updateMarkerLocation: function() { 215 this._marker.setLocation(this._latlng, this._accuracy); 216 }, 217 218 _updateMapPositionWhileFollowing: function() { 219 this._updateFollowingStartPosition = this._map.getCenter(); 220 this._updateFollowingDestPosition = this._latlng; 221 this._map.panTo(this._latlng); 222 }, 223 224 _setViewToLocation: function(preferAutoZoom) { 225 if (!this._map || !this._latlng) { 226 return; 227 } 228 229 // autoZoom -- to fit accuracy cirlce on screen, but not more then options.maxAutoZoom (17) 230 // if current zoom more then options.minAutoZoomDeltaForAuto less then autoZoom, set autoZoom 231 // if map center far from geolocation, set autoZoom 232 // if map center not far from geolocation 233 // if accuracy circle does not fit at current zoom, zoom out to fit 234 // if current zoom is less then minAutoZoomDeltaForAuto less then autoZoom 235 // or >= autoZoom and circle fits screen, keep current zoom 236 237 const currentZoom = this._map.getZoom(); 238 let zoomFitAccuracy = this._map.getBoundsZoom(this._latlng.toBounds(this._accuracy * 2)); 239 let autoZoom = zoomFitAccuracy; 240 let newZoom; 241 autoZoom = Math.min(autoZoom, this.options.maxAutoZoom); 242 243 if (preferAutoZoom || autoZoom - currentZoom >= this.options.minAutoZoomDeltaForAuto) { 244 newZoom = autoZoom; 245 } else { 246 const p1 = this._map.project(this._map.getCenter()); 247 const p2 = this._map.project(this._latlng); 248 const screenSize = this._map.getSize(); 249 const averageScreenSize = (screenSize.x + screenSize.y) / 2; 250 if (p1.distanceTo(p2) > averageScreenSize * this.options.minDistForAutoZoom) { 251 newZoom = autoZoom; 252 } else { 253 newZoom = currentZoom > zoomFitAccuracy ? zoomFitAccuracy : currentZoom; 254 } 255 } 256 this._map.setView(this._latlng, newZoom); 257 }, 258 259 _onMapMove: function() { 260 this._handleEvent(EVENT_MAP_MOVE); 261 }, 262 263 _onMapMoveEnd: function() { 264 const ll = this._map.getCenter(); 265 setTimeout(() => { 266 if (this._map.getCenter().equals(ll)) { 267 this._handleEvent(EVENT_MAP_MOVE_END); 268 } 269 }, 100 270 ); 271 }, 272 273 _isMapOffsetFromFollowingSegment: function() { 274 if (this._updateFollowingStartPosition) { 275 const p = this._map.project(this._map.getCenter()); 276 const p1 = this._map.project(this._updateFollowingStartPosition); 277 const p2 = this._map.project(this._updateFollowingDestPosition); 278 return L.LineUtil.pointToSegmentDistance(p, p1, p2) > 5; 279 } 280 return true; 281 }, 282 283 _isMapCenteredAtLocation: function() { 284 if (!this._latlng || !this._map) { 285 return false; 286 } 287 let p1 = this._map.project(this._latlng); 288 let p2 = this._map.project(this._map.getCenter()); 289 return p1.distanceTo(p2) < 5; 290 }, 291 292 _updateButtonClasses: function(add, remove) { 293 for (let cls of add) { 294 L.DomUtil.addClass(this._container, cls); 295 } 296 for (let cls of remove) { 297 L.DomUtil.removeClass(this._container, cls); 298 } 299 }, 300 301 _setEvents: function(on) { 302 const f = on ? 'on' : 'off'; 303 this._map[f]('move', this._onMapMove, this); 304 this._map[f]('moveend', this._onMapMoveEnd, this); 305 }, 306 307 _handleEvent: function(event, data) { 308 this._events.push({event, data}); 309 if (!this._processingEvent) { 310 this._processingEvent = true; 311 while (this._events.length) { 312 this._processEvent(this._events.shift()); 313 } 314 this._processingEvent = false; 315 } 316 }, 317 318 _processEvent: function({event, data}) { // eslint-disable-line complexity 319 // console.log('PROCESS EVENT', event); 320 const state = this._state; 321 switch (event) { 322 case EVENT_INIT: 323 this._setState(STATE_DISABLED); 324 break; 325 case EVENT_BUTTON_CLICK: 326 if (state === STATE_DISABLED) { 327 this._setState(STATE_LOCATING); 328 } else if (state === STATE_ENABLED) { 329 this._setState(STATE_MOVING_TO_FOLLOWING); 330 this._setViewToLocation(); 331 } else { 332 this._setState(STATE_DISABLED); 333 } 334 break; 335 case EVENT_LOCATION_RECEIVED: 336 if (state === STATE_DISABLED) { 337 return; 338 } 339 this._storeLocation(data); 340 this._updateMarkerLocation(); 341 if (state === STATE_LOCATING || state === STATE_MOVING_TO_FOLLOWING_FIRST) { 342 this._setViewToLocation(true); 343 this._setState(STATE_MOVING_TO_FOLLOWING_FIRST); 344 } else if (state === STATE_MOVING_TO_FOLLOWING) { 345 this._setViewToLocation(); 346 } else if (this._state === STATE_ENABLED_FOLLOWING || state === STATE_UPDATING_FOLLOWING) { 347 this._updateMapPositionWhileFollowing(); 348 this._setState(STATE_UPDATING_FOLLOWING); 349 } 350 break; 351 case EVENT_LOCATION_ERROR: 352 if (state === STATE_DISABLED) { 353 return; 354 } 355 this.options.showError(data); 356 this._setState(STATE_DISABLED); 357 break; 358 case EVENT_MAP_MOVE: 359 if (state === STATE_ENABLED_FOLLOWING) { 360 if (!this._isMapCenteredAtLocation() && this._isMapOffsetFromFollowingSegment()) { 361 this._setState(STATE_ENABLED); 362 } 363 } 364 break; 365 case EVENT_MAP_MOVE_END: 366 if (state === STATE_MOVING_TO_FOLLOWING) { 367 if (this._isMapCenteredAtLocation()) { 368 this._setState(STATE_ENABLED_FOLLOWING); 369 } else { 370 this._setState(STATE_ENABLED); 371 } 372 } else if (state === STATE_UPDATING_FOLLOWING || state === STATE_MOVING_TO_FOLLOWING_FIRST) { 373 if (this._isMapCenteredAtLocation() || !this._isMapOffsetFromFollowingSegment()) { 374 this._setState(STATE_ENABLED_FOLLOWING); 375 } else { 376 this._setState(STATE_ENABLED); 377 } 378 } 379 break; 380 default: 381 } 382 }, 383 384 _setState: function(newState) { 385 const oldState = this._state; 386 if (oldState === newState) { 387 return; 388 } 389 // console.log(`STATE: ${oldState} -> ${newState}`); 390 switch (newState) { 391 case STATE_LOCATING: 392 this._startLocating(); 393 this._updateButtonClasses(['requesting'], ['active', 'following']); 394 this._setEvents(true); 395 break; 396 case STATE_DISABLED: 397 this._stopLocating(); 398 this._marker.removeFrom(this._map); 399 this._setEvents(false); 400 this._updateButtonClasses([], ['active', 'highlight', 'following', 'requesting']); 401 break; 402 case STATE_ENABLED: 403 this._updateButtonClasses(['active', 'highlight'], ['following', 'requesting']); 404 break; 405 case STATE_MOVING_TO_FOLLOWING_FIRST: 406 this._marker.addTo(this._map); 407 break; 408 case STATE_ENABLED_FOLLOWING: 409 this._updateButtonClasses(['active', 'highlight', 'following'], ['requesting']); 410 break; 411 default: 412 } 413 this._state = newState; 414 }, 415 } 416 ); 417 418 export {LocateControl};