From 593fa67ca6909e8e45e7cc099f159832ec2f8f9a Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Wed, 3 May 2017 12:35:24 -0700 Subject: [PATCH] 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 --- src/components/interactive-map.js | 4 +- src/components/map-controls.js | 41 ++- src/utils/map-state.js | 481 +++++++++++++++--------------- test/utils/map-state.spec.js | 147 +++++---- 4 files changed, 353 insertions(+), 320 deletions(-) diff --git a/src/components/interactive-map.js b/src/components/interactive-map.js index a4a5fb60..2d6b503e 100644 --- a/src/components/interactive-map.js +++ b/src/components/interactive-map.js @@ -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', diff --git a/src/components/map-controls.js b/src/components/map-controls.js index 9e651c49..a456e90a 100644 --- a/src/components/map-controls.js +++ b/src/components/map-controls.js @@ -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() { diff --git a/src/utils/map-state.js b/src/utils/map-state.js index a542a8da..6dadc020 100644 --- a/src/utils/map-state.js +++ b/src/utils/map-state.js @@ -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; diff --git a/test/utils/map-state.spec.js b/test/utils/map-state.spec.js index b27a7a85..68dcbb87 100644 --- a/test/utils/map-state.spec.js +++ b/test/utils/map-state.spec.js @@ -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');