Spaces:
Sleeping
Sleeping
| // @flow | |
| import assert from 'assert'; | |
| import DOM from '../../util/dom'; | |
| import {ease as _ease, bindAll, bezier} from '../../util/util'; | |
| import browser from '../../util/browser'; | |
| import window from '../../util/window'; | |
| import {number as interpolate} from '../../style-spec/util/interpolate'; | |
| import LngLat from '../../geo/lng_lat'; | |
| import type Map from '../map'; | |
| import type HandlerManager from '../handler_manager'; | |
| import type Point from '@mapbox/point-geometry'; | |
| // deltaY value for mouse scroll wheel identification | |
| const wheelZoomDelta = 4.000244140625; | |
| // These magic numbers control the rate of zoom. Trackpad events fire at a greater | |
| // frequency than mouse scroll wheel, so reduce the zoom rate per wheel tick | |
| const defaultZoomRate = 1 / 100; | |
| const wheelZoomRate = 1 / 450; | |
| // upper bound on how much we scale the map in any single render frame; this | |
| // is used to limit zoom rate in the case of very fast scrolling | |
| const maxScalePerFrame = 2; | |
| /** | |
| * The `ScrollZoomHandler` allows the user to zoom the map by scrolling. | |
| */ | |
| class ScrollZoomHandler { | |
| _map: Map; | |
| _el: HTMLElement; | |
| _enabled: boolean; | |
| _active: boolean; | |
| _zooming: boolean; | |
| _aroundCenter: boolean; | |
| _around: Point; | |
| _aroundPoint: Point; | |
| _type: 'wheel' | 'trackpad' | null; | |
| _lastValue: number; | |
| _timeout: ?TimeoutID; // used for delayed-handling of a single wheel movement | |
| _finishTimeout: ?TimeoutID; // used to delay final '{move,zoom}end' events | |
| _lastWheelEvent: any; | |
| _lastWheelEventTime: number; | |
| _startZoom: ?number; | |
| _targetZoom: ?number; | |
| _delta: number; | |
| _easing: ?((number) => number); | |
| _prevEase: ?{start: number, duration: number, easing: (_: number) => number}; | |
| _frameId: ?boolean; | |
| _handler: HandlerManager; | |
| _defaultZoomRate: number; | |
| _wheelZoomRate: number; | |
| /** | |
| * @private | |
| */ | |
| constructor(map: Map, handler: HandlerManager) { | |
| this._map = map; | |
| this._el = map.getCanvasContainer(); | |
| this._handler = handler; | |
| this._delta = 0; | |
| this._defaultZoomRate = defaultZoomRate; | |
| this._wheelZoomRate = wheelZoomRate; | |
| bindAll(['_onTimeout'], this); | |
| } | |
| /** | |
| * Set the zoom rate of a trackpad | |
| * @param {number} [zoomRate=1/100] The rate used to scale trackpad movement to a zoom value. | |
| * @example | |
| * // Speed up trackpad zoom | |
| * map.scrollZoom.setZoomRate(1/25); | |
| */ | |
| setZoomRate(zoomRate: number) { | |
| this._defaultZoomRate = zoomRate; | |
| } | |
| /** | |
| * Set the zoom rate of a mouse wheel | |
| * @param {number} [wheelZoomRate=1/450] The rate used to scale mouse wheel movement to a zoom value. | |
| * @example | |
| * // Slow down zoom of mouse wheel | |
| * map.scrollZoom.setWheelZoomRate(1/600); | |
| */ | |
| setWheelZoomRate(wheelZoomRate: number) { | |
| this._wheelZoomRate = wheelZoomRate; | |
| } | |
| /** | |
| * Returns a Boolean indicating whether the "scroll to zoom" interaction is enabled. | |
| * | |
| * @returns {boolean} `true` if the "scroll to zoom" interaction is enabled. | |
| */ | |
| isEnabled() { | |
| return !!this._enabled; | |
| } | |
| /* | |
| * Active state is turned on and off with every scroll wheel event and is set back to false before the map | |
| * render is called, so _active is not a good candidate for determining if a scroll zoom animation is in | |
| * progress. | |
| */ | |
| isActive() { | |
| return !!this._active || this._finishTimeout !== undefined; | |
| } | |
| isZooming() { | |
| return !!this._zooming; | |
| } | |
| /** | |
| * Enables the "scroll to zoom" interaction. | |
| * | |
| * @param {Object} [options] Options object. | |
| * @param {string} [options.around] If "center" is passed, map will zoom around center of map | |
| * | |
| * @example | |
| * map.scrollZoom.enable(); | |
| * @example | |
| * map.scrollZoom.enable({ around: 'center' }) | |
| */ | |
| enable(options: any) { | |
| if (this.isEnabled()) return; | |
| this._enabled = true; | |
| this._aroundCenter = options && options.around === 'center'; | |
| } | |
| /** | |
| * Disables the "scroll to zoom" interaction. | |
| * | |
| * @example | |
| * map.scrollZoom.disable(); | |
| */ | |
| disable() { | |
| if (!this.isEnabled()) return; | |
| this._enabled = false; | |
| } | |
| wheel(e: WheelEvent) { | |
| if (!this.isEnabled()) return; | |
| // Remove `any` cast when https://github.com/facebook/flow/issues/4879 is fixed. | |
| let value = e.deltaMode === (window.WheelEvent: any).DOM_DELTA_LINE ? e.deltaY * 40 : e.deltaY; | |
| const now = browser.now(), | |
| timeDelta = now - (this._lastWheelEventTime || 0); | |
| this._lastWheelEventTime = now; | |
| if (value !== 0 && (value % wheelZoomDelta) === 0) { | |
| // This one is definitely a mouse wheel event. | |
| this._type = 'wheel'; | |
| } else if (value !== 0 && Math.abs(value) < 4) { | |
| // This one is definitely a trackpad event because it is so small. | |
| this._type = 'trackpad'; | |
| } else if (timeDelta > 400) { | |
| // This is likely a new scroll action. | |
| this._type = null; | |
| this._lastValue = value; | |
| // Start a timeout in case this was a singular event, and dely it by up to 40ms. | |
| this._timeout = setTimeout(this._onTimeout, 40, e); | |
| } else if (!this._type) { | |
| // This is a repeating event, but we don't know the type of event just yet. | |
| // If the delta per time is small, we assume it's a fast trackpad; otherwise we switch into wheel mode. | |
| this._type = (Math.abs(timeDelta * value) < 200) ? 'trackpad' : 'wheel'; | |
| // Make sure our delayed event isn't fired again, because we accumulate | |
| // the previous event (which was less than 40ms ago) into this event. | |
| if (this._timeout) { | |
| clearTimeout(this._timeout); | |
| this._timeout = null; | |
| value += this._lastValue; | |
| } | |
| } | |
| // Slow down zoom if shift key is held for more precise zooming | |
| if (e.shiftKey && value) value = value / 4; | |
| // Only fire the callback if we actually know what type of scrolling device the user uses. | |
| if (this._type) { | |
| this._lastWheelEvent = e; | |
| this._delta -= value; | |
| if (!this._active) { | |
| this._start(e); | |
| } | |
| } | |
| e.preventDefault(); | |
| } | |
| _onTimeout(initialEvent: any) { | |
| this._type = 'wheel'; | |
| this._delta -= this._lastValue; | |
| if (!this._active) { | |
| this._start(initialEvent); | |
| } | |
| } | |
| _start(e: any) { | |
| if (!this._delta) return; | |
| if (this._frameId) { | |
| this._frameId = null; | |
| } | |
| this._active = true; | |
| if (!this.isZooming()) { | |
| this._zooming = true; | |
| } | |
| if (this._finishTimeout) { | |
| clearTimeout(this._finishTimeout); | |
| delete this._finishTimeout; | |
| } | |
| const pos = DOM.mousePos(this._el, e); | |
| this._around = LngLat.convert(this._aroundCenter ? this._map.getCenter() : this._map.unproject(pos)); | |
| this._aroundPoint = this._map.transform.locationPoint(this._around); | |
| if (!this._frameId) { | |
| this._frameId = true; | |
| this._handler._triggerRenderFrame(); | |
| } | |
| } | |
| renderFrame() { | |
| if (!this._frameId) return; | |
| this._frameId = null; | |
| if (!this.isActive()) return; | |
| const tr = this._map.transform; | |
| // if we've had scroll events since the last render frame, consume the | |
| // accumulated delta, and update the target zoom level accordingly | |
| if (this._delta !== 0) { | |
| // For trackpad events and single mouse wheel ticks, use the default zoom rate | |
| const zoomRate = (this._type === 'wheel' && Math.abs(this._delta) > wheelZoomDelta) ? this._wheelZoomRate : this._defaultZoomRate; | |
| // Scale by sigmoid of scroll wheel delta. | |
| let scale = maxScalePerFrame / (1 + Math.exp(-Math.abs(this._delta * zoomRate))); | |
| if (this._delta < 0 && scale !== 0) { | |
| scale = 1 / scale; | |
| } | |
| const fromScale = typeof this._targetZoom === 'number' ? tr.zoomScale(this._targetZoom) : tr.scale; | |
| this._targetZoom = Math.min(tr.maxZoom, Math.max(tr.minZoom, tr.scaleZoom(fromScale * scale))); | |
| // if this is a mouse wheel, refresh the starting zoom and easing | |
| // function we're using to smooth out the zooming between wheel | |
| // events | |
| if (this._type === 'wheel') { | |
| this._startZoom = tr.zoom; | |
| this._easing = this._smoothOutEasing(200); | |
| } | |
| this._delta = 0; | |
| } | |
| const targetZoom = typeof this._targetZoom === 'number' ? | |
| this._targetZoom : tr.zoom; | |
| const startZoom = this._startZoom; | |
| const easing = this._easing; | |
| let finished = false; | |
| let zoom; | |
| if (this._type === 'wheel' && startZoom && easing) { | |
| assert(easing && typeof startZoom === 'number'); | |
| const t = Math.min((browser.now() - this._lastWheelEventTime) / 200, 1); | |
| const k = easing(t); | |
| zoom = interpolate(startZoom, targetZoom, k); | |
| if (t < 1) { | |
| if (!this._frameId) { | |
| this._frameId = true; | |
| } | |
| } else { | |
| finished = true; | |
| } | |
| } else { | |
| zoom = targetZoom; | |
| finished = true; | |
| } | |
| this._active = true; | |
| if (finished) { | |
| this._active = false; | |
| this._finishTimeout = setTimeout(() => { | |
| this._zooming = false; | |
| this._handler._triggerRenderFrame(); | |
| delete this._targetZoom; | |
| delete this._finishTimeout; | |
| }, 200); | |
| } | |
| return { | |
| noInertia: true, | |
| needsRenderFrame: !finished, | |
| zoomDelta: zoom - tr.zoom, | |
| around: this._aroundPoint, | |
| originalEvent: this._lastWheelEvent | |
| }; | |
| } | |
| _smoothOutEasing(duration: number) { | |
| let easing = _ease; | |
| if (this._prevEase) { | |
| const ease = this._prevEase, | |
| t = (browser.now() - ease.start) / ease.duration, | |
| speed = ease.easing(t + 0.01) - ease.easing(t), | |
| // Quick hack to make new bezier that is continuous with last | |
| x = 0.27 / Math.sqrt(speed * speed + 0.0001) * 0.01, | |
| y = Math.sqrt(0.27 * 0.27 - x * x); | |
| easing = bezier(x, y, 0.25, 1); | |
| } | |
| this._prevEase = { | |
| start: browser.now(), | |
| duration, | |
| easing | |
| }; | |
| return easing; | |
| } | |
| blur() { | |
| this.reset(); | |
| } | |
| reset() { | |
| this._active = false; | |
| } | |
| } | |
| export default ScrollZoomHandler; | |