MapControls Refactor (#245)

This commit is contained in:
Xiaoji Chen 2017-05-20 17:37:17 -07:00 committed by GitHub
parent 4a6164daa0
commit 2eccb0328a
6 changed files with 366 additions and 266 deletions

View File

@ -5,6 +5,9 @@
[TBD]
- New event management system based on hammer.js
- FIX: Touch interaction
- Remove `MapControls` React component
- Remove `ControllerClass` prop from `InteractiveMap`
- Add `mapControls` prop to `InteractiveMap`
### Version 3.0.0-alpha.10 - Add `ControllerClass` prop to `InteractiveMap`

View File

@ -0,0 +1,52 @@
# Custom Map Controls
## Overriding The Default Map Controller
To change the default behavior of map interaction, you can implement/extend the `MapControls`
class add pass an instance to the `mapControls` prop of `InteractiveMap`.
A simple example to disable mouse wheel:
```js
/// my-map-controls.js
import {experimental} from 'react-map-gl';
export default class MyMapControls extends experimental.MapControls {
// override the default handler in MapControls
handle(event) {
if (event.type === 'wheel') {
return false;
}
return super.handle(event);
}
}
```
Then pass it to the map during render:
```jsx
<MapGL mapControls={new MyMapControls()} ... />
```
## MapControls Interface
A custom map controls class must implement the following interface:
### Properties
##### `events` (Array)
A list of event names that this control subscribes to.
Available events: `click`, `tap`, `doubletap`, `press`, `pinch`, `pinchin`, `pinchout`, `pinchstart`, `pinchmove`, `pinchend`, `pinchcancel`, `rotate`, `rotatestart`, `rotatemove`, `rotateend`, `rotatecancel`, `pan`, `panstart`, `panmove`, `panup`, `pandown`, `panleft`, `panright`, `panend`, `pancancel`, `swipe`, `swipeleft`, `swiperight`, `swipeup`, `swipedown`, `pointerdown`, `pointermove`, `pointerup`, `touchstart`, `touchmove`, `touchend`, `mousedown`, `mousemove`, and `mouseup`.
[Event object](http://hammerjs.github.io/api/#event-object) is generated by [hammer.js](http://hammerjs.github.io).
### Methods
##### `setState(state)`
Used by `InteractiveMap` to update this control's state.
##### `handle(event)`
Called by `InteractiveMap` to handle pointer events.

View File

@ -3,10 +3,13 @@ import PropTypes from 'prop-types';
import autobind from '../utils/autobind';
import StaticMap from './static-map';
import MapControls from './map-controls';
import MapState, {MAPBOX_MAX_PITCH, MAPBOX_MAX_ZOOM} from '../utils/map-state';
import {PerspectiveMercatorViewport} from 'viewport-mercator-project';
import EventManager from '../utils/event-manager/event-manager';
import MapControls from '../utils/map-controls';
import config from '../config';
const propTypes = Object.assign({}, StaticMap.propTypes, {
// Additional props on top of StaticMap
@ -38,15 +41,33 @@ const propTypes = Object.assign({}, StaticMap.propTypes, {
*/
onChangeViewport: PropTypes.func,
/** Enables perspective control event handling */
perspectiveEnabled: PropTypes.bool,
/**
* Is the component currently being dragged. This is used to show/hide the
* drag cursor. Also used as an optimization in some overlays by preventing
* rendering while dragging.
*/
isHovering: PropTypes.bool,
isDragging: PropTypes.bool,
/** Advanced features */
// Contraints for displaying the map. If not met, then the map is hidden.
displayConstraints: PropTypes.object.isRequired,
// A React component class definition to replace the default map controls
ControllerClass: PropTypes.func
// A map control instance to replace the default map controls
// The object must expose one property: `events` as an array of subscribed
// event names; and two methods: `setState(state)` and `handle(event)`
mapControls: PropTypes.shape({
events: PropTypes.arrayOf(PropTypes.string),
setState: PropTypes.func,
handle: PropTypes.func
})
});
const defaultProps = Object.assign({}, StaticMap.defaultProps, {
onChangeViewport: null,
perspectiveEnabled: false,
/** Viewport constraints */
maxZoom: MAPBOX_MAX_ZOOM,
@ -58,7 +79,8 @@ const defaultProps = Object.assign({}, StaticMap.defaultProps, {
maxZoom: MAPBOX_MAX_ZOOM,
maxPitch: MAPBOX_MAX_PITCH
},
ControllerClass: MapControls
mapControls: new MapControls()
});
export default class InteractiveMap extends PureComponent {
@ -78,11 +100,58 @@ export default class InteractiveMap extends PureComponent {
};
}
componentDidMount() {
// Register event handlers
const {eventCanvas} = this.refs;
const {mapControls} = this.props;
this._eventManager = new EventManager(eventCanvas);
mapControls.events.forEach(event => this._eventManager.on(event, this._handleEvent));
}
componentWillUnmount() {
if (this._eventManager) {
// Must destroy because hammer adds event listeners to window
this._eventManager.destroy();
}
}
_handleEvent(event) {
const {mapControls} = this.props;
// MapControls only extracts the states that it recognizes.
// This allows custom map controls to add new states and callbacks.
mapControls.setState(Object.assign({}, this.props, {
mapState: new MapState(this.props)
}));
return mapControls.handle(event);
}
// TODO - Remove once Viewport alternative is good enough
_getMap() {
return this._map._getMap();
}
// Calculate a cursor style to show that we are in "dragging state"
_getCursor() {
const isInteractive =
this.props.onChangeViewport ||
this.props.onClickFeature ||
this.props.onHoverFeatures;
if (!isInteractive) {
return 'inherit';
}
if (this.props.isDragging) {
return config.CURSOR.GRABBING;
}
if (this.props.isHovering) {
return config.CURSOR.POINTER;
}
return config.CURSOR.GRAB;
}
// Checks a displayConstraints object to see if the map should be displayed
checkDisplayConstraints(props) {
const capitalize = s => s[0].toUpperCase() + s.slice(1);
@ -104,7 +173,7 @@ export default class InteractiveMap extends PureComponent {
}
render() {
const {width, height, ControllerClass} = this.props;
const {width, height} = this.props;
const mapVisible = this.checkDisplayConstraints(this.props);
const visibility = mapVisible ? 'visible' : 'hidden';
const overlayContainerStyle = {
@ -116,12 +185,19 @@ export default class InteractiveMap extends PureComponent {
overflow: 'hidden'
};
const eventCanvasStyle = {
width,
height,
position: 'relative',
cursor: this._getCursor()
};
return (
createElement(ControllerClass, Object.assign({}, this.props, {
createElement('div', {
key: 'map-controls',
style: {position: 'relative'},
mapState: new MapState(this.props)
}), [
ref: 'eventCanvas',
style: eventCanvasStyle
}, [
createElement(StaticMap, Object.assign({}, this.props, {
key: 'map-static',
style: {visibility},

View File

@ -1,256 +0,0 @@
// Copyright (c) 2015 Uber Technologies, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import autobind from '../utils/autobind';
import MapState from '../utils/map-state';
import config from '../config';
import EventManager from '../utils/event-manager/event-manager';
// EVENT HANDLING PARAMETERS
const PITCH_MOUSE_THRESHOLD = 5;
const PITCH_ACCEL = 1.2;
const ZOOM_ACCEL = 0.01;
const propTypes = {
mapState: PropTypes.instanceOf(MapState).isRequired,
/** Enables perspective control event handling */
perspectiveEnabled: PropTypes.bool,
/**
* `onChangeViewport` callback is fired when the user interacted with the
* map. The object passed to the callback contains `latitude`,
* `longitude` and `zoom` and additional state information.
*/
onChangeViewport: PropTypes.func,
/**
* Is the component currently being dragged. This is used to show/hide the
* drag cursor. Also used as an optimization in some overlays by preventing
* rendering while dragging.
*/
isHovering: PropTypes.bool,
isDragging: PropTypes.bool
};
const defaultProps = {
perspectiveEnabled: false,
onChangeViewport: null
};
export default class MapControls extends PureComponent {
/**
* @classdesc
* A component that monitors events and updates mercator style viewport parameters
* It can be used with our without a mapbox map
* (e.g. it could pan over a static map image)
*/
constructor(props) {
super(props);
autobind(this);
}
componentDidMount() {
// Register event handlers
const {canvas} = this.refs;
this._eventManager = new EventManager(canvas)
.on({
panstart: this._onPanStart,
pan: this._onPan,
panend: this._onPanEnd,
pinchstart: this._onPinchStart,
pinch: this._onPinch,
pinchend: this._onPinchEnd,
doubletap: this._onDoubleTap,
wheel: this._onWheel
});
}
componentWillUnmount() {
if (this._eventManager) {
// Must destroy because hammer adds event listeners to window
this._eventManager.destroy();
}
}
/* Event utils */
// Event object: http://hammerjs.github.io/api/#event-object
_getCenter(event) {
const {center, target} = event;
const rect = target.getBoundingClientRect();
return [
center.x - rect.left - target.clientLeft,
center.y - rect.top - target.clientTop
];
}
_isFunctionKeyPressed(event) {
const {srcEvent} = event;
return Boolean(srcEvent.metaKey || srcEvent.altKey ||
srcEvent.ctrlKey || srcEvent.shiftKey);
}
// Calculate a cursor style to show that we are in "dragging state"
_getCursor() {
const isInteractive =
this.props.onChangeViewport ||
this.props.onClickFeature ||
this.props.onHoverFeatures;
if (!isInteractive) {
return 'inherit';
}
if (this.props.isDragging) {
return config.CURSOR.GRABBING;
}
if (this.props.isHovering) {
return config.CURSOR.POINTER;
}
return config.CURSOR.GRAB;
}
_updateViewport(mapState, extraState = {}) {
if (!this.props.onChangeViewport) {
return false;
}
const {isDragging} = this.props;
return this.props.onChangeViewport(Object.assign(
{isDragging},
mapState.getViewportProps(),
extraState
));
}
_onPanStart(event) {
const pos = this._getCenter(event);
const newMapState = this.props.mapState.panStart({pos}).rotateStart({pos});
this._updateViewport(newMapState, {isDragging: true});
}
_onPan(event) {
return this._isFunctionKeyPressed(event) ? this._onRotateMap(event) : this._onPanMap(event);
}
_onPanEnd(event) {
const newMapState = this.props.mapState.panEnd().rotateEnd();
this._updateViewport(newMapState, {isDragging: false});
}
_onPanMap(event) {
const pos = this._getCenter(event);
const newMapState = this.props.mapState.pan({pos});
this._updateViewport(newMapState);
}
_onRotateMap(event) {
if (!this.props.perspectiveEnabled) {
return;
}
const {deltaX, deltaY} = event;
const [, centerY] = this._getCenter(event);
const startY = centerY - deltaY;
const deltaScaleX = deltaX / this.props.width;
let deltaScaleY = 0;
if (deltaY > 0) {
if (Math.abs(this.props.height - startY) > PITCH_MOUSE_THRESHOLD) {
// Move from 0 to -1 as we drag upwards
deltaScaleY = deltaY / (startY - this.props.height) * PITCH_ACCEL;
}
} else if (deltaY < 0) {
if (startY > PITCH_MOUSE_THRESHOLD) {
// Move from 0 to 1 as we drag upwards
deltaScaleY = 1 - centerY / startY;
}
}
deltaScaleY = Math.min(1, Math.max(-1, deltaScaleY));
const newMapState = this.props.mapState.rotate({deltaScaleX, deltaScaleY});
this._updateViewport(newMapState);
}
_onWheel(event) {
const pos = this._getCenter(event);
const {delta} = event;
// Map wheel delta to relative scale
let scale = 2 / (1 + Math.exp(-Math.abs(delta * ZOOM_ACCEL)));
if (delta < 0 && scale !== 0) {
scale = 1 / scale;
}
const newMapState = this.props.mapState.zoom({pos, scale});
this._updateViewport(newMapState);
}
_onPinchStart(event) {
const pos = this._getCenter(event);
const newMapState = this.props.mapState.zoomStart({pos});
this._updateViewport(newMapState, {isDragging: true});
}
_onPinch(event) {
const pos = this._getCenter(event);
const {scale} = event;
const newMapState = this.props.mapState.zoom({pos, scale});
this._updateViewport(newMapState);
}
_onPinchEnd(event) {
const newMapState = this.props.mapState.zoomEnd();
this._updateViewport(newMapState, {isDragging: false});
}
_onDoubleTap(event) {
const pos = this._getCenter(event);
const isZoomOut = this._isFunctionKeyPressed(event);
const newMapState = this.props.mapState.zoom({pos, scale: isZoomOut ? 0.5 : 2});
this._updateViewport(newMapState);
}
render() {
const {className, width, height, style} = this.props;
const mapEventLayerStyle = Object.assign({}, style, {
width,
height,
position: 'relative',
cursor: this._getCursor()
});
return React.createElement('div', {
ref: 'canvas',
style: mapEventLayerStyle,
className
}, this.props.children);
}
}
MapControls.displayName = 'MapControls';
MapControls.propTypes = propTypes;
MapControls.defaultProps = defaultProps;

View File

@ -21,7 +21,6 @@
// React Components
export {default as InteractiveMap} from './components/interactive-map';
export {default as StaticMap} from './components/static-map';
export {default as MapControls} from './components/map-controls';
export {default as default} from './components/interactive-map';
export {default as MapGL} from './components/interactive-map';
@ -49,3 +48,10 @@ export {default as DraggablePointsOverlay} from './overlays/draggable-points-ove
export {default as HTMLOverlay} from './overlays/html-overlay';
export {default as ScatterplotOverlay} from './overlays/scatterplot-overlay';
export {default as SVGOverlay} from './overlays/svg-overlay';
// Experimental Features (May change in minor version bumps, use at your own risk)
import MapControls from './utils/map-controls';
export const experimental = {
MapControls
};

219
src/utils/map-controls.js Normal file
View File

@ -0,0 +1,219 @@
// Copyright (c) 2015 Uber Technologies, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// EVENT HANDLING PARAMETERS
const PITCH_MOUSE_THRESHOLD = 5;
const PITCH_ACCEL = 1.2;
const ZOOM_ACCEL = 0.01;
const SUBSCRIBED_EVENTS = [
'panstart',
'pan',
'panend',
'pinchstart',
'pinch',
'pinchend',
'doubletap',
'wheel'
];
export default class MapControls {
/**
* @classdesc
* A class that handles events and updates mercator style viewport parameters
*/
constructor() {
this.events = SUBSCRIBED_EVENTS;
}
/**
* Update the state of the control
* @param {MapState} state.mapState - current map state
* @param {Function} state.onChangeViewport - callback
*/
setState({mapState, onChangeViewport, isDragging, perspectiveEnabled}) {
this.mapState = mapState;
this.onChangeViewport = onChangeViewport;
this.isDragging = isDragging;
this.perspectiveEnabled = perspectiveEnabled;
}
/**
* Callback for events
* @param {hammer.Event} event
*/
handle(event) {
switch (event.type) {
case 'panstart':
return this._onPanStart(event);
case 'pan':
return this._onPan(event);
case 'panend':
return this._onPanEnd(event);
case 'pinchstart':
return this._onPinchStart(event);
case 'pinch':
return this._onPinch(event);
case 'pinchend':
return this._onPinchEnd(event);
case 'doubletap':
return this._onDoubleTap(event);
case 'wheel':
return this._onWheel(event);
default:
return false;
}
}
/* Event utils */
// Event object: http://hammerjs.github.io/api/#event-object
getCenter(event) {
const {center, target} = event;
const rect = target.getBoundingClientRect();
return [
center.x - rect.left - target.clientLeft,
center.y - rect.top - target.clientTop
];
}
isFunctionKeyPressed(event) {
const {srcEvent} = event;
return Boolean(srcEvent.metaKey || srcEvent.altKey ||
srcEvent.ctrlKey || srcEvent.shiftKey);
}
/* Callback util */
// formats map state and invokes callback function
updateViewport(mapState, extraState = {}) {
if (!this.onChangeViewport) {
return false;
}
return this.onChangeViewport(Object.assign(
{isDragging: this.isDragging},
mapState.getViewportProps(),
extraState
));
}
/* Event handlers */
// Default handler for the `panstart` event.
_onPanStart(event) {
const pos = this.getCenter(event);
const newMapState = this.mapState.panStart({pos}).rotateStart({pos});
return this.updateViewport(newMapState, {isDragging: true});
}
// Default handler for the `pan` event.
_onPan(event) {
return this.isFunctionKeyPressed(event) ? this._onPanRotate(event) : this._onPanMove(event);
}
// Default handler for the `panend` event.
_onPanEnd(event) {
const newMapState = this.mapState.panEnd().rotateEnd();
return this.updateViewport(newMapState, {isDragging: false});
}
// Default handler for panning to move.
// Called by `_onPan` when panning without function key pressed.
_onPanMove(event) {
const pos = this.getCenter(event);
const newMapState = this.mapState.pan({pos});
return this.updateViewport(newMapState);
}
// Default handler for panning to rotate.
// Called by `_onPan` when panning with function key pressed.
_onPanRotate(event) {
if (!this.perspectiveEnabled) {
return false;
}
const {deltaX, deltaY} = event;
const [, centerY] = this.getCenter(event);
const startY = centerY - deltaY;
const {width, height} = this.mapState.getViewportProps();
const deltaScaleX = deltaX / width;
let deltaScaleY = 0;
if (deltaY > 0) {
if (Math.abs(height - startY) > PITCH_MOUSE_THRESHOLD) {
// Move from 0 to -1 as we drag upwards
deltaScaleY = deltaY / (startY - height) * PITCH_ACCEL;
}
} else if (deltaY < 0) {
if (startY > PITCH_MOUSE_THRESHOLD) {
// Move from 0 to 1 as we drag upwards
deltaScaleY = 1 - centerY / startY;
}
}
deltaScaleY = Math.min(1, Math.max(-1, deltaScaleY));
const newMapState = this.mapState.rotate({deltaScaleX, deltaScaleY});
return this.updateViewport(newMapState);
}
// Default handler for the `wheel` event.
_onWheel(event) {
const pos = this.getCenter(event);
const {delta} = event;
// Map wheel delta to relative scale
let scale = 2 / (1 + Math.exp(-Math.abs(delta * ZOOM_ACCEL)));
if (delta < 0 && scale !== 0) {
scale = 1 / scale;
}
const newMapState = this.mapState.zoom({pos, scale});
return this.updateViewport(newMapState);
}
// Default handler for the `pinchstart` event.
_onPinchStart(event) {
const pos = this.getCenter(event);
const newMapState = this.mapState.zoomStart({pos});
return this.updateViewport(newMapState, {isDragging: true});
}
// Default handler for the `pinch` event.
_onPinch(event) {
const pos = this.getCenter(event);
const {scale} = event;
const newMapState = this.mapState.zoom({pos, scale});
return this.updateViewport(newMapState);
}
// Default handler for the `pinchend` event.
_onPinchEnd(event) {
const newMapState = this.mapState.zoomEnd();
return this.updateViewport(newMapState, {isDragging: false});
}
// Default handler for the `doubletap` event.
_onDoubleTap(event) {
const pos = this.getCenter(event);
const isZoomOut = this.isFunctionKeyPressed(event);
const newMapState = this.mapState.zoom({pos, scale: isZoomOut ? 0.5 : 2});
return this.updateViewport(newMapState);
}
}