mapInteractor.js

var inherit = require('./inherit');
var object = require('./object');
var util = require('./util');
var Mousetrap = require('mousetrap');

/**
 * Map Interactor specification.
 *
 * @typedef {object} geo.mapInteractor.spec
 * @property {number} [throttle=30] Mouse events are throttled so that an event
 *      occurs no more often that this number of milliseconds.
 * @property {boolean|number} [discreteZoom=false] If `true`, only allow
 *      discrete (integer) zoom levels and debounce with a 400 ms delay.  If a
 *      positive number, debounce zoom events with the given delay in
 *      milliseconds.
 * @property {geo.actionRecord[]} [actions] The list of available actions.  See
 *      the code for the full default list.
 * @property {object} [click] An object specifying if click events should be
 *      handled.
 * @property {boolean} [click.enabled=true] Truthy to enable click events.
 * @property {object} [click.buttons] An object with button names (`left`,
 *      `right`, `middle`), each of which is a boolean which indicates if that
 *      button triggers a click event.
 * @property {number} [click.duration=0] If a positive number, the mouse up
 *      event must occur within this time in milliseconds of the mouse down
 *      event for it to be considered a click.
 * @property {boolean|number} [click.cancelOnMove=true] If true, don't generate
 *      click events if the mouse moved at all.  If a positive number, the
 *      distance at which to cancel click events when the mouse moves.
 * @property {object} [keyboard] An object describing which keyboard events are
 *      handled.
 * @property {object} [keyboard.actions] An object with different actions that
 *      are trigger by the keyboard.  Each key is the event that is triggered,
 *      with the values a list of keys that trigger the event.  See the code
 *      for the defaults.
 * @property {object} [keyboard.meta] Keyboard events can generate actions of
 *      different magnitudes.  This is an object with keys of `0`, `1`, and
 *      `2`, corresponding to small, medium, and large actions.  Each entry is
 *      an object with keys of the meta keys that are required to be down or
 *      up for that scale action to trigger.  If the value of the meta key is
 *      truthy, it must be down.  If `false`, it must be up.
 * @property {string[]} [keyboard.metakeyMouseEvents] A list of meta keys
 * *    that, when typed singly, trigger a repeat of the last mousemove or
 *      actionmove event so that listeners can  update metakey information.
 * @property {boolean} [keyboard.focusHighlight=true] If truthy, when the map
 *      gains focus, a highlight style is shown around it.  This gives an
 *      indicator that keyboard events will affect the map, but may not be
 *      visually desirable.
 * @property {boolean} [alwaysTouch=false] If true, add touch support even if
 *      the browser doesn't apepar to be touch-aware.
 * @property {number} [wheelScaleX=1] A scale multiplier for horizontal wheel
 *      interactions.
 * @property {number} [wheelScaleY=1] A scale multiplier for vertical wheel
 *      interactions.
 * @property {number} [zoomScale=1] This affects how far the mouse must be
 *      dragged to zoom one level.  Roughly, the mouse must move `zoomScale` *
 *      120 pixels per level.
 * @property {number} [rotateWheelScale=0.105] When the mouse wheel is used for
 *      rotation, this is the number of radians per wheel step.
 * @property {number} [zoomrotateMinimumRotation=0.087] The minimum angle of
 *      rotation in radians before a `geo_action.zoomrotate` action will allow
 *      rotation.  Set to 0 to always include rotation.
 * @property {number} [zoomrotateReverseRotation=0.698] The minimum angle of
 *      rotation (in radians) before the `geo_action.zoomrotate` action will
 *      reverse the rotation direction.  This helps reduce chatter when zooms
 *      and pans are combined with rotations.
 * @property {number} [zoomrotateMinimumZoom=0.05] The minimum zoom factor
 *      change (increasing or desceasing) before the `geo_action.zoomrotate`
 *      action will allow zoom.  Set to 0 to always include zoom.
 * @property {number} [zoomrotateMinimumPan=5] The minimum number of pixels
 *      before the `geo_action.zoomrotate` action will allow panning.  Set to 0
 *      to always include panning.
 * @property {number} [touchPanDelay=50] The touch pan delay prevents a touch
 *      pan event from immediately following a rotate (including zoom) event.
 *      No touch pan event is processed within this number of milliseconds of a
 *      non-pan touch event.
 * @property {object} [momentum] Enable momentum when panning and zooming.
 * @property {boolean} [momentum.enabled=true] Truthy to allow momentum.
 * @property {number} [momentum.maxSpeed=2.5] Maximum animation speed.
 * @property {number} [momentum.minSpeed=0.01] Animations stop when they drop
 *      below this speed.
 * @property {number} [momentum.stopTime=250] If the mouse hasn't moved in this
 *      many milliseconds, don't apply momentum.  The movement is a separate
 *      action from the preceding movement.
 * @property {number} [momentum.drag=0.01] Drag coefficient; larger values slow
 *      down faster.
 * @property {string[]} [momentum.actions] A list of actions on which to apply
 *      momentum.  Defaults to pan and zoom.
 * @property {object} [spring] Enable spring clamping to screen edges.
 * @property {boolean} [spring.enabled=true] Truthy to allow edge spring back.
 * @property {number} [spring.springConstant=0.00005] Higher values spring back
 *      faster.
 * @property {object} [zoomAnimation] Enable zoom animation for both discrete
 *      and continuous zoom.
 * @property {boolean} [zoomAnimation.enabled=true] Truthy to allow zoom
 *      animation.
 * @property {number} [zoomAnimation.duration=500] The time it takes for the
 *      final zoom to be reached.
 * @property {function} [zoomAnimation.ease] The easing function for the zoom.
 *      The default is `(2 - t) * t`.
 */

/**
 * The state of the mouse.
 *
 * @typedef {object} geo.mouseState
 * @property {geo.screenPosition} page Mouse position relative to the page.
 * @property {geo.geoPosition} map Mouse position relative to the map.
 * @property {geo.geoPosition} geo Mouse position in map interface gcs.
 * @property {geo.geoPosition} mapgcs Mouse position in map gcs
 * @property {object} buttons Which mouse buttons are down.
 * @property {boolean} buttons.left State of the left mouse button.
 * @property {boolean} buttons.right State of the right mouse button.
 * @property {boolean} buttons.middle State of the middle mouse button.
 * @property {object} modifiers Which modifier keys are down.
 * @property {boolean} modifiers.alt State of the alt key.
 * @property {boolean} modifiers.ctrl State of the ctrl key.
 * @property {boolean} modifiers.shift State of the shift key.
 * @property {boolean} modifiers.meta State of the meta key.
 * @property {number} time Time (epoch ms) the event was captured.
 * @property {number} deltaTime Time elapsed (ms) since the last mouse event.
 * @property {geo.screenPosition} velocity Mouse speed in pixels/ms.
 */

/**
 * The mapInteractor class is responsible for handling raw events from the
 * browser and interpreting them as map navigation interactions.  This class
 * will call the navigation methods on the connected map, which will make
 * modifications to the camera directly.
 *
 * @class
 * @alias geo.mapInteractor
 * @extends geo.object
 * @param {geo.mapInterator.spec} args Interactor specification object.
 * @returns {geo.mapInteractor}
 */
