mirror of
https://github.com/visgl/react-map-gl.git
synced 2025-12-08 20:16:02 +00:00
Separating input events and semantic transform in map control (#212)
This commit is contained in:
parent
c6d6db9c8e
commit
7ef7b19860
@ -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() {
|
||||
|
||||
@ -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
332
src/utils/map-state.js
Normal 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;
|
||||
@ -1,2 +1,3 @@
|
||||
// import './fit-bounds.spec';
|
||||
import './style-utils.spec';
|
||||
import './map-state.spec';
|
||||
|
||||
184
test/utils/map-state.spec.js
Normal file
184
test/utils/map-state.spec.js
Normal 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();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user