diff --git a/src/css/mapbox-gl.css b/src/css/mapbox-gl.css index 92a1731546a..e0c16e6e568 100644 --- a/src/css/mapbox-gl.css +++ b/src/css/mapbox-gl.css @@ -761,6 +761,13 @@ a.mapboxgl-ctrl-logo.mapboxgl-compact { display: none; } +.mapboxgl-user-location-accuracy-circle { + background-color: #1da1f233; + width: 1px; + height: 1px; + border-radius: 100%; +} + .mapboxgl-crosshair, .mapboxgl-crosshair .mapboxgl-interactive, .mapboxgl-crosshair .mapboxgl-interactive:active { diff --git a/src/ui/control/geolocate_control.js b/src/ui/control/geolocate_control.js index d9b024753c2..88e17390471 100644 --- a/src/ui/control/geolocate_control.js +++ b/src/ui/control/geolocate_control.js @@ -15,6 +15,7 @@ type Options = { positionOptions?: PositionOptions, fitBoundsOptions?: AnimationOptions & CameraOptions, trackUserLocation?: boolean, + showAccuracyCircle?: boolean, showUserLocation?: boolean }; @@ -28,6 +29,7 @@ const defaultOptions: Options = { maxZoom: 15 }, trackUserLocation: false, + showAccuracyCircle: true, showUserLocation: true }; @@ -78,6 +80,7 @@ let noTimeout = false; * @param {Object} [options.positionOptions={enableHighAccuracy: false, timeout: 6000}] A Geolocation API [PositionOptions](https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions) object. * @param {Object} [options.fitBoundsOptions={maxZoom: 15}] A [`fitBounds`](#map#fitbounds) options object to use when the map is panned and zoomed to the user's location. The default is to use a `maxZoom` of 15 to limit how far the map will zoom in for very accurate locations. * @param {Object} [options.trackUserLocation=false] If `true` the Geolocate Control becomes a toggle button and when active the map will receive updates to the user's location as it changes. + * @param {Object} [options.showAccuracyCircle=true] By default, if showUserLocation is `true`, a transparent circle will be drawn around the user location indicating the accuracy (95% confidence level) of the user's location. Set to `false` to disable. Always disabled when showUserLocation is `false`. * @param {Object} [options.showUserLocation=true] By default a dot will be shown on the map at the user's location. Set to `false` to disable. * * @example @@ -94,12 +97,15 @@ class GeolocateControl extends Evented { options: Options; _container: HTMLElement; _dotElement: HTMLElement; + _circleElement: HTMLElement; _geolocateButton: HTMLButtonElement; _geolocationWatchID: number; _timeoutId: ?TimeoutID; _watchState: 'OFF' | 'ACTIVE_LOCK' | 'WAITING_ACTIVE' | 'ACTIVE_ERROR' | 'BACKGROUND' | 'BACKGROUND_ERROR'; _lastKnownPosition: any; _userLocationDotMarker: Marker; + _accuracyCircleMarker: Marker; + _accuracy: number; _setup: boolean; // set to true once the control has been setup constructor(options: Options) { @@ -109,6 +115,7 @@ class GeolocateControl extends Evented { bindAll([ '_onSuccess', '_onError', + '_onZoom', '_finish', '_setupUI', '_updateCamera', @@ -120,6 +127,7 @@ class GeolocateControl extends Evented { this._map = map; this._container = DOM.create('div', `mapboxgl-ctrl mapboxgl-ctrl-group`); checkGeolocationSupport(this._setupUI); + this._map.on('zoom', this._onZoom); return this._container; } @@ -130,12 +138,16 @@ class GeolocateControl extends Evented { this._geolocationWatchID = (undefined: any); } - // clear the marker from the map + // clear the markers from the map if (this.options.showUserLocation && this._userLocationDotMarker) { this._userLocationDotMarker.remove(); } + if (this.options.showAccuracyCircle && this._accuracyCircleMarker) { + this._accuracyCircleMarker.remove(); + } DOM.remove(this._container); + this._map.off('zoom', this._onZoom); this._map = (undefined: any); numberOfWatches = 0; noTimeout = false; @@ -251,9 +263,33 @@ class GeolocateControl extends Evented { _updateMarker(position: ?Position) { if (position) { - this._userLocationDotMarker.setLngLat([position.coords.longitude, position.coords.latitude]).addTo(this._map); + const center = new LngLat(position.coords.longitude, position.coords.latitude); + this._accuracyCircleMarker.setLngLat(center).addTo(this._map); + this._userLocationDotMarker.setLngLat(center).addTo(this._map); + this._accuracy = position.coords.accuracy; + if (this.options.showUserLocation && this.options.showAccuracyCircle) { + this._updateCircleRadius(); + } } else { this._userLocationDotMarker.remove(); + this._accuracyCircleMarker.remove(); + } + } + + _updateCircleRadius() { + assert(this._circleElement); + const y = this._map._container.clientHeight / 2; + const a = this._map.unproject([0, y]); + const b = this._map.unproject([1, y]); + const metersPerPixel = a.distanceTo(b); + const circleDiameter = Math.ceil(2.0 * this._accuracy / metersPerPixel); + this._circleElement.style.width = `${circleDiameter}px`; + this._circleElement.style.height = `${circleDiameter}px`; + } + + _onZoom() { + if (this.options.showUserLocation && this.options.showAccuracyCircle) { + this._updateCircleRadius(); } } @@ -329,6 +365,9 @@ class GeolocateControl extends Evented { this._userLocationDotMarker = new Marker(this._dotElement); + this._circleElement = DOM.create('div', 'mapboxgl-user-location-accuracy-circle'); + this._accuracyCircleMarker = new Marker({element: this._circleElement, pitchAlignment: 'map'}); + if (this.options.trackUserLocation) this._watchState = 'OFF'; } diff --git a/test/unit/ui/control/geolocate.test.js b/test/unit/ui/control/geolocate.test.js index 523e96076e7..fe91812a5c3 100644 --- a/test/unit/ui/control/geolocate.test.js +++ b/test/unit/ui/control/geolocate.test.js @@ -422,3 +422,86 @@ test('GeolocateControl switches to BACKGROUND state on map manipulation', (t) => geolocate._geolocateButton.dispatchEvent(click); geolocation.send({latitude: 10, longitude: 20, accuracy: 30, timestamp: 40}); }); + +test('GeolocateControl accuracy circle not shown if showAccuracyCircle = false', (t) => { + const map = createMap(t); + const geolocate = new GeolocateControl({ + trackUserLocation: true, + showUserLocation: true, + showAccuracyCircle: false, + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.once('geolocate', () => { + map.jumpTo({ + center: [10, 20] + }); + map.once('zoomend', () => { + t.ok(!geolocate._circleElement.style.width); + t.end(); + }); + map.zoomTo(10, {duration: 0}); + }); + + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 700}); +}); + +test('GeolocateControl accuracy circle radius matches reported accuracy', (t) => { + const map = createMap(t); + const geolocate = new GeolocateControl({ + trackUserLocation: true, + showUserLocation: true, + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.once('geolocate', () => { + t.ok(geolocate._accuracyCircleMarker._map, 'userLocation accuracy circle marker on map'); + t.equal(geolocate._accuracy, 700); + map.jumpTo({ + center: [10, 20] + }); + map.once('zoomend', () => { + t.equal(geolocate._circleElement.style.width, '20px'); // 700m = 20px at zoom 10 + map.once('zoomend', () => { + t.equal(geolocate._circleElement.style.width, '79px'); // 700m = 79px at zoom 12 + t.end(); + }); + map.zoomTo(12, {duration: 0}); + }); + map.zoomTo(10, {duration: 0}); + }); + + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 700}); +}); + +test('GeolocateControl shown even if trackUserLocation = false', (t) => { + const map = createMap(t); + const geolocate = new GeolocateControl({ + trackUserLocation: false, + showUserLocation: true, + showAccuracyCircle: true, + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.once('geolocate', () => { + map.jumpTo({ + center: [10, 20] + }); + map.once('zoomend', () => { + t.ok(geolocate._circleElement.style.width); + t.end(); + }); + map.zoomTo(10, {duration: 0}); + }); + + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 700}); +});