var mapInteractor = function (args) {
  'use strict';
  if (!(this instanceof mapInteractor)) {
    return new mapInteractor(args);
  }
  object.call(this);

  var $ = require('jquery');
  var geo_event = require('./event');
  var geo_action = require('./action');
  var throttle = require('./util').throttle;
  var debounce = require('./util').debounce;
  var actionMatch = require('./util').actionMatch;
  var quadFeature = require('./quadFeature');

  var m_options,
      m_this = this,
      m_mouse,
      m_keyHandler,
      m_boundKeys,
      m_touchHandler,
      m_state,
      m_nextStateId = 0,
      m_queue,
      $node,
      m_selectionLayer = null,
      m_selectionQuad,
      m_paused = false,
      // if m_clickMaybe is not false, it contains the x, y, and buttons that
      // were present when the mouse down event occurred.
      m_clickMaybe = false,
      m_clickMaybeTimeout,
      m_callZoom = function () {};

  // Helper method to calculate the speed from a velocity
  function calcSpeed(v) {
    var x = v.x, y = v.y;
    return Math.sqrt(x * x + y * y);
  }

  // copy the options object with defaults
  m_options = util.deepMerge(
    {},
    {
      throttle: 30,
      discreteZoom: false,

      /* There should only be one action with any specific combination of event
       * and modifiers.  When that event and modifiers occur, the specified
       * action is triggered.  The event and modifiers fields can either be a
       * simple string or an object with multiple entries with each entry set
       * to true, false, or undefined.  If an object, all values that are
       * truthy must match, all values that are false must not match, and all
       * other values that are falsy are ignored.
       *   Available actions:
       * see geo_action list
       *   Available events:
       * left, right, middle, wheel
       *   Available modifiers:
       * shift, ctrl, alt, meta
       *   Useful fields:
       * action: the name of the action.  Multiple events may trigger the same
       *    action.
       * input: the name of the input or an object with input names for keys
       *    and boolean values that indicates the combination of events that
       *    trigger this action.
       * modifiers: the name of a modifier or an object with modifier names for
       *    keys and boolean values that indicates the combination of modifiers
       *    that trigger this action.
       * selectionRectangle: truthy if a selection rectangle should be shown
       *    during the action.  This can be the name of an event that will be
       *    triggered when the selection is complete.
       * selectionConstraint: if a function and a selection rectangle is being
       *    drawn, this is a function that takes (mousexy, originxy, state)
       *    with the coordinates objects with x, y in display coordinates.
       *    The function returns a modified x, y for the mouse coordinates, or
       *    undefined for no change.
       * name: a string that can be used to reference this action.
       * owner: a string that can be used to reference this action.
       */
      actions: [{
        action: geo_action.pan,
        input: 'left',
        modifiers: {shift: false, ctrl: false},
        owner: 'geo.mapInteractor',
        name: 'button pan'
      }, {
        action: geo_action.zoom,
        input: 'right',
        modifiers: {shift: false, ctrl: false},
        owner: 'geo.mapInteractor',
        name: 'button zoom'
      }, {
        action: geo_action.zoom,
        input: 'wheel',
        modifiers: {shift: false, ctrl: false},
        owner: 'geo.mapInteractor',
        name: 'wheel zoom'
      }, {
        action: geo_action.rotate,
        input: 'left',
        modifiers: {shift: false, ctrl: true},
        owner: 'geo.mapInteractor',
        name: 'button rotate'
      }, {
        action: geo_action.rotate,
        input: 'wheel',
        modifiers: {shift: false, ctrl: true},
        owner: 'geo.mapInteractor',
        name: 'wheel rotate'
      }, {
        action: geo_action.select,
        input: 'left',
        modifiers: {shift: true, ctrl: true},
        selectionRectangle: geo_event.select,
        owner: 'geo.mapInteractor',
        name: 'drag select'
      }, {
        action: geo_action.zoomselect,
        input: 'left',
        modifiers: {shift: true, ctrl: false},
        selectionRectangle: geo_event.zoomselect,
        owner: 'geo.mapInteractor',
        name: 'drag zoom'
      }, {
        action: geo_action.unzoomselect,
        input: 'right',
        modifiers: {shift: true, ctrl: false},
        selectionRectangle: geo_event.unzoomselect,
        owner: 'geo.mapInteractor',
        name: 'drag unzoom'
      }, {
        action: geo_action.pan,
        input: 'pan',
        owner: 'geo.mapInteractor',
        name: 'touch pan'
      }, {
        action: geo_action.zoomrotate,
        input: 'rotate',
        owner: 'geo.mapInteractor',
        name: 'touch zoom and rotate'
      }, {
        action: geo_action.pan,
        input: 'middle',
        modifiers: {shift: false, ctrl: false},
        owner: 'geo.mapInteractor',
        name: 'button pan'
      }],

      click: {
        enabled: true,
        buttons: {left: true, right: true, middle: true},
        duration: 0,
        cancelOnMove: true
      },

      keyboard: {
        actions: {
          /* Specific actions can be disabled by removing them from this object
           * or setting an empty list as the key bindings.  Additional actions
           * can be added to the dictionary, each of which gets a list of key
           * bindings.  See Mousetrap documentation for special key names. */
          'zoom.in': ['plus', 'shift+plus', 'shift+ctrl+plus', '=', 'shift+=', 'shift+ctrl+='],
          'zoom.out': ['-', 'shift+-', 'shift+ctrl+-', '_', 'shift+_', 'shift+ctrl+_'],
          'zoom.0': ['1'],
          'zoom.3': ['2'],
          'zoom.6': ['3'],
          'zoom.9': ['4'],
          'zoom.12': ['5'],
          'zoom.15': ['6'],
          'zoom.18': ['7'],
          'pan.left': ['left', 'shift+left', 'shift+ctrl+left'],
          'pan.right': ['right', 'shift+right', 'shift+ctrl+right'],
          'pan.up': ['up', 'shift+up', 'shift+ctrl+up'],
          'pan.down': ['down', 'shift+down', 'shift+ctrl+down'],
          'rotate.ccw': ['<', 'shift+<', 'shift+ctrl+<', '.', 'shift+.', 'shift+ctrl+.'],
          'rotate.cw': ['>', 'shift+>', 'shift+ctrl+>', ',', 'shift+,', 'shift+ctrl+,'],
          'rotate.0': ['0']
        },
        meta: {
          /* the metakeys that are down during a key event determine the
           * magnitude of the action, where 0 is the default small action
           * (1-pixel pan, small zoom, small rotation), 1 is a middle-sized
           * action, and 2 is the largest action.  Metakeys that aren't listed
           * are ignored.  Metakeys include shift, ctrl, alt, and meta (alt is
           * either the alt or option key, and meta is either windows or
           * command). */
          0: {shift: false, ctrl: false},
          1: {shift: true, ctrl: true},
          2: {shift: true, ctrl: false}
        },
        /* This is a list of meta keys that when typed singly trigger a repeat
         * of the last mousemove or actionmove event so that listeners can
         * update metakey information. */
        metakeyMouseEvents: ['shift', 'ctrl', 'shift+ctrl', 'shift+alt', 'shift+meta'],
        /* if focusHighlight is truthy, then a class is added to the map such
         * that when the map gets focus, it is indicated inside the border of
         * the map -- browsers usually show focus on the outside, which isn't
         * useful if the map is full window.  It might be desirable to change
         * this so it is only present if the focus is reached via the keyboard
         * (which would probably require detecting keyup events). */
        focusHighlight: true
      },
      /* Set alwaysTouch to false to only add touch support on devices that
       * report touch support.  Set to true to add touch support on all
       * devices. */
      alwaysTouch: false,
      wheelScaleX: 1,
      wheelScaleY: 1,
      zoomScale: 1,
      rotateWheelScale: 6 * Math.PI / 180,
      /* The minimum angle of rotation (in radians) before the
       * geo_action.zoomrotate action will allow rotation.  Set to 0 to always
       * include rotation. */
      zoomrotateMinimumRotation: 5.0 * Math.PI / 180,
      /* The minimum angle of rotation (in radians) before the
       * geo_action.zoomrotate action will reverse the rotation direction.
       * This helps reduce chatter when zooms and pans are combined with
       * rotations. */
      zoomrotateReverseRotation: 4.0 * Math.PI / 180,
      /* The minimum zoom factor change (increasing or decreasing) before the
       * geo_action.zoomrotate action will allow zoom.  Set to 0 to always
       * include zoom. */
      zoomrotateMinimumZoom: 0.05,
      /* The minimum number of pixels before the geo_action.zoomrotate action
       * will allow panning.  Set to 0 to always include panning. */
      zoomrotateMinimumPan: 5,
      /* The touch pan delay prevents a touch pan event from immediately
       * following a rotate (including zoom) event.  No touch pan event is
       * processed within this number of milliseconds of a non-pan touch
       * event. */
      touchPanDelay: 50,
      momentum: {
        enabled: true,
        maxSpeed: 2.5,
        minSpeed: 0.01,
        stopTime: 250,
        drag: 0.01,
        actions: [geo_action.pan, geo_action.zoom]
      },
      spring: {
        enabled: false,
        springConstant: 0.00005
      },
      zoomAnimation: {
        enabled: true,
        duration: 500,
        ease: function (t) { return (2 - t) * t; }
      }
    },
    args || {}
  );
  /* We don't want to merge the original arrays with arrays passed in the args,
   * so override that as necessary for actions. */
  if (args && args.actions) {
    m_options.actions = util.deepMerge([], args.actions);
  }
  if (args && args.momentum && args.momentum.actions) {
    m_options.momentum.actions = util.deepMerge([], args.momentum.actions);
  }
  if (args && args.keyboard && args.keyboard.actions !== undefined) {
    m_options.keyboard.actions = util.deepMerge({}, args.keyboard.actions);
  }

  // default mouse object
  m_mouse = {
    page: {x: 0, y: 0}, // mouse position relative to the page
    map: {x: 0, y: 0}, // mouse position relative to the map
    geo: {x: 0, y: 0},  // mouse position in map interface gcs
    mapgcs: {x: 0, y: 0},  // mouse position in map gcs
    // mouse button status
    buttons: {
      left: false,
      right: false,
      middle: false
    },
    // keyboard modifier status
    modifiers: {
      alt: false,
      ctrl: false,
      shift: false,
      meta: false
    },
    // time the event was captured
    time: new Date(),
    // time elapsed since the last mouse event
    deltaTime: 1,
    // pixels/ms
    velocity: {
      x: 0,
      y: 0
    }
  };

  /*
   * The interactor state determines what actions are taken in response to
   * core browser events.
   *
   * i.e.
   *  {
   *    'action': geo_action.pan,    * an ongoing pan event
   *    'origin': {...},       * mouse object at the start of the action
   *    'delta': {x: *, y: *} // mouse movement since action start
   *                           * not including the current event
   *  }
   *
   *  {
   *    'action': geo_action.zoom,   * an ongoing zoom event
   *    ...
   *  }
   *
   *  {
   *    'action': geo_action.rotate,   * an ongoing rotate event
   *    'origin': {...},       * mouse object at the start of the action
   *    'delta': {x: *, y: *} // mouse movement since action start
   *                           * not including the current event
   *  }
   *
   *  {
   *    'acton': geo_action.select,
   *    'origin': {...},
   *    'delta': {x: *, y: *}
   *  }
   *
   *  {
   *    'action': geo_action.momentum,
   *    'origin': {...},
   *    'handler': function () { }, // called in animation loop
   *    'timer': animate loop timer
   *  }
   */
  m_state = {};

  /**
   * Store queued map navigation commands (due to throttling) here
   * {
   *   kind: 'move' | 'wheel',  // what kind of mouse action triggered this
   *   method: function () {},  // the throttled method
   *   scroll: {x: ..., y: ...} // accumulated scroll wheel deltas
   * }
   */
  m_queue = {};

  /**
   * Process keys that we've captured.  Metakeys determine the magnitude of
   * the action.
   *
   * @param {string} action The basic action to take.
   * @param {object} evt The event with metakeys.
   * @param {object} keys Keys used to trigger the event.  `keys.simulated` is
   *    true if artificially triggered.
   * @fires geo.event.keyaction
   */
  this._handleKeys = function (action, evt, keys) {
    if (keys && keys.simulated === true) {
      evt = keys;
    }
    var meta = m_options.keyboard.meta || {0: {}},
        map = m_this.map(),
        mapSize = map.size(),
        actionBase = action, actionValue = '',
        value, factor, move = {};

    for (value in meta) {
      if (meta.hasOwnProperty(value)) {
        if ((meta[value].shift === undefined || evt.shiftKey === !!meta[value].shift) &&
            (meta[value].ctrl === undefined || evt.ctrlKey === !!meta[value].ctrl) &&
            (meta[value].alt === undefined || evt.altKey === !!meta[value].alt) &&
            (meta[value].meta === undefined || evt.metaKey === !!meta[value].meta)) {
          factor = value;
        }
      }
    }
    if (factor === undefined) {
      /* metakeys don't match, so don't trigger an event. */
      return;
    }

    evt.stopPropagation();
    evt.preventDefault();

    if (action.indexOf('.') >= 0) {
      actionBase = action.substr(0, action.indexOf('.'));
      actionValue = action.substr(action.indexOf('.') + 1);
    }
    switch (actionBase) {
      case 'zoom':
        switch (actionValue) {
          case 'in':
            move.zoomDelta = [0.05, 0.25, 1][factor];
            break;
          case 'out':
            move.zoomDelta = -[0.05, 0.25, 1][factor];
            break;
          default:
            if (!isNaN(parseFloat(actionValue))) {
              move.zoom = parseFloat(actionValue);
            }
            break;
        }
        break;
      case 'pan':
        switch (actionValue) {
          case 'down':
            move.panY = -Math.max(1, [0, 0.05, 0.5][factor] * mapSize.height);
            break;
          case 'left':
            move.panX = Math.max(1, [0, 0.05, 0.5][factor] * mapSize.width);
            break;
          case 'right':
            move.panX = -Math.max(1, [0, 0.05, 0.5][factor] * mapSize.width);
            break;
          case 'up':
            move.panY = Math.max(1, [0, 0.05, 0.5][factor] * mapSize.height);
            break;
        }
        break;
      case 'rotate':
        switch (actionValue) {
          case 'ccw':
            move.rotationDelta = [1, 5, 90][factor] * Math.PI / 180;
            break;
          case 'cw':
            move.rotationDelta = -[1, 5, 90][factor] * Math.PI / 180;
            break;
          default:
            if (!isNaN(parseFloat(actionValue))) {
              move.rotation = parseFloat(actionValue);
            }
            break;
        }
        break;
    }
    map.geoTrigger(geo_event.keyaction, {
      move: move,
      action: action,
      factor: factor,
      event: evt
    });
    if (move.cancel) {
      return;
    }
    if (move.zoom !== undefined) {
      map.zoom(move.zoom);
    } else if (move.zoomDelta) {
      map.zoom(map.zoom() + move.zoomDelta);
    }
    if (move.rotation !== undefined) {
      map.rotation(move.rotation);
    } else if (move.rotationDelta) {
      map.rotation(map.rotation() + move.rotationDelta);
    }
    if (move.panX || move.panY) {
      map.pan({x: move.panX || 0, y: move.panY || 0});
    }
  };

  /**
   * Check if this browser has touch support.
   * Copied from https://github.com/hammerjs/touchemulator under the MIT
   * license.
   *
   * @returns {boolean} `true` if there is touch support.
   */
  this.hasTouchSupport = function () {
    return ('ontouchstart' in window) || // touch events
           (window.TouchEvent) ||
           (window.DocumentTouch && document instanceof window.DocumentTouch) ||
           (navigator.msMaxTouchPoints || navigator.maxTouchPoints) > 1; // pointer events
  };

  /**
   * Handle touch events.
   *
   * @param {object} evt The touch event.
   */
  this._handleTouch = function (evt) {
    var endIfBound = false;
    if (evt.pointerType === 'mouse' && m_touchHandler.touchSupport) {
      endIfBound = true;
    }
    if (evt.type === 'hammer.input') {
      if (m_touchHandler.lastEventType === 'pan' && evt.pointers.length !== 1) {
        endIfBound = true;
        m_touchHandler.lastEventType = null;
      } else {
        return;
      }
    }
    var evtType = /^(.*)(start|end|move|tap)$/.exec(evt.type);
    if (!evtType || evtType.length !== 3) {
      endIfBound = true;
    }
    if (endIfBound) {
      if (m_state.boundDocumentHandlers && m_touchHandler.lastEvent) {
        m_this._handleMouseUpDocument(m_touchHandler.lastEvent);
      }
      return;
    }
    evt.which = evtType[1];
    var time = (new Date()).valueOf();
    if (evt.which === 'pan' && m_touchHandler.lastEventType !== 'pan' &&
        time - m_touchHandler.lastTime < m_options.touchPanDelay) {
      return;
    }
    m_touchHandler.lastTime = time;
    m_touchHandler.lastEventType = evt.which;
    m_touchHandler.lastEvent = evt;
    /* convert touch events to have page locations */
    if (evt.pageX === undefined && evt.center !== undefined && evt.center.x !== undefined) {
      evt.pageX = evt.center.x;
      evt.pageY = evt.center.y;
    }
    /* start events should occur *before* the triggering delta.  By using the
     * mouse handlers, we get all of the action properties we expect (and
     * actions can be changed or defined as we see fit). */
    if (evtType[2] === 'start') {
      m_this._handleMouseDown(evt);
      m_this._setClickMaybe(false);
      if (m_state.boundDocumentHandlers) {
        $(document).on('mousemove.geojs', m_this._handleMouseUpDocument);
      }
    }
    /* start and move events both trigger a movement */
    if (evtType[2] === 'start' || evtType[2] === 'move') {
      if (m_state.boundDocumentHandlers) {
        m_this._handleMouseMoveDocument(evt);
      } else {
        m_this._handleMouseMove(evt);
      }
    }
    if (evtType[2] === 'end' || evtType[2] === 'cancel') {
      if (m_state.boundDocumentHandlers) {
        m_this._handleMouseUpDocument(evt);
      } else {
        m_this._handleMouseUp(evt);
      }
      m_touchHandler.lastEvent = null;
    }
    /* tap events are represented as a mouse left button down and up. */
    if (evtType[2] === 'tap' && !m_state.boundDocumentHandlers) {
      evt.which = 1;
      m_touchHandler.lastEventType = null;
      m_this._handleMouseDown(evt);
      m_this._handleMouseUp(evt);
      m_touchHandler.lastEventType = evtType[2];
    }
  };

  /**
   * Repeat a mousemove or actionmove event with new metakey information.
   *
   * @param {event} [evt] A Mousetrap event with the key change.
   * @fires geo.event.mousemove
   * @fires geo.event.actionmove
   */
  this._repeatMouseMoveEvent = function (evt) {
    if (!m_mouse || !m_mouse.modifiers) {
      return;
    }
    const old = {
      alt: m_mouse.modifiers.alt,
      ctrl: m_mouse.modifiers.ctrl,
      meta: m_mouse.modifiers.meta,
      shift: m_mouse.modifiers.shift
    };
    m_this._getMouseModifiers(evt);
    if (m_mouse.modifiers.alt === old.alt && m_mouse.modifiers.ctrl === old.ctrl && m_mouse.modifiers.meta === old.meta && m_mouse.modifiers.shift === old.shift) {
      return;
    }
    if (m_state.boundDocumentHandlers) {
      m_this.map().geoTrigger(geo_event.actionmove, {
        state: m_this.state(), mouse: m_this.mouse(), event: evt});
    } else {
      m_this.map().geoTrigger(geo_event.mousemove, m_this.mouse());
    }
  };

  /**
   * Retrigger a mouse movement with the current mouse state.
   * @fires geo.event.mousemove
   */
  this.retriggerMouseMove = function () {
    m_this.map().geoTrigger(geo_event.mousemove, m_this.mouse());
  };

  /**
   * Connects events to a map.  If the map is not set, then this does nothing.
   * @returns {this}
   */
  this._connectEvents = function () {
    if (!m_options.map) {
      return m_this;
    }

    // prevent double binding to DOM elements
    m_this._disconnectEvents();

    // store the connected element
    $node = $(m_options.map.node());

    // set methods related to asynchronous event handling
    m_this._handleMouseWheel = throttled_wheel();
    m_callZoom = debounced_zoom();

    // catalog what inputs we are using
    util.adjustActions(m_options.actions);
    var usedInputs = {};
    ['right', 'pan', 'rotate'].forEach(function (input) {
      usedInputs[input] = m_options.actions.some(function (action) {
        return action.input[input];
      });
    });
    // add event handlers
    $node.on('wheel.geojs', m_this._handleMouseWheel);
    $node.on('mousemove.geojs', m_this._handleMouseMove);
    $node.on('mousedown.geojs', m_this._handleMouseDown);
    $node.on('mouseup.geojs', m_this._handleMouseUp);
    // Disable dragging images and such
    $node.on('dragstart', function () { return false; });
    if (usedInputs.right) {
      $node.on('contextmenu.geojs', function () { return false; });
    }

    // bind keyboard events
    if (m_options.keyboard && m_options.keyboard.actions) {
      m_keyHandler = Mousetrap($node[0]);
      var bound = [];
      for (var keyAction in m_options.keyboard.actions) {
        if (m_options.keyboard.actions.hasOwnProperty(keyAction)) {
          m_keyHandler.bind(
            m_options.keyboard.actions[keyAction],
            (function (action) {
              return function (evt, keys) {
                m_this._handleKeys(action, evt, keys);
              };
            })(keyAction)
          );
          bound = bound.concat(m_options.keyboard.actions[keyAction]);
        }
      }
      if (m_options.keyboard.metakeyMouseEvents) {
        m_options.keyboard.metakeyMouseEvents.forEach((meta) => {
          m_keyHandler.bind(meta, m_this._repeatMouseMoveEvent, 'keydown');
          m_keyHandler.bind(meta, m_this._repeatMouseMoveEvent, 'keyup');
          bound.push(meta);
        });
      }
      m_boundKeys = bound;
    }
    $node.toggleClass(
      'highlight-focus',
      !!(m_boundKeys && m_boundKeys.length && m_options.keyboard.focusHighlight));
    // bind touch events
    if ((m_this.hasTouchSupport() || m_options.alwaysTouch) &&
        (usedInputs.pan || usedInputs.rotate)) {
      // webpack expects optional dependencies to be wrapped in a try-catch
      var Hammer;
      try {
        Hammer = require('hammerjs');
        if (!Hammer || !Hammer.Manager) {
          Hammer = undefined;
        }
      } catch (_error) {}
      if (Hammer !== undefined) {
        var recog = [],
            touchEvents = ['hammer.input'];
        if (usedInputs.rotate) {
          recog.push([Hammer.Rotate, {enable: true}]);
          touchEvents = touchEvents.concat(['rotatestart', 'rotateend', 'rotatemove']);
        }
        if (usedInputs.pan) {
          recog.push([Hammer.Pan, {direction: Hammer.DIRECTION_ALL}]);
          touchEvents = touchEvents.concat(['panstart', 'panend', 'panmove']);
        }
        /* Always handle tap events.  Reject double taps. */
        recog.push([Hammer.Tap, {event: 'doubletap', taps: 2}]);
        recog.push([Hammer.Tap, {event: 'singletap'}, undefined, ['doubletap']]);
        touchEvents = touchEvents.concat(['singletap']);
        var hammerParams = {recognizers: recog, preventDefault: true};
        m_touchHandler = {
          manager: new Hammer.Manager($node[0], hammerParams),
          touchSupport: m_this.hasTouchSupport(),
          lastTime: 0
        };
        m_touchHandler.manager.get('doubletap').recognizeWith('singletap');
        touchEvents.forEach(function (touchEvent) {
          m_touchHandler.manager.on(touchEvent, m_this._handleTouch);
        });
      }
    }

    return m_this;
  };

  /**
   * Disconnects events to a map.  If the map is not set, then this does
   * nothing.
   * @returns {this}
   */
  this._disconnectEvents = function () {
    if (m_boundKeys) {
      if (m_keyHandler) {
        m_keyHandler.unbind(m_boundKeys);
      }
      m_boundKeys = null;
      m_keyHandler = null;
    }
    if (m_touchHandler) {
      m_touchHandler.manager.destroy();
      m_touchHandler = null;
    }
    if ($node) {
      $node.off('.geojs');
      $node = null;
    }
    m_this._handleMouseWheel = function () {};
    m_callZoom = function () {};

    return m_this;
  };

  /**
   * Get or set the map for this interactor, adds draw region layer if needed.
   *
   * @param {geo.map} [val] Either a new map object for `undefined` to return
   *    the current map object.
   * @returns {geo.map|this} Either the current map object or the
   *    mapInteractor class instance.
   */
  this.map = function (val) {
    if (val !== undefined) {
      m_options.map = val;
      m_this._connectEvents();
      return m_this;
    }
    return m_options.map;
  };

  /**
   * Gets/sets the options object for the interactor.
   *
   * @param {geo.mapInteractor.spec} opts Options to set.
   * @returns {geo.mapInteractor.spec|this}
   */
  this.options = function (opts) {
    if (opts === undefined) {
      return Object.assign({}, m_options);
    }
    Object.assign(m_options, opts);

    // reset event handlers for new options
    m_this._connectEvents();
    return m_this;
  };

  /**
   * Stores the current mouse position from an event.
   *
   * @param {jQuery.Event} evt JQuery event with the mouse position.
   */
  this._getMousePosition = function (evt) {
    if (evt.pageX === undefined || evt.pageY === undefined) {
      return;
    }
    var offset = $node.offset(), dt, t;

    t = (new Date()).valueOf();
    dt = t - m_mouse.time;
    m_mouse.time = t;
    m_mouse.deltaTime = dt;
    m_mouse.velocity = {
      x: (evt.pageX - m_mouse.page.x) / dt,
      y: (evt.pageY - m_mouse.page.y) / dt
    };
    m_mouse.page = {
      x: evt.pageX,
      y: evt.pageY
    };
    m_mouse.map = {
      x: evt.pageX - offset.left,
      y: evt.pageY - offset.top
    };
    try {
      m_mouse.geo = m_this.map().displayToGcs(m_mouse.map);
      m_mouse.mapgcs = m_this.map().displayToGcs(m_mouse.map, null);
    } catch (e) {
      // catch georeferencing problems and move on
      m_mouse.geo = m_mouse.mapgcs = null;
    }
  };

  /**
   * Stores the current mouse button state in the m_mouse object.
   *
   * @param {jQuery.Event} evt The event that trigger the mouse action.
   */
  this._getMouseButton = function (evt) {
    if (evt.buttons === undefined && evt.which === undefined) {
      return;
    }
    for (var prop in m_mouse.buttons) {
      if (m_mouse.buttons.hasOwnProperty(prop)) {
        m_mouse.buttons[prop] = false;
      }
    }
    /* If the event buttons are specified, use them in preference to the
     * evt.which for determining which buttons are down.  buttons is a bitfield
     * and therefore can represent more than one button at a time. */
    if (evt.buttons !== undefined) {
      m_mouse.buttons.left = !!(evt.buttons & 1);
      m_mouse.buttons.right = !!(evt.buttons & 2);
      m_mouse.buttons.middle = !!(evt.buttons & 4);
    } else if (evt.type !== 'mouseup') {
      /* If we don't evt.buttons, fall back to which, but not on mouseup. */
      switch (evt.which) {
        case 1: m_mouse.buttons.left = true; break;
        case 2: m_mouse.buttons.middle = true; break;
        case 3: m_mouse.buttons.right = true; break;
      }
    }
    /* When handling touch events, evt.which can be a string, in which case
     * handle a "button" with that name -- a non-integer string will not
     * evaluate as being between 1 and 3. */
    if (evt.which && !(evt.which >= 1 && evt.which <= 3)) {
      m_mouse.buttons[evt.which] = true;
    }
  };

  /**
   * Stores the current keyboard modifiers from an event in the m_mouse
   * object.
   *
   * @param {jQuery.Event} evt JQuery event with the keyboard modifiers.
   */
  this._getMouseModifiers = function (evt) {
    m_mouse.modifiers.alt = evt.altKey;
    m_mouse.modifiers.ctrl = evt.ctrlKey;
    m_mouse.modifiers.meta = evt.metaKey;
    m_mouse.modifiers.shift = evt.shiftKey;
  };

  /**
   * Compute a selection information object.
   *
   * @private
   * @returns {geo.brushSelection}
   */
  this._getSelection = function () {
    var origin = m_state.origin,
        mouse = m_this.mouse(),
        map = m_this.map(),
        display = {}, gcs = {};

    let mousexy = mouse.map;
    if (m_state.actionRecord && util.isFunction(m_state.actionRecord.selectionConstraint)) {
      const constraint = m_state.actionRecord.selectionConstraint(mouse.mapgcs, origin.mapgcs);
      mousexy = constraint ? map.gcsToDisplay(constraint.pos, null) : mousexy;
    } else if (mouse.modifiers.shift) {
      const width =  Math.abs((mousexy.x - origin.map.x) * (mousexy.y - origin.map.y)) ** 0.5;
      mousexy = {
        x: origin.map.x + Math.sign(mousexy.x - origin.map.x) * width,
        y: origin.map.y + Math.sign(mousexy.y - origin.map.y) * width
      };
    }
    // Get the display coordinates
    display.upperLeft = {
      x: Math.min(origin.map.x, mousexy.x),
      y: Math.min(origin.map.y, mousexy.y)
    };

    display.lowerRight = {
      x: Math.max(origin.map.x, mousexy.x),
      y: Math.max(origin.map.y, mousexy.y)
    };

    display.upperRight = {
      x: display.lowerRight.x,
      y: display.upperLeft.y
    };

    display.lowerLeft = {
      x: display.upperLeft.x,
      y: display.lowerRight.y
    };

    // Get the gcs coordinates
    gcs.upperLeft = map.displayToGcs(display.upperLeft, null);
    gcs.lowerRight = map.displayToGcs(display.lowerRight, null);
    gcs.upperRight = map.displayToGcs(display.upperRight, null);
    gcs.lowerLeft = map.displayToGcs(display.lowerLeft, null);

    m_selectionQuad.data([{
      ul: gcs.upperLeft,
      ur: gcs.upperRight,
      ll: gcs.lowerLeft,
      lr: gcs.lowerRight
    }]);
    m_selectionQuad.draw();

    return {
      display: display,
      gcs: gcs,
      mouse: mouse,
      origin: Object.assign({}, m_state.origin)
    };
  };

  /**
   * Immediately cancel an ongoing action.
   *
   * @param {string?} action The action type, if `null` cancel any action.
   * @param {boolean} [keepQueue] If truthy, keep the queue event if an action
   *    is canceled.
   * @returns {boolean} Set if an action was canceled.
   */
  this.cancel = function (action, keepQueue) {
    var out;
    if (!action) {
      out = !!m_state.action;
    } else {
      out = m_state.action === action;
    }
    if (out) {
      // cancel any queued interaction events
      if (!keepQueue) {
        m_queue = {};
      }
      clearState();
    }
    return out;
  };

  /**
   * Set the value of whether a click is possible.  Cancel any outstanding
   * timer for this process.
   *
   * @param {false|object} value If `false`, there is no chance of a future
   *    click.  If an object with coordinates and the mouse button state,
   *    a click is possible if the same button is released nearby.
   */
  this._setClickMaybe = function (value) {
    m_clickMaybe = value;
    if (m_clickMaybeTimeout) {
      window.clearTimeout(m_clickMaybeTimeout);
      m_clickMaybeTimeout = null;
    }
  };

  /**
   * Handle event when a mouse button is pressed.
   *
   * @param {jQuery.Event} evt The event that triggered this.
   * @fires geo.event.brushstart
   * @fires geo.event.actiondown
   * @fires geo.event.mousedown
   */
  this._handleMouseDown = function (evt) {
    var action, actionRecord;

    if (m_paused) {
      return;
    }
    /* In some scenarios, we get both a tap event and then, somewhat later, a
     * set of mousedown/mouseup events.  Ignore the spurious down/up set if we
     * just handled a tap. */
    if (m_touchHandler && m_touchHandler.lastEventType === 'tap' &&
        (new Date()).valueOf() - m_touchHandler.lastTime < 1000) {
      return;
    }

    m_this._getMousePosition(evt);
    m_this._getMouseButton(evt);
    m_this._getMouseModifiers(evt);

    m_this.map().geoTrigger(geo_event.mousedown, m_this.mouse());

    if (m_options.click.enabled &&
        (!m_mouse.buttons.left || m_options.click.buttons.left) &&
        (!m_mouse.buttons.right || m_options.click.buttons.right) &&
        (!m_mouse.buttons.middle || m_options.click.buttons.middle)) {
      m_this._setClickMaybe({
        x: m_mouse.page.x,
        y: m_mouse.page.y,
        buttons: Object.assign({}, m_mouse.buttons)
      });
      if (m_options.click.duration > 0) {
        m_clickMaybeTimeout = window.setTimeout(function () {
          m_clickMaybe = false;
          m_clickMaybeTimeout = null;
        }, m_options.click.duration);
      }
    }
    actionRecord = actionMatch(m_mouse.buttons, m_mouse.modifiers,
                               m_options.actions);
    action = (actionRecord || {}).action;

    var map = m_this.map();
    // cancel transitions and momentum on click
    map.transitionCancel('_handleMouseDown' + (action ? '.' + action : ''));
    m_this.cancel(geo_action.momentum);

    m_mouse.velocity = {
      x: 0,
      y: 0
    };

    if (action) {
      // cancel any ongoing interaction queue
      m_queue = {
        kind: 'move'
      };

      // store the state object
      m_nextStateId += 1;
      m_state = {
        action: action,
        actionRecord: actionRecord,
        origin: util.deepMerge({}, m_mouse),
        initialZoom: map.zoom(),
        initialRotation: map.rotation(),
        initialEventRotation: evt.rotation,
        stateId: m_nextStateId,
        delta: {x: 0, y: 0}
      };

      if (actionRecord.selectionRectangle) {
        // Make sure the old selection layer is gone.
        if (m_selectionLayer) {
          m_selectionLayer.clear();
          map.deleteLayer(m_selectionLayer);
          m_selectionLayer = null;
        }
        m_selectionLayer = map.createLayer(
          'feature', {features: [quadFeature.capabilities.color], autoshareRenderer: false});
        m_selectionQuad = m_selectionLayer.createFeature(
          'quad', {gcs: map.gcs()});
        m_selectionQuad.style({
          opacity: 0.25,
          color: {r: 0.3, g: 0.3, b: 0.3}
        });
        map.geoTrigger(geo_event.brushstart, m_this._getSelection());
      }
      map.geoTrigger(geo_event.actiondown, {
        state: m_this.state(), mouse: m_this.mouse(), event: evt});

      // bind temporary handlers to document
      if (m_options.throttle > 0) {
        $(document).on(
          'mousemove.geojs',
          throttle(
            m_options.throttle,
            m_this._handleMouseMoveDocument
          )
        );
      } else {
        $(document).on('mousemove.geojs', m_this._handleMouseMoveDocument);
      }
      $(document).on('mouseup.geojs', m_this._handleMouseUpDocument);
      m_state.boundDocumentHandlers = true;
    }

  };

  /**
   * Handle mouse move event.
   *
   * @param {jQuery.Event} evt The event that triggered this.
   * @fires geo.event.mousemove
   */
  this._handleMouseMove = function (evt) {
    if (m_paused) {
      return;
    }

    if (m_state.boundDocumentHandlers) {
      // If currently performing a navigation action, the mouse
      // coordinates will be captured by the document handler.
      return;
    }

    if (m_options.click.cancelOnMove && m_clickMaybe) {
      m_this._setClickMaybe(false);
    }

    m_this._getMousePosition(evt);
    m_this._getMouseButton(evt);
    m_this._getMouseModifiers(evt);

    if (m_clickMaybe) {
      return;
    }

    m_this.map().geoTrigger(geo_event.mousemove, m_this.mouse());
  };

  /**
   * Handle the zoomrotate action.
   *
   * @param {object} evt The mouse event that triggered this.
   */
  this._handleZoomrotate = function (evt) {
    /* Only zoom if we have once exceeded the initial zoom threshold. */
    var deltaZoom = Math.log2(evt.scale);
    if (!m_state.zoomrotateAllowZoom && deltaZoom &&
        Math.abs(deltaZoom) >= Math.log2(1 + m_options.zoomrotateMinimumZoom)) {
      if (m_options.zoomrotateMinimumZoom) {
        m_state.initialZoom -= deltaZoom;
      }
      m_state.zoomrotateAllowZoom = true;
    }
    if (m_state.zoomrotateAllowZoom && deltaZoom) {
      var zoom = m_state.initialZoom + deltaZoom;
      m_this.map().zoom(zoom, m_state.origin);
    }
    /* Only rotate if we have once exceeded the initial rotation threshold.  The
     * first time this happens (if the threshold is greater than zero), set the
     * start of rotation to the current position, so that there is no sudden
     * jump. */
    var deltaTheta = (evt.rotation - m_state.initialEventRotation) * Math.PI / 180;
    if (!m_state.zoomrotateAllowRotation && deltaTheta &&
        Math.abs(deltaTheta) >= m_options.zoomrotateMinimumRotation) {
      if (m_options.zoomrotateMinimumRotation) {
        m_state.initialEventRotation = evt.rotation;
        deltaTheta = 0;
      }
      m_state.zoomrotateAllowRotation = true;
    }
    if (m_state.zoomrotateAllowRotation) {
      var theta = m_state.initialRotation + deltaTheta;
      /* Compute the delta in the range of [-PI, PI). */
      deltaTheta = util.wrapAngle(theta - m_this.map().rotation());
      /* If we reverse direction, don't rotate until some threshold is
       * exceeded.  This helps prevent rotation bouncing while panning. */
      if (deltaTheta && (deltaTheta * (m_state.lastRotationDelta || 0) >= 0 ||
          Math.abs(deltaTheta) >= m_options.zoomrotateReverseRotation)) {
        m_this.map().rotation(theta, m_state.origin);
        m_state.lastRotationDelta = deltaTheta;
      }
    }
    /* Only pan if we have once exceed the initial pan threshold. */
    var panOrigin = m_state.origin.page;
    if (m_state.initialEventGeo) {
      var offset = $node.offset();
      panOrigin = m_this.map().gcsToDisplay(m_state.initialEventGeo);
      panOrigin.x += offset.left;
      panOrigin.y += offset.top;
    }
    var x = evt.pageX, deltaX = x - panOrigin.x,
        y = evt.pageY, deltaY = y - panOrigin.y,
        deltaPan2 = deltaX * deltaX + deltaY * deltaY;
    if (!m_state.zoomrotateAllowPan && deltaPan2 &&
        deltaPan2 >= m_options.zoomrotateMinimumPan * m_options.zoomrotateMinimumPan) {
      if (m_options.zoomrotateMinimumPan) {
        deltaX = deltaY = 0;
        m_state.initialEventGeo = m_this.mouse().geo;
      } else {
        m_state.initialEventGeo = m_state.origin.geo;
      }
      m_state.zoomrotateAllowPan = true;
    }
    if (m_state.zoomrotateAllowPan && (deltaX || deltaY)) {
      m_this.map().pan({x: deltaX, y: deltaY});
    }
  };

  /**
   * Handle mouse move event on the document (temporary bindings).
   *
   * @param {jQuery.Event} evt The event that triggered this.
   * @fires geo.event.brush
   * @fires geo.event.actionmove
   */
  this._handleMouseMoveDocument = function (evt) {
    var dx, dy, selectionObj;

    // If the map has been disconnected, we do nothing.
    if (!m_this.map()) {
      return;
    }

    if (m_paused || m_queue.kind !== 'move') {
      return;
    }

    m_this._getMousePosition(evt);
    m_this._getMouseButton(evt);
    m_this._getMouseModifiers(evt);

    /* Only cancel possible clicks on move if we actually moved */
    if (m_options.click.cancelOnMove) {
      if (m_clickMaybe.x === undefined || m_clickMaybe.y === undefined) {
        m_this._setClickMaybe(false);
      } else if (m_options.click.cancelOnMove === true &&
                 (m_mouse.page.x !== m_clickMaybe.x ||
                  m_mouse.page.y !== m_clickMaybe.y)) {
        m_this._setClickMaybe(false);
      } else {
        const dist = Math.sqrt((m_mouse.page.x - m_clickMaybe.x) ** 2 + (m_mouse.page.y - m_clickMaybe.y) ** 2);
        if (dist >= m_options.click.cancelOnMove) {
          m_this._setClickMaybe(false);
        }
      }
    }
    if (m_clickMaybe) {
      return;
    }

    if (!m_state.action) {
      // This shouldn't happen
      console.log('WARNING: Invalid state in mapInteractor.');
      return;
    }

    // calculate the delta from the origin point to avoid
    // accumulation of floating point errors
    dx = m_mouse.map.x - m_state.origin.map.x - m_state.delta.x;
    dy = m_mouse.map.y - m_state.origin.map.y - m_state.delta.y;
    m_state.delta.x += dx;
    m_state.delta.y += dy;

    if (m_state.action === geo_action.pan) {
      m_this.map().pan({x: dx, y: dy}, undefined, 'limited');
    } else if (m_state.action === geo_action.zoom) {
      m_callZoom(-dy * m_options.zoomScale / 120, m_state);
    } else if (m_state.action === geo_action.rotate) {
      var cx, cy;
      if (m_state.origin.rotation === undefined) {
        cx = m_state.origin.map.x - m_this.map().size().width / 2;
        cy = m_state.origin.map.y - m_this.map().size().height / 2;
        m_state.origin.rotation = m_this.map().rotation() - Math.atan2(cy, cx);
      }
      cx = m_mouse.map.x - m_this.map().size().width / 2;
      cy = m_mouse.map.y - m_this.map().size().height / 2;
      m_this.map().rotation(m_state.origin.rotation + Math.atan2(cy, cx));
    } else if (m_state.action === geo_action.zoomrotate) {
      m_this._handleZoomrotate(evt);
    } else if (m_state.actionRecord.selectionRectangle) {
      // Get the bounds of the current selection
      selectionObj = m_this._getSelection();
      m_this.map().geoTrigger(geo_event.brush, selectionObj);
    }
    m_this.map().geoTrigger(geo_event.actionmove, {
      state: m_this.state(), mouse: m_this.mouse(), event: evt});

    // Prevent default to stop text selection in particular
    evt.preventDefault();
  };

  /**
   * Clear the action state, but remember if we have bound document handlers.
   * @private
   */
  function clearState() {
    m_state = {boundDocumentHandlers: m_state.boundDocumentHandlers};
  }

  /**
   * Use interactor options to modify the mouse velocity by momentum
   * or spring equations depending on the current map state.
   * @private
   * @param {object} v Current velocity in pixels / millisecond.
   * @param {number} deltaT The time delta.
   * @returns {object} New velocity.
   */
  function modifyVelocity(v, deltaT) {
    deltaT = deltaT <= 0 ? 30 : deltaT;
    var sf = springForce();
    var speed = calcSpeed(v);
    var vx = v.x / speed;
    var vy = v.y / speed;

    speed = speed * Math.exp(-m_options.momentum.drag * deltaT);

    // |force| + |velocity| < c <- stopping condition
    if (calcSpeed(sf) * deltaT + speed < m_options.momentum.minSpeed) {
      return null;
    }

    if (speed > 0) {
      vx = vx * speed;
      vy = vy * speed;
    } else {
      vx = 0;
      vy = 0;
    }

    return {
      x: vx - sf.x * deltaT,
      y: vy - sf.y * deltaT
    };
  }

  /**
   * Get the spring force for the current map bounds.
   * @private
   * @returns {object} The spring force.
   */
  function springForce() {
    var xplus,  // force to the right
        xminus, // force to the left
        yplus,  // force to the top
        yminus; // force to the bottom

    if (!m_options.spring.enabled) {
      return {x: 0, y: 0};
    }
    // get screen coordinates of corners
    var maxBounds = m_this.map().maxBounds(undefined, null);
    var ul = m_this.map().gcsToDisplay({
      x: maxBounds.left,
      y: maxBounds.top
    }, null);
    var lr = m_this.map().gcsToDisplay({
      x: maxBounds.right,
      y: maxBounds.bottom
    }, null);

    var c = m_options.spring.springConstant;
    // Arg... map needs to expose the canvas size
    var width = m_this.map().node().width();
    var height = m_this.map().node().height();

    xplus = c * Math.max(0, ul.x);
    xminus = c * Math.max(0, width - lr.x);
    yplus = c * Math.max(0, ul.y) / 2;
    yminus = c * Math.max(0, height - lr.y) / 2;

    return {
      x: xplus - xminus,
      y: yplus - yminus
    };
  }

  /**
   * Based on the screen coordinates of a selection, zoom or unzoom and
   * recenter.
   *
   * @private
   * @param {string} action Either `geo_action.zoomselect` or
   *    `geo_action.unzoomselect`.
   * @param {object} lowerLeft The x and y coordinates of the lower left corner
   *    of the zoom rectangle.
   * @param {object} upperRight The x and y coordinates of the upper right
   *    corner of the zoom rectangle.
   */
  this._zoomFromSelection = function (action, lowerLeft, upperRight) {
    if (action !== geo_action.zoomselect && action !== geo_action.unzoomselect) {
      return;
    }
    if (lowerLeft.x === upperRight.x || lowerLeft.y === upperRight.y) {
      return;
    }
    var zoom, center,
        map = m_this.map(),
        mapsize = map.size();
    /* To arbitrarily handle rotation and projection, we center the map at the
     * central coordinate of the selection and set the zoom level such that the
     * four corners are just barely on the map.  When unzooming (zooming out),
     * we ensure that the previous view is centered in the selection but use
     * the maximal size for the zoom factor. */
    var scaling = {
      x: Math.abs((upperRight.x - lowerLeft.x) / mapsize.width),
      y: Math.abs((upperRight.y - lowerLeft.y) / mapsize.height)
    };
    if (action === geo_action.zoomselect) {
      center = map.displayToGcs({
        x: (lowerLeft.x + upperRight.x) / 2,
        y: (lowerLeft.y + upperRight.y) / 2
      }, null);
      zoom = map.zoom() - Math.log2(Math.max(scaling.x, scaling.y));
    } else {  /* unzoom */
      /* To make the existing visible map entirely within the selection
       * rectangle, this would be changed to Math.min instead of Math.max of
       * the scaling factors.  This felt wrong, though. */
      zoom = map.zoom() + Math.log2(Math.max(scaling.x, scaling.y));
      /* Record the current center.  Later, this is panned to the center of the
       * selection rectangle. */
      center = map.center(undefined, null);
    }
    /* When discrete zoom is enable, always round down.  We have to do this
     * explicitly, as otherwise we may zoom too far and the selection will not
     * be completely visible. */
    if (map.discreteZoom()) {
      zoom = Math.floor(zoom);
    }
    map.zoom(zoom);
    if (action === geo_action.zoomselect) {
      map.center(center, null);
    } else {
      var newcenter = map.gcsToDisplay(center, null);
      map.pan({
        x: (lowerLeft.x + upperRight.x) / 2 - newcenter.x,
        y: (lowerLeft.y + upperRight.y) / 2 - newcenter.y
      });
    }
  };

  /**
   * Handle event when a mouse button is unpressed on the document.
   * Removes temporary bindings.
   *
   * @param {jQuery.Event} evt The event that triggered this.
   * @fires geo.event.brushend
   * @fires geo.event.actionselection
   * @fires geo.event.actionup
   * @fires geo.event.mouseup
   * @fires geo.event.select
   * @fires geo.event.zoomselect
   * @fires geo.event.unzoomselect
   */
  this._handleMouseUpDocument = function (evt) {
    var selectionObj, oldAction;

    if (m_paused) {
      return;
    }

    // cancel queued interactions
    m_queue = {};

    m_this._setClickMaybe(false);
    m_this._getMouseButton(evt);
    m_this._getMouseModifiers(evt);

    // unbind temporary handlers on document
    $(document).off('.geojs');
    m_state.boundDocumentHandlers = false;

    if (m_mouse.buttons.right) {
      evt.preventDefault();
    }

    if (m_state.actionRecord && m_state.actionRecord.selectionRectangle) {
      m_this._getMousePosition(evt);
      selectionObj = m_this._getSelection();

      m_selectionLayer.clear();
      m_this.map().deleteLayer(m_selectionLayer);
      m_selectionLayer = null;
      m_selectionQuad = null;

      m_this.map().geoTrigger(geo_event.brushend, selectionObj);
      if (m_state.actionRecord.selectionRectangle !== true) {
        m_this.map().geoTrigger(m_state.actionRecord.selectionRectangle,
                                selectionObj);
      }
      m_this._zoomFromSelection(m_state.action, selectionObj.display.lowerLeft,
                                selectionObj.display.upperRight);
      m_this.map().geoTrigger(geo_event.actionselection, {
        state: m_this.state(),
        mouse: m_this.mouse(),
        event: evt,
        lowerLeft: selectionObj.display.lowerLeft,
        upperRight: selectionObj.display.upperRight});
    }
    m_this.map().geoTrigger(geo_event.actionup, {
      state: m_this.state(), mouse: m_this.mouse(), event: evt});

    // reset the interactor state
    oldAction = m_state.action;
    clearState();

    // if momentum is enabled, start the action here
    if (m_options.momentum.enabled && m_options.momentum.actions.includes(oldAction)) {
      var t = (new Date()).valueOf();
      var dt = t - m_mouse.time + m_mouse.deltaTime;
      if (t - m_mouse.time < m_options.momentum.stopTime) {
        m_mouse.velocity.x *= m_mouse.deltaTime / dt;
        m_mouse.velocity.y *= m_mouse.deltaTime / dt;
        m_mouse.deltaTime = dt;
      } else {
        m_mouse.velocity.x = m_mouse.velocity.y = 0;
      }
      m_this.springBack(true, oldAction);
    }
  };

  /**
   * Handle event when a mouse button is unpressed.
   *
   * @param {jQuery.Event} evt The event that triggered this.
   */
  this._handleMouseUp = function (evt) {
    if (m_paused) {
      return;
    }

    m_this._getMouseButton(evt);

    if (m_clickMaybe) {
      m_this._handleMouseClick(evt);
    }
    var details = m_this.mouse();
    details.buttonsDown = m_clickMaybe.buttons;
    m_this.map().geoTrigger(geo_event.mouseup, details);
  };

  /**
   * Handle event when a mouse click is detected.  A mouse click is a simulated
   * event that occurs when the time between a mouse down and mouse up
   * is less than the configured duration and (optionally) if no mousemove
   * events were triggered in the interim.
   *
   * @param {jQuery.Event} evt The event that triggered this.
   * @fires geo.event.mouseup
   */
  this._handleMouseClick = function (evt) {

    /* Cancel a selection if it is occurring */
    if (m_state.actionRecord && m_state.actionRecord.selectionRectangle) {
      m_selectionLayer.clear();
      m_this.map().deleteLayer(m_selectionLayer);
      m_selectionLayer = null;
      m_selectionQuad = null;
      m_state.action = m_state.actionRecord = null;
    }
    m_this._getMouseButton(evt);
    m_this._getMouseModifiers(evt);

    // cancel any ongoing pan action
    m_this.cancel(geo_action.pan);

    // unbind temporary handlers on document
    $(document).off('.geojs');
    m_state.boundDocumentHandlers = false;
    // add information about the button state to the event information
    var details = m_this.mouse();
    details.buttonsDown = m_clickMaybe.buttons;
    details.evt = evt;

    // reset click detector variable
    m_this._setClickMaybe(false);
    // fire a click event
    m_this.map().geoTrigger(geo_event.mouseclick, details);
  };

  /**
   * Private wrapper around the map zoom method that is debounced to support
   * discrete zoom interactions.
   *
   * @returns {function} A function to handle zooms that debounces such
   *    events.  This function is passed the zoom level and the mouse state.
   */
  function debounced_zoom() {
    var deltaZ = 0, delay = 400, origin, startZoom, targetZoom;

    function accum(dz, org) {
      var map = m_this.map(), zoom;

      origin = util.deepMerge({}, org);
      deltaZ += dz;
      if (targetZoom === undefined) {
        startZoom = targetZoom = map.zoom();
      }
      targetZoom += dz;

      // Respond to debounced events when they add up to a change in the
      // discrete zoom level.
      if (map && Math.abs(deltaZ) >= 1 && m_options.discreteZoom &&
            !m_options.zoomAnimation.enabled) {

        zoom = Math.round(deltaZ + map.zoom());

        // delta is what is left over from the zoom delta after the new zoom
        // value
        deltaZ = deltaZ + map.zoom() - zoom;

        map.zoom(zoom, origin);
      }

    }

    function apply() {
      var map = m_this.map(), zoom;
      if (map) {
        if (m_options.zoomAnimation.enabled) {
          zoom = targetZoom;
          if (m_options.discreteZoom) {
            zoom = Math.round(zoom);
            if (zoom === startZoom && targetZoom !== startZoom) {
              zoom = startZoom + (targetZoom > startZoom ? 1 : -1);
            }
          }
          zoom = Math.round(map._fix_zoom(zoom) * 1e6) / 1e6;
          if (zoom !== map.zoom()) {
            map.transitionCancel('debounced_zoom.' + geo_action.zoom);
            map.transition({
              zoom: zoom,
              zoomOrigin: origin,
              duration: m_options.zoomAnimation.duration,
              ease: m_options.zoomAnimation.ease,
              done: function (status) {
                status = status || {};
                var zoomRE = new RegExp('\\.' + geo_action.zoom + '$');
                if (!status.next && (!status.cancel ||
                    ('' + status.source).search(zoomRE) < 0)) {
                  targetZoom = undefined;
                }
                /* If we were animating the zoom, if the zoom is continuous, just
                 * stop where we are.  If using discrete zoom, we need to make
                 * sure we end up discrete.  However, we don't want to do that if
                 * the next action is further zooming. */
                if (m_options.discreteZoom && status.cancel &&
                    status.transition && status.transition.end &&
                    ('' + status.source).search(zoomRE) < 0) {
                  map.zoom(status.transition.end.zoom,
                           status.transition.end.zoomOrigin);
                }
              }
            });
          } else {
            targetZoom = undefined;
          }
        } else {
          zoom = deltaZ + map.zoom();
          if (m_options.discreteZoom) {
            // round off the zoom to an integer and throw away the rest
            zoom = Math.round(zoom);
          }
          map.zoom(zoom, origin);
        }
      }
      deltaZ = 0;
    }

    if (m_options.discreteZoom !== true && m_options.discreteZoom > 0) {
      delay = m_options.discreteZoom;
    }
    if ((m_options.discreteZoom === true || m_options.discreteZoom > 0) &&
            !m_options.zoomAnimation.enabled) {
      return debounce(delay, false, apply, accum);
    } else {
      return function (dz, org) {
        if (!dz && targetZoom === undefined) {
          return;
        }
        accum(dz, org);
        apply();
      };
    }
  }

  /**
   * Attaches wrapped methods for accumulating fast mouse wheel events and
   * throttling map interactions.
   * @private
   *
   * @returns {function} A function that takes a jQuery.Event for mouse wheel
   *    actions.
   * @fires geo.event.actionwheel
   */
  function throttled_wheel() {
    var my_queue = {};

    function accum(evt) {
      var dx, dy;

      if (m_paused) {
        return;
      }

      if (my_queue !== m_queue) {
        my_queue = {
          kind: 'wheel',
          scroll: {x: 0, y: 0}
        };
        m_queue = my_queue;
      }

      evt.preventDefault();

      // try to normalize deltas using the wheel event standard:
      //   https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent
      evt.deltaFactor = 1;
      if (evt.originalEvent.deltaMode === 1) {
        // DOM_DELTA_LINE -- estimate line height
        evt.deltaFactor = 40;
      } else if (evt.originalEvent.deltaMode === 2) {
        // DOM_DELTA_PAGE -- get window height
        evt.deltaFactor = $(window).height();
      }

      // prevent NaN's on legacy browsers
      dx = evt.originalEvent.deltaX || 0;
      dy = evt.originalEvent.deltaY || 0;

      // scale according to the options
      dx = dx * m_options.wheelScaleX * evt.deltaFactor / 120;
      dy = dy * m_options.wheelScaleY * evt.deltaFactor / 120;

      my_queue.scroll.x += dx;
      my_queue.scroll.y += dy;
    }

    function wheel(evt) {
      var zoomFactor, action, actionRecord;

      // If the current queue doesn't match the queue passed in as an argument,
      // assume it was cancelled and do nothing.
      if (my_queue !== m_queue) {
        return;
      }

      // perform the map navigation event
      m_this._getMousePosition(evt);
      m_this._getMouseButton(evt);
      m_this._getMouseModifiers(evt);

      actionRecord = actionMatch({wheel: true}, m_mouse.modifiers,
                                 m_options.actions);
      action = (actionRecord || {}).action;

      if (action) {
        // if we were moving because of momentum or a transition, cancel it and
        // recompute where the mouse action is occurring.
        var recompute = m_this.map().transitionCancel('wheel.' + action);
        recompute |= m_this.cancel(geo_action.momentum, true);
        if (recompute) {
          m_mouse.geo = m_this.map().displayToGcs(m_mouse.map);
          m_mouse.mapgcs = m_this.map().displayToGcs(m_mouse.map, null);
        }
        switch (action) {
          case geo_action.pan:
            m_this.map().pan({
              x: m_queue.scroll.x,
              y: m_queue.scroll.y
            }, undefined, 'limited');
            break;
          case geo_action.zoom:
            zoomFactor = -m_queue.scroll.y;
            m_callZoom(zoomFactor, m_mouse);
            break;
          case geo_action.rotate:
            m_this.map().rotation(
              m_this.map().rotation() +
              m_queue.scroll.y * m_options.rotateWheelScale,
              m_mouse);
            break;
        }
        m_this.map().geoTrigger(geo_event.actionwheel, {
          state: m_this.state(), mouse: m_this.mouse(), eventData: evt});
      }

      // reset the queue
      m_queue = {};
    }

    if (m_options.throttle > 0) {
      return throttle(m_options.throttle, false, wheel, accum);
    } else {
      return function (evt) {
        accum(evt);
        wheel(evt);
      };
    }
  }

  /**
   * Handle mouse wheel event.  (Defined inside _connectEvents).
   */
  this._handleMouseWheel = function () {};

  /**
   * Start up a spring back action when the map bounds are out of range.
   * Not to be user callable.
   * @protected
   *
   * @param {boolean} initialVelocity Truthy if the mouse's current velocity
   *    should be used as the initial velocity.  False to assume no initial
   *    velocity.
   * @param {object} origAction The original action that started the spring
   *    back.  If this was a zoom action, the spring back includes zoom;
   *    otherwise it only includes panning.
   */
  this.springBack = function (initialVelocity, origAction) {
    if (m_state.action === geo_action.momentum) {
      return;
    }
    if (!initialVelocity) {
      m_mouse.velocity = {
        x: 0,
        y: 0
      };
    }
    m_state.origAction = origAction;
    m_state.action = geo_action.momentum;
    m_state.origin = m_this.mouse();
    m_state.momentum = m_this.mouse();
    m_state.start = new Date();
    m_state.handler = function () {
      var v, s, last, dt;

      if (m_state.action !== geo_action.momentum ||
          !m_this.map() ||
          m_this.map().transition()) {
        // cancel if a new action was performed
        return;
      }
      // Not sure the correct way to do this.  We need the delta t for the
      // next time step...  Maybe use a better interpolator and the time
      // parameter from requestAnimationFrame.
      dt = Math.min(m_state.momentum.deltaTime, 30);

      last = m_state.start.valueOf();
      m_state.start = new Date();

      v = modifyVelocity(m_state.momentum.velocity, m_state.start - last);

      // stop panning when the speed is below the threshold
      if (!v) {
        clearState();
        return;
      }

      s = calcSpeed(v);
      if (s > m_options.momentum.maxSpeed) {
        s = m_options.momentum.maxSpeed / s;
        v.x = v.x * s;
        v.y = v.y * s;
      }

      if (!isFinite(v.x) || !isFinite(v.y)) {
        v.x = 0;
        v.y = 0;
      }
      m_state.momentum.velocity.x = v.x;
      m_state.momentum.velocity.y = v.y;

      switch (m_state.origAction) {
        case geo_action.zoom:
          var dy = m_state.momentum.velocity.y * dt;
          m_callZoom(-dy * m_options.zoomScale / 120, m_state);
          break;
        default:
          m_this.map().pan({
            x: m_state.momentum.velocity.x * dt,
            y: m_state.momentum.velocity.y * dt
          }, undefined, 'limited');
          break;
      }

      if (m_state.handler) {
        m_this.map().scheduleAnimationFrame(m_state.handler);
      }
    };
    if (m_state.handler) {
      m_this.map().scheduleAnimationFrame(m_state.handler);
    }
  };

  /**
   * Public method that unbinds all events.
   */
  this.destroy = function () {
    m_this._disconnectEvents();
    if (m_this.map()) {
      $(m_this.map().node()).removeClass('highlight-focus');
    }
    m_this.map(null);
  };

  /**
   * Get current mouse information.
   *
   * @returns {object} The current mouse state.
   */
  this.mouse = function () {
    return util.deepMerge({}, m_mouse);
  };

  /**
   * Get/set current keyboard information.
   *
   * @param {object} [newValue] Either a object with new options for the
   *    keyboard actions or `undefined` to get the current options.
   * @returns {object|this} Either the current keyboard options or this
   *    mapInteractor class instance.
   */
  this.keyboard = function (newValue) {
    if (newValue === undefined) {
      return util.deepMerge({}, m_options.keyboard || {});
    }
    return m_this.options({keyboard: newValue});
  };

  /**
   * Get the current interactor state.
   *
   * @returns {geo.interactorState}
   */
  this.state = function () {
    return util.deepMerge({}, m_state);
  };

  /**
   * Get or set the pause state of the interactor, which ignores all native
   * mouse and keyboard events.
   *
   * @param {boolean} [value] The pause state to set or undefined to return
   *    the current state.
   * @returns {boolean|this} The current pause state or this class instance.
   */
  this.pause = function (value) {
    if (value === undefined) {
      return m_paused;
    }
    m_paused = !!value;
    return m_this;
  };

  /**
   * Add an action to the list of handled actions.
   *
   * @param {object} action An object defining the action.  This must have
   *    action and input properties, and may have modifiers, name, and owner.
   *    Use action, name, and owner to make this entry distinct if it will need
   *    to be removed later.
   * @param {boolean} [toEnd] The action is added at the beginning of the
   *    actions list unless truthy.  Earlier actions prevent later actions with
   *    similar input and modifiers.
   */
  this.addAction = function (action, toEnd) {
    if (!action || !action.action || !action.input) {
      return;
    }
    util.addAction(m_options.actions, action, toEnd);
    if (m_options.map && m_options.actions.some(function (action) {
      return action.input.right;
    })) {
      $node.off('contextmenu.geojs');
      $node.on('contextmenu.geojs', function () { return false; });
    }
  };

  /**
   * Check if an action is in the actions list.  An action matches if the
   * action, name, and owner match.  A null or undefined value will match all
   * actions.  If using an action object, this is the same as passing
   * (action.action, action.name, action.owner).
   *
   * @param {object|string} action Either an action object or the name of an
   *    action.
   * @param {string} name Optional name associated with the action.
   * @param {string} owner Optional owner associated with the action.
   * @returns {object|null} The first matching action or null.
   */
  this.hasAction = function (action, name, owner) {
    return util.hasAction(m_options.actions, action, name, owner);
  };

  /**
   * Remove all matching actions.  Actions are matched as with hasAction.
   *
   * @param {object|string} action Either an action object or the name of an
   *    action.
   * @param {string} name Optional name associated with the action.
   * @param {string} owner Optional owner associated with the action.
   * @returns {number} The number of actions that were removed.
   */
  this.removeAction = function (action, name, owner) {
    var removed = util.removeAction(
      m_options.actions, action, name, owner);
    if (m_options.map && !m_options.actions.some(function (action) {
      return action.input.right;
    })) {
      $node.off('contextmenu.geojs');
    }
    return removed;
  };

  /**
   * Simulate a DOM mouse event on connected map.  Not all options are required
   * for every event type.
   *
   * @param {string} type Event type `mousemove`, `mousedown`, `mouseup`, etc.
   *    `keyboard` is treated separately from other types.
   * @param {object} options Options for the simulated event.
   * @param {boolean} [options.shift] A boolean if a `keyboard` event has the
   *    shift key down or explicitly up.
   * @param {boolean} [options.shiftKey] Alternate name to `shift`.
   * @param {boolean} [options.ctrl] A boolean if a `keyboard` event has the
   *    control key down or explicitly up.
   * @param {boolean} [options.ctrlKey] Alternate name to `ctrl`.
   * @param {boolean} [options.alt] A boolean if a `keyboard` event has the
   *    alt key down or explicitly up.
   * @param {boolean} [options.altKey] Alternate name to `alt`.
   * @param {boolean} [options.meta] A boolean if a `keyboard` event has the
   *    meta key down or explicitly up.
   * @param {boolean} [options.metaKey] Alternate name to `meta`.
   * @param {string} [options.keys] A Mousetrap-style key sequence string for
   *    `keyboard` events.
   * @param {geo.screenPosition} [options.page] The position of the event on
   *    the window.  Overridden by `map`.
   * @param {geo.screenPosition} [options.map] The position of the event on the
   *    map in pixels relative to the map's div.
   * @param {geo.screenPosition} [options.center] The position of a touch
   *    event center relative to the window.
   * @param {string} [options.button] One of `left`, `middle`, or `right` for
   *    mouse events.
   * @param {string} [options.modifiers] A space-separated list of metakeys
   *    that are down on mouse events.
   * @param {geo.screenPosition} [options.wheelDelta] The amount the wheel
   *    moved in both directions for wheel events.  One step is often 20 units
   *    of movement.
   * @param {number} [options.wheelMode] The wheel delta mode.  See
   *    https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent .
   * @param {boolean} [options.touch] `truthy` if this is a touch event.
   * @param {number} [options.rotation] Touch event rotation in degrees.
   * @param {number} [options.scale] Touch event scale.  Initial events should
   *    have a scale of 1; subsequent events should increase or decrease this
   *    to simulate spread and pinch actions.
   * @param {number[]} [options.pointers] A list of pointer numbers involved in
   *    a touch event.  Pointers are number from one up, so `[1]` is the first
   *    touch point, `[1, 2]` are two touch points, and `[2]` is when the first
   *    point was released but the second is still touching.
   * @param {string} [options.pointerType] `mouse` if this is a mouse action
   *    rather than a touch action.
   * @returns {mapInteractor}
   */
  this.simulateEvent = function (type, options) {
    var evt, page, offset, which, buttons;

    if (!m_this.map()) {
      return m_this;
    }

    if (type === 'keyboard' && m_keyHandler) {
      /* Mousetrap passes through the keys we send, but not an event object,
       * so we construct an artificial event object as the keys, and use that.
       */
      var keys = {
        shiftKey: options.shift || options.shiftKey || false,
        ctrlKey: options.ctrl || options.ctrlKey || false,
        altKey: options.alt || options.altKey || false,
        metaKey: options.meta || options.metaKey || false,
        toString: function () { return options.keys; },
        stopPropagation: function () {},
        preventDefault: function () {},
        simulated: true
      };
      m_keyHandler.trigger(keys, options.event);
      return;
    }

    page = options.page || {};

    if (options.map) {
      offset = $node.offset();
      page.x = options.map.x + offset.left;
      page.y = options.map.y + offset.top;
    }

    if (options.button === 'left') {
      which = 1;
      buttons = 1;
    } else if (options.button === 'right') {
      which = 3;
      buttons = 2;
    } else if (options.button === 'middle') {
      which = 2;
      buttons = 4;
    }

    options.modifiers = options.modifiers || [];
    options.wheelDelta = options.wheelDelta || {};

    evt = $.Event(
      type,
      {
        pageX: page.x,
        pageY: page.y,
        which: which,
        buttons: buttons,
        altKey: options.modifiers.indexOf('alt') >= 0,
        ctrlKey: options.modifiers.indexOf('ctrl') >= 0,
        metaKey: options.modifiers.indexOf('meta') >= 0,
        shiftKey: options.modifiers.indexOf('shift') >= 0,
        center: options.center,
        rotation: options.touch ? options.rotation || 0 : options.rotation,
        scale: options.touch ? options.scale || 1 : options.scale,
        pointers: options.pointers,
        pointerType: options.pointerType,

        originalEvent: {
          deltaX: options.wheelDelta.x,
          deltaY: options.wheelDelta.y,
          deltaMode: options.wheelMode,
          preventDefault: function () {},
          stopPropagation: function () {},
          stopImmediatePropagation: function () {}
        }
      }
    );
    if (options.touch && m_touchHandler) {
      m_this._handleTouch(evt);
    } else {
      $node.trigger(evt);
    }
    if (type.indexOf('.geojs') >= 0) {
      $(document).trigger(evt);
    }
  };
  this._connectEvents();
  return this;
};

inherit(mapInteractor, object);
module.exports = mapInteractor;