Skip to content

Commit

Permalink
Modularize support for raw and gestural input events (#636)
Browse files Browse the repository at this point in the history
* First pass at new EventManager.
Provides single API for both raw and gestural events; leverages hammer.js for both.
Replaces use of luma.gl's addEvents.
Does not yet reimplement support for dragging (i.e. dragging is broken as of this commit).

* Consolidate "event" and "gesture" layers into a single layer by using manager.emit().
Alias basic and gestural events as necessary.
Add documentation and cleanup.

* Refactor wheel-input to be generic wheel input handler module instead of a hammer-specific implementation
Wire refactored wheel-input into hammer.js event management in event-manager

* Implement pointer/touch/mouse move events as a standalone module rather than extending Hammer input.
This enables us to rely on Hammer's feature detection by not specifying an `inputClass`.

* Bugfixes from @Pessimistress, address comments from @ibgreen.
Break out constants into separate file.
Add `destroy()` methods to input classes.

* Consolidate event-manager-related classes into utils/events

* Remove 300ms click delay
Ensure concurrent registration of single and double tap handlers works properly

* Clean up wheel-input.

* Pass event map directly via constructor.
  • Loading branch information
ericsoco committed May 22, 2017
1 parent 4f23a4a commit d8b0c7f
Show file tree
Hide file tree
Showing 6 changed files with 446 additions and 22 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"d3-hexbin": "^0.2.1",
"earcut": "^2.0.6",
"gl-matrix": "^2.3.2",
"hammerjs": "^2.0.8",
"lodash.flattendeep": "^4.4.0",
"prop-types": "^15.5.8",
"seer": "^0.1.0"
Expand Down
46 changes: 24 additions & 22 deletions src/react/deckgl.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ import autobind from './autobind';
import WebGLRenderer from './webgl-renderer';
import {LayerManager, Layer} from '../lib';
import {EffectManager, Effect} from '../experimental';
import {GL, addEvents} from 'luma.gl';
import {GL} from 'luma.gl';
import {Viewport, WebMercatorViewport} from '../lib/viewports';
import {log} from '../lib/utils';
import EventManager from '../utils/events/event-manager';

function noop() {}

Expand Down Expand Up @@ -71,6 +72,7 @@ export default class DeckGL extends React.Component {
super(props);
this.state = {};
this.needsRedraw = true;
this.eventManager = null;
this.layerManager = null;
this.effectManager = null;
autobind(this);
Expand Down Expand Up @@ -128,58 +130,58 @@ export default class DeckGL extends React.Component {
);
}

this.events = addEvents(canvas, {
cacheSize: false,
cachePosition: false,
centerOrigin: false,
onClick: this._onClick,
onMouseMove: this._onMouseMove,
onDragStart: this._onDragEvent,
onDragMove: this._onDragEvent,
onDragEnd: this._onDragEvent,
onDragCancel: this._onDragCancel
// TODO: add handlers on demand at runtime, not all at once on init
this.eventManager = new EventManager(canvas, {
events: {
click: this._onClick,
mousemove: this._onMouseMove,
dragstart: this._onDragEvent,
dragmove: this._onDragEvent,
dragend: this._onDragEvent,
dragcancel: this._onDragCancel
}
});
}

// Route events to layers
_onClick(event) {
// use offsetX|Y for relative position to the container, drop event if falsy
if (!event || !event.event || !Number.isFinite(event.event.offsetX)) {
if (!event || !event.srcEvent || !Number.isFinite(event.srcEvent.offsetX)) {
return;
}
const {event: {offsetX: x, offsetY: y}} = event;
const {srcEvent: {offsetX: x, offsetY: y}} = event;
const radius = this.props.pickingRadius;
const selectedInfos = this.layerManager.pickLayer({x, y, radius, mode: 'click'});
if (selectedInfos.length) {
const firstInfo = selectedInfos.find(info => info.index >= 0);
// Event.event holds the original MouseEvent object
this.props.onLayerClick(firstInfo, selectedInfos, event.event);
this.props.onLayerClick(firstInfo, selectedInfos, event.srcEvent);
}
}

// Route events to layers
_onMouseMove(event) {
// use offsetX|Y for relative position to the container, drop event if falsy
if (!event || !event.event || !Number.isFinite(event.event.offsetX)) {
if (!event || !event.srcEvent || !Number.isFinite(event.srcEvent.offsetX)) {
return;
}
const {event: {offsetX: x, offsetY: y}} = event;
const {srcEvent: {offsetX: x, offsetY: y}} = event;
const radius = this.props.pickingRadius;
const selectedInfos = this.layerManager.pickLayer({x, y, radius, mode: 'hover'});
if (selectedInfos.length) {
const firstInfo = selectedInfos.find(info => info.index >= 0);
// Event.event holds the original MouseEvent object
this.props.onLayerHover(firstInfo, selectedInfos, event.event);
this.props.onLayerHover(firstInfo, selectedInfos, event.srcEvent);
}
}

_onDragEvent(event, explicitType) {
// use offsetX|Y for relative position to the container, drop event if falsy
if (!event || !event.event || !Number.isFinite(event.event.offsetX)) {
if (!event || !event.srcEvent || !Number.isFinite(event.srcEvent.offsetX)) {
return;
}
const {event: {offsetX: x, offsetY: y}} = event;
const type = typeof explicitType === 'string' ? explicitType : event.event.type;
const {srcEvent: {offsetX: x, offsetY: y}} = event;
const type = typeof explicitType === 'string' ? explicitType : event.srcEvent.type;
let mode;
let layerEventHandler;
switch (type) {
Expand Down Expand Up @@ -209,8 +211,8 @@ export default class DeckGL extends React.Component {
const selectedInfos = this.layerManager.pickLayer({x, y, radius, mode});
if (selectedInfos.length) {
const firstInfo = selectedInfos.find(info => info.index >= 0);
// Event.event holds the original MouseEvent object
layerEventHandler(firstInfo, selectedInfos, event.event);
// srcEvent holds the original MouseEvent object
layerEventHandler(firstInfo, selectedInfos, event.srcEvent);
}
}
}
Expand Down
93 changes: 93 additions & 0 deletions src/utils/events/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
Tap,
Press,
Pinch,
Rotate,
Pan,
Swipe
} from 'hammerjs';

/**
* Only one set of basic input events will be fired by Hammer.js:
* either pointer, touch, or mouse, depending on system support.
* In order to enable an application to be agnostic of system support,
* alias basic input events into "classes" of events: down, move, and up.
* See `_onBasicInput()` for usage of these aliases.
*/
/* eslint-disable */
export const BASIC_EVENT_CLASSES = {
down: ['pointerdown', 'touchstart', 'mousedown'],
move: ['pointermove', 'touchmove', 'mousemove'],
up: ['pointerup', 'touchend', 'mouseup']
};
/* eslint-enable */

export const BASIC_EVENT_ALIASES = {
pointerdown: BASIC_EVENT_CLASSES.down,
pointermove: BASIC_EVENT_CLASSES.move,
pointerup: BASIC_EVENT_CLASSES.up,
touchstart: BASIC_EVENT_CLASSES.down,
touchmove: BASIC_EVENT_CLASSES.move,
touchend: BASIC_EVENT_CLASSES.up,
mousedown: BASIC_EVENT_CLASSES.down,
mousemove: BASIC_EVENT_CLASSES.move,
mouseup: BASIC_EVENT_CLASSES.up
};

/**
* "Gestural" events are those that have semantic meaning beyond the basic input event,
* e.g. a click or tap is a sequence of `down` and `up` events with no `move` event in between.
* Hammer.js handles these with its Recognizer system;
* this block maps event names to the Recognizers required to detect the events.
*/
export const EVENT_RECOGNIZER_MAP = {
click: 'tap',
tap: 'tap',
doubletap: 'doubletap',
press: 'press',
pinch: 'pinch',
pinchin: 'pinch',
pinchout: 'pinch',
pinchstart: 'pinch',
pinchmove: 'pinch',
pinchend: 'pinch',
pinchcancel: 'pinch',
rotate: 'rotate',
rotatestart: 'rotate',
rotatemove: 'rotate',
rotateend: 'rotate',
rotatecancel: 'rotate',
pan: 'pan',
panstart: 'pan',
panmove: 'pan',
panup: 'pan',
pandown: 'pan',
panleft: 'pan',
panright: 'pan',
panend: 'pan',
pancancel: 'pan',
swipe: 'swipe',
swipeleft: 'swipe',
swiperight: 'swipe',
swipeup: 'swipe',
swipedown: 'swipe'
};

export const RECOGNIZERS = [
[Rotate, {enable: false}],
[Pinch, {enable: false}, ['rotate']],
[Pan, {threshold: 10, enable: false}],
[Swipe, {enable: false}],
[Press, {enable: false}],
[Tap, {event: 'doubletap', taps: 2, enable: false}],
[Tap, {enable: false, interval: 0}]
];

/**
* Map gestural events typically provided by browsers
* that are not reported in 'hammer.input' events
* to corresponding Hammer.js gestures.
*/
export const GESTURE_EVENT_ALIASES = {
click: 'tap'
};
170 changes: 170 additions & 0 deletions src/utils/events/event-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {Manager} from 'hammerjs';

import {
BASIC_EVENT_ALIASES,
EVENT_RECOGNIZER_MAP,
RECOGNIZERS,
GESTURE_EVENT_ALIASES
} from './constants';
import WheelInput from './wheel-input';
import MoveInput from './move-input';

/**
* Single API for subscribing to events about both
* basic input events (e.g. 'mousemove', 'touchstart', 'wheel')
* and gestural input (e.g. 'click', 'tap', 'panstart').
* Delegates event registration and handling to Hammer.js.
* @param {DOM Element} element DOM element on which event handlers will be registered.
* @param {Object} options Options for instantiation
* @param {Object} options.events Map of {event name: handler} to register on init.
* @param {Object} options.recognizers Gesture recognizers from Hammer.js to register.
* Not yet implemented.
*/
export default class EventManager {
constructor(element, options) {
// TODO: support overriding default RECOGNIZERS by passing
// recognizers / configs, keyed to event name.

this._onBasicInput = this._onBasicInput.bind(this);
this.manager = new Manager(element, {recognizers: RECOGNIZERS})
.on('hammer.input', this._onBasicInput);

this.aliasedEventHandlers = {};

// Handle events not handled by Hammer.js:
// - mouse wheel
// - pointer/touch/mouse move
this._onOtherEvent = this._onOtherEvent.bind(this);
this.wheelInput = new WheelInput(element, this._onOtherEvent);
this.moveInput = new MoveInput(element, this._onOtherEvent);

// Register all passed events.
const {events} = options;
if (events) {
this.on(events);
}
}

destroy() {
this.wheelInput.destroy();
this.moveInput.destroy();
this.manager.destroy();
}

/**
* Register an event handler function to be called on `event`.
* @param {string|Object} event An event name (String) or map of event names to handlers.
* @param {Function} [handler] The function to be called on `event`.
*/
on(event, handler) {
if (typeof event === 'string') {
// Special handling for gestural events.
const recognizerEvent = EVENT_RECOGNIZER_MAP[event];
if (recognizerEvent) {
// Enable recognizer for this event.
this.manager.get(recognizerEvent).set({enable: true});

// Handle concurrent single and double tap registration as necessary.
this._reconcileSingleAndDoubleTap(recognizerEvent);

// Alias to a recognized gesture as necessary.
const eventAlias = GESTURE_EVENT_ALIASES[event];
if (eventAlias && !this.aliasedEventHandlers[event]) {
const aliasedEventHandler = this._aliasEventHandler(event);
this.manager.on(eventAlias, aliasedEventHandler);
// TODO: multiple handlers for the same aliased event will override one another.
// This should be an array of aliased handlers instead.
this.aliasedEventHandlers[event] = aliasedEventHandler;
}
}

// Register event handler.
this.manager.on(event, handler);
} else {
// If `event` is a map, call `on()` for each entry.
for (const eventName in event) {
this.on(eventName, event[eventName]);
}
}
}

/**
* Deregister a previously-registered event handler.
* @param {string|Object} event An event name (String) or map of event names to handlers
* @param {Function} [handler] The function to be called on `event`.
*/
off(event, handler) {
if (typeof event === 'string') {
// Clean up aliased gesture handler as necessary.
const recognizerEvent = EVENT_RECOGNIZER_MAP[event];
if (recognizerEvent) {
const eventAlias = GESTURE_EVENT_ALIASES[event];
if (eventAlias && this.aliasedEventHandlers[event]) {
this.manager.off(eventAlias, this.aliasedEventHandlers[event]);
delete this.aliasedEventHandlers[event];
}
}

// Deregister event handler.
this.manager.off(event, handler);
} else {
// If `event` is a map, call `off()` for each entry.
for (const eventName in event) {
this.off(eventName, event[eventName]);
}
}
}

/**
* Handle basic events using the 'hammer.input' Hammer.js API:
* Before running Recognizers, Hammer emits a 'hammer.input' event
* with the basic event info. This function emits all basic events
* aliased to the "class" of event received.
* See constants.BASIC_EVENT_CLASSES basic event class definitions.
*/
_onBasicInput(event) {
const {srcEvent} = event;
const eventAliases = BASIC_EVENT_ALIASES[srcEvent.type];
if (eventAliases) {
// fire all events aliased to srcEvent.type
eventAliases.forEach(alias => {
const emitEvent = Object.assign({}, event, {type: alias});
this.manager.emit(alias, emitEvent);
});
}
}

/**
* Handle events not supported by Hammer.js,
* and pipe back out through same (Hammer) channel used by other events.
*/
_onOtherEvent(event) {
const {srcEvent: {type}} = event;
this.manager.emit(type, event);
}

/**
* Alias one event name to another,
* to support events supported by Hammer.js under a different name.
* See constants.GESTURE_EVENT_ALIASES.
*/
_aliasEventHandler(eventAlias) {
return event => this.manager.emit(eventAlias, event);
}

/**
* If single and double tap are both enabled,
* The single tap recognizer must wait for the double tap recognizer
* to fail before resolving. Note that enabling both incurs a slight delay
* on tap/click handler resolution.
*/
_reconcileSingleAndDoubleTap(event) {
if (event === 'tap' || event === 'doubletap') {
const singletapRecognizer = this.manager.get('tap');
const doubletapRecognizer = this.manager.get('doubletap');
if (singletapRecognizer.options.enable && doubletapRecognizer.options.enable) {
singletapRecognizer.requireFailure('doubletap');
}
}
}
}

0 comments on commit d8b0c7f

Please sign in to comment.