@mapbox/react-map-gl
Version:
A React wrapper for MapboxGL-js and overlay API.
385 lines (332 loc) • 10.8 kB
JavaScript
// @flow
import WebMercatorViewport, {normalizeViewportProps} from 'viewport-mercator-project';
import {TransitionInterpolator} from './transition';
import {clamp} from './math-utils';
import assert from './assert';
// MAPBOX LIMITS
export const MAPBOX_LIMITS = {
minZoom: 0,
maxZoom: 24,
minPitch: 0,
maxPitch: 60
};
const DEFAULT_STATE = {
pitch: 0,
bearing: 0,
altitude: 1.5
};
type ViewportProps = {
width: number,
height: number,
latitude: number,
longitude: number,
zoom: number,
bearing: number,
pitch: number,
altitude: number,
maxZoom: number,
minZoom: number,
maxPitch: number,
minPitch: number,
transitionDuration: number,
transitionEasing: number => number,
transitionInterpolator: TransitionInterpolator,
transitionInterruption: number
};
type InteractiveState = {
startPanLngLat?: Array<number>,
startZoomLngLat?: Array<number>,
startBearing?: number,
startPitch?: number,
startZoom?: number
};
export type MapStateProps = ViewportProps & InteractiveState & {
altitude?: number,
maxZoom?: number,
minZoom?: number,
maxPitch?: number,
minPitch?: number,
};
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,
/** The bearing of the viewport in degrees */
bearing = DEFAULT_STATE.bearing,
/** The pitch of the viewport in degrees */
pitch = DEFAULT_STATE.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 = DEFAULT_STATE.altitude,
/** Viewport constraints */
maxZoom = MAPBOX_LIMITS.maxZoom,
minZoom = MAPBOX_LIMITS.minZoom,
maxPitch = MAPBOX_LIMITS.maxPitch,
minPitch = MAPBOX_LIMITS.minPitch,
/** Transition props */
transitionDuration,
transitionEasing,
transitionInterpolator,
transitionInterruption,
/** 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
}: MapStateProps) {
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._viewportProps = this._applyConstraints({
width,
height,
latitude,
longitude,
zoom,
bearing,
pitch,
altitude,
maxZoom,
minZoom,
maxPitch,
minPitch,
transitionDuration,
transitionEasing,
transitionInterpolator,
transitionInterruption
});
this._interactiveState = {
startPanLngLat,
startZoomLngLat,
startBearing,
startPitch,
startZoom
};
}
_viewportProps: ViewportProps;
_interactiveState: InteractiveState;
/* Public API */
getViewportProps() {
return this._viewportProps;
}
getInteractiveState() {
return this._interactiveState;
}
/**
* Start panning
* @param {[Number, Number]} pos - position on screen where the pointer grabs
*/
panStart({pos} : {pos: Array<number>}) {
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} : {pos: Array<number>, startPos?: Array<number>}) {
const startPanLngLat = this._interactiveState.startPanLngLat || this._unproject(startPos);
if (!startPanLngLat) {
return this;
}
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} : {pos: Array<number>}) {
return this._getUpdatedMapState({
startBearing: this._viewportProps.bearing,
startPitch: this._viewportProps.pitch
});
}
/**
* Rotate
* @param {Number} deltaScaleX - a number between [-1, 1] specifying the
* change to bearing.
* @param {Number} deltaScaleY - a number between [-1, 1] specifying the
* change to pitch. -1 sets to minPitch and 1 sets to maxPitch.
*/
rotate({deltaScaleX = 0, deltaScaleY = 0} : {deltaScaleX?: number, deltaScaleY?: number}) {
const {startBearing, startPitch} = this._interactiveState;
if (!Number.isFinite(startBearing) || !Number.isFinite(startPitch)) {
return this;
}
const {pitch, bearing} = this._calculateNewPitchAndBearing({
deltaScaleX,
deltaScaleY,
startBearing: startBearing || 0,
startPitch: startPitch || 0
});
return this._getUpdatedMapState({
bearing,
pitch
});
}
/**
* End rotating
* Must call if `rotateStart()` was called
*/
rotateEnd() {
return this._getUpdatedMapState({
startBearing: null,
startPitch: null
});
}
/**
* Start zooming
* @param {[Number, Number]} pos - position on screen where the center is
*/
zoomStart({pos} : {pos: Array<number>}) {
return this._getUpdatedMapState({
startZoomLngLat: this._unproject(pos),
startZoom: this._viewportProps.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} : {pos: Array<number>, startPos?: Array<number>, scale: number}) {
assert(scale > 0, '`scale` must be a positive number');
// Make sure we zoom around the current mouse position rather than map center
let {startZoom, startZoomLngLat} = this._interactiveState;
if (!Number.isFinite(startZoom)) {
// We have two modes of zoom:
// scroll zoom that are discrete events (transform from the current zoom level),
// and pinch zoom that are continuous events (transform from the zoom level when
// pinch started).
// If startZoom state is defined, then use the startZoom state;
// otherwise assume discrete zooming
startZoom = this._viewportProps.zoom;
startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
}
// 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: startZoom || 0});
const zoomedViewport = new WebMercatorViewport(
Object.assign({}, this._viewportProps, {zoom})
);
// $FlowFixMe
const [longitude, latitude] = zoomedViewport.getMapCenterByLngLatPosition({
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: any): MapState {
// Update _viewportProps
return new MapState(Object.assign({}, this._viewportProps, this._interactiveState, newProps));
}
// Apply any constraints (mathematical or defined by _viewportProps) to map state
_applyConstraints(props: ViewportProps): ViewportProps {
// Ensure zoom is within specified range
const {maxZoom, minZoom, zoom} = props;
props.zoom = clamp(zoom, minZoom, maxZoom);
// Ensure pitch is within specified range
const {maxPitch, minPitch, pitch} = props;
props.pitch = clamp(pitch, minPitch, maxPitch);
Object.assign(props, normalizeViewportProps(props));
return props;
}
_unproject(pos: ?Array<number>): ?Array<number> {
const viewport = new WebMercatorViewport(this._viewportProps);
return pos && viewport.unproject(pos);
}
// Calculate a new lnglat based on pixel dragging position
_calculateNewLngLat({startPanLngLat, pos}: {
startPanLngLat: Array<number>,
pos: Array<number>
}): Array<number> {
const viewport = new WebMercatorViewport(this._viewportProps);
return viewport.getMapCenterByLngLatPosition({lngLat: startPanLngLat, pos});
}
// Calculates new zoom
_calculateNewZoom({scale, startZoom}: {scale: number, startZoom: number}): number {
const {maxZoom, minZoom} = this._viewportProps;
const zoom = startZoom + Math.log2(scale);
return clamp(zoom, minZoom, maxZoom);
}
// Calculates a new pitch and bearing from a position (coming from an event)
_calculateNewPitchAndBearing({deltaScaleX, deltaScaleY, startBearing, startPitch} : {
deltaScaleX: number,
deltaScaleY: number,
startBearing: number,
startPitch: number
}) {
// clamp deltaScaleY to [-1, 1] so that rotation is constrained between minPitch and maxPitch.
// deltaScaleX does not need to be clamped as bearing does not have constraints.
deltaScaleY = clamp(deltaScaleY, -1, 1);
const {minPitch, maxPitch} = this._viewportProps;
const bearing = startBearing + 180 * deltaScaleX;
let pitch = startPitch;
if (deltaScaleY > 0) {
// Gradually increase pitch
pitch = startPitch + deltaScaleY * (maxPitch - startPitch);
} else if (deltaScaleY < 0) {
// Gradually decrease pitch
pitch = startPitch - deltaScaleY * (minPitch - startPitch);
}
return {
pitch,
bearing
};
}
}