commit 0caf9140f91d99e43db93284a7807938751edd05
parent 344306b614882062ef61bd196dbd8bb9281c603f
Author: Sergej Orlov <wladimirych@gmail.com>
Date: Wed, 9 May 2018 15:05:58 +0300
Merge branch 'geolocation'
Diffstat:
6 files changed, 511 insertions(+), 9 deletions(-)
diff --git a/src/App.js b/src/App.js
@@ -26,6 +26,14 @@ import 'lib/leaflet.control.jnx';
import 'lib/leaflet.control.jnx/hash-state';
import 'lib/leaflet.control.azimuth';
import {hashState, bindHashStateReadOnly} from 'lib/leaflet.hashState/hashState';
+import {LocateControl} from 'lib/leaflet.control.locate';
+import {notify} from 'lib/notifications';
+
+const locationErrorMessage = {
+ 0: 'Your browser does not support geolocation.',
+ 1: 'Geolocation is blocked for this site. Please, enable in browser setting.',
+ 2: 'Failed to acquire position for unknown reason.',
+};
function setUp() {
fixAll();
@@ -39,7 +47,6 @@ function setUp() {
maxZoom: 18
}
);
- map.enableHashState('m', [10, 55.75185, 37.61856]);
const tracklist = new L.Control.TrackList();
@@ -61,11 +68,28 @@ function setUp() {
.enableHashState('n2');
L.Control.Panoramas.hashStateUpgrader(panoramas).enableHashState('n');
-
new L.Control.Coordinates({position: 'topleft'}).addTo(map);
const azimuthControl = new L.Control.Azimuth({position: 'topleft'}).addTo(map);
+ const locateControl = new LocateControl({
+ position: 'topleft',
+ showError: function({code, message}) {
+ let customMessage = locationErrorMessage[code];
+ if (!customMessage) {
+ customMessage = `Geolocation error: ${message}`;
+ }
+ notify(customMessage);
+ }
+ }).addTo(map);
+
+ const defaultLocation = L.latLng(55.75185, 37.61856);
+ const defaultZoom = 10;
+
+ let {lat, lng, zoom, valid} = map.validateState(hashState.getState('m'));
+ locateControl.moveMapToCurrentLocation(defaultZoom, defaultLocation,
+ valid ? L.latLng(lat, lng) : null, valid ? zoom : null);
+ map.enableHashState('m');
/////////// controls top-right corner
const layersControl = L.control.layers(null, null, {collapsed: false})
diff --git a/src/lib/leaflet.control.locate/index.js b/src/lib/leaflet.control.locate/index.js
@@ -0,0 +1,427 @@
+import L from 'leaflet';
+import 'lib/leaflet.control.commons'
+import './style.css';
+import localStorage from 'lib/safe-localstorage';
+
+const STATE_DISABLED = 'disabled';
+const STATE_LOCATING = 'locating';
+const STATE_ENABLED = 'enabled';
+const STATE_ENABLED_FOLLOWING = 'enabled_following';
+const STATE_MOVING_TO_FOLLOWING = 'moving_to_following';
+const STATE_MOVING_TO_FOLLOWING_FIRST = 'moving_to_following_first';
+const STATE_UPDATING_FOLLOWING = 'updating_following';
+
+const EVENT_INIT = 'init';
+const EVENT_BUTTON_CLICK = 'button_click';
+const EVENT_LOCATION_RECEIVED = 'location_received';
+const EVENT_LOCATION_ERROR = 'location_error';
+const EVENT_MAP_MOVE = 'user_move';
+const EVENT_MAP_MOVE_END = 'map_move_end';
+
+const LOCALSTORAGE_POSITION = 'leaflet_locate_position';
+
+const PositionMarker = L.LayerGroup.extend({
+ initialize: function(options) {
+ L.LayerGroup.prototype.initialize.call(this, options);
+ this._locationSet = false;
+ this._elements = {
+ accuracyCircle: L.circle([0, 0], {
+ radius: 1,
+ interactive: false,
+ fillColor: '#4271a8',
+ color: '#2ba3f7',
+ weight: 2
+ }),
+ markerCircle: L.circleMarker([0, 0], {
+ interactive: false,
+ radius: 10,
+ color: '#2ba3f7',
+ weight: 2.5,
+ fill: null,
+ opacity: 0.8
+ }),
+ markerPoint: L.circleMarker([0, 0], {
+ interactive: false,
+ radius: 2,
+ weight: 0,
+ color: '#2ba3f7',
+ fillOpacity: 0.8
+ }),
+ };
+ this.addLayer(this._elements.accuracyCircle);
+
+ },
+
+ onAdd: function(map) {
+ L.LayerGroup.prototype.onAdd.call(this, map);
+ map.on('zoom', this._onZoom, this);
+ this._updatePrecisionState();
+ },
+
+ onRemove: function(map) {
+ map.off('zoom', this._onZoom, this);
+ L.LayerGroup.prototype.onRemove.call(this, map);
+ },
+
+ _updatePrecisionState: function() {
+ if (!this._map || !this._locationSet) {
+ return;
+ }
+ const precise = this._elements.accuracyCircle._radius <= this._elements.markerCircle.options.radius * 0.8;
+ if (precise !== this._precise) {
+ if (precise) {
+ this._elements.accuracyCircle.setStyle({opacity: 0, fillOpacity: 0});
+ this.addLayer(this._elements.markerPoint);
+ this.addLayer(this._elements.markerCircle);
+ } else {
+ this._elements.accuracyCircle.setStyle({opacity: 0.8, fillOpacity: 0.4});
+ this.removeLayer(this._elements.markerPoint);
+ this.removeLayer(this._elements.markerCircle);
+
+ }
+ this._precise = precise;
+ }
+ },
+
+ setLocation: function(latlng, accuracy) {
+ this._elements.accuracyCircle.setLatLng(latlng);
+ this._elements.accuracyCircle.setRadius(accuracy);
+ this._elements.markerCircle.setLatLng(latlng);
+ this._elements.markerPoint.setLatLng(latlng);
+ this._locationSet = true;
+ this._updatePrecisionState();
+ },
+
+ _onZoom: function(e) {
+ this._updatePrecisionState();
+ }
+
+});
+
+const LocateControl = L.Control.extend({
+ // button click behavior:
+ // if button turned off -- turn on, maps follows marker
+ // if button turned on
+ // if map is following marker -- turn off
+ // if map not following marker -- center map at marker, start following
+
+ options: {
+ locationAcquireTimeoutMS: Infinity,
+ showError: ({message}) => {
+ alert(message);
+ },
+ maxAutoZoom: 17,
+ minAutoZoomDeltaForAuto: 4,
+ minDistForAutoZoom: 2 // in average screen sizes
+ },
+
+ initialize: function(options) {
+ L.Control.prototype.initialize.call(this, options);
+ this._events = [];
+ },
+
+ onAdd: function(map) {
+ this._map = map;
+ const container = this._container = L.DomUtil.create(
+ 'div', 'leaflet-bar leaflet-control leaflet-control-locate'
+ );
+ this._stopContainerEvents();
+ const link = L.DomUtil.create('a', '', container);
+ link.title = 'Where am I?';
+ L.DomUtil.create('div', 'icon-position', link);
+ L.DomEvent.on(container, 'click', () => this._handleEvent(EVENT_BUTTON_CLICK));
+ this._marker = new PositionMarker();
+ this._handleEvent(EVENT_INIT);
+ return container;
+ },
+
+ moveMapToCurrentLocation: function(zoom, fallbackLatLng, forceLatLng, forceZoom) {
+ let storedPosition;
+ try {
+ storedPosition = JSON.parse(localStorage.getItem(LOCALSTORAGE_POSITION));
+ let {lat, lon} = storedPosition;
+ if (lat && lon) {
+ storedPosition = L.latLng(lat, lon);
+ } else {
+ storedPosition = null;
+ }
+ } catch (e) {}
+
+ if (storedPosition) {
+ this._map.setView(forceLatLng ? forceLatLng : storedPosition, forceZoom ? forceZoom : zoom, {animate: false});
+ if (!('geolocation' in navigator)) {
+ return;
+ }
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ this._storePositionToLocalStorage(pos);
+ if (!forceLatLng) {
+ // TODO: check if map has not moved
+ this._map.setView(L.latLng(pos.coords.latitude, pos.coords.longitude), zoom, {animate: false});
+ }
+ },
+ (e) => {
+ if (e.code === 1) {
+ localStorage.removeItem(LOCALSTORAGE_POSITION);
+ }
+ }, {
+ enableHighAccuracy: false,
+ timeout: 500,
+ maximumAge: 0
+ });
+ } else {
+ this._map.setView(forceLatLng ? forceLatLng : fallbackLatLng, forceZoom ? forceZoom : zoom,
+ {animate: false});
+ }
+
+ },
+
+ _startLocating: function() {
+ if (!('geolocation' in navigator)) {
+ const error = {code: 0, message: 'Geolocation not supported'};
+ setTimeout(() => {
+ this._onLocationError(error);
+ }, 0
+ )
+ }
+ this._watchID = navigator.geolocation.watchPosition(
+ this._onLocationSuccess.bind(this), this._onLocationError.bind(this),
+ {
+ enableHighAccuracy: true,
+ timeout: this.options.locationAcquireTimeoutMS,
+ }
+ );
+ },
+
+ _storePositionToLocalStorage: function(pos) {
+ const coords = {lat: pos.coords.latitude, lon: pos.coords.latitude};
+ localStorage.setItem(LOCALSTORAGE_POSITION, JSON.stringify(coords));
+ },
+
+ _onLocationSuccess: function(pos) {
+ this._handleEvent(EVENT_LOCATION_RECEIVED, pos);
+ this._storePositionToLocalStorage(pos);
+ },
+
+ _onLocationError: function(e) {
+ this._handleEvent(EVENT_LOCATION_ERROR, e);
+ if (e.code === 1) {
+ localStorage.removeItem(LOCALSTORAGE_POSITION);
+ }
+ },
+
+ _stopLocating: function() {
+ if (this._watchID && navigator.geolocation) {
+ navigator.geolocation.clearWatch(this._watchID);
+ }
+ },
+
+ _storeLocation: function(position) {
+ this._latlng = L.latLng(position.coords.latitude, position.coords.longitude);
+ this._accuracy = position.coords.accuracy;
+ },
+
+ _updateMarkerLocation: function() {
+ this._marker.setLocation(this._latlng, this._accuracy);
+ },
+
+ _updateMapPositionWhileFollowing: function() {
+ this._updateFollowingStartPosition = this._map.getCenter();
+ this._updateFollowingDestPosition = this._latlng;
+ this._map.panTo(this._latlng);
+ },
+
+ _setViewToLocation: function(preferAutoZoom) {
+ if (!this._map || !this._latlng) {
+ return;
+ }
+
+ // autoZoom -- to fit accuracy cirlce on screen, but not more then options.maxAutoZoom (17)
+ // if current zoom more then options.minAutoZoomDeltaForAuto less then autoZoom, set autoZoom
+ // if map center far from geolocation, set autoZoom
+ // if map center not far from geolocation
+ // if accuracy circle does not fit at current zoom, zoom out to fit
+ // if current zoom is less then minAutoZoomDeltaForAuto less then autoZoom or >= autoZoom and circle fits screen, keep current zoom
+
+ const currentZoom = this._map.getZoom();
+ let zoomFitAccuracy = this._map.getBoundsZoom(this._latlng.toBounds(this._accuracy * 2));
+ let autoZoom = zoomFitAccuracy;
+ let newZoom;
+ autoZoom = Math.min(autoZoom, this.options.maxAutoZoom);
+
+ if (preferAutoZoom || autoZoom - currentZoom >= this.options.minAutoZoomDeltaForAuto) {
+ newZoom = autoZoom;
+ } else {
+ const p1 = this._map.project(this._map.getCenter());
+ const p2 = this._map.project(this._latlng);
+ const screenSize = this._map.getSize();
+ const averageScreenSize = (screenSize.x + screenSize.y) / 2;
+ if (p1.distanceTo(p2) > averageScreenSize * this.options.minDistForAutoZoom) {
+ newZoom = autoZoom
+ } else {
+ newZoom = currentZoom > zoomFitAccuracy ? zoomFitAccuracy : currentZoom;
+ }
+ }
+ this._map.setView(this._latlng, newZoom);
+ },
+
+ _onMapMove: function() {
+ this._handleEvent(EVENT_MAP_MOVE)
+ },
+
+ _onMapMoveEnd: function() {
+ const ll = this._map.getCenter();
+ setTimeout(() => {
+ if (this._map.getCenter().equals(ll)) {
+ this._handleEvent(EVENT_MAP_MOVE_END)
+ }
+ }, 100
+ );
+ },
+
+ _isMapOffsetFromFollowingSegment: function() {
+ if (this._updateFollowingStartPosition) {
+ const p = this._map.project(this._map.getCenter());
+ const p1 = this._map.project(this._updateFollowingStartPosition);
+ const p2 = this._map.project(this._updateFollowingDestPosition);
+ return L.LineUtil.pointToSegmentDistance(p, p1, p2) > 5;
+ }
+ return true;
+ },
+
+ _isMapCenteredAtLocation: function() {
+ if (!this._latlng || !this._map) {
+ return false;
+ }
+ let p1 = this._map.project(this._latlng);
+ let p2 = this._map.project(this._map.getCenter());
+ return p1.distanceTo(p2) < 5;
+ },
+
+ _updateButtonClasses: function(add, remove) {
+ for (let cls of add) {
+ L.DomUtil.addClass(this._container, cls);
+ }
+ for (let cls of remove) {
+ L.DomUtil.removeClass(this._container, cls);
+ }
+ },
+
+ _setEvents: function(on) {
+ const f = on ? 'on' : 'off';
+ this._map[f]('move', this._onMapMove, this);
+ this._map[f]('moveend', this._onMapMoveEnd, this);
+ },
+
+ _handleEvent: function(event, data) {
+ this._events.push({event, data});
+ if (!this._processingEvent) {
+ this._processingEvent = true;
+ while (this._events.length) {
+ this._processEvent(this._events.shift());
+ }
+ this._processingEvent = false;
+ }
+ },
+
+ _processEvent: function({event, data}) {
+ // console.log('PROCESS EVENT', event);
+ const state = this._state;
+ switch (event) {
+ case EVENT_INIT:
+ this._setState(STATE_DISABLED);
+ break;
+ case EVENT_BUTTON_CLICK:
+ if (state === STATE_DISABLED) {
+ this._setState(STATE_LOCATING);
+ } else if (state === STATE_ENABLED) {
+ this._setState(STATE_MOVING_TO_FOLLOWING);
+ this._setViewToLocation();
+ } else {
+ this._setState(STATE_DISABLED);
+ }
+ break;
+ case EVENT_LOCATION_RECEIVED:
+ if (state === STATE_DISABLED) {
+ return;
+ }
+ this._storeLocation(data);
+ this._updateMarkerLocation();
+ if (state === STATE_LOCATING || state === STATE_MOVING_TO_FOLLOWING_FIRST) {
+ this._setViewToLocation(true);
+ this._setState(STATE_MOVING_TO_FOLLOWING_FIRST);
+ } else if (state === STATE_MOVING_TO_FOLLOWING) {
+ this._setViewToLocation();
+ } else if (this._state === STATE_ENABLED_FOLLOWING || state === STATE_UPDATING_FOLLOWING) {
+ this._updateMapPositionWhileFollowing();
+ this._setState(STATE_UPDATING_FOLLOWING)
+ }
+ break;
+ case EVENT_LOCATION_ERROR:
+ if (state === STATE_DISABLED) {
+ return
+ }
+ this.options.showError(data);
+ this._setState(STATE_DISABLED);
+ break;
+ case EVENT_MAP_MOVE:
+ if (state === STATE_ENABLED_FOLLOWING) {
+ if (!this._isMapCenteredAtLocation() && this._isMapOffsetFromFollowingSegment()) {
+ this._setState(STATE_ENABLED);
+ }
+ }
+ break;
+ case EVENT_MAP_MOVE_END:
+ if (state === STATE_MOVING_TO_FOLLOWING) {
+ if (this._isMapCenteredAtLocation()) {
+ this._setState(STATE_ENABLED_FOLLOWING);
+ } else {
+ this._setState(STATE_ENABLED);
+ }
+ } else if (state === STATE_UPDATING_FOLLOWING || state === STATE_MOVING_TO_FOLLOWING_FIRST) {
+ if (this._isMapCenteredAtLocation() || !this._isMapOffsetFromFollowingSegment()) {
+ this._setState(STATE_ENABLED_FOLLOWING);
+ } else {
+ this._setState(STATE_ENABLED);
+ }
+ }
+ break;
+ default:
+ }
+ },
+
+ _setState: function(newState) {
+ const oldState = this._state;
+ if (oldState === newState) {
+ return;
+ }
+ // console.log(`STATE: ${oldState} -> ${newState}`);
+ switch (newState) {
+ case STATE_LOCATING:
+ this._startLocating();
+ this._updateButtonClasses(['requesting'], ['active', 'following']);
+ this._setEvents(true);
+ break;
+ case STATE_DISABLED:
+ this._stopLocating();
+ this._marker.removeFrom(this._map);
+ this._setEvents(false);
+ this._updateButtonClasses([], ['active', 'following', 'requesting']);
+ break;
+ case STATE_ENABLED:
+ this._updateButtonClasses(['active'], ['following', 'requesting']);
+ break;
+ case STATE_MOVING_TO_FOLLOWING_FIRST:
+ this._marker.addTo(this._map);
+ break;
+ case STATE_ENABLED_FOLLOWING:
+ this._updateButtonClasses(['active', 'following'], ['requesting']);
+ break;
+ default:
+ }
+ this._state = newState;
+ },
+ }
+);
+
+export {LocateControl};
diff --git a/src/lib/leaflet.control.locate/location-arrow-active.svg b/src/lib/leaflet.control.locate/location-arrow-active.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" ?>
+<svg height="1792" viewBox="0 0 1792 1792" width="1792" xmlns="http://www.w3.org/2000/svg">
+ <path style="fill:#c00;" d="M1593 349l-640 1280q-17 35-57 35-5 0-15-2-22-5-35.5-22.5t-13.5-39.5v-576h-576q-22 0-39.5-13.5t-22.5-35.5 4-42 29-30l1280-640q13-7 29-7 27 0 45 19 15 14 18.5 34.5t-6.5 39.5z"/>
+</svg>
+\ No newline at end of file
diff --git a/src/lib/leaflet.control.locate/location-arrow.svg b/src/lib/leaflet.control.locate/location-arrow.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" ?>
+<svg height="1792" viewBox="0 0 1792 1792" width="1792" xmlns="http://www.w3.org/2000/svg">
+ <path style="fill:#555;" d="M1593 349l-640 1280q-17 35-57 35-5 0-15-2-22-5-35.5-22.5t-13.5-39.5v-576h-576q-22 0-39.5-13.5t-22.5-35.5 4-42 29-30l1280-640q13-7 29-7 27 0 45 19 15 14 18.5 34.5t-6.5 39.5z"/>
+</svg>
+\ No newline at end of file
diff --git a/src/lib/leaflet.control.locate/style.css b/src/lib/leaflet.control.locate/style.css
@@ -0,0 +1,33 @@
+.icon-position {
+ background-image: url("location-arrow.svg");
+ background-size: 61%;
+ background-repeat: no-repeat;
+ background-position: 50% 50%;
+ width: 100%;
+ height: 100%;
+}
+
+.following .icon-position {
+ background-image: url("location-arrow-active.svg");
+}
+
+.requesting .icon-position {
+ animation-name: spin;
+ animation-duration: 500ms;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+ animation-delay: 100ms;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.leaflet-control-locate.active a {
+ background-color: #cce8ff;
+}
diff --git a/src/lib/leaflet.hashState/Leaflet.Map.js b/src/lib/leaflet.hashState/Leaflet.Map.js
@@ -18,18 +18,26 @@ L.Map.include({
return state;
},
- unserializeState: function(values) {
+ validateState: function(values) {
if (!values || values.length !== 3) {
- return false;
+ return {valid: false};
}
- var zoom = parseInt(values[0], 10),
+ let zoom = parseInt(values[0], 10),
lat = parseFloat(values[1]),
lng = parseFloat(values[2]);
- if (isNaN(zoom) || isNaN(lat) || isNaN(lng) || zoom < 0 || zoom > 32 || lat < -90 || lat > 90 ) {
- return false;
+ if (isNaN(zoom) || isNaN(lat) || isNaN(lng) || zoom < 0 || zoom > 32 || lat < -90 || lat > 90) {
+ return {valid: false};
+ }
+ return {lat, lng, zoom, valid: true};
+ },
+
+ unserializeState: function(values) {
+ let {lat, lng, zoom, valid} = this.validateState(values);
+ if (valid) {
+ this.setView([lat, lng], zoom);
+ return true;
}
- this.setView([lat, lng], zoom);
- return true;
+ return false;
}
}
);