UNPKG

mapbox-gl

Version:
417 lines (364 loc) 13.9 kB
// @flow import DOM from '../../util/dom'; import {bezier, bindAll, extend} from '../../util/util'; import window from '../../util/window'; import browser from '../../util/browser'; import {Event} from '../../util/evented'; import assert from 'assert'; import type Map from '../map'; import type Point from '@mapbox/point-geometry'; import type {TaskID} from '../../util/task_queue'; const defaultInertia = { linearity: 0.3, easing: bezier(0, 0, 0.3, 1), maxSpeed: 1400, deceleration: 2500, }; export type PanInertiaOptions = typeof defaultInertia; export type DragPanOptions = boolean | PanInertiaOptions; /** * The `DragPanHandler` allows the user to pan the map by clicking and dragging * the cursor. */ class DragPanHandler { _map: Map; _el: HTMLElement; _state: 'disabled' | 'enabled' | 'pending' | 'active'; _startPos: Point; _mouseDownPos: Point; _prevPos: Point; _lastPos: Point; _startTouch: ?Array<Point>; _lastTouch: ?Array<Point>; _lastMoveEvent: MouseEvent | TouchEvent | void; _inertia: Array<[number, Point]>; _frameId: ?TaskID; _clickTolerance: number; _shouldStart: ?boolean; _inertiaOptions: PanInertiaOptions; /** * @private */ constructor(map: Map, options: { clickTolerance?: number }) { this._map = map; this._el = map.getCanvasContainer(); this._state = 'disabled'; this._clickTolerance = options.clickTolerance || 1; this._inertiaOptions = defaultInertia; bindAll([ '_onMove', '_onMouseUp', '_onTouchEnd', '_onBlur', '_onDragFrame' ], this); } /** * Returns a Boolean indicating whether the "drag to pan" interaction is enabled. * * @returns {boolean} `true` if the "drag to pan" interaction is enabled. */ isEnabled() { return this._state !== 'disabled'; } /** * Returns a Boolean indicating whether the "drag to pan" interaction is active, i.e. currently being used. * * @returns {boolean} `true` if the "drag to pan" interaction is active. */ isActive() { return this._state === 'active'; } /** * Enables the "drag to pan" interaction. * * @param {Object} [options] * @param {number} [options.linearity=0] factor used to scale the drag velocity * @param {Function} [options.easing=bezier(0, 0, 0.3, 1)] easing function applled to `map.panTo` when applying the drag. * @param {number} [options.maxSpeed=1400] the maximum value of the drag velocity. * @param {number} [options.deceleration=2500] the rate at which the speed reduces after the pan ends. * * @example * map.dragPan.enable(); * @example * map.dragpan.enable({ * linearity: 0.3, * easing: bezier(0, 0, 0.3, 1), * maxSpeed: 1400, * deceleration: 2500, * }); */ enable(options: DragPanOptions) { if (this.isEnabled()) return; this._el.classList.add('mapboxgl-touch-drag-pan'); this._state = 'enabled'; this._inertiaOptions = extend(defaultInertia, options); } /** * Disables the "drag to pan" interaction. * * @example * map.dragPan.disable(); */ disable() { if (!this.isEnabled()) return; this._el.classList.remove('mapboxgl-touch-drag-pan'); switch (this._state) { case 'active': this._state = 'disabled'; this._unbind(); this._deactivate(); this._fireEvent('dragend'); this._fireEvent('moveend'); break; case 'pending': this._state = 'disabled'; this._unbind(); break; default: this._state = 'disabled'; break; } } onMouseDown(e: MouseEvent) { if (this._state !== 'enabled') return; if (e.ctrlKey || DOM.mouseButton(e) !== 0) return; // Bind window-level event listeners for mousemove/up events. In the absence of // the pointer capture API, which is not supported by all necessary platforms, // window-level event listeners give us the best shot at capturing events that // fall outside the map canvas element. Use `{capture: true}` for the move event // to prevent map move events from being fired during a drag. DOM.addEventListener(window.document, 'mousemove', this._onMove, {capture: true}); DOM.addEventListener(window.document, 'mouseup', this._onMouseUp); this._start(e); } onTouchStart(e: TouchEvent) { if (!this.isEnabled()) return; if (e.touches && e.touches.length > 1) { // multi-finger touch // If we are already dragging (e.g. with one finger) and add another finger, // keep the handler active but don't attempt to ._start() again if (this._state === 'pending' || this._state === 'active') return; } // Bind window-level event listeners for touchmove/end events. In the absence of // the pointer capture API, which is not supported by all necessary platforms, // window-level event listeners give us the best shot at capturing events that // fall outside the map canvas element. Use `{capture: true}` for the move event // to prevent map move events from being fired during a drag. DOM.addEventListener(window.document, 'touchmove', this._onMove, {capture: true, passive: false}); DOM.addEventListener(window.document, 'touchend', this._onTouchEnd); this._start(e); } _start(e: MouseEvent | TouchEvent) { // Deactivate when the window loses focus. Otherwise if a mouseup occurs when the window // isn't in focus, dragging will continue even though the mouse is no longer pressed. window.addEventListener('blur', this._onBlur); this._state = 'pending'; this._startPos = this._mouseDownPos = this._prevPos = this._lastPos = DOM.mousePos(this._el, e); this._startTouch = this._lastTouch = (window.TouchEvent && e instanceof window.TouchEvent) ? DOM.touchPos(this._el, e) : null; this._inertia = [[browser.now(), this._startPos]]; } _touchesMatch(lastTouch: ?Array<Point>, thisTouch: ?Array<Point>) { if (!lastTouch || !thisTouch || lastTouch.length !== thisTouch.length) return false; return lastTouch.every((pos, i) => thisTouch[i] === pos); } _onMove(e: MouseEvent | TouchEvent) { e.preventDefault(); const touchPos = (window.TouchEvent && e instanceof window.TouchEvent) ? DOM.touchPos(this._el, e) : null; const pos = DOM.mousePos(this._el, e); const matchesLastPos = touchPos ? this._touchesMatch(this._lastTouch, touchPos) : this._lastPos.equals(pos); if (matchesLastPos || (this._state === 'pending' && pos.dist(this._mouseDownPos) < this._clickTolerance)) { return; } this._lastMoveEvent = e; this._lastPos = pos; this._lastTouch = touchPos; this._drainInertiaBuffer(); this._inertia.push([browser.now(), this._lastPos]); if (this._state === 'pending') { this._state = 'active'; this._shouldStart = true; } if (!this._frameId) { this._frameId = this._map._requestRenderFrame(this._onDragFrame); } } /** * Called in each render frame while dragging is happening. * @private */ _onDragFrame() { this._frameId = null; const e = this._lastMoveEvent; if (!e) return; if (this._map.touchZoomRotate.isActive()) { this._abort(e); return; } if (this._shouldStart) { // we treat the first drag frame (rather than the mousedown event) // as the start of the drag this._fireEvent('dragstart', e); this._fireEvent('movestart', e); this._shouldStart = false; } if (!this.isActive()) return; // It's possible for the dragstart event to trigger a disable() call (#2419) so we must account for that const tr = this._map.transform; tr.setLocationAtPoint(tr.pointLocation(this._prevPos), this._lastPos); this._fireEvent('drag', e); this._fireEvent('move', e); this._prevPos = this._lastPos; delete this._lastMoveEvent; } _onMouseUp(e: MouseEvent) { if (DOM.mouseButton(e) !== 0) return; switch (this._state) { case 'active': this._state = 'enabled'; DOM.suppressClick(); this._unbind(); this._deactivate(); this._inertialPan(e); break; case 'pending': this._state = 'enabled'; this._unbind(); break; default: assert(false); break; } } _onTouchEnd(e: TouchEvent) { if (!e.touches || e.touches.length === 0) { // only stop drag if all fingers have been removed switch (this._state) { case 'active': this._state = 'enabled'; this._unbind(); this._deactivate(); this._inertialPan(e); break; case 'pending': this._state = 'enabled'; this._unbind(); break; case 'enabled': this._unbind(); break; default: assert(false); break; } } else { // some finger(s) still touching the screen switch (this._state) { case 'pending': case 'active': // we are already dragging; continue break; case 'enabled': // not currently dragging; get ready to start a new drag this.onTouchStart(e); break; default: assert(false); break; } } } _abort(e: FocusEvent | MouseEvent | TouchEvent) { switch (this._state) { case 'active': this._state = 'enabled'; if (!this._shouldStart) { // If we scheduled the dragstart but never fired, nothing to end // We already started the drag, end it this._fireEvent('dragend', e); this._fireEvent('moveend', e); } this._unbind(); this._deactivate(); if ((window.TouchEvent && e instanceof window.TouchEvent) && e.touches.length > 1) { // If there are multiple fingers touching, reattach touchend listener in case // all but one finger is removed and we need to restart a drag on touchend DOM.addEventListener(window.document, 'touchend', this._onTouchEnd); } break; case 'pending': this._state = 'enabled'; this._unbind(); break; case 'enabled': this._unbind(); break; default: assert(false); break; } } _onBlur(e: FocusEvent) { this._abort(e); } _unbind() { DOM.removeEventListener(window.document, 'touchmove', this._onMove, {capture: true, passive: false}); DOM.removeEventListener(window.document, 'touchend', this._onTouchEnd); DOM.removeEventListener(window.document, 'mousemove', this._onMove, {capture: true}); DOM.removeEventListener(window.document, 'mouseup', this._onMouseUp); DOM.removeEventListener(window, 'blur', this._onBlur); } _deactivate() { if (this._frameId) { this._map._cancelRenderFrame(this._frameId); this._frameId = null; } delete this._lastMoveEvent; delete this._startPos; delete this._prevPos; delete this._mouseDownPos; delete this._lastPos; delete this._startTouch; delete this._lastTouch; delete this._shouldStart; } _inertialPan(e: MouseEvent | TouchEvent) { this._fireEvent('dragend', e); this._drainInertiaBuffer(); const inertia = this._inertia; if (inertia.length < 2) { this._fireEvent('moveend', e); return; } const last = inertia[inertia.length - 1], first = inertia[0], flingOffset = last[1].sub(first[1]), flingDuration = (last[0] - first[0]) / 1000; if (flingDuration === 0 || last[1].equals(first[1])) { this._fireEvent('moveend', e); return; } const {linearity, easing, maxSpeed, deceleration} = this._inertiaOptions; // calculate px/s velocity & adjust for increased initial animation speed when easing out const velocity = flingOffset.mult(linearity / flingDuration); let speed = velocity.mag(); // px/s if (speed > maxSpeed) { speed = maxSpeed; velocity._unit()._mult(speed); } const duration = speed / (deceleration * linearity), offset = velocity.mult(-duration / 2); this._map.panBy(offset, { duration: duration * 1000, easing, noMoveStart: true }, {originalEvent: e}); } _fireEvent(type: string, e: *) { return this._map.fire(new Event(type, e ? {originalEvent: e} : {})); } _drainInertiaBuffer() { const inertia = this._inertia, now = browser.now(), cutoff = 160; // msec while (inertia.length > 0 && now - inertia[0][0] > cutoff) inertia.shift(); } } export default DragPanHandler;