Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add geolocate bearing #10817

Merged
merged 21 commits into from
Jul 8, 2021
1 change: 1 addition & 0 deletions debug/debug.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
},
trackUserLocation: true,
showUserLocation: true,
showUserHeading: true,
fitBoundsOptions: {
maxZoom: 20
}
Expand Down
25 changes: 25 additions & 0 deletions src/css/mapbox-gl.css
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,31 @@ a.mapboxgl-ctrl-logo.mapboxgl-compact {
box-shadow: 0 0 3px rgba(0, 0, 0, 0.35);
}

.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading {
width: 0;
height: 0;
}

.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading::before {
content: "";
width: 1;
height: 1;
border-left: 7.5px solid transparent;
border-bottom: 7.5px solid #4aa1eb;
transform: translate(0, -28px) skewY(-20deg);
position: absolute;
}

.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading::after {
content: "";
width: 1;
height: 1;
border-right: 7.5px solid transparent;
border-bottom: 7.5px solid #4aa1eb;
transform: translate(7.5px, -28px) skewY(20deg);
tsuz marked this conversation as resolved.
Show resolved Hide resolved
position: absolute;
}

@keyframes mapboxgl-user-location-dot-pulse {
0% { transform: scale(1); opacity: 1; }
70% { transform: scale(3); opacity: 0; }
Expand Down
78 changes: 70 additions & 8 deletions src/ui/control/geolocate_control.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import DOM from '../../util/dom.js';
import window from '../../util/window.js';
import {extend, bindAll, warnOnce} from '../../util/util.js';
import assert from 'assert';
import LngLat from '../../geo/lng_lat.js';
import Marker from '../marker.js';
import LngLat from '../../geo/lng_lat.js';
import throttle from '../../util/throttle.js';

import type Map from '../map.js';
import type {AnimationOptions, CameraOptions} from '../camera.js';
Expand All @@ -16,9 +17,17 @@ type Options = {
fitBoundsOptions?: AnimationOptions & CameraOptions,
trackUserLocation?: boolean,
showAccuracyCircle?: boolean,
showUserLocation?: boolean
showUserLocation?: boolean,
showUserHeading?: boolean
};

type DeviceOrientationEvent = {
absolute: Boolean,
alpha: number,
beta: number,
gamma: number
}

const defaultOptions: Options = {
positionOptions: {
enableHighAccuracy: false,
Expand All @@ -30,7 +39,8 @@ const defaultOptions: Options = {
},
trackUserLocation: false,
showAccuracyCircle: true,
showUserLocation: true
showUserLocation: true,
showUserHeading: false
};

let supportsGeolocation;
Expand Down Expand Up @@ -85,13 +95,15 @@ let noTimeout = false;
* @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.
* @param {Object} [options.showUserHeading=false] If `true` an arrow will be drawn next to the user location dot indicating the device's heading. This only has affect when `trackUserLocation` is `true`.
*
* @example
* map.addControl(new mapboxgl.GeolocateControl({
* positionOptions: {
* enableHighAccuracy: true
* },
* trackUserLocation: true
* trackUserLocation: true,
* showUserHeading: true
* }));
* @see [Locate the user](https://www.mapbox.com/mapbox-gl-js/example/locate-user/)
*/
Expand All @@ -110,6 +122,8 @@ class GeolocateControl extends Evented {
_accuracyCircleMarker: Marker;
_accuracy: number;
_setup: boolean; // set to true once the control has been setup
_heading: ?number;
_updateMarkerRotationThrottled: Function;

constructor(options: Options) {
super();
Expand All @@ -122,8 +136,11 @@ class GeolocateControl extends Evented {
'_finish',
'_setupUI',
'_updateCamera',
'_updateMarker'
'_updateMarker',
'_updateMarkerRotation'
], this);

this._updateMarkerRotationThrottled = throttle(this._updateMarkerRotation, 20);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the deviceorientation event called much more often than 20ms to necessitate throttling? Just curious.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mourner The motion sensor frequency can be uncapped due to this bug https://w3c.github.io/deviceorientation/spec-source-orientation.html#security-and-privacy

Some say it's suggested to 60 Hz (equalling once every 16.6ms) but I couldn't find the source of this suggestion.

}

onAdd(map: Map) {
Expand Down Expand Up @@ -325,6 +342,21 @@ class GeolocateControl extends Evented {
}
}

/**
* Update the user location dot Marker rotation to the current heading
*
* @private
*/
_updateMarkerRotation() {
if (this._userLocationDotMarker && typeof this._heading === 'number') {
this._userLocationDotMarker.setRotation(this._heading);
this._dotElement.classList.add('mapboxgl-user-location-show-heading');
} else {
this._dotElement.classList.remove('mapboxgl-user-location-show-heading');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move manipulating classList in the update method from here to the trigger method which hosts all other lines like this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mourner I thought about that. The trigger would add an event listener for devicrorientation but it should not show the heading until the information is available in _updateMarkerRotation.

this._userLocationDotMarker.setRotation(0);
}
}

_onError(error: PositionError) {
if (!this._map) {
// control has since been removed
Expand Down Expand Up @@ -398,9 +430,15 @@ class GeolocateControl extends Evented {

// when showUserLocation is enabled, keep the Geolocate button disabled until the device location marker is setup on the map
if (this.options.showUserLocation) {
this._dotElement = DOM.create('div', 'mapboxgl-user-location-dot');

this._userLocationDotMarker = new Marker(this._dotElement);
this._dotElement = DOM.create('div', 'mapboxgl-user-location');
this._dotElement.appendChild(DOM.create('div', 'mapboxgl-user-location-dot'));
this._dotElement.appendChild(DOM.create('div', 'mapboxgl-user-location-heading'));

this._userLocationDotMarker = new Marker({
element: this._dotElement,
rotationAlignment: 'map',
pitchAlignment: 'map'
});

this._circleElement = DOM.create('div', 'mapboxgl-user-location-accuracy-circle');
this._accuracyCircleMarker = new Marker({element: this._circleElement, pitchAlignment: 'map'});
Expand Down Expand Up @@ -448,7 +486,24 @@ class GeolocateControl extends Evented {
* map.on('load', function() {
* geolocate.trigger();
* });
* Called on a deviceorientationabsolute or deviceorientation event.
*
* @param deviceOrientationEvent {DeviceOrientationEvent}
* @private
*/
_onDeviceOrientation(deviceOrientationEvent: DeviceOrientationEvent) {
if (this._userLocationDotMarker) {
// alpha increases counter clockwise around the z axis
this._heading = deviceOrientationEvent.alpha * -1;
this._updateMarkerRotationThrottled();
}
}

/**
* Trigger a geolocation
*
* @returns {boolean} Returns `false` if called before control was added to a map, otherwise returns `true`.
*/
trigger() {
if (!this._setup) {
warnOnce('Geolocate control triggered before added to a map');
Expand Down Expand Up @@ -539,6 +594,10 @@ class GeolocateControl extends Evented {

this._geolocationWatchID = window.navigator.geolocation.watchPosition(
this._onSuccess, this._onError, positionOptions);

if (this.options.showUserHeading) {
window.addEventListener('deviceorientation', this._onDeviceOrientation.bind(this));
}
}
} else {
window.navigator.geolocation.getCurrentPosition(
Expand All @@ -555,6 +614,9 @@ class GeolocateControl extends Evented {
_clearWatch() {
window.navigator.geolocation.clearWatch(this._geolocationWatchID);

window.removeEventListener('deviceorientationabsolute', this._onDeviceOrientation);
tsuz marked this conversation as resolved.
Show resolved Hide resolved
window.removeEventListener('deviceorientation', this._onDeviceOrientation);

this._geolocationWatchID = (undefined: any);
this._geolocateButton.classList.remove('mapboxgl-ctrl-geolocate-waiting');
this._geolocateButton.setAttribute('aria-pressed', 'false');
Expand Down
57 changes: 57 additions & 0 deletions test/unit/ui/control/geolocate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,60 @@ test('GeolocateControl shown even if trackUserLocation = false', (t) => {
geolocate._geolocateButton.dispatchEvent(click);
geolocation.send({latitude: 10, longitude: 20, accuracy: 700});
});

test('GeolocateControl watching device orientation event', (t) => {
t.plan(7);
const map = createMap(t);
const geolocate = new GeolocateControl({
fitBoundsOptions: {
linear: true,
duration: 0
},
showUserHeading: true,
showUserLocation: true,
trackUserLocation: true,
});
map.addControl(geolocate);

const click = new window.Event('click');

// since DeviceOrientationEvent is not supported: https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent
const deviceOrientationEventLike = (alpha) => {
const instance = new window.Event('deviceorientation');
instance.alpha = alpha;
return instance;
};

t.notOk(geolocate._dotElement.classList.contains('mapboxgl-user-location-show-heading'), 'userLocation should not have heading');

let moveendCount = 0;
map.once('moveend', () => {
// moveend was being called a second time, this ensures that we don't run the tests a second time
if (moveendCount > 0) return;
moveendCount++;
t.same(lngLatAsFixed(map.getCenter(), 4), {lat: 10, lng: 20}, 'map centered on location after 1st update');
t.ok(geolocate._userLocationDotMarker._map, 'userLocation dot marker on map');
t.notOk(geolocate._userLocationDotMarker._element.classList.contains('mapboxgl-user-location-dot-stale'), 'userLocation does not have stale class');
geolocate.once('trackuserlocationend', () => {
const event = deviceOrientationEventLike(-359);
window.dispatchEvent(event);
setImmediate(() => {
t.ok(geolocate._dotElement.classList.contains('mapboxgl-user-location-show-heading'), 'userLocation should have heading');
t.equal(geolocate._userLocationDotMarker._rotation, 359, 'userLocation rotation is not rotated by 359 degrees');

const event = deviceOrientationEventLike(-15);
window.dispatchEvent(event);
setImmediate(() => {
t.equal(geolocate._userLocationDotMarker._rotation, 15, 'userLocation rotation is not rotated by 15 degrees');
t.end();
});
});
});
// manually pan the map away from the geolocation position which should trigger the 'trackuserlocationend' event above
map.jumpTo({
center: [20, 10]
});
});
geolocate._geolocateButton.dispatchEvent(click);
geolocation.send({latitude: 10, longitude: 20, accuracy: 30});
});