diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e405557..a63ca000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/docs/advanced/custom-map-controls.md b/docs/advanced/custom-map-controls.md new file mode 100644 index 00000000..7b8552c5 --- /dev/null +++ b/docs/advanced/custom-map-controls.md @@ -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 + +``` + + +## 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. diff --git a/src/components/interactive-map.js b/src/components/interactive-map.js index c645fc21..85ceaac5 100644 --- a/src/components/interactive-map.js +++ b/src/components/interactive-map.js @@ -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}, diff --git a/src/components/map-controls.js b/src/components/map-controls.js deleted file mode 100644 index d584025a..00000000 --- a/src/components/map-controls.js +++ /dev/null @@ -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; diff --git a/src/index.js b/src/index.js index 0145e8ab..bd28781f 100644 --- a/src/index.js +++ b/src/index.js @@ -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 +}; diff --git a/src/utils/map-controls.js b/src/utils/map-controls.js new file mode 100644 index 00000000..e860d1e2 --- /dev/null +++ b/src/utils/map-controls.js @@ -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); + } +}