text: // @flow
  
  import DOM from '../../util/dom';
  
  import { bezier, bindAll } 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 inertiaLinearity = 0.25,
      inertiaEasing = bezier(0, 0, inertiaLinearity, 1),
      inertiaMaxSpeed = 180, // deg/s
      inertiaDeceleration = 720; // deg/s^2
  
  /**
   * The `DragRotateHandler` allows the user to rotate the map by clicking and
   * dragging the cursor while holding the right mouse button or `ctrl` key.
   */
  class DragRotateHandler {
      _map: Map;
      _el: HTMLElement;
      _state: 'disabled' | 'enabled' | 'pending' | 'active';
      _button: 'right' | 'left';
      _eventButton: number;
      _bearingSnap: number;
      _pitchWithRotate: boolean;
  
      _startPos: Point;
      _prevPos: Point;
      _lastPos: Point;
      _startTime: number;
      _lastMoveEvent: MouseEvent;
      _inertia: Array<[number, number]>;
      _center: Point;
      _frameId: ?TaskID;
  
      /**
       * @param {Map} map The Mapbox GL JS map to add the handler to.
       * @param {Object} [options]
       * @param {number} [options.bearingSnap] The threshold, measured in degrees, that determines when the map's
       *   bearing will snap to north.
       * @param {bool} [options.pitchWithRotate=true] Control the map pitch in addition to the bearing
       * @private
       */
      constructor(map: Map, options: {
          button?: 'right' | 'left',
          element?: HTMLElement,
          bearingSnap?: number,
          pitchWithRotate?: boolean
      }) {
          this._map = map;
          this._el = options.element || map.getCanvasContainer();
          this._state = 'disabled';
          this._button = options.button || 'right';
          this._bearingSnap = options.bearingSnap || 0;
          this._pitchWithRotate = options.pitchWithRotate !== false;
  
          bindAll([
              'onMouseDown',
              '_onMouseMove',
              '_onMouseUp',
              '_onBlur',
              '_onDragFrame'
          ], this);
      }
  
      /**
       * Returns a Boolean indicating whether the "drag to rotate" interaction is enabled.
       *
       * @returns {boolean} `true` if the "drag to rotate" interaction is enabled.
       */
      isEnabled() {
          return this._state !== 'disabled';
      }
  
      /**
       * Returns a Boolean indicating whether the "drag to rotate" interaction is active, i.e. currently being used.
       *
       * @returns {boolean} `true` if the "drag to rotate" interaction is active.
       */
      isActive() {
          return this._state === 'active';
      }
  
      /**
       * Enables the "drag to rotate" interaction.
       *
       * @example
       * map.dragRotate.enable();
       */
      enable() {
          if (this.isEnabled()) return;
          this._state = 'enabled';
      }
  
      /**
       * Disables the "drag to rotate" interaction.
       *
       * @example
       * map.dragRotate.disable();
       */
      disable() {
          if (!this.isEnabled()) return;
          switch (this._state) {
          case 'active':
              this._state = 'disabled';
              this._unbind();
              this._deactivate();
              this._fireEvent('rotateend');
              if (this._pitchWithRotate) {
                  this._fireEvent('pitchend');
              }
              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;
  
          const touchEvent = e.type === 'touchstart';
  
          if (touchEvent) {
              this._startTime = Date.now();
          } else {
              if (this._button === 'right') {
                  this._eventButton = DOM.mouseButton(e);
                  if (this._eventButton !== (e.ctrlKey ? 0 : 2)) return;
              } else {
                  if (e.ctrlKey || DOM.mouseButton(e) !== 0) return;
                  this._eventButton = 0;
              }
          }
  
          DOM.disableDrag();
  
          // Bind window-level event listeners for move and up/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.
          if (touchEvent) {
              window.document.addEventListener('touchmove', this._onMouseMove, { capture: true });
              window.document.addEventListener('touchend', this._onMouseUp);
          } else {
              window.document.addEventListener('mousemove', this._onMouseMove, { capture: true });
              window.document.addEventListener('mouseup', this._onMouseUp);
          }
  
          // 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._inertia = [[browser.now(), this._map.getBearing()]];
          this._startPos = this._prevPos = this._lastPos = DOM.mousePos(this._el, e);
          this._center = this._map.transform.centerPoint;  // Center of rotation
  
          e.preventDefault();
      }
  
      _onMouseMove(e: MouseEvent) {
          const pos = DOM.mousePos(this._el, e);
          if (this._lastPos.equals(pos)) {
              return;
          }
  
          this._lastMoveEvent = e;
          this._lastPos = pos;
  
          if (this._state === 'pending') {
              this._state = 'active';
              this._fireEvent('rotatestart', e);
              this._fireEvent('movestart', e);
              if (this._pitchWithRotate) {
                  this._fireEvent('pitchstart', e);
              }
          }
  
          if (!this._frameId) {
              this._frameId = this._map._requestRenderFrame(this._onDragFrame);
          }
      }
  
      _onDragFrame() {
          this._frameId = null;
  
          const e = this._lastMoveEvent;
          if (!e) return;
          const tr = this._map.transform;
  
          const p1 = this._prevPos,
              p2 = this._lastPos,
              bearingDiff = (p1.x - p2.x) * 0.8,
              pitchDiff = (p1.y - p2.y) * -0.5,
              bearing = tr.bearing - bearingDiff,
              pitch = tr.pitch - pitchDiff,
              inertia = this._inertia,
              last = inertia[inertia.length - 1];
  
          this._drainInertiaBuffer();
          inertia.push([browser.now(), this._map._normalizeBearing(bearing, last[1])]);
  
          tr.bearing = bearing;
          if (this._pitchWithRotate) {
              this._fireEvent('pitch', e);
              tr.pitch = pitch;
          }
  
          this._fireEvent('rotate', e);
          this._fireEvent('move', e);
  
          delete this._lastMoveEvent;
          this._prevPos = this._lastPos;
      }
  
      _onMouseUp(e: MouseEvent) {
          const touchEvent = e.type === 'touchend';
  
          if (touchEvent && (this._startPos === this._lastPos) && (Date.now() - this._startTime) < 300) {
              this._el.click();
          }
  
          if (DOM.mouseButton(e) !== this._eventButton) return;
          switch (this._state) {
          case 'active':
              this._state = 'enabled';
              DOM.suppressClick();
              this._unbind();
              this._deactivate();
              this._inertialRotate(e);
              break;
          case 'pending':
              this._state = 'enabled';
              this._unbind();
              break;
          default:
              assert(false);
              break;
          }
      }
  
      _onBlur(e: FocusEvent) {
          switch (this._state) {
          case 'active':
              this._state = 'enabled';
              this._unbind();
              this._deactivate();
              this._fireEvent('rotateend', e);
              if (this._pitchWithRotate) {
                  this._fireEvent('pitchend', e);
              }
              this._fireEvent('moveend', e);
              break;
          case 'pending':
              this._state = 'enabled';
              this._unbind();
              break;
          default:
              assert(false);
              break;
          }
      }
  
      _unbind() {
          window.document.removeEventListener('mousemove', this._onMouseMove, { capture: true });
          window.document.removeEventListener('mouseup', this._onMouseUp);
          window.document.removeEventListener('touchmove', this._onMouseMove, { capture: true });
          window.document.removeEventListener('touchend', this._onMouseUp);
          window.removeEventListener('blur', this._onBlur);
          DOM.enableDrag();
      }
  
      _deactivate() {
          if (this._frameId) {
              this._map._cancelRenderFrame(this._frameId);
              this._frameId = null;
          }
          delete this._lastMoveEvent;
          delete this._startPos;
          delete this._prevPos;
          delete this._lastPos;
      }
  
      _inertialRotate(e: MouseEvent) {
          this._fireEvent('rotateend', e);
          this._drainInertiaBuffer();
  
          const map = this._map,
              mapBearing = map.getBearing(),
              inertia = this._inertia;
  
          const finish = () => {
              if (Math.abs(mapBearing) < this._bearingSnap) {
                  map.resetNorth({noMoveStart: true}, { originalEvent: e });
              } else {
                  this._fireEvent('moveend', e);
              }
              if (this._pitchWithRotate) this._fireEvent('pitchend', e);
          };
  
          if (inertia.length < 2) {
              finish();
              return;
          }
  
          const first = inertia[0],
              last = inertia[inertia.length - 1],
              previous = inertia[inertia.length - 2];
          let bearing = map._normalizeBearing(mapBearing, previous[1]);
          const flingDiff = last[1] - first[1],
              sign = flingDiff < 0 ? -1 : 1,
              flingDuration = (last[0] - first[0]) / 1000;
  
          if (flingDiff === 0 || flingDuration === 0) {
              finish();
              return;
          }
  
          let speed = Math.abs(flingDiff * (inertiaLinearity / flingDuration));  // deg/s
          if (speed > inertiaMaxSpeed) {
              speed = inertiaMaxSpeed;
          }
  
          const duration = speed / (inertiaDeceleration * inertiaLinearity),
              offset = sign * speed * (duration / 2);
  
          bearing += offset;
  
          if (Math.abs(map._normalizeBearing(bearing, 0)) < this._bearingSnap) {
              bearing = map._normalizeBearing(0, bearing);
          }
  
          map.rotateTo(bearing, {
              duration: duration * 1000,
              easing: inertiaEasing,
              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 DragRotateHandler;
  