import Point from '@mapbox/point-geometry'; import {DOM} from '../../util/dom'; import type {Map} from '../map'; import {Handler, HandlerResult} from '../handler_manager'; /** * An options object sent to the enable function of some of the handlers */ export type AroundCenterOptions = { /** * If "center" is passed, map will zoom around the center of map */ around: 'center'; } /** * The `TwoFingersTouchHandler`s allows the user to zoom, pitch and rotate the map using two fingers * */ abstract class TwoFingersTouchHandler implements Handler { _enabled?: boolean; _active?: boolean; _firstTwoTouches?: [number, number]; _vector?: Point; _startVector?: Point; _aroundCenter?: boolean; /** @internal */ constructor() { this.reset(); } reset(): void { this._active = false; delete this._firstTwoTouches; } abstract _start(points: [Point, Point]): void; abstract _move(points: [Point, Point], pinchAround: Point | null, e: TouchEvent): HandlerResult | void; touchstart(e: TouchEvent, points: Array, mapTouches: Array): void { //log('touchstart', points, e.target.innerHTML, e.targetTouches.length ? e.targetTouches[0].target.innerHTML: undefined); if (this._firstTwoTouches || mapTouches.length < 2) return; this._firstTwoTouches = [ mapTouches[0].identifier, mapTouches[1].identifier ]; // implemented by child classes this._start([points[0], points[1]]); } touchmove(e: TouchEvent, points: Array, mapTouches: Array): HandlerResult | void { if (!this._firstTwoTouches) return; e.preventDefault(); const [idA, idB] = this._firstTwoTouches; const a = getTouchById(mapTouches, points, idA); const b = getTouchById(mapTouches, points, idB); if (!a || !b) return; const pinchAround = this._aroundCenter ? null : a.add(b).div(2); // implemented by child classes return this._move([a, b], pinchAround, e); } touchend(e: TouchEvent, points: Array, mapTouches: Array): void { if (!this._firstTwoTouches) return; const [idA, idB] = this._firstTwoTouches; const a = getTouchById(mapTouches, points, idA); const b = getTouchById(mapTouches, points, idB); if (a && b) return; if (this._active) DOM.suppressClick(); this.reset(); } touchcancel(): void { this.reset(); } /** * Enables the "drag to pitch" interaction. * * @example * ```ts * map.touchPitch.enable(); * ``` */ enable(options?: AroundCenterOptions | boolean | null): void { this._enabled = true; this._aroundCenter = !!options && (options as AroundCenterOptions).around === 'center'; } /** * Disables the "drag to pitch" interaction. * * @example * ```ts * map.touchPitch.disable(); * ``` */ disable(): void { this._enabled = false; this.reset(); } /** * Returns a Boolean indicating whether the "drag to pitch" interaction is enabled. * * @returns `true` if the "drag to pitch" interaction is enabled. */ isEnabled(): boolean { return !!this._enabled; } /** * Returns a Boolean indicating whether the "drag to pitch" interaction is active, i.e. currently being used. * * @returns `true` if the "drag to pitch" interaction is active. */ isActive(): boolean { return !!this._active; } } function getTouchById(mapTouches: Array, points: Array, identifier: number): Point | undefined { for (let i = 0; i < mapTouches.length; i++) { if (mapTouches[i].identifier === identifier) return points[i]; } return undefined; } /* ZOOM */ const ZOOM_THRESHOLD = 0.1; function getZoomDelta(distance: number, lastDistance: number): number { return Math.log(distance / lastDistance) / Math.LN2; } /** * The `TwoFingersTouchHandler`s allows the user to zoom the map two fingers * * @group Handlers */ export class TwoFingersTouchZoomHandler extends TwoFingersTouchHandler { _distance?: number; _startDistance?: number; reset() { super.reset(); delete this._distance; delete this._startDistance; } _start(points: [Point, Point]): void { this._startDistance = this._distance = points[0].dist(points[1]); } _move(points: [Point, Point], pinchAround: Point | null): HandlerResult | void { const lastDistance = this._distance!; this._distance = points[0].dist(points[1]); if (!this._active && Math.abs(getZoomDelta(this._distance, this._startDistance!)) < ZOOM_THRESHOLD) return; this._active = true; return { zoomDelta: getZoomDelta(this._distance, lastDistance), pinchAround }; } } /* ROTATE */ const ROTATION_THRESHOLD = 25; // pixels along circumference of touch circle function getBearingDelta(a: Point, b: Point): number { return a.angleWith(b) * 180 / Math.PI; } /** * The `TwoFingersTouchHandler`s allows the user to rotate the map two fingers * * @group Handlers */ export class TwoFingersTouchRotateHandler extends TwoFingersTouchHandler { _minDiameter?: number; reset(): void { super.reset(); delete this._minDiameter; delete this._startVector; delete this._vector; } _start(points: [Point, Point]): void { this._startVector = this._vector = points[0].sub(points[1]); this._minDiameter = points[0].dist(points[1]); } _move(points: [Point, Point], pinchAround: Point | null, _e: TouchEvent): HandlerResult | void { const lastVector = this._vector!; this._vector = points[0].sub(points[1]); if (!this._active && this._isBelowThreshold(this._vector)) return; this._active = true; return { bearingDelta: getBearingDelta(this._vector, lastVector), pinchAround }; } _isBelowThreshold(vector: Point): boolean { /* * The threshold before a rotation actually happens is configured in * pixels along the circumference of the circle formed by the two fingers. * This makes the threshold in degrees larger when the fingers are close * together and smaller when the fingers are far apart. * * Use the smallest diameter from the whole gesture to reduce sensitivity * when pinching in and out. */ this._minDiameter = Math.min(this._minDiameter!, vector.mag()); const circumference = Math.PI * this._minDiameter; const threshold = ROTATION_THRESHOLD / circumference * 360; const bearingDeltaSinceStart = getBearingDelta(vector, this._startVector!); return Math.abs(bearingDeltaSinceStart) < threshold; } } /* PITCH */ function isVertical(vector: Point): boolean { return Math.abs(vector.y) > Math.abs(vector.x); } const ALLOWED_SINGLE_TOUCH_TIME = 100; /** * The `TwoFingersTouchPitchHandler` allows the user to pitch the map by dragging up and down with two fingers. * * @group Handlers */ export class TwoFingersTouchPitchHandler extends TwoFingersTouchHandler { _valid?: boolean; _firstMove?: number; _lastPoints?: [Point, Point]; _map: Map; _currentTouchCount: number = 0; constructor(map: Map) { super(); this._map = map; } reset(): void { super.reset(); this._valid = undefined; delete this._firstMove; delete this._lastPoints; } touchstart(e: TouchEvent, points: Array, mapTouches: Array): void { super.touchstart(e, points, mapTouches); this._currentTouchCount = mapTouches.length; } _start(points: [Point, Point]): void { this._lastPoints = points; if (isVertical(points[0].sub(points[1]))) { // fingers are more horizontal than vertical this._valid = false; } } _move(points: [Point, Point], center: Point | null, e: TouchEvent): HandlerResult | void { // If cooperative gestures is enabled, we need a 3-finger minimum for this gesture to register if (this._map.cooperativeGestures.isEnabled() && this._currentTouchCount < 3) { return; } const vectorA = points[0].sub(this._lastPoints![0]); const vectorB = points[1].sub(this._lastPoints![1]); this._valid = this.gestureBeginsVertically(vectorA, vectorB, e.timeStamp); if (!this._valid) return; this._lastPoints = points; this._active = true; const yDeltaAverage = (vectorA.y + vectorB.y) / 2; const degreesPerPixelMoved = -0.5; return { pitchDelta: yDeltaAverage * degreesPerPixelMoved }; } gestureBeginsVertically(vectorA: Point, vectorB: Point, timeStamp: number): boolean | undefined { if (this._valid !== undefined) return this._valid; const threshold = 2; const movedA = vectorA.mag() >= threshold; const movedB = vectorB.mag() >= threshold; // neither finger has moved a meaningful amount, wait if (!movedA && !movedB) return; // One finger has moved and the other has not. // If enough time has passed, decide it is not a pitch. if (!movedA || !movedB) { if (this._firstMove === undefined) { this._firstMove = timeStamp; } if (timeStamp - this._firstMove < ALLOWED_SINGLE_TOUCH_TIME) { // still waiting for a movement from the second finger return undefined; } else { return false; } } const isSameDirection = vectorA.y > 0 === vectorB.y > 0; return isVertical(vectorA) && isVertical(vectorB) && isSameDirection; } }