mirror of
https://github.com/visgl/react-map-gl.git
synced 2026-01-25 16:02:50 +00:00
Improve MapState class and tests (#223)
- `MapControl` takes a `MapState` instance instead of list of viewport props - Always return a new `MapState` object after transform - Better naming of properties and methods - Code consistency and error checking - Tests cover more use cases
This commit is contained in:
parent
010b64e85c
commit
593fa67ca6
@ -4,6 +4,7 @@ import autobind from '../utils/autobind';
|
||||
|
||||
import StaticMap from './static-map';
|
||||
import MapControls from './map-controls';
|
||||
import MapState from '../utils/map-state';
|
||||
|
||||
const propTypes = {
|
||||
displayConstraints: PropTypes.object.isRequired
|
||||
@ -58,7 +59,8 @@ export default class InteractiveMap extends PureComponent {
|
||||
return (
|
||||
createElement(MapControls, Object.assign({}, this.props, {
|
||||
key: 'map-controls',
|
||||
style: {position: 'relative'}
|
||||
style: {position: 'relative'},
|
||||
mapState: new MapState(this.props)
|
||||
}), [
|
||||
createElement(StaticMap, Object.assign({}, this.props, {
|
||||
key: 'map-static',
|
||||
|
||||
@ -33,7 +33,8 @@ const PITCH_MOUSE_THRESHOLD = 5;
|
||||
const PITCH_ACCEL = 1.2;
|
||||
const ZOOM_ACCEL = 0.01;
|
||||
|
||||
const propTypes = Object.assign({}, MapState.propTypes, {
|
||||
const propTypes = {
|
||||
mapState: PropTypes.instanceOf(MapState).isRequired,
|
||||
|
||||
/** Enables perspective control event handling */
|
||||
perspectiveEnabled: PropTypes.bool,
|
||||
@ -51,7 +52,7 @@ const propTypes = Object.assign({}, MapState.propTypes, {
|
||||
*/
|
||||
isHovering: PropTypes.bool,
|
||||
isDragging: PropTypes.bool
|
||||
});
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
perspectiveEnabled: false,
|
||||
@ -108,9 +109,17 @@ export default class MapControls extends PureComponent {
|
||||
return config.CURSOR.GRAB;
|
||||
}
|
||||
|
||||
_updateViewport(...opts) {
|
||||
_updateViewport(mapState, extraState = {}) {
|
||||
if (!this.props.onChangeViewport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {isDragging} = this.props;
|
||||
return this.props.onChangeViewport(Object.assign({isDragging}, ...opts));
|
||||
return this.props.onChangeViewport(Object.assign(
|
||||
{isDragging},
|
||||
mapState.getViewportProps(),
|
||||
extraState
|
||||
));
|
||||
}
|
||||
|
||||
_onTouchRotate(opts) {
|
||||
@ -118,8 +127,8 @@ export default class MapControls extends PureComponent {
|
||||
}
|
||||
|
||||
_onMouseDown({pos}) {
|
||||
const mapState = new MapState(this.props).panStart({pos}).rotateStart({pos});
|
||||
this._updateViewport(mapState.props, {isDragging: true});
|
||||
const newMapState = this.props.mapState.panStart({pos}).rotateStart({pos});
|
||||
this._updateViewport(newMapState, {isDragging: true});
|
||||
}
|
||||
|
||||
_onMouseDrag({pos, startPos, modifier}) {
|
||||
@ -135,8 +144,8 @@ export default class MapControls extends PureComponent {
|
||||
}
|
||||
|
||||
_onMousePan({pos}) {
|
||||
const mapState = new MapState(this.props).pan({pos});
|
||||
this._updateViewport(mapState.props);
|
||||
const newMapState = this.props.mapState.pan({pos});
|
||||
this._updateViewport(newMapState);
|
||||
}
|
||||
|
||||
_onMouseRotate({pos, startPos}) {
|
||||
@ -162,13 +171,13 @@ export default class MapControls extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const mapState = new MapState(this.props).rotate({xDeltaScale, yDeltaScale});
|
||||
this._updateViewport(mapState.props);
|
||||
const newMapState = this.props.mapState.rotate({xDeltaScale, yDeltaScale});
|
||||
this._updateViewport(newMapState);
|
||||
}
|
||||
|
||||
_onMouseUp() {
|
||||
const mapState = new MapState(this.props).panEnd().rotateEnd();
|
||||
this._updateViewport(mapState.props, {isDragging: false});
|
||||
const newMapState = this.props.mapState.panEnd().rotateEnd();
|
||||
this._updateViewport(newMapState, {isDragging: false});
|
||||
}
|
||||
|
||||
_onWheel({pos, delta}) {
|
||||
@ -177,13 +186,13 @@ export default class MapControls extends PureComponent {
|
||||
scale = 1 / scale;
|
||||
}
|
||||
|
||||
const mapState = new MapState(this.props).zoom({pos, startPos: pos, scale});
|
||||
this._updateViewport(mapState.props, {isDragging: true});
|
||||
const newMapState = this.props.mapState.zoom({pos, scale});
|
||||
this._updateViewport(newMapState, {isDragging: true});
|
||||
}
|
||||
|
||||
_onWheelEnd() {
|
||||
const mapState = new MapState(this.props).zoomEnd();
|
||||
this._updateViewport(mapState.props, {isDragging: false});
|
||||
const newMapState = this.props.mapState.zoomEnd();
|
||||
this._updateViewport(newMapState, {isDragging: false});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {PerspectiveMercatorViewport} from 'viewport-mercator-project';
|
||||
import assert from 'assert';
|
||||
|
||||
@ -6,144 +5,299 @@ import assert from 'assert';
|
||||
const MAX_PITCH = 60;
|
||||
const MAX_ZOOM = 20;
|
||||
|
||||
const defaultState = {
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
altitude: 1.5,
|
||||
maxZoom: MAX_ZOOM,
|
||||
minZoom: 0,
|
||||
maxPitch: MAX_PITCH,
|
||||
minPitch: 0
|
||||
};
|
||||
|
||||
/* Utils */
|
||||
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
|
||||
};
|
||||
function ensureFinite(value, fallbackValue) {
|
||||
return Number.isFinite(value) ? value : fallbackValue;
|
||||
}
|
||||
|
||||
export default class MapState {
|
||||
|
||||
constructor({
|
||||
/** Mapbox viewport properties */
|
||||
/** The width of the viewport */
|
||||
width,
|
||||
/** The height of the viewport */
|
||||
height,
|
||||
/** The latitude at the center of the viewport */
|
||||
latitude,
|
||||
/** The longitude at the center of the viewport */
|
||||
longitude,
|
||||
/** The tile zoom level of the map. */
|
||||
zoom,
|
||||
bearing = 0,
|
||||
pitch = 0,
|
||||
altitude = 1.5,
|
||||
/** The bearing of the viewport in degrees */
|
||||
bearing,
|
||||
/** The pitch of the viewport in degrees */
|
||||
pitch,
|
||||
/**
|
||||
* 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,
|
||||
|
||||
/** Viewport constraints */
|
||||
maxZoom = MAX_ZOOM,
|
||||
minZoom = 0,
|
||||
maxPitch = MAX_PITCH,
|
||||
minPitch = 0,
|
||||
maxZoom,
|
||||
minZoom,
|
||||
maxPitch,
|
||||
minPitch,
|
||||
|
||||
/** Interaction states */
|
||||
/** Interaction states, required to calculate change during transform */
|
||||
/* The point on map being grabbed when the operation first started */
|
||||
startPanLngLat,
|
||||
/* Center of the zoom when the operation first started */
|
||||
startZoomLngLat,
|
||||
/** Bearing when current perspective rotate operation started */
|
||||
startBearing,
|
||||
/** Pitch when current perspective rotate operation started */
|
||||
startPitch,
|
||||
/** Zoom when current zoom operation started */
|
||||
startZoom
|
||||
} = {}) {
|
||||
this.props = {
|
||||
assert(Number.isFinite(width), '`width` must be supplied');
|
||||
assert(Number.isFinite(height), '`height` must be supplied');
|
||||
assert(Number.isFinite(longitude), '`longitude` must be supplied');
|
||||
assert(Number.isFinite(latitude), '`latitude` must be supplied');
|
||||
assert(Number.isFinite(zoom), '`zoom` must be supplied');
|
||||
|
||||
this._state = this._applyConstraints({
|
||||
width,
|
||||
height,
|
||||
latitude,
|
||||
longitude,
|
||||
zoom,
|
||||
bearing,
|
||||
pitch,
|
||||
altitude,
|
||||
maxZoom,
|
||||
minZoom,
|
||||
maxPitch,
|
||||
minPitch,
|
||||
bearing: ensureFinite(bearing, defaultState.bearing),
|
||||
pitch: ensureFinite(pitch, defaultState.pitch),
|
||||
altitude: ensureFinite(altitude, defaultState.altitude),
|
||||
maxZoom: ensureFinite(maxZoom, defaultState.maxZoom),
|
||||
minZoom: ensureFinite(minZoom, defaultState.minZoom),
|
||||
maxPitch: ensureFinite(maxPitch, defaultState.maxPitch),
|
||||
minPitch: ensureFinite(minPitch, defaultState.minPitch),
|
||||
startPanLngLat,
|
||||
startZoomLngLat,
|
||||
startBearing,
|
||||
startPitch,
|
||||
startZoom
|
||||
});
|
||||
}
|
||||
|
||||
/* Public API */
|
||||
|
||||
getViewportProps() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start panning
|
||||
* @param {[Number, Number]} pos - position on screen where the pointer grabs
|
||||
*/
|
||||
panStart({pos}) {
|
||||
return this._getUpdatedMapState({
|
||||
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._state.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._getUpdatedMapState({
|
||||
longitude,
|
||||
latitude
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End panning
|
||||
* Must call if `panStart()` was called
|
||||
*/
|
||||
panEnd() {
|
||||
return this._getUpdatedMapState({
|
||||
startPanLngLat: null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start rotating
|
||||
* @param {[Number, Number]} pos - position on screen where the center is
|
||||
*/
|
||||
rotateStart({pos}) {
|
||||
return this._getUpdatedMapState({
|
||||
startBearing: this._state.bearing,
|
||||
startPitch: this._state.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._state;
|
||||
|
||||
if (!Number.isFinite(startBearing)) {
|
||||
startBearing = this._state.bearing;
|
||||
}
|
||||
if (!Number.isFinite(startPitch)) {
|
||||
startPitch = this._state.pitch;
|
||||
}
|
||||
|
||||
const {pitch, bearing} = this._calculateNewPitchAndBearing({
|
||||
xDeltaScale,
|
||||
yDeltaScale,
|
||||
startBearing,
|
||||
startPitch
|
||||
};
|
||||
});
|
||||
|
||||
return this._getUpdatedMapState({
|
||||
bearing,
|
||||
pitch
|
||||
});
|
||||
}
|
||||
|
||||
_updateViewport(opts) {
|
||||
// Update props
|
||||
Object.assign(this.props, opts);
|
||||
this._applyConstraints();
|
||||
return this;
|
||||
/**
|
||||
* End rotating
|
||||
* Must call if `rotateStart()` was called
|
||||
*/
|
||||
rotateEnd() {
|
||||
return this._getUpdatedMapState({
|
||||
startBearing: null,
|
||||
startPitch: null
|
||||
});
|
||||
}
|
||||
|
||||
// Apply any constraints (mathematical or defined by props) to viewport params
|
||||
_applyConstraints() {
|
||||
const viewport = this.props;
|
||||
/**
|
||||
* Start zooming
|
||||
* @param {[Number, Number]} pos - position on screen where the center is
|
||||
*/
|
||||
zoomStart({pos}) {
|
||||
return this._getUpdatedMapState({
|
||||
startZoomLngLat: this._unproject(pos),
|
||||
startZoom: this._state.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._state.startZoomLngLat ||
|
||||
this._unproject(startPos) || this._unproject(pos);
|
||||
let {startZoom} = this._state;
|
||||
|
||||
if (!Number.isFinite(startZoom)) {
|
||||
startZoom = this._state.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._state, {zoom}));
|
||||
const [longitude, latitude] = zoomedViewport.getLocationAtPoint({lngLat: startZoomLngLat, pos});
|
||||
|
||||
return this._getUpdatedMapState({
|
||||
zoom,
|
||||
longitude,
|
||||
latitude
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End zooming
|
||||
* Must call if `zoomStart()` was called
|
||||
*/
|
||||
zoomEnd() {
|
||||
return this._getUpdatedMapState({
|
||||
startZoomLngLat: null,
|
||||
startZoom: null
|
||||
});
|
||||
}
|
||||
|
||||
/* Private methods */
|
||||
|
||||
_getUpdatedMapState(newProps) {
|
||||
// Update _state
|
||||
return new MapState(Object.assign({}, this._state, newProps));
|
||||
}
|
||||
|
||||
// Apply any constraints (mathematical or defined by _state) to map state
|
||||
_applyConstraints(props) {
|
||||
// Normalize degrees
|
||||
viewport.longitude = mod(viewport.longitude + 180, 360) - 180;
|
||||
viewport.bearing = mod(viewport.bearing + 180, 360) - 180;
|
||||
props.longitude = mod(props.longitude + 180, 360) - 180;
|
||||
props.bearing = mod(props.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;
|
||||
const {maxZoom, minZoom, zoom} = props;
|
||||
props.zoom = zoom > maxZoom ? maxZoom : zoom;
|
||||
props.zoom = zoom < minZoom ? minZoom : zoom;
|
||||
|
||||
// Ensure pitch is within specified range
|
||||
const {maxPitch, minPitch} = this.props;
|
||||
const {maxPitch, minPitch, pitch} = props;
|
||||
|
||||
viewport.pitch = viewport.pitch > maxPitch ? maxPitch : viewport.pitch;
|
||||
viewport.pitch = viewport.pitch < minPitch ? minPitch : viewport.pitch;
|
||||
props.pitch = pitch > maxPitch ? maxPitch : pitch;
|
||||
props.pitch = pitch < minPitch ? minPitch : pitch;
|
||||
|
||||
return viewport;
|
||||
return props;
|
||||
}
|
||||
|
||||
_unproject(pos) {
|
||||
const viewport = new PerspectiveMercatorViewport(this.props);
|
||||
const viewport = new PerspectiveMercatorViewport(this._state);
|
||||
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);
|
||||
const viewport = new PerspectiveMercatorViewport(this._state);
|
||||
return viewport.getLocationAtPoint({lngLat: startPanLngLat, pos});
|
||||
}
|
||||
|
||||
// Calculates new zoom
|
||||
_calculateNewZoom({scale, startZoom}) {
|
||||
const {maxZoom, minZoom} = this.props;
|
||||
const {maxZoom, minZoom} = this._state;
|
||||
let zoom = startZoom + Math.log2(scale);
|
||||
zoom = zoom > maxZoom ? maxZoom : zoom;
|
||||
zoom = zoom < minZoom ? minZoom : zoom;
|
||||
@ -152,7 +306,7 @@ export default class MapState {
|
||||
|
||||
// Calculates a new pitch and bearing from a position (coming from an event)
|
||||
_calculateNewPitchAndBearing({xDeltaScale, yDeltaScale, startBearing, startPitch}) {
|
||||
const {minPitch, maxPitch} = this.props;
|
||||
const {minPitch, maxPitch} = this._state;
|
||||
|
||||
const bearing = startBearing + 180 * xDeltaScale;
|
||||
let pitch = startPitch;
|
||||
@ -170,163 +324,4 @@ export default class MapState {
|
||||
};
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@ -34,13 +34,7 @@ const SAMPLE_VIEWPORTS = [
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
const toLowPrecision = (input, precision = 11) => Number(input.toPrecision(precision));
|
||||
|
||||
// Compare two [lng, lat] locations, account for longitude wrapping
|
||||
function isSameLocation(lngLat1, lngLat2) {
|
||||
@ -51,38 +45,66 @@ function isSameLocation(lngLat1, lngLat2) {
|
||||
return ((lng1 - lng2) % 360) === 0 && lat1 === lat2;
|
||||
}
|
||||
|
||||
test('MapState - Constructor', t => {
|
||||
SAMPLE_VIEWPORTS.forEach(viewport => {
|
||||
t.ok(new MapState(viewport), 'Constructed MapState instance');
|
||||
});
|
||||
|
||||
// Normalize props
|
||||
{
|
||||
const mapState = new MapState(Object.assign({}, SAMPLE_VIEWPORTS[0], {longitude: -200}));
|
||||
t.is(mapState.getViewportProps().longitude, 160, 'Normalized props');
|
||||
}
|
||||
|
||||
// Missing required prop
|
||||
try {
|
||||
t.notOk(new MapState({width: 100, height: 100}), 'Should throw error for missing prop');
|
||||
} catch (error) {
|
||||
t.ok(/must be supplied/.test(error.message), 'Should throw error for missing prop');
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('MapState - Pan', t => {
|
||||
const POS = [300, 300];
|
||||
const START_POS = [100, 100];
|
||||
const POS_START = [100, 100];
|
||||
const POS_IMTERMIDIATE = [200, 200];
|
||||
const POS_END = [300, 300];
|
||||
|
||||
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),
|
||||
const viewport1 = new MapState(viewport)
|
||||
.pan({pos: POS_END, startPos: POS_START})
|
||||
.getViewportProps();
|
||||
|
||||
t.ok(toLowPrecision(viewport1.longitude) !== toLowPrecision(viewport.longitude) ||
|
||||
toLowPrecision(viewport1.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(viewport1.longitude < 180 &&
|
||||
viewport1.longitude >= -180, 'Longitude is within bounds');
|
||||
t.ok(viewport1.latitude <= 90 &&
|
||||
viewport1.latitude >= -90, 'Latitude is within bounds');
|
||||
t.ok(isSameLocation(
|
||||
new PerspectiveMercatorViewport(viewport).unproject(START_POS),
|
||||
new PerspectiveMercatorViewport(mapState1.props).unproject(POS)),
|
||||
new PerspectiveMercatorViewport(viewport).unproject(POS_START),
|
||||
new PerspectiveMercatorViewport(viewport1).unproject(POS_END)),
|
||||
'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),
|
||||
const viewport2 = new MapState(viewport)
|
||||
.panStart({pos: POS_START})
|
||||
.pan({pos: POS_IMTERMIDIATE})
|
||||
.pan({pos: POS_END})
|
||||
.panEnd()
|
||||
.getViewportProps();
|
||||
|
||||
t.ok(toLowPrecision(viewport1.longitude) === toLowPrecision(viewport2.longitude) &&
|
||||
toLowPrecision(viewport1.latitude) === toLowPrecision(viewport2.latitude),
|
||||
'Consistent result');
|
||||
});
|
||||
|
||||
// insufficient arguments
|
||||
try {
|
||||
new MapState(SAMPLE_VIEWPORTS[0]).pan({pos: POS});
|
||||
new MapState(SAMPLE_VIEWPORTS[0]).pan({pos: POS_START});
|
||||
t.fail('Should throw error for missing argument');
|
||||
} catch (error) {
|
||||
t.ok(/startPanLngLat/.test(error.message), 'Should throw error for missing argument');
|
||||
@ -97,23 +119,29 @@ test('MapState - Rotate', t => {
|
||||
|
||||
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),
|
||||
const viewport1 = new MapState(viewport)
|
||||
.rotate({xDeltaScale: X_DELTA, yDeltaScale: Y_DELTA})
|
||||
.getViewportProps();
|
||||
|
||||
t.ok(toLowPrecision(viewport1.bearing) !== toLowPrecision(viewport.bearing || 0),
|
||||
'Bearing has changed');
|
||||
t.ok(toLowPrecision(mapState1.props.pitch) !== toLowPrecision(viewport.pitch),
|
||||
t.ok(toLowPrecision(viewport1.pitch) !== toLowPrecision(viewport.pitch || 0),
|
||||
'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');
|
||||
t.ok(viewport1.pitch <= viewport1.maxPitch &&
|
||||
viewport1.pitch >= viewport1.minPitch, 'Pitch is within bounds');
|
||||
t.ok(viewport1.bearing < 180 &&
|
||||
viewport1.bearing >= -180, 'Bearing is within bounds');
|
||||
|
||||
// chained rotating
|
||||
const mapState2 = new MapState(viewport)
|
||||
const viewport2 = new MapState(viewport)
|
||||
.rotateStart({})
|
||||
.rotate({xDeltaScale: 0, yDeltaScale: 0})
|
||||
.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),
|
||||
.rotateEnd()
|
||||
.getViewportProps();
|
||||
|
||||
t.ok(toLowPrecision(viewport1.pitch) === toLowPrecision(viewport2.pitch) &&
|
||||
toLowPrecision(viewport1.bearing) === toLowPrecision(viewport2.bearing),
|
||||
'Consistent result');
|
||||
});
|
||||
|
||||
@ -137,44 +165,43 @@ test('MapState - Rotate', t => {
|
||||
});
|
||||
|
||||
test('MapState - Zoom', t => {
|
||||
const POS = [100, 100];
|
||||
const START_POS = [200, 200];
|
||||
const POS_START = [300, 300];
|
||||
const POS_IMTERMIDIATE = [200, 200];
|
||||
const POS_END = [100, 100];
|
||||
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),
|
||||
const viewport1 = new MapState(viewport)
|
||||
.zoom({pos: POS_END, startPos: POS_START, scale: SCALE})
|
||||
.getViewportProps();
|
||||
|
||||
t.ok(toLowPrecision(viewport1.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(viewport1.zoom <= viewport1.maxZoom &&
|
||||
viewport1.zoom >= viewport1.minZoom, 'Zoom is within bounds');
|
||||
t.ok(isSameLocation(
|
||||
new PerspectiveMercatorViewport(viewport).unproject(START_POS),
|
||||
new PerspectiveMercatorViewport(mapState1.props).unproject(POS)),
|
||||
new PerspectiveMercatorViewport(viewport).unproject(POS_START),
|
||||
new PerspectiveMercatorViewport(viewport1).unproject(POS_END)),
|
||||
'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),
|
||||
const viewport2 = new MapState(viewport)
|
||||
.zoomStart({pos: POS_START})
|
||||
.zoom({pos: POS_IMTERMIDIATE, scale: 1.5})
|
||||
.zoom({pos: POS_END, scale: SCALE})
|
||||
.zoomEnd()
|
||||
.getViewportProps();
|
||||
|
||||
t.ok(toLowPrecision(viewport1.longitude) === toLowPrecision(viewport2.longitude) &&
|
||||
toLowPrecision(viewport1.latitude) === toLowPrecision(viewport2.latitude) &&
|
||||
toLowPrecision(viewport1.zoom) === toLowPrecision(viewport2.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});
|
||||
new MapState(SAMPLE_VIEWPORTS[0]).zoom({pos: POS_END, 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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user