@mapbox/react-map-gl
Version:
A React wrapper for MapboxGL-js and overlay API.
511 lines (430 loc) • 14.4 kB
JavaScript
// @flow
import {PureComponent, createElement, createRef} from 'react';
import PropTypes from 'prop-types';
import StaticMap from './static-map';
import {MAPBOX_LIMITS} from '../utils/map-state';
import WebMercatorViewport from 'viewport-mercator-project';
import TransitionManager from '../utils/transition-manager';
import MapContext from './map-context';
import {EventManager} from 'mjolnir.js';
import MapController from '../utils/map-controller';
import deprecateWarn from '../utils/deprecate-warn';
import type {ViewState} from '../mapbox/mapbox';
import type {StaticMapProps} from './static-map';
import type {MjolnirEvent} from 'mjolnir.js';
const propTypes = Object.assign({}, StaticMap.propTypes, {
// Additional props on top of StaticMap
/** Viewport constraints */
// Max zoom level
maxZoom: PropTypes.number,
// Min zoom level
minZoom: PropTypes.number,
// Max pitch in degrees
maxPitch: PropTypes.number,
// Min pitch in degrees
minPitch: PropTypes.number,
// Callbacks fired when the user interacted with the map. The object passed to the callbacks
// contains viewport properties such as `longitude`, `latitude`, `zoom` etc.
onViewStateChange: PropTypes.func,
onViewportChange: PropTypes.func,
onInteractionStateChange: PropTypes.func,
/** Viewport transition **/
// transition duration for viewport change
transitionDuration: PropTypes.number,
// TransitionInterpolator instance, can be used to perform custom transitions.
transitionInterpolator: PropTypes.object,
// type of interruption of current transition on update.
transitionInterruption: PropTypes.number,
// easing function
transitionEasing: PropTypes.func,
// transition status update functions
onTransitionStart: PropTypes.func,
onTransitionInterrupt: PropTypes.func,
onTransitionEnd: PropTypes.func,
/** Enables control event handling */
// Scroll to zoom
scrollZoom: PropTypes.bool,
// Drag to pan
dragPan: PropTypes.bool,
// Drag to rotate
dragRotate: PropTypes.bool,
// Double click to zoom
doubleClickZoom: PropTypes.bool,
// Multitouch zoom
touchZoom: PropTypes.bool,
// Multitouch rotate
touchRotate: PropTypes.bool,
// Keyboard
keyboard: PropTypes.bool,
/** Event callbacks */
onHover: PropTypes.func,
onClick: PropTypes.func,
onDblClick: PropTypes.func,
onContextMenu: PropTypes.func,
onMouseDown: PropTypes.func,
onMouseMove: PropTypes.func,
onMouseUp: PropTypes.func,
onTouchStart: PropTypes.func,
onTouchMove: PropTypes.func,
onTouchEnd: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
onMouseOut: PropTypes.func,
onWheel: PropTypes.func,
/** Custom touch-action CSS for the event canvas. Defaults to 'none' */
touchAction: PropTypes.string,
/** Radius to detect features around a clicked point. Defaults to 0. */
clickRadius: PropTypes.number,
/** List of layers that are interactive */
interactiveLayerIds: PropTypes.array,
/** Accessor that returns a cursor style to show interactive state */
getCursor: PropTypes.func,
// A map control instance to replace the default map controller
// The object must expose a method: `setOptions(opts)`
controller: PropTypes.instanceOf(MapController)
});
const getDefaultCursor = ({isDragging, isHovering}) => isDragging ?
'grabbing' :
(isHovering ? 'pointer' : 'grab');
const defaultProps = Object.assign({},
StaticMap.defaultProps, MAPBOX_LIMITS, TransitionManager.defaultProps,
{
onViewStateChange: null,
onViewportChange: null,
onClick: null,
onNativeClick: null,
onHover: null,
onContextMenu: event => event.preventDefault(),
scrollZoom: true,
dragPan: true,
dragRotate: true,
doubleClickZoom: true,
touchZoom: true,
touchRotate: false,
keyboard: true,
touchAction: 'none',
clickRadius: 0,
getCursor: getDefaultCursor
}
);
type InteractionState = {
isDragging: boolean,
};
type MapEvent = MjolnirEvent & {
point: Array<number>,
lngLat: Array<number>,
features: ?Array<any>
};
type InteractiveMapProps = StaticMapProps & {
onViewStateChange: Function,
onViewportChange: Function,
onInteractionStateChange: Function,
onHover: Function,
onClick: Function,
onNativeClick: Function,
onDblClick: Function,
onContextMenu: Function,
onMouseDown: Function,
onMouseMove: Function,
onMouseUp: Function,
onTouchStart: Function,
onTouchMove: Function,
onTouchEnd: Function,
onMouseEnter: Function,
onMouseLeave: Function,
onMouseOut: Function,
onWheel: Function,
transitionDuration: number,
transitionInterpolator: any,
transitionInterruption: number,
transitionEasing: Function,
scrollZoom: boolean,
dragPan: boolean,
dragRotate: boolean,
doubleClickZoom: boolean,
touchZoom: boolean,
touchRotate: boolean,
keyboard: boolean,
touchAction: string,
clickRadius: number,
interactiveLayerIds: Array<string>,
getCursor: Function,
controller: MapController
};
type State = {
isLoaded: boolean,
isDragging: boolean,
isHovering: boolean
};
type InteractiveContextProps = {
isDragging: boolean,
eventManager: any,
mapContainer: null | HTMLDivElement
};
export default class InteractiveMap extends PureComponent<InteractiveMapProps, State> {
static supported() {
return StaticMap.supported();
}
static propTypes = propTypes;
static defaultProps = defaultProps;
constructor(props : InteractiveMapProps) {
super(props);
// Check for deprecated props
deprecateWarn(props);
// If props.controller is not provided, fallback to default MapController instance
// Cannot use defaultProps here because it needs to be per map instance
this._controller = props.controller || new MapController();
this._eventManager = new EventManager(null, {
touchAction: props.touchAction
});
this._updateInteractiveContext({
isDragging: false,
eventManager: this._eventManager
});
}
state : State = {
// Whether mapbox styles have finished loading
isLoaded: false,
// Whether the cursor is down
isDragging: false,
// Whether the cursor is over a clickable feature
isHovering: false
};
componentDidMount() {
const eventManager = this._eventManager;
const mapContainer = this._eventCanvasRef.current;
eventManager.setElement(mapContainer);
// Register additional event handlers for click and hover
eventManager.on({
pointerdown: this._onPointerDown,
pointermove: this._onPointerMove,
pointerup: this._onPointerUp,
pointerleave: this._onEvent.bind(this, 'onMouseOut'),
click: this._onClick,
anyclick: this._onClick,
dblclick: this._onEvent.bind(this, 'onDblClick'),
wheel: this._onEvent.bind(this, 'onWheel'),
contextmenu: this._onEvent.bind(this, 'onContextMenu')
});
this._setControllerProps(this.props);
this._updateInteractiveContext({mapContainer});
}
componentWillUpdate(nextProps : InteractiveMapProps, nextState : State) {
this._setControllerProps(nextProps);
if (nextState.isDragging !== this.state.isDragging) {
this._updateInteractiveContext({isDragging: nextState.isDragging});
}
}
_controller : MapController;
_eventManager : any;
_interactiveContext : InteractiveContextProps;
_width : number = 0;
_height : number = 0;
_eventCanvasRef: { current: null | HTMLDivElement } = createRef();
_staticMapRef: { current: null | StaticMap } = createRef();
getMap = () => {
return this._staticMapRef.current ? this._staticMapRef.current.getMap() : null;
}
queryRenderedFeatures = (geometry : any, options : any = {}) => {
const map = this.getMap();
return map && map.queryRenderedFeatures(geometry, options);
}
_setControllerProps(props : InteractiveMapProps) {
props = Object.assign({}, props, props.viewState, {
isInteractive: Boolean(props.onViewStateChange || props.onViewportChange),
onViewportChange: this._onViewportChange,
onStateChange: this._onInteractionStateChange,
eventManager: this._eventManager,
width: this._width,
height: this._height
});
this._controller.setOptions(props);
}
_getFeatures({pos, radius} : {pos : Array<number>, radius : number}) {
let features;
const queryParams = {};
const map = this.getMap();
if (this.props.interactiveLayerIds) {
queryParams.layers = this.props.interactiveLayerIds;
}
if (radius) {
// Radius enables point features, like marker symbols, to be clicked.
const size = radius;
const bbox = [[pos[0] - size, pos[1] + size], [pos[0] + size, pos[1] - size]];
features = map && map.queryRenderedFeatures(bbox, queryParams);
} else {
features = map && map.queryRenderedFeatures(pos, queryParams);
}
return features;
}
_onInteractionStateChange = (interactionState : InteractionState) => {
const {isDragging = false} = interactionState;
if (isDragging !== this.state.isDragging) {
this.setState({isDragging});
}
const {onInteractionStateChange} = this.props;
if (onInteractionStateChange) {
onInteractionStateChange(interactionState);
}
}
_updateInteractiveContext(updatedContext : $Shape<InteractiveContextProps>) {
this._interactiveContext = Object.assign({}, this._interactiveContext, updatedContext);
}
_onResize = ({width, height} : {width : number, height : number}) => {
this._width = width;
this._height = height;
this._setControllerProps(this.props);
this.props.onResize({width, height});
}
_onViewportChange = (
viewState : ViewState,
interactionState : InteractionState,
oldViewState : ViewState
) => {
const {onViewStateChange, onViewportChange} = this.props;
if (onViewStateChange) {
onViewStateChange({viewState, interactionState, oldViewState});
}
if (onViewportChange) {
onViewportChange(viewState, interactionState, oldViewState);
}
}
/* Generic event handling */
_normalizeEvent(event : MapEvent) {
if (event.lngLat) {
// Already unprojected
return event;
}
const {offsetCenter: {x, y}} = event;
const pos = [x, y];
// $FlowFixMe
const viewport = new WebMercatorViewport(Object.assign({}, this.props, {
width: this._width,
height: this._height
}));
event.point = pos;
event.lngLat = viewport.unproject(pos);
return event;
}
_onLoad = (event : MapEvent) => {
this.setState({isLoaded: true});
this.props.onLoad(event);
}
_onEvent = (callbackName : string, event : MapEvent) => {
const func = this.props[callbackName];
if (func) {
func(this._normalizeEvent(event));
}
}
/* Special case event handling */
_onPointerDown = (event : MapEvent) => {
switch (event.pointerType) {
case 'touch':
this._onEvent('onTouchStart', event);
break;
default:
this._onEvent('onMouseDown', event);
}
}
_onPointerUp = (event : MapEvent) => {
switch (event.pointerType) {
case 'touch':
this._onEvent('onTouchEnd', event);
break;
default:
this._onEvent('onMouseUp', event);
}
}
_onPointerMove = (event : MapEvent) => {
switch (event.pointerType) {
case 'touch':
this._onEvent('onTouchMove', event);
break;
default:
this._onEvent('onMouseMove', event);
}
if (!this.state.isDragging) {
const {onHover, interactiveLayerIds} = this.props;
let features;
event = this._normalizeEvent(event);
if (this.state.isLoaded && (interactiveLayerIds || onHover)) {
features = this._getFeatures({pos: event.point, radius: this.props.clickRadius});
}
if (onHover) {
// backward compatibility: v3 `onHover` interface
event.features = features;
onHover(event);
}
const isHovering = Boolean(interactiveLayerIds && features && features.length > 0);
const isEntering = isHovering && !this.state.isHovering;
const isExiting = !isHovering && this.state.isHovering;
if (isEntering) {
this._onEvent('onMouseEnter', event);
}
if (isExiting) {
this._onEvent('onMouseLeave', event);
}
if (isEntering || isExiting) {
this.setState({isHovering});
}
}
}
_onClick = (event : MapEvent) => {
const {onClick, onNativeClick, onDblClick, doubleClickZoom} = this.props;
let callbacks = [];
const isDoubleClickEnabled = onDblClick || doubleClickZoom;
// `click` is only fired on single click. `anyclick` is fired twice if double clicking.
// `click` has a delay period after pointer up that prevents it from firing when
// double clicking. `anyclick` is always fired immediately after pointer up.
// If double click is turned off by the user, we want to immediately fire the
// onClick event. Otherwise, we wait to make sure it's a single click.
switch (event.type) {
case 'anyclick':
callbacks.push(onNativeClick);
if (!isDoubleClickEnabled) {
callbacks.push(onClick);
}
break;
case 'click':
if (isDoubleClickEnabled) {
callbacks.push(onClick);
}
break;
default:
}
callbacks = callbacks.filter(Boolean);
if (callbacks.length) {
event = this._normalizeEvent(event);
// backward compatibility: v3 `onClick` interface
event.features = this._getFeatures({pos: event.point, radius: this.props.clickRadius});
callbacks.forEach(cb => cb(event));
}
}
render() {
const {width, height, style, getCursor} = this.props;
const eventCanvasStyle = Object.assign({position: 'relative'}, style, {
width,
height,
cursor: getCursor(this.state)
});
return createElement(MapContext.Provider, {value: this._interactiveContext},
createElement('div', {
key: 'event-canvas',
ref: this._eventCanvasRef,
style: eventCanvasStyle
},
createElement(StaticMap, Object.assign({}, this.props,
{
width: '100%',
height: '100%',
style: null,
onResize: this._onResize,
onLoad: this._onLoad,
ref: this._staticMapRef,
children: this.props.children
}
))
)
);
}
}