Separating input events and semantic transform in map control (#212)

This commit is contained in:
Xiaoji Chen 2017-05-02 17:39:14 -07:00 committed by GitHub
parent c6d6db9c8e
commit 7ef7b19860
5 changed files with 591 additions and 287 deletions

View File

@ -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() {

View File

@ -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);
}
}

332
src/utils/map-state.js Normal file
View File

@ -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;

View File

@ -1,2 +1,3 @@
// import './fit-bounds.spec';
import './style-utils.spec';
import './map-state.spec';

View File

@ -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();
});