diff --git a/debug/debug.html b/debug/debug.html index f6459f01fd9..1bb56c7c876 100644 --- a/debug/debug.html +++ b/debug/debug.html @@ -45,7 +45,11 @@ positionOptions: { enableHighAccuracy: true }, - watchPosition: true + trackUserLocation: true, + showUserLocation: true, + fitBoundsOptions: { + maxZoom: 20 + } })); map.addControl(new mapboxgl.ScaleControl()); diff --git a/dist/mapbox-gl.css b/dist/mapbox-gl.css index 841457e2a6e..a80d9251be3 100644 --- a/dist/mapbox-gl.css +++ b/dist/mapbox-gl.css @@ -79,11 +79,51 @@ .mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-in { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%23333333%3B%27%20d%3D%27M%2010%206%20C%209.446%206%209%206.4459904%209%207%20L%209%209%20L%207%209%20C%206.446%209%206%209.446%206%2010%20C%206%2010.554%206.446%2011%207%2011%20L%209%2011%20L%209%2013%20C%209%2013.55401%209.446%2014%2010%2014%20C%2010.554%2014%2011%2013.55401%2011%2013%20L%2011%2011%20L%2013%2011%20C%2013.554%2011%2014%2010.554%2014%2010%20C%2014%209.446%2013.554%209%2013%209%20L%2011%209%20L%2011%207%20C%2011%206.4459904%2010.554%206%2010%206%20z%27%20%2F%3E%0A%3C%2Fsvg%3E%0A"); } -.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate { +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0D%0A%20%20%3Cpath%20style%3D%27fill%3A%23333%3B%27%20d%3D%27M10%204C9%204%209%205%209%205L9%205.1A5%205%200%200%200%205.1%209L5%209C5%209%204%209%204%2010%204%2011%205%2011%205%2011L5.1%2011A5%205%200%200%200%209%2014.9L9%2015C9%2015%209%2016%2010%2016%2011%2016%2011%2015%2011%2015L11%2014.9A5%205%200%200%200%2014.9%2011L15%2011C15%2011%2016%2011%2016%2010%2016%209%2015%209%2015%209L14.9%209A5%205%200%200%200%2011%205.1L11%205C11%205%2011%204%2010%204zM10%206.5A3.5%203.5%200%200%201%2013.5%2010%203.5%203.5%200%200%201%2010%2013.5%203.5%203.5%200%200%201%206.5%2010%203.5%203.5%200%200%201%2010%206.5zM10%208.3A1.8%201.8%200%200%200%208.3%2010%201.8%201.8%200%200%200%2010%2011.8%201.8%201.8%200%200%200%2011.8%2010%201.8%201.8%200%200%200%2010%208.3z%27%20%2F%3E%0D%0A%3C%2Fsvg%3E"); } -.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate.watching { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0D%0A%20%20%3Cpath%20style%3D%27fill%3A%2300f%3B%27%20d%3D%27M10%204C9%204%209%205%209%205L9%205.1A5%205%200%200%200%205.1%209L5%209C5%209%204%209%204%2010%204%2011%205%2011%205%2011L5.1%2011A5%205%200%200%200%209%2014.9L9%2015C9%2015%209%2016%2010%2016%2011%2016%2011%2015%2011%2015L11%2014.9A5%205%200%200%200%2014.9%2011L15%2011C15%2011%2016%2011%2016%2010%2016%209%2015%209%2015%209L14.9%209A5%205%200%200%200%2011%205.1L11%205C11%205%2011%204%2010%204zM10%206.5A3.5%203.5%200%200%201%2013.5%2010%203.5%203.5%200%200%201%2010%2013.5%203.5%203.5%200%200%201%206.5%2010%203.5%203.5%200%200%201%2010%206.5zM10%208.3A1.8%201.8%200%200%200%208.3%2010%201.8%201.8%200%200%200%2010%2011.8%201.8%201.8%200%200%200%2011.8%2010%201.8%201.8%200%200%200%2010%208.3z%27%20%2F%3E%0D%0A%3C%2Fsvg%3E"); +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate:disabled { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0D%0A%20%20%3Cpath%20style%3D%27fill%3A%23aaa%3B%27%20d%3D%27M10%204C9%204%209%205%209%205L9%205.1A5%205%200%200%200%205.1%209L5%209C5%209%204%209%204%2010%204%2011%205%2011%205%2011L5.1%2011A5%205%200%200%200%209%2014.9L9%2015C9%2015%209%2016%2010%2016%2011%2016%2011%2015%2011%2015L11%2014.9A5%205%200%200%200%2014.9%2011L15%2011C15%2011%2016%2011%2016%2010%2016%209%2015%209%2015%209L14.9%209A5%205%200%200%200%2011%205.1L11%205C11%205%2011%204%2010%204zM10%206.5A3.5%203.5%200%200%201%2013.5%2010%203.5%203.5%200%200%201%2010%2013.5%203.5%203.5%200%200%201%206.5%2010%203.5%203.5%200%200%201%2010%206.5zM10%208.3A1.8%201.8%200%200%200%208.3%2010%201.8%201.8%200%200%200%2010%2011.8%201.8%201.8%200%200%200%2011.8%2010%201.8%201.8%200%200%200%2010%208.3z%27%20%2F%3E%0D%0A%3C%2Fsvg%3E"); +} +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate.active { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0D%0A%20%20%3Cpath%20style%3D%27fill%3A%2333b5e5%3B%27%20d%3D%27M10%204C9%204%209%205%209%205L9%205.1A5%205%200%200%200%205.1%209L5%209C5%209%204%209%204%2010%204%2011%205%2011%205%2011L5.1%2011A5%205%200%200%200%209%2014.9L9%2015C9%2015%209%2016%2010%2016%2011%2016%2011%2015%2011%2015L11%2014.9A5%205%200%200%200%2014.9%2011L15%2011C15%2011%2016%2011%2016%2010%2016%209%2015%209%2015%209L14.9%209A5%205%200%200%200%2011%205.1L11%205C11%205%2011%204%2010%204zM10%206.5A3.5%203.5%200%200%201%2013.5%2010%203.5%203.5%200%200%201%2010%2013.5%203.5%203.5%200%200%201%206.5%2010%203.5%203.5%200%200%201%2010%206.5zM10%208.3A1.8%201.8%200%200%200%208.3%2010%201.8%201.8%200%200%200%2010%2011.8%201.8%201.8%200%200%200%2011.8%2010%201.8%201.8%200%200%200%2010%208.3z%27%20%2F%3E%0D%0A%3C%2Fsvg%3E"); +} +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate.active-error { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0D%0A%20%20%3Cpath%20style%3D%27fill%3A%23e58978%3B%27%20d%3D%27M10%204C9%204%209%205%209%205L9%205.1A5%205%200%200%200%205.1%209L5%209C5%209%204%209%204%2010%204%2011%205%2011%205%2011L5.1%2011A5%205%200%200%200%209%2014.9L9%2015C9%2015%209%2016%2010%2016%2011%2016%2011%2015%2011%2015L11%2014.9A5%205%200%200%200%2014.9%2011L15%2011C15%2011%2016%2011%2016%2010%2016%209%2015%209%2015%209L14.9%209A5%205%200%200%200%2011%205.1L11%205C11%205%2011%204%2010%204zM10%206.5A3.5%203.5%200%200%201%2013.5%2010%203.5%203.5%200%200%201%2010%2013.5%203.5%203.5%200%200%201%206.5%2010%203.5%203.5%200%200%201%2010%206.5zM10%208.3A1.8%201.8%200%200%200%208.3%2010%201.8%201.8%200%200%200%2010%2011.8%201.8%201.8%200%200%200%2011.8%2010%201.8%201.8%200%200%200%2010%208.3z%27%20%2F%3E%0D%0A%3C%2Fsvg%3E"); +} +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate.background { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%2333b5e5%3B%27%20d%3D%27M%2010%2C4%20C%209%2C4%209%2C5%209%2C5%20L%209%2C5.1%20C%207.0357113%2C5.5006048%205.5006048%2C7.0357113%205.1%2C9%20L%205%2C9%20c%200%2C0%20-1%2C0%20-1%2C1%200%2C1%201%2C1%201%2C1%20l%200.1%2C0%20c%200.4006048%2C1.964289%201.9357113%2C3.499395%203.9%2C3.9%20L%209%2C15%20c%200%2C0%200%2C1%201%2C1%201%2C0%201%2C-1%201%2C-1%20l%200%2C-0.1%20c%201.964289%2C-0.400605%203.499395%2C-1.935711%203.9%2C-3.9%20l%200.1%2C0%20c%200%2C0%201%2C0%201%2C-1%20C%2016%2C9%2015%2C9%2015%2C9%20L%2014.9%2C9%20C%2014.499395%2C7.0357113%2012.964289%2C5.5006048%2011%2C5.1%20L%2011%2C5%20c%200%2C0%200%2C-1%20-1%2C-1%20z%20m%200%2C2.5%20c%201.932997%2C0%203.5%2C1.5670034%203.5%2C3.5%200%2C1.932997%20-1.567003%2C3.5%20-3.5%2C3.5%20C%208.0670034%2C13.5%206.5%2C11.932997%206.5%2C10%206.5%2C8.0670034%208.0670034%2C6.5%2010%2C6.5%20Z%27%20%2F%3E%0A%3C%2Fsvg%3E"); +} +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate.background-error { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%23e54e33%3B%27%20d%3D%27M%2010%2C4%20C%209%2C4%209%2C5%209%2C5%20L%209%2C5.1%20C%207.0357113%2C5.5006048%205.5006048%2C7.0357113%205.1%2C9%20L%205%2C9%20c%200%2C0%20-1%2C0%20-1%2C1%200%2C1%201%2C1%201%2C1%20l%200.1%2C0%20c%200.4006048%2C1.964289%201.9357113%2C3.499395%203.9%2C3.9%20L%209%2C15%20c%200%2C0%200%2C1%201%2C1%201%2C0%201%2C-1%201%2C-1%20l%200%2C-0.1%20c%201.964289%2C-0.400605%203.499395%2C-1.935711%203.9%2C-3.9%20l%200.1%2C0%20c%200%2C0%201%2C0%201%2C-1%20C%2016%2C9%2015%2C9%2015%2C9%20L%2014.9%2C9%20C%2014.499395%2C7.0357113%2012.964289%2C5.5006048%2011%2C5.1%20L%2011%2C5%20c%200%2C0%200%2C-1%20-1%2C-1%20z%20m%200%2C2.5%20c%201.932997%2C0%203.5%2C1.5670034%203.5%2C3.5%200%2C1.932997%20-1.567003%2C3.5%20-3.5%2C3.5%20C%208.0670034%2C13.5%206.5%2C11.932997%206.5%2C10%206.5%2C8.0670034%208.0670034%2C6.5%2010%2C6.5%20Z%27%20%2F%3E%0A%3C%2Fsvg%3E"); +} +.mapboxgl-ctrl-icon.mapboxgl-ctrl-geolocate.waiting { + -webkit-animation: mapboxgl-spin 2s infinite linear; + -moz-animation: mapboxgl-spin 2s infinite linear; + -o-animation: mapboxgl-spin 2s infinite linear; + -ms-animation: mapboxgl-spin 2s infinite linear; + animation: mapboxgl-spin 2s infinite linear; +} + +@-webkit-keyframes mapboxgl-spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} +@-moz-keyframes mapboxgl-spin { + 0% { -moz-transform: rotate(0deg); } + 100% { -moz-transform: rotate(360deg); } +} +@-o-keyframes mapboxgl-spin { + 0% { -o-transform: rotate(0deg); } + 100% { -o-transform: rotate(360deg); } +} +@-ms-keyframes mapboxgl-spin { + 0% { -ms-transform: rotate(0deg); } + 100% { -ms-transform: rotate(360deg); } +} +@-keyframes mapboxgl-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } .mapboxgl-ctrl-icon.mapboxgl-ctrl-compass > span.arrow { diff --git a/js/geo/lng_lat.js b/js/geo/lng_lat.js index ed26453f78e..99202e3814f 100644 --- a/js/geo/lng_lat.js +++ b/js/geo/lng_lat.js @@ -68,6 +68,24 @@ class LngLat { toString() { return `LngLat(${this.lng}, ${this.lat})`; } + + /** + * Returns a `LngLatBounds` from the coordinates extended by a given `radius`. + * + * @param {number} radius Distance in meters from the coordinates to extend the bounds. + * @returns {LngLatBounds} A new `LngLatBounds` object representing the coordinates extended by the `radius`. + * @example + * var ll = new mapboxgl.LngLat(-73.9749, 40.7736); + * ll.toBounds(100).toArray(); // = [[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]] + */ + toBounds(radius) { + const latAccuracy = 360 * radius / 40075017, + lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + const LngLatBounds = require('./lng_lat_bounds'); + return new LngLatBounds(new LngLat(this.lng - lngAccuracy, this.lat - latAccuracy), + new LngLat(this.lng + lngAccuracy, this.lat + latAccuracy)); + } } /** diff --git a/js/ui/control/geolocate_control.js b/js/ui/control/geolocate_control.js index 8e3c9a3cab2..4b0b13e1cc2 100644 --- a/js/ui/control/geolocate_control.js +++ b/js/ui/control/geolocate_control.js @@ -4,10 +4,17 @@ const Evented = require('../../util/evented'); const DOM = require('../../util/dom'); const window = require('../../util/window'); const util = require('../../util/util'); +const assert = require('assert'); +const LngLat = require('../../geo/lng_lat'); const defaultGeoPositionOptions = { enableHighAccuracy: false, timeout: 6000 /* 6sec */ }; +const defaultFitBoundsOptions = { maxZoom: 18 }; const className = 'mapboxgl-ctrl'; +const markerLayerName = '_geolocate-control-marker'; +const markerShadowLayerName = '_geolocate-control-marker-shadow'; +const markerSourceName = '_geolocate-control-marker-position'; + let supportsGeolocation; function checkGeolocationSupport(callback) { @@ -40,14 +47,28 @@ function checkGeolocationSupport(callback) { * geolocation support is not available, the GeolocateControl will not * be visible. * + * The zoom level applied will depend on the accuracy of the geolocation provided by the device. + * + * The GeolocateControl has two modes. If `trackUserLocation` is `false` (default) the control acts as a button, which when pressed will set the map's camera to target the user location. If the user moves, the map won't update. This is most suited for the desktop. If `trackUserLocation` is `true` the control acts as a toggle button that when active the user's location is actively monitored for changes. In this mode there is a concept of an active lock and background. In active lock the map's camera will automatically update as the users's location changes until the user manually changes the camera (such as panning or zooming). When this happens the control is in background so the user's location dot still updates but the camera doesn't. + * * @implements {IControl} * @param {Object} [options] - * @param {Object} [options.positionOptions={enableHighAccuracy: false, timeout: 6000}] A [PositionOptions](https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions) object. - * @param {Object} [options.watchPosition=false] If `true` the map will reposition each time the position of the device changes and the control becomes a toggle. + * @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: 18}] 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 18 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.showUserLocation=true] By default a dot will be shown on the map at the user's location. Set to `false` to disable. + * @param {Object} [options.userLocationPaintProperties={'circle-radius': 10, 'circle-color': '#33b5e5', 'circle-stroke-color': '#fff', 'circle-stroke-width': 2}] A [Circle Layer Paint Properties](https://www.mapbox.com/mapbox-gl-style-spec/#paint_circle) object to customize the user's location dot. The default is a blue dot with a white stroke. + * @param {Object} [options.userLocationShadowPaintProperties={ 'circle-radius': 14, 'circle-color': '#000', 'circle-opacity': 0.5, 'circle-blur': 0.4, 'circle-translate': [2, 2], 'circle-translate-anchor': 'viewport' }] A [Circle Layer Paint Properties](https://www.mapbox.com/mapbox-gl-style-spec/#paint_circle) object to customize the user's location dot, used as a "shadow" layer. The default is a blurred semi-transparent black shadow. + * @param {Object} [options.userLocationStalePaintProperties={'circle-color': '#a6d5e5', 'circle-opacity': 0.5, 'circle-stroke-opacity': 0.8}] A [Circle Layer Paint Properties](https://www.mapbox.com/mapbox-gl-style-spec/#paint_circle) object applied to the base userLocationPaintProperties to customize the user's location dot in a stale state. The dot is stale when there was a Geolocation error leading to the previous reported location to be used, which may no longer be current. The default is a faded blue dot with a white stroke. + * * @example * map.addControl(new mapboxgl.GeolocateControl({ * positionOptions: { * enableHighAccuracy: true + * }, + * trackUserLocation: true, + * userLocationPaintProperties: { + * 'circle-color': '#000' * } * })); */ @@ -56,11 +77,19 @@ class GeolocateControl extends Evented { constructor(options) { super(); this.options = options || {}; + + // apply default for options.showUserLocation + this.options.showUserLocation = (this.options && 'showUserLocation' in this.options) ? this.options.showUserLocation : true; + util.bindAll([ '_onSuccess', '_onError', '_finish', - '_setupUI' + '_setupUI', + '_updateCamera', + '_updateMarker', + '_setupMarker', + '_onClickGeolocate' ], this); } @@ -72,24 +101,161 @@ class GeolocateControl extends Evented { } onRemove() { + // clear the geolocation watch if exists + if (this._geolocationWatchID !== undefined) { + window.navigator.geolocation.clearWatch(this._geolocationWatchID); + this._geolocationWatchID = undefined; + } + + // clear the marker from the map + if (this.options.showUserLocation) { + if (this._map.getLayer(markerLayerName)) this._map.removeLayer(markerLayerName); + if (this._map.getSource(markerSourceName)) this._map.removeSource(markerSourceName); + } + this._container.parentNode.removeChild(this._container); this._map = undefined; } _onSuccess(position) { - this._map.jumpTo({ - center: [position.coords.longitude, position.coords.latitude], - zoom: 17, - bearing: 0, - pitch: 0 - }); + if (this.options.trackUserLocation) { + // keep a record of the position so that if the state is BACKGROUND and the user + // clicks the button, we can move to ACTIVE_LOCK immediately without waiting for + // watchPosition to trigger _onSuccess + this._lastKnownPosition = position; + + switch (this._watchState) { + case 'WAITING_ACTIVE': + case 'ACTIVE_LOCK': + case 'ACTIVE_ERROR': + this._watchState = 'ACTIVE_LOCK'; + this._geolocateButton.classList.remove('waiting'); + this._geolocateButton.classList.remove('active-error'); + this._geolocateButton.classList.add('active'); + break; + case 'BACKGROUND': + case 'BACKGROUND_ERROR': + this._watchState = 'BACKGROUND'; + this._geolocateButton.classList.remove('waiting'); + this._geolocateButton.classList.remove('background-error'); + this._geolocateButton.classList.add('background'); + break; + default: + assert(false, `Unexpected watchState ${this._watchState}`); + } + this._geolocateButton.classList.remove('waiting'); + } + + // if in normal mode (not watch mode), or if in watch mode and the state is active watch + // then update the camera + if (!this.options.trackUserLocation || this._watchState === 'ACTIVE_LOCK') { + this._updateCamera(position); + } + + // if showUserLocation and the watch state isn't off then update the marker location + if (this.options.showUserLocation && this._watchState !== 'OFF') { + this._updateMarker(position); + } + + if (this.options.showUserLocation) { + // restore any paint properties which were changed to make the marker stale + for (const paintProperty in this._userLocationStalePaintProperties) { + this._map.setPaintProperty(markerLayerName, paintProperty, this._userLocationPaintProperties[paintProperty]); + } + } + + if (this._watchState === 'ACTIVE_LOCK') { + this.fire('activeLock'); + } this.fire('geolocate', position); this._finish(); } + _updateCamera(position) { + const center = new LngLat(position.coords.longitude, position.coords.latitude); + const radius = position.coords.accuracy; + + this._map.fitBounds(center.toBounds(radius), util.extend(defaultFitBoundsOptions, this.options.fitBoundsOptions || {}), { + geolocateSource: true // tag this camera change so it won't cause the control to change to background state + }); + } + + _updateMarker(position) { + if (position) { + this._map.getSource(markerSourceName).setData({ + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "properties": { + "accuracy": position.coords.accuracy + }, + "geometry": { + "type": "Point", + "coordinates": [position.coords.longitude, position.coords.latitude] + } + }] + }); + } else { + this._map.getSource(markerSourceName).setData({ + "type": "FeatureCollection", + "features": [] + }); + } + } + _onError(error) { + if (this.options.trackUserLocation) { + if (error.code === 1) { + // PERMISSION_DENIED + this._watchState = 'OFF'; + this._geolocateButton.classList.remove('waiting'); + this._geolocateButton.classList.remove('active'); + this._geolocateButton.classList.remove('active-error'); + this._geolocateButton.classList.remove('background'); + this._geolocateButton.classList.remove('background-error'); + + if (this._geolocationWatchID !== undefined) { + this._clearWatch(); + } + } else { + switch (this._watchState) { + case 'WAITING_ACTIVE': + this._watchState = 'ACTIVE_ERROR'; + this._geolocateButton.classList.remove('active'); + this._geolocateButton.classList.add('active-error'); + break; + case 'ACTIVE_LOCK': + this._watchState = 'ACTIVE_ERROR'; + this._geolocateButton.classList.remove('active'); + this._geolocateButton.classList.add('active-error'); + this._geolocateButton.classList.add('waiting'); + // turn marker grey + break; + case 'BACKGROUND': + this._watchState = 'BACKGROUND_ERROR'; + this._geolocateButton.classList.remove('background'); + this._geolocateButton.classList.add('background-error'); + this._geolocateButton.classList.add('waiting'); + // turn marker grey + break; + case 'ACTIVE_ERROR': + break; + default: + assert(false, `Unexpected watchState ${this._watchState}`); + } + } + } + + if (this._watchState !== 'OFF' && this.options.showUserLocation) { + // apply paint properties to make the marker stale + for (const paintProperty in this._userLocationStalePaintProperties) { + this._map.setPaintProperty(markerLayerName, paintProperty, this._userLocationStalePaintProperties[paintProperty]); + } + } + this.fire('error', error); + this._finish(); } @@ -107,29 +273,172 @@ class GeolocateControl extends Evented { this._container); this._geolocateButton.type = 'button'; this._geolocateButton.setAttribute('aria-label', 'Geolocate'); - if (this.options.watchPosition) this._geolocateButton.setAttribute('aria-pressed', false); + + if (this.options.trackUserLocation) { + this._geolocateButton.setAttribute('aria-pressed', false); + this._watchState = 'OFF'; + } + + // when showUserLocation is enabled, keep the Geolocate button disabled until the device location marker is setup on the map + if (this.options.showUserLocation) { + if (this.options.trackUserLocation) this._watchState = 'INITILIZE'; + this._geolocateButton.disabled = true; + this._setupMarker(); + } + this._geolocateButton.addEventListener('click', this._onClickGeolocate.bind(this)); + + // when the camera is changed (and it's not as a result of the Geolocation Control) change + // the watch mode to background watch, so that the marker is updated but not the camera. + if (this.options.trackUserLocation) { + this._map.on('movestart', (event) => { + if (!event.geolocateSource) { + if (this._watchState === 'ACTIVE_LOCK') { + this._watchState = 'BACKGROUND'; + this._geolocateButton.classList.add('background'); + this._geolocateButton.classList.remove('active'); + + this.fire('background'); + } + } + }); + } + + if (!this.options.showUserLocation) this.fire('ready'); + } + + _setupMarker() { + const defaultMarkerPaintProperties = { + 'circle-radius': 9, + 'circle-color': '#33b5e5', + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 3 + }; + const defaultMarkerShadowPaintProperties = { + 'circle-radius': 14, + 'circle-color': '#000', + 'circle-opacity': 0.5, + 'circle-blur': 0.4, + 'circle-translate': [2, 2], + 'circle-translate-anchor': 'viewport' + }; + const defaultMarkerStalePaintProperties = { + 'circle-color': '#a6d5e5', + 'circle-opacity': 0.5, + 'circle-stroke-opacity': 0.8 + }; + + this._userLocationPaintProperties = this.options.userLocationPaintProperties || defaultMarkerPaintProperties; + this._userLocationShadowPaintProperties = this.options.userLocationShadowPaintProperties || defaultMarkerShadowPaintProperties; + this._userLocationStalePaintProperties = util.extend({}, this._userLocationPaintProperties, this.options.userLocationStalePaintProperties || defaultMarkerStalePaintProperties); + + // sources can't be added until the Map style is loaded + this._map.on('load', () => { + this._map.addSource(markerSourceName, { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [] + } + }); + + this._map.addLayer({ + id: markerShadowLayerName, + source: markerSourceName, + type: 'circle', + paint: this._userLocationShadowPaintProperties + }); + this._map.addLayer({ + id: markerLayerName, + source: markerSourceName, + type: 'circle', + paint: this._userLocationPaintProperties + }); + + if (this.options.trackUserLocation) this._watchState = 'OFF'; + + this._geolocateButton.disabled = false; + + this.fire('ready'); + }); } _onClickGeolocate() { const positionOptions = util.extend(defaultGeoPositionOptions, this.options && this.options.positionOptions || {}); - // toggle watching the device location - if (this.options.watchPosition) { - if (this._geolocationWatchID !== undefined) { - // clear watchPosition - this._geolocateButton.classList.remove('watching'); - this._geolocateButton.setAttribute('aria-pressed', false); - window.navigator.geolocation.clearWatch(this._geolocationWatchID); - this._geolocationWatchID = undefined; - } else { - // enable watchPosition - this._geolocateButton.classList.add('watching'); + if (this.options.trackUserLocation) { + // update watchState and do any outgoing state cleanup + switch (this._watchState) { + case 'OFF': + // turn on the Geolocate Control + this._watchState = 'WAITING_ACTIVE'; + break; + case 'WAITING_ACTIVE': + case 'ACTIVE_LOCK': + case 'ACTIVE_ERROR': + case 'BACKGROUND_ERROR': + // turn off the Geolocate Control + this._watchState = 'OFF'; + this._geolocateButton.classList.remove('waiting'); + this._geolocateButton.classList.remove('active'); + this._geolocateButton.classList.remove('active-error'); + this._geolocateButton.classList.remove('background'); + this._geolocateButton.classList.remove('background-error'); + break; + case 'BACKGROUND': + this._watchState = 'ACTIVE_LOCK'; + this._geolocateButton.classList.remove('background'); + // set camera to last known location + if (this._lastKnownPosition) this._updateCamera(this._lastKnownPosition); + break; + default: + assert(false, `Unexpected watchState ${this._watchState}`); + } + + // incoming state setup + switch (this._watchState) { + case 'WAITING_ACTIVE': + this._geolocateButton.classList.add('waiting'); + this._geolocateButton.classList.add('active'); + break; + case 'ACTIVE_LOCK': + this._geolocateButton.classList.add('active'); + break; + case 'ACTIVE_ERROR': + this._geolocateButton.classList.add('waiting'); + this._geolocateButton.classList.add('active-error'); + break; + case 'BACKGROUND': + this._geolocateButton.classList.add('background'); + break; + case 'BACKGROUND_ERROR': + this._geolocateButton.classList.add('waiting'); + this._geolocateButton.classList.add('background-error'); + break; + case 'OFF': + break; + default: + assert(false, `Unexpected watchState ${this._watchState}`); + } + + // manage geolocation.watchPosition / geolocation.clearWatch + if (this._watchState === 'OFF' && this._geolocationWatchID !== undefined) { + // clear watchPosition as we've changed to an OFF state + this._clearWatch(); + } else if (this._geolocationWatchID === undefined) { + // enable watchPosition since watchState is not OFF and there is no watchPosition already running + + this._geolocateButton.classList.add('waiting'); this._geolocateButton.setAttribute('aria-pressed', true); + this._geolocationWatchID = window.navigator.geolocation.watchPosition( this._onSuccess, this._onError, positionOptions); } + + if (this._watchState === 'ACTIVE_LOCK') { + this.fire('activeLock'); + } } else { window.navigator.geolocation.getCurrentPosition( this._onSuccess, this._onError, positionOptions); @@ -139,26 +448,65 @@ class GeolocateControl extends Evented { this._timeoutId = setTimeout(this._finish, 10000 /* 10sec */); } } + + _clearWatch() { + window.navigator.geolocation.clearWatch(this._geolocationWatchID); + + this._geolocationWatchID = undefined; + this._geolocateButton.classList.remove('waiting'); + this._geolocateButton.setAttribute('aria-pressed', false); + + if (this.options.showUserLocation) { + this._updateMarker(null); + } + } } module.exports = GeolocateControl; /** - * geolocate event. + * Fired on each Geolocation API position update which returned as success. * * @event geolocate * @memberof GeolocateControl * @instance - * @property {Position} data The returned [Position](https://developer.mozilla.org/en-US/docs/Web/API/Position) object from the callback in [Geolocation.getCurrentPosition()](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition). + * @property {Position} data The returned [Position](https://developer.mozilla.org/en-US/docs/Web/API/Position) object from the callback in [Geolocation.getCurrentPosition()](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition) or [Geolocation.watchPosition()](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition). * */ /** - * error event. + * Fired on each Geolocation API position update which returned as an error. * * @event error * @memberof GeolocateControl * @instance - * @property {PositionError} data The returned [PositionError](https://developer.mozilla.org/en-US/docs/Web/API/PositionError) object from the callback in [Geolocation.getCurrentPosition()](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition). + * @property {PositionError} data The returned [PositionError](https://developer.mozilla.org/en-US/docs/Web/API/PositionError) object from the callback in [Geolocation.getCurrentPosition()](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition) or [Geolocation.watchPosition()](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition). + * + */ + +/** + * Fired when the Geolocate Control is ready and able to be clicked. + * + * @event ready + * @memberof GeolocateControl + * @instance + * + */ + +/** + * Fired when the Geolocate Control changes to the active lock state, which happens either upon first obtaining a successful Geolocation API position for the user (a geolocate event will follow), or the user clicks the geolocate button when in the background state which uses the last known position to recenter the map and enter active lock state (no geolocate event will follow unless the users's location changes). + * + * @event activeLock + * @memberof GeolocateControl + * @instance + * + */ + +/** + * Fired when the Geolocate Control changes to the background state, which happens when a user changes the camera during an active position lock. This only applies when trackUserLocation is true. In the background state, the dot on the map will update with location updates but the camera will not. + * + * @event background + * @memberof GeolocateControl + * @instance * */ diff --git a/package.json b/package.json index 9c73a28b35c..b497b762321 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "jsonlint": "^1.6.2", "lodash.template": "^4.4.0", "minifyify": "^7.0.1", + "mock-geolocation": "^1.0.11", "npm-run-all": "^3.0.0", "nyc": "^8.3.0", "pixelmatch": "^4.0.2", diff --git a/test/js/geo/lng_lat.test.js b/test/js/geo/lng_lat.test.js index 0f0e5ff09d5..e01257dfcab 100644 --- a/test/js/geo/lng_lat.test.js +++ b/test/js/geo/lng_lat.test.js @@ -50,5 +50,11 @@ test('LngLat', (t) => { t.end(); }); + t.test('#toBounds', (t) => { + t.deepEqual(new LngLat(0, 0).toBounds(10).toArray(), [[-0.00008983152770714982, -0.00008983152770714982], [0.00008983152770714982, 0.00008983152770714982]]); + t.deepEqual(new LngLat(-73.9749, 40.7736).toBounds(10).toArray(), [[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]]); + t.end(); + }); + t.end(); }); diff --git a/test/js/ui/control/geolocate.test.js b/test/js/ui/control/geolocate.test.js new file mode 100644 index 00000000000..93012c29ab4 --- /dev/null +++ b/test/js/ui/control/geolocate.test.js @@ -0,0 +1,304 @@ +'use strict'; + +const test = require('mapbox-gl-js-test').test; +const window = require('../../../../js/util/window'); +const Map = require('../../../../js/ui/map'); +const GeolocateControl = require('../../../../js/ui/control/geolocate_control'); + +// window and navigator globals need to be set for mock-geolocation +global.window = {}; +global.navigator = {}; +const geolocation = require('mock-geolocation'); +geolocation.use(); + +// assign the mock geolocation to window +global.window.navigator = global.navigator; +window.navigator.geolocation = global.window.navigator.geolocation; + +function createMap() { + const container = window.document.createElement('div'); + return new Map({ + container: container, + style: { + version: 8, + sources: {}, + layers: [] + } + }); +} + +// convert the coordinates of a LngLat object to a fixed number of digits +function lngLatAsFixed(lngLat, digits) { + return Object.keys(lngLat).reduce((previous, current) => { + previous[current] = lngLat[current].toFixed(digits); + return previous; + }, {}); +} + +test('GeolocateControl with no options', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl(); + map.addControl(geolocate); + t.end(); +}); + +test('GeolocateControl showUserLocation button state and ready event', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl(); + + map.addControl(geolocate); + + geolocate.on('ready', () => { + t.false(geolocate._geolocateButton.disabled, 'button enabled when control is ready'); + t.end(); + }); +}); + +test('GeolocateControl error event', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl(); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.on('ready', () => { + geolocate.on('error', (error) => { + t.equal(error.code, 2, 'error code matches'); + t.equal(error.message, 'error message', 'error message matches'); + t.end(); + }); + geolocate._geolocateButton.dispatchEvent(click); + geolocation.sendError({code: 2, message: 'error message'}); + }); +}); + +test('GeolocateControl geolocate event', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl(); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.on('ready', () => { + geolocate.on('geolocate', (position) => { + t.equal(position.coords.latitude, 10, 'geolocate position latitude'); + t.equal(position.coords.longitude, 20, 'geolocate position longitude'); + t.equal(position.coords.accuracy, 30, 'geolocate position accuracy'); + t.equal(position.timestamp, 40, 'geolocate timestamp'); + t.end(); + }); + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 30, timestamp: 40}); + }); +}); + +test('GeolocateControl geolocate fitBoundsOptions', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl({ + fitBoundsOptions: { + linear: true, + duration: 0, + maxZoom: 10 + } + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.on('ready', () => { + map.once('moveend', () => { + t.equal(map.getZoom(), 10, 'geolocate fitBounds maxZoom'); + t.end(); + }); + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 1}); + }); +}); + +test('GeolocateControl no watching map camera on geolocation', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl({ + fitBoundsOptions: { + maxZoom: 20, + linear: true, + duration: 0 + } + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.on('ready', () => { + map.on('moveend', () => { + t.deepEqual(lngLatAsFixed(map.getCenter(), 4), { lat: 10, lng: 20 }, 'map centered on location'); + + const mapBounds = map.getBounds(); + + // map bounds should contain or equal accuracy bounds, that is the ensure accuracy bounds doesn't fall outside the map bounds + const accuracyBounds = map.getCenter().toBounds(1000); + t.ok(accuracyBounds.getNorth().toFixed(4) <= mapBounds.getNorth().toFixed(4), 'map contains north of accuracy radius'); + t.ok(accuracyBounds.getSouth().toFixed(4) >= mapBounds.getSouth().toFixed(4), 'map contains south of accuracy radius'); + t.ok(accuracyBounds.getEast().toFixed(4) <= mapBounds.getEast().toFixed(4), 'map contains east of accuracy radius'); + t.ok(accuracyBounds.getWest().toFixed(4) >= mapBounds.getWest().toFixed(4), 'map contains west of accuracy radius'); + + // map bounds should not be too much bigger on all edges of the accuracy bounds (this test will only work for an orthogonal bearing), + // ensures map bounds does not contain buffered accuracy bounds, as if it does there is too much gap around the accuracy bounds + const bufferedAccuracyBounds = map.getCenter().toBounds(1100); + t.notOk( + (bufferedAccuracyBounds.getNorth().toFixed(4) < mapBounds.getNorth().toFixed(4)) && + (bufferedAccuracyBounds.getSouth().toFixed(4) > mapBounds.getSouth().toFixed(4)) && + (bufferedAccuracyBounds.getEast().toFixed(4) < mapBounds.getEast().toFixed(4)) && + (bufferedAccuracyBounds.getWest().toFixed(4) > mapBounds.getWest().toFixed(4)), + 'map bounds is much is larger than the accuracy radius'); + + t.end(); + }); + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 1000}); + }); +}); + +test('GeolocateControl watching map updates recenter on location with dot', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl({ + trackUserLocation: true, + showUserLocation: true, + fitBoundsOptions: { + linear: true, + duration: 0 + }, + userLocationPaintProperties: { + 'circle-radius': 10, + 'circle-color': '#000', + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2 + }, + userLocationStalePaintProperties: { + 'circle-color': '#f00', + } + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.on('ready', () => { + map.once('moveend', () => { + t.deepEqual(lngLatAsFixed(map.getCenter(), 4), { lat: 10, lng: 20 }, 'map centered on location after 1st update'); + t.ok(map.getLayer('_geolocate-control-marker'), 'has marker layer'); + t.equals(map.getPaintProperty('_geolocate-control-marker', 'circle-color'), '#000', 'userLocationPaintProperty circle-color'); + map.once('moveend', () => { + t.deepEqual(lngLatAsFixed(map.getCenter(), 4), { lat: 40, lng: 50 }, 'map centered on location after 2nd update'); + geolocate.once('error', () => { + t.equals(map.getPaintProperty('_geolocate-control-marker', 'circle-color'), '#f00', 'userLocationStalePaintProperty circle-color'); + t.end(); + }); + geolocation.changeError({code: 2, message: 'position unavaliable'}); + }); + geolocation.change({latitude: 40, longitude: 50, accuracy: 60}); + }); + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 30}); + }); +}); + +test('GeolocateControl watching map background event', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl({ + trackUserLocation: true, + fitBoundsOptions: { + linear: true, + duration: 0 + } + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + // when the geolocate control is ready + geolocate.once('ready', () => { + map.once('moveend', () => { + geolocate.once('background', () => { + t.end(); + }); + + // manually pan the map away from the geolocation position which should trigger the 'background' event above + map.jumpTo({ + center: [10, 5] + }); + }); + // click the button to activate it into the enabled watch state + geolocate._geolocateButton.dispatchEvent(click); + // send through a location update which should reposition the map and trigger the 'moveend' event above + geolocation.send({latitude: 10, longitude: 20, accuracy: 30}); + }); +}); + +test('GeolocateControl watching map background state', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl({ + trackUserLocation: true, + fitBoundsOptions: { + linear: true, + duration: 0 + } + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + // when the geolocate control is ready + geolocate.once('ready', () => { + map.once('moveend', () => { + map.once('moveend', () => { + geolocate.once('geolocate', () => { + t.deepEquals(map.getCenter(), {lng: 10, lat: 5}, 'camera not changed after geolocation update in background state'); + t.end(); + }); + // update the geolocation position, since we are in background state when 'geolocate' is triggered above, the camera shouldn't have changed + geolocation.change({latitude: 0, longitude: 0, accuracy: 10}); + }); + + // manually pan the map away from the geolocation position which should trigger the 'moveend' event above + map.jumpTo({ + center: [10, 5] + }); + }); + // click the button to activate it into the enabled watch state + geolocate._geolocateButton.dispatchEvent(click); + // send through a location update which should reposition the map and trigger the 'moveend' event above + geolocation.send({latitude: 10, longitude: 20, accuracy: 30}); + }); +}); + +test('GeolocateControl activeLock event', (t) => { + const map = createMap(); + const geolocate = new GeolocateControl({ + trackUserLocation: true, + fitBoundsOptions: { + linear: true, + duration: 0 + } + }); + map.addControl(geolocate); + + const click = new window.Event('click'); + + geolocate.once('ready', () => { + geolocate.once('activeLock', () => { + geolocate.once('background', () => { + geolocate.once('activeLock', () => { + t.end(); + }); + // click the geolocate control button again which should transition back to active_lock state + geolocate._geolocateButton.dispatchEvent(click); + }); + + // manually pan the map away from the geolocation position which should trigger the 'background' event above + map.jumpTo({ + center: [10, 5] + }); + }); + geolocate._geolocateButton.dispatchEvent(click); + geolocation.send({latitude: 10, longitude: 20, accuracy: 30, timestamp: 40}); + }); +});