From 7ef7b19860f74db521950d0a141c977601430499 Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Tue, 2 May 2017 17:39:14 -0700 Subject: [PATCH] Separating input events and semantic transform in map control (#212) --- src/components/map-controls.js | 307 ++++++------------------------ src/utils/event-manager.js | 54 ++---- src/utils/map-state.js | 332 +++++++++++++++++++++++++++++++++ test/utils/index.js | 1 + test/utils/map-state.spec.js | 184 ++++++++++++++++++ 5 files changed, 591 insertions(+), 287 deletions(-) create mode 100644 src/utils/map-state.js create mode 100644 test/utils/map-state.spec.js diff --git a/src/components/map-controls.js b/src/components/map-controls.js index 68813d16..3eafd141 100644 --- a/src/components/map-controls.js +++ b/src/components/map-controls.js @@ -17,53 +17,23 @@ // 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 {PerspectiveMercatorViewport} from 'viewport-mercator-project'; import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import assert from 'assert'; import autobind from '../utils/autobind'; // MapControls uses non-react event manager to register events import EventManager from '../utils/event-manager'; +import MapState from '../utils/map-state'; import config from '../config'; -function mod(value, divisor) { - const modulus = value % divisor; - return modulus < 0 ? divisor + modulus : modulus; -} -// MAPBOX LIMITS -const MAX_PITCH = 60; -const MAX_ZOOM = 40; - // EVENT HANDLING PARAMETERS const PITCH_MOUSE_THRESHOLD = 5; const PITCH_ACCEL = 1.2; +const ZOOM_ACCEL = 0.01; -const propTypes = { - /** The width of the map */ - width: PropTypes.number.isRequired, - /** The height of the map */ - height: PropTypes.number.isRequired, - /** The latitude of the center of the map. */ - latitude: PropTypes.number.isRequired, - /** The longitude of the center of the map. */ - longitude: PropTypes.number.isRequired, - /** The tile zoom level of the map. */ - zoom: PropTypes.number.isRequired, - /** Specify the bearing of the viewport */ - bearing: React.PropTypes.number, - /** Specify the pitch of the viewport */ - pitch: React.PropTypes.number, - /** - * Specify the altitude of the viewport camera - * Unit: map heights, default 1.5 - * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137 - */ - altitude: React.PropTypes.number, - - constraints: React.PropTypes.object, +const propTypes = Object.assign({}, MapState.propTypes, { /** Enables perspective control event handling */ perspectiveEnabled: PropTypes.bool, @@ -79,37 +49,19 @@ const propTypes = { * drag cursor. Also used as an optimization in some overlays by preventing * rendering while dragging. */ + isHovering: PropTypes.bool, isDragging: PropTypes.bool, - /** - * Required to calculate the mouse projection after the first click event - * during dragging. Where the map is depends on where you first clicked on - * the map. - */ - startDragLngLat: PropTypes.arrayOf(PropTypes.number), - /** Bearing when current perspective drag operation started */ - startBearing: PropTypes.number, - /** Pitch when current perspective drag operation started */ - startPitch: PropTypes.number, /** * True means key must be pressed to rotate instead of pan * false to means key must be pressed to pan */ pressKeyToRotate: PropTypes.bool -}; +}); const defaultProps = { - bearing: 0, - pitch: 0, - altitude: 1.5, - clickRadius: 15, + perspectiveEnabled: false, onChangeViewport: null, - - maxZoom: MAX_ZOOM, - minZoom: 0, - maxPitch: MAX_PITCH, - minPitch: 0, - pressKeyToRotate: true }; @@ -122,13 +74,6 @@ export default class MapControls extends PureComponent { */ constructor(props) { super(props); - this.state = { - isDragging: false, - isHovering: false, - startDragLngLat: null, - startBearing: null, - startPitch: null - }; autobind(this); } @@ -142,27 +87,12 @@ export default class MapControls extends PureComponent { this._eventManager = new EventManager(this.refs.canvas, { onMouseDown: this._onMouseDown, - // onMouseMove: is bound only after a mouse down is detected onMouseDrag: this._onMouseDrag, - onMouseRotate: this._onMouseRotate, onMouseUp: this._onMouseUp, - onTouchStart: this._onTouchStart, - onTouchDrag: this._onTouchDrag, onTouchRotate: this._onTouchRotate, - onTouchEnd: this._onTouchEnd, - onTouchTap: this._onTouchTap, - onZoom: this._onZoom, - onZoomEnd: this._onZoomEnd, - mapTouchToMouse: true, - pressKeyToRotate: this.props.pressKeyToRotate - }); - } - - // New props are comin' round the corner! - componentWillReceiveProps(newProps) { - const {startDragLngLat} = newProps; - this.setState({ - startDragLngLat: startDragLngLat && [...startDragLngLat] + onWheel: this._onWheel, + onWheelEnd: this._onWheelEnd, + mapTouchToMouse: true }); } @@ -179,158 +109,41 @@ export default class MapControls extends PureComponent { if (this.props.isDragging) { return config.CURSOR.GRABBING; } - if (this.state.isHovering) { + if (this.props.isHovering) { return config.CURSOR.POINTER; } return config.CURSOR.GRAB; } - _updateViewport(opts) { - let viewport = Object.assign({ - latitude: this.props.latitude, - longitude: this.props.longitude, - zoom: this.props.zoom, - bearing: this.props.bearing, - pitch: this.props.pitch, - altitude: this.props.altitude, - isDragging: this.props.isDragging, - startDragLngLat: this.props.startDragLngLat, - startBearing: this.props.startBearing, - startPitch: this.props.startPitch - }, opts); - - viewport = this._applyConstraints(viewport); - - // if (viewport.startDragLngLat) { - // const dragViewport = new FlatMercatorViewport(Object.assign({}, this.props, { - // longitude: viewport.startDragLngLat[0], - // latitude: viewport.startDragLngLat[1] - // })); - // this.setState({dragViewport}); - // } - - return this.props.onChangeViewport(viewport); - } - - // Apply any constraints (mathematical or defined by props) to viewport params - _applyConstraints(viewport) { - // Normalize degrees - viewport.longitude = mod(viewport.longitude + 180, 360) - 180; - viewport.bearing = mod(viewport.bearing + 180, 360) - 180; - - // Ensure zoom is within specified range - const {maxZoom, minZoom} = this.props; - viewport.zoom = viewport.zoom > maxZoom ? maxZoom : viewport.zoom; - viewport.zoom = viewport.zoom < minZoom ? minZoom : viewport.zoom; - - // Ensure pitch is within specified range - const {maxPitch, minPitch} = this.props; - - viewport.pitch = viewport.pitch > maxPitch ? maxPitch : viewport.pitch; - viewport.pitch = viewport.pitch < minPitch ? minPitch : viewport.pitch; - - return viewport; - } - - _unproject(pos) { - const viewport = new PerspectiveMercatorViewport(this.props); - return viewport.unproject(pos, {topLeft: false}); - } - - // Calculate a new lnglat based on pixel dragging position - // TODO - We should have a mapbox-independent implementation of panning - // Panning calculation is currently done using an undocumented mapbox function - _calculateNewLngLat({startDragLngLat, pos, startPos}) { - const viewport = new PerspectiveMercatorViewport(this.props); - - return viewport.getLocationAtPoint({lngLat: startDragLngLat, pos}); - } - - // Calculates new zoom - _calculateNewZoom({relativeScale}) { - return this.props.zoom + Math.log2(relativeScale); - } - - // Calculates a new pitch and bearing from a position (coming from an event) - _calculateNewPitchAndBearing({pos, startPos, startBearing, startPitch}) { - const {maxPitch} = this.props; - // TODO minPitch - - const xDelta = pos[0] - startPos[0]; - const yDelta = pos[1] - startPos[1]; - - const bearing = startBearing + 180 * xDelta / this.props.width; - - let pitch = startPitch; - if (yDelta > 0) { - // Dragging downwards, gradually decrease pitch - if (Math.abs(this.props.height - startPos[1]) > PITCH_MOUSE_THRESHOLD) { - const scale = yDelta / (this.props.height - startPos[1]); - pitch = (1 - scale * PITCH_ACCEL) * startPitch; - } - } else if (yDelta < 0) { - // Dragging upwards, gradually increase pitch - if (startPos[1] > PITCH_MOUSE_THRESHOLD) { - // Move from 0 to 1 as we drag upwards - const yScale = 1 - pos[1] / startPos[1]; - // Gradually add until we hit max pitch - pitch = startPitch + yScale * (maxPitch - startPitch); - } - } - - return { - pitch, - bearing - }; - } - - _onTouchStart(opts) { - this._onMouseDown(opts); - } - - _onTouchDrag(opts) { - this._onMouseDrag(opts); + _updateViewport(...opts) { + const {isDragging} = this.props; + return this.props.onChangeViewport(Object.assign({isDragging}, ...opts)); } _onTouchRotate(opts) { this._onMouseRotate(opts); } - _onTouchEnd(opts) { - this._onMouseUp(opts); - } - - _onTouchTap(opts) { - this._onMouseClick(opts); - } - _onMouseDown({pos}) { - this._updateViewport({ - isDragging: true, - startDragLngLat: this._unproject(pos), - startBearing: this.props.bearing, - startPitch: this.props.pitch - }); + const mapState = new MapState(this.props).panStart({pos}).rotateStart({pos}); + this._updateViewport(mapState.props, {isDragging: true}); } - _onMouseDrag({pos}) { + _onMouseDrag({pos, startPos, modifier}) { if (!this.props.onChangeViewport) { return; } - const {startDragLngLat} = this.state; + if (this.props.pressKeyToRotate === modifier) { + this._onMouseRotate({pos, startPos}); + } else { + this._onMousePan({pos}); + } + } - // take the start lnglat and put it where the mouse is down. - assert(startDragLngLat, '`startDragLngLat` prop is required ' + - 'for mouse drag behavior to calculate where to position the map.'); - - const [longitude, latitude] = this._calculateNewLngLat({startDragLngLat, pos}); - - this._updateViewport({ - longitude, - latitude, - isDragging: true - }); + _onMousePan({pos}) { + const mapState = new MapState(this.props).pan({pos}); + this._updateViewport(mapState.props); } _onMouseRotate({pos, startPos}) { @@ -338,54 +151,46 @@ export default class MapControls extends PureComponent { return; } - const {startBearing, startPitch} = this.props; - assert(typeof startBearing === 'number', - '`startBearing` prop is required for mouse rotate behavior'); - assert(typeof startPitch === 'number', - '`startPitch` prop is required for mouse rotate behavior'); + const xDelta = pos[0] - startPos[0]; + const yDelta = pos[1] - startPos[1]; - const {pitch, bearing} = this._calculateNewPitchAndBearing({ - pos, - startPos, - startBearing, - startPitch - }); + const xDeltaScale = xDelta / this.props.width; + let yDeltaScale = 0; - this._updateViewport({ - bearing, - pitch, - isDragging: true - }); + if (yDelta > 0) { + if (Math.abs(this.props.height - startPos[1]) > PITCH_MOUSE_THRESHOLD) { + // Move from 0 to -1 as we drag upwards + yDeltaScale = yDelta / (startPos[1] - this.props.height) * PITCH_ACCEL; + } + } else if (yDelta < 0) { + if (startPos[1] > PITCH_MOUSE_THRESHOLD) { + // Move from 0 to 1 as we drag upwards + yDeltaScale = 1 - pos[1] / startPos[1]; + } + } + + const mapState = new MapState(this.props).rotate({xDeltaScale, yDeltaScale}); + this._updateViewport(mapState.props); } - _onMouseUp(opt) { - this._updateViewport({ - isDragging: false, - startDragLngLat: null, - startBearing: null, - startPitch: null - }); + _onMouseUp() { + const mapState = new MapState(this.props).panEnd().rotateEnd(); + this._updateViewport(mapState.props, {isDragging: false}); } - _onZoom({pos, scale}) { - // Make sure we zoom around the current mouse position rather than map center - const aroundLngLat = this._unproject(pos); + _onWheel({pos, delta}) { + let scale = 2 / (1 + Math.exp(-Math.abs(delta * ZOOM_ACCEL))); + if (delta < 0 && scale !== 0) { + scale = 1 / scale; + } - const zoom = this._calculateNewZoom({relativeScale: scale}); - - const zoomedViewport = new PerspectiveMercatorViewport(Object.assign({}, this.props, {zoom})); - const [longitude, latitude] = zoomedViewport.getLocationAtPoint({lngLat: aroundLngLat, pos}); - - this._updateViewport({ - zoom, - longitude, - latitude, - isDragging: true - }); + const mapState = new MapState(this.props).zoom({pos, startPos: pos, scale}); + this._updateViewport(mapState.props, {isDragging: true}); } - _onZoomEnd() { - this._updateViewport({isDragging: false}); + _onWheelEnd() { + const mapState = new MapState(this.props).zoomEnd(); + this._updateViewport(mapState.props, {isDragging: false}); } render() { diff --git a/src/utils/event-manager.js b/src/utils/event-manager.js index 302ba9b0..986fa012 100644 --- a/src/utils/event-manager.js +++ b/src/utils/event-manager.js @@ -19,7 +19,7 @@ // THE SOFTWARE. // Portions of the code below originally from: -// https://github.com/mapbox/mapbox-gl-js/blob/master/js/ui/handler/scroll_zoom.js +// https://github.com/mapbox/mapbox-gl-js/blob/master/js/ui/handler/scroll_wheel.js import autobind from '../utils/autobind'; import {window, document} from './globals'; @@ -69,29 +69,24 @@ export default class EventManager { onMouseClick = noop, onMouseDown = noop, onMouseUp = noop, - onMouseRotate = noop, onMouseDrag = noop, onTouchStart = noop, onTouchRotate = noop, onTouchDrag = noop, onTouchEnd = noop, onTouchTap = noop, - onZoom = noop, - onZoomEnd = noop, - mapTouchToMouse = true, - pressKeyToRotate = false + onWheel = noop, + onWheelEnd = noop, + mapTouchToMouse = true } = {}) { onTouchStart = onTouchStart || (mapTouchToMouse && onMouseDown); onTouchDrag = onTouchDrag || (mapTouchToMouse && onMouseDrag); - onTouchRotate = onTouchRotate || (mapTouchToMouse && onMouseRotate); + onTouchRotate = onTouchRotate; onTouchEnd = onTouchEnd || (mapTouchToMouse && onMouseUp); onTouchTap = onTouchTap || (mapTouchToMouse && onMouseClick); this._canvas = canvas; - // Public member can be changed by app - this.pressKeyToRotate = pressKeyToRotate; - this.state = { didDrag: false, isFunctionKeyPressed: false, @@ -106,14 +101,13 @@ export default class EventManager { onMouseDown, onMouseUp, onTouchStart, - onMouseRotate, onMouseDrag, onTouchRotate, onTouchDrag, onTouchEnd, onTouchTap, - onZoom, - onZoomEnd + onWheel, + onWheelEnd }; autobind(this); @@ -179,31 +173,19 @@ export default class EventManager { _onMouseDrag(event) { const pos = this._getMousePos(event); this.setState({pos, didDrag: true}); - const {startPos} = this.state; - const {isFunctionKeyPressed} = this.state; - const rotate = this.pressKeyToRotate ? isFunctionKeyPressed : !isFunctionKeyPressed; + const {isFunctionKeyPressed, startPos} = this.state; - if (rotate) { - this.callbacks.onMouseRotate({pos, startPos}); - } else { - this.callbacks.onMouseDrag({pos, startPos}); - } + this.callbacks.onMouseDrag({pos, startPos, modifier: isFunctionKeyPressed}); } _onTouchDrag(event) { const pos = this._getTouchPos(event); this.setState({pos, didDrag: true}); - const {isFunctionKeyPressed} = this.state; - const rotate = this.pressKeyToRotate ? isFunctionKeyPressed : !isFunctionKeyPressed; + const {isFunctionKeyPressed, startPos} = this.state; - if (rotate) { - const {startPos} = this.state; - this.callbacks.onTouchRotate({pos, startPos}); - } else { - this.callbacks.onTouchDrag({pos}); - } + this.callbacks.onTouchDrag({pos, startPos, modifier: isFunctionKeyPressed}); event.preventDefault(); } @@ -273,7 +255,7 @@ export default class EventManager { // to 40ms. timeout = window.setTimeout(function setTimeout() { const _type = 'wheel'; - this._zoom(-this.state.mouseWheelLastValue, this.state.mouseWheelPos); + this._wheel(-this.state.mouseWheelLastValue, this.state.mouseWheelPos); this.setState({mouseWheelType: _type}); }.bind(this), 40); } else if (!this._type) { @@ -300,7 +282,7 @@ export default class EventManager { // Only fire the callback if we actually know what type of scrolling device // the user uses. if (type) { - this._zoom(-value, pos); + this._wheel(-value, pos); } this.setState({ @@ -313,16 +295,16 @@ export default class EventManager { } /* eslint-enable complexity, max-statements */ - _zoom(delta, pos) { + _wheel(delta, pos) { // Scale by sigmoid of scroll wheel delta. let scale = 2 / (1 + Math.exp(-Math.abs(delta / 100))); if (delta < 0 && scale !== 0) { scale = 1 / scale; } - this.callbacks.onZoom({pos, delta, scale}); - window.clearTimeout(this._zoomEndTimeout); - this._zoomEndTimeout = window.setTimeout(function _setTimeout() { - this.callbacks.onZoomEnd(); + this.callbacks.onWheel({pos, delta, scale}); + window.clearTimeout(this._wheelEndTimeout); + this._wheelEndTimeout = window.setTimeout(function _setTimeout() { + this.callbacks.onWheelEnd(); }.bind(this), 200); } } diff --git a/src/utils/map-state.js b/src/utils/map-state.js new file mode 100644 index 00000000..a542a8da --- /dev/null +++ b/src/utils/map-state.js @@ -0,0 +1,332 @@ +import PropTypes from 'prop-types'; +import {PerspectiveMercatorViewport} from 'viewport-mercator-project'; +import assert from 'assert'; + +// MAPBOX LIMITS +const MAX_PITCH = 60; +const MAX_ZOOM = 20; + +function mod(value, divisor) { + const modulus = value % divisor; + return modulus < 0 ? divisor + modulus : modulus; +} + +const propTypes = { + /** The width of the map */ + width: PropTypes.number.isRequired, + /** The height of the map */ + height: PropTypes.number.isRequired, + /** The latitude of the center of the map. */ + latitude: PropTypes.number.isRequired, + /** The longitude of the center of the map. */ + longitude: PropTypes.number.isRequired, + /** The tile zoom level of the map. */ + zoom: PropTypes.number.isRequired, + /** Specify the bearing of the viewport */ + bearing: PropTypes.number, + /** Specify the pitch of the viewport */ + pitch: PropTypes.number, + /** + * Specify the altitude of the viewport camera + * Unit: map heights, default 1.5 + * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137 + */ + altitude: PropTypes.number, + + /** Constraints */ + maxZoom: PropTypes.number, + minZoom: PropTypes.number, + maxPitch: PropTypes.number, + minPitch: PropTypes.number, + + /** + * Required to calculate the mouse projection during panning. + * The point on map being grabbed when the operation first started. + */ + startPanLngLat: PropTypes.arrayOf(PropTypes.number), + /** + * Required to calculate the mouse projection during zooming. + * Center of the zoom when the operation first started. + */ + startZoomLngLat: PropTypes.arrayOf(PropTypes.number), + /** Bearing when current perspective rotate operation started */ + startBearing: PropTypes.number, + /** Pitch when current perspective rotate operation started */ + startPitch: PropTypes.number, + /** Zoom when current zoom operation started */ + startZoom: PropTypes.number +}; + +export default class MapState { + + constructor({ + /** Mapbox viewport properties */ + width, + height, + latitude, + longitude, + zoom, + bearing = 0, + pitch = 0, + altitude = 1.5, + + /** Viewport constraints */ + maxZoom = MAX_ZOOM, + minZoom = 0, + maxPitch = MAX_PITCH, + minPitch = 0, + + /** Interaction states */ + startPanLngLat, + startZoomLngLat, + startBearing, + startPitch, + startZoom + } = {}) { + this.props = { + width, + height, + latitude, + longitude, + zoom, + bearing, + pitch, + altitude, + maxZoom, + minZoom, + maxPitch, + minPitch, + startPanLngLat, + startBearing, + startPitch + }; + } + + _updateViewport(opts) { + // Update props + Object.assign(this.props, opts); + this._applyConstraints(); + return this; + } + + // Apply any constraints (mathematical or defined by props) to viewport params + _applyConstraints() { + const viewport = this.props; + // Normalize degrees + viewport.longitude = mod(viewport.longitude + 180, 360) - 180; + viewport.bearing = mod(viewport.bearing + 180, 360) - 180; + + // Ensure zoom is within specified range + const {maxZoom, minZoom} = this.props; + viewport.zoom = viewport.zoom > maxZoom ? maxZoom : viewport.zoom; + viewport.zoom = viewport.zoom < minZoom ? minZoom : viewport.zoom; + + // Ensure pitch is within specified range + const {maxPitch, minPitch} = this.props; + + viewport.pitch = viewport.pitch > maxPitch ? maxPitch : viewport.pitch; + viewport.pitch = viewport.pitch < minPitch ? minPitch : viewport.pitch; + + return viewport; + } + + _unproject(pos) { + const viewport = new PerspectiveMercatorViewport(this.props); + return pos && viewport.unproject(pos, {topLeft: false}); + } + + // Calculate a new lnglat based on pixel dragging position + _calculateNewLngLat({startPanLngLat, pos}) { + const viewport = new PerspectiveMercatorViewport(this.props); + return viewport.getLocationAtPoint({lngLat: startPanLngLat, pos}); + } + + // Calculates new zoom + _calculateNewZoom({scale, startZoom}) { + const {maxZoom, minZoom} = this.props; + let zoom = startZoom + Math.log2(scale); + zoom = zoom > maxZoom ? maxZoom : zoom; + zoom = zoom < minZoom ? minZoom : zoom; + return zoom; + } + + // Calculates a new pitch and bearing from a position (coming from an event) + _calculateNewPitchAndBearing({xDeltaScale, yDeltaScale, startBearing, startPitch}) { + const {minPitch, maxPitch} = this.props; + + const bearing = startBearing + 180 * xDeltaScale; + let pitch = startPitch; + if (yDeltaScale > 0) { + // Gradually increase pitch + pitch = startPitch + yDeltaScale * (maxPitch - startPitch); + } else if (yDeltaScale < 0) { + // Gradually decrease pitch + pitch = startPitch - yDeltaScale * (minPitch - startPitch); + } + + return { + pitch, + bearing + }; + } + + /* Public API */ + + /** + * Start panning + * @param {[Number, Number]} pos - position on screen where the pointer grabs + */ + panStart({pos}) { + return this._updateViewport({ + startPanLngLat: this._unproject(pos) + }); + } + + /** + * Pan + * @param {[Number, Number]} pos - position on screen where the pointer is + * @param {[Number, Number], optional} startPos - where the pointer grabbed at + * the start of the operation. Must be supplied of `panStart()` was not called + */ + pan({pos, startPos}) { + const startPanLngLat = this.props.startPanLngLat || this._unproject(startPos); + + // take the start lnglat and put it where the mouse is down. + assert(startPanLngLat, '`startPanLngLat` prop is required ' + + 'for mouse pan behavior to calculate where to position the map.'); + + const [longitude, latitude] = this._calculateNewLngLat({startPanLngLat, pos}); + + return this._updateViewport({ + longitude, + latitude + }); + } + + /** + * End panning + * Must call if `panStart()` was called + */ + panEnd() { + return this._updateViewport({ + startPanLngLat: null + }); + } + + /** + * Start rotating + * @param {[Number, Number]} pos - position on screen where the center is + */ + rotateStart({pos}) { + return this._updateViewport({ + startBearing: this.props.bearing, + startPitch: this.props.pitch + }); + } + + /** + * Rotate + * @param {Number} xDeltaScale - a number between [-1, 1] specifying the + * change to bearing. + * @param {Number} yDeltaScale - a number between [-1, 1] specifying the + * change to pitch. -1 sets to minPitch and 1 sets to maxPitch. + */ + rotate({xDeltaScale, yDeltaScale}) { + assert(xDeltaScale >= -1 && xDeltaScale <= 1, + '`xDeltaScale` must be a number between [-1, 1]'); + assert(yDeltaScale >= -1 && yDeltaScale <= 1, + '`yDeltaScale` must be a number between [-1, 1]'); + + let {startBearing, startPitch} = this.props; + + if (!Number.isFinite(startBearing)) { + startBearing = this.props.bearing; + } + if (!Number.isFinite(startPitch)) { + startPitch = this.props.pitch; + } + + const {pitch, bearing} = this._calculateNewPitchAndBearing({ + xDeltaScale, + yDeltaScale, + startBearing, + startPitch + }); + + return this._updateViewport({ + bearing, + pitch + }); + } + + /** + * End rotating + * Must call if `rotateStart()` was called + */ + rotateEnd() { + return this._updateViewport({ + startBearing: null, + startPitch: null + }); + } + + /** + * Start zooming + * @param {[Number, Number]} pos - position on screen where the center is + */ + zoomStart({pos}) { + return this._updateViewport({ + startZoomLngLat: this._unproject(pos), + startZoom: this.props.zoom + }); + } + + /** + * Zoom + * @param {[Number, Number]} pos - position on screen where the current center is + * @param {[Number, Number]} startPos - the center position at + * the start of the operation. Must be supplied of `zoomStart()` was not called + * @param {Number} scale - a number between [0, 1] specifying the accumulated + * relative scale. + */ + zoom({pos, startPos, scale}) { + assert(scale > 0, '`scale` must be a positive number'); + + // Make sure we zoom around the current mouse position rather than map center + const startZoomLngLat = this.props.startZoomLngLat || this._unproject(startPos); + let {startZoom} = this.props; + + if (!Number.isFinite(startZoom)) { + startZoom = this.props.zoom; + } + + // take the start lnglat and put it where the mouse is down. + assert(startZoomLngLat, '`startZoomLngLat` prop is required ' + + 'for zoom behavior to calculate where to position the map.'); + + const zoom = this._calculateNewZoom({scale, startZoom}); + + const zoomedViewport = new PerspectiveMercatorViewport(Object.assign({}, this.props, {zoom})); + const [longitude, latitude] = zoomedViewport.getLocationAtPoint({lngLat: startZoomLngLat, pos}); + + return this._updateViewport({ + zoom, + longitude, + latitude + }); + } + + /** + * End zooming + * Must call if `zoomStart()` was called + */ + zoomEnd() { + return this._updateViewport({ + startZoomLngLat: null, + startZoom: null + }); + } + +} + +MapState.propTypes = propTypes; diff --git a/test/utils/index.js b/test/utils/index.js index 8e8ae11a..a4fc5acb 100644 --- a/test/utils/index.js +++ b/test/utils/index.js @@ -1,2 +1,3 @@ // import './fit-bounds.spec'; import './style-utils.spec'; +import './map-state.spec'; diff --git a/test/utils/map-state.spec.js b/test/utils/map-state.spec.js new file mode 100644 index 00000000..b27a7a85 --- /dev/null +++ b/test/utils/map-state.spec.js @@ -0,0 +1,184 @@ +import test from 'tape-catch'; +import MapState from '../../src/utils/map-state'; +import {PerspectiveMercatorViewport} from 'viewport-mercator-project'; + +const SAMPLE_VIEWPORTS = [ + // SF + { + width: 800, + height: 600, + longitude: -122.58, + latitude: 37.74, + zoom: 14 + }, + // Edge location, custom zoom limit + { + width: 800, + height: 600, + longitude: -179, + latitude: 90, + zoom: 0, + maxZoom: 0.5 + }, + // SF with rotation, custom pitch limit + { + width: 800, + height: 600, + longitude: -122.58, + latitude: 37.74, + zoom: 14, + pitch: 60, + bearing: 45, + maxPitch: 90 + } +]; + +// Discard precision errors for comparison +function toLowPrecision(input, precision = 11) { + if (typeof input === 'number') { + input = Number(input.toPrecision(precision)); + } + /* eslint-enable guard-for-in */ + return input; +} + +// Compare two [lng, lat] locations, account for longitude wrapping +function isSameLocation(lngLat1, lngLat2) { + const lng1 = toLowPrecision(lngLat1[0]); + const lat1 = toLowPrecision(lngLat1[1]); + const lng2 = toLowPrecision(lngLat2[0]); + const lat2 = toLowPrecision(lngLat2[1]); + return ((lng1 - lng2) % 360) === 0 && lat1 === lat2; +} + +test('MapState - Pan', t => { + const POS = [300, 300]; + const START_POS = [100, 100]; + + SAMPLE_VIEWPORTS.forEach(viewport => { + // one-off panning + const mapState1 = new MapState(viewport).pan({pos: POS, startPos: START_POS}); + t.ok(toLowPrecision(mapState1.props.longitude) !== toLowPrecision(viewport.longitude) || + toLowPrecision(mapState1.props.latitude) !== toLowPrecision(viewport.latitude), + 'Map center has changed'); + t.ok(mapState1.props.longitude < 180 && + mapState1.props.longitude >= -180, 'Longitude is within bounds'); + t.ok(mapState1.props.latitude <= 90 && + mapState1.props.latitude >= -90, 'Latitude is within bounds'); + t.ok(isSameLocation( + new PerspectiveMercatorViewport(viewport).unproject(START_POS), + new PerspectiveMercatorViewport(mapState1.props).unproject(POS)), + 'Location under the pointer remains the same'); + + // chained panning + const mapState2 = new MapState(viewport) + .panStart({pos: START_POS}) + .pan({pos: POS}) + .panEnd(); + t.ok(toLowPrecision(mapState1.props.longitude) === toLowPrecision(mapState2.props.longitude) && + toLowPrecision(mapState1.props.latitude) === toLowPrecision(mapState2.props.latitude), + 'Consistent result'); + }); + + // insufficient arguments + try { + new MapState(SAMPLE_VIEWPORTS[0]).pan({pos: POS}); + t.fail('Should throw error for missing argument'); + } catch (error) { + t.ok(/startPanLngLat/.test(error.message), 'Should throw error for missing argument'); + } + + t.end(); +}); + +test('MapState - Rotate', t => { + const X_DELTA = -0.2; + const Y_DELTA = 0.2; + + SAMPLE_VIEWPORTS.forEach(viewport => { + // one-off rotating + const mapState1 = new MapState(viewport).rotate({xDeltaScale: X_DELTA, yDeltaScale: Y_DELTA}); + t.ok(toLowPrecision(mapState1.props.bearing) !== toLowPrecision(viewport.bearing), + 'Bearing has changed'); + t.ok(toLowPrecision(mapState1.props.pitch) !== toLowPrecision(viewport.pitch), + 'Pitch has changed'); + t.ok(mapState1.props.pitch <= mapState1.props.maxPitch && + mapState1.props.pitch >= mapState1.props.minPitch, 'Pitch is within bounds'); + t.ok(mapState1.props.bearing < 180 && + mapState1.props.bearing >= -180, 'Bearing is within bounds'); + + // chained rotating + const mapState2 = new MapState(viewport) + .rotateStart({}) + .rotate({xDeltaScale: X_DELTA, yDeltaScale: Y_DELTA}) + .rotateEnd(); + t.ok(toLowPrecision(mapState1.props.pitch) === toLowPrecision(mapState2.props.pitch) && + toLowPrecision(mapState1.props.bearing) === toLowPrecision(mapState2.props.bearing), + 'Consistent result'); + }); + + // argument out of bounds + try { + new MapState(SAMPLE_VIEWPORTS[0]).rotate({xDeltaScale: 2, yDeltaScale: 0}); + t.fail('Should throw error with out of bounds argument'); + } catch (error) { + t.ok(/xDeltaScale/.test(error.message), 'Should throw error with out of bounds argument'); + } + + // insufficient arguments + try { + new MapState(SAMPLE_VIEWPORTS[0]).rotate({xDeltaScale: 0}); + t.fail('Should throw error for missing argument'); + } catch (error) { + t.ok(/yDeltaScale/.test(error.message), 'Should throw error for missing argument'); + } + + t.end(); +}); + +test('MapState - Zoom', t => { + const POS = [100, 100]; + const START_POS = [200, 200]; + const SCALE = 2; + + SAMPLE_VIEWPORTS.forEach(viewport => { + // one-off panning + const mapState1 = new MapState(viewport).zoom({pos: POS, startPos: START_POS, scale: SCALE}); + t.ok(toLowPrecision(mapState1.props.zoom) !== toLowPrecision(viewport.zoom), + 'Zoom has changed'); + t.ok(mapState1.props.zoom <= mapState1.props.maxZoom && + mapState1.props.zoom >= mapState1.props.minZoom, 'Zoom is within bounds'); + t.ok(isSameLocation( + new PerspectiveMercatorViewport(viewport).unproject(START_POS), + new PerspectiveMercatorViewport(mapState1.props).unproject(POS)), + 'Location under the pointer remains the same'); + + // chained panning + const mapState2 = new MapState(viewport) + .zoomStart({pos: START_POS}) + .zoom({pos: POS, scale: SCALE}) + .zoomEnd(); + t.ok(toLowPrecision(mapState1.props.longitude) === toLowPrecision(mapState2.props.longitude) && + toLowPrecision(mapState1.props.latitude) === toLowPrecision(mapState2.props.latitude) && + toLowPrecision(mapState1.props.zoom) === toLowPrecision(mapState2.props.zoom), + 'Consistent result'); + }); + + // insufficient arguments + try { + new MapState(SAMPLE_VIEWPORTS[0]).zoom({pos: POS, scale: SCALE}); + t.fail('Should throw error for missing argument'); + } catch (error) { + t.ok(/startZoomLngLat/.test(error.message), 'Should throw error for missing argument'); + } + + // argument out of bounds + try { + new MapState(SAMPLE_VIEWPORTS[0]).zoom({pos: POS, startPos: START_POS, scale: -1}); + t.fail('Should throw error with out of bounds argument'); + } catch (error) { + t.ok(/scale/.test(error.message), 'Should throw error with out of bounds argument'); + } + + t.end(); +});