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:
Xiaoji Chen 2017-05-03 12:35:24 -07:00 committed by GitHub
parent 010b64e85c
commit 593fa67ca6
4 changed files with 353 additions and 320 deletions

View File

@ -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',

View File

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

View File

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

View File

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