UNPKG

@mapbox/react-map-gl

Version:

A React wrapper for MapboxGL-js and overlay API.

511 lines (430 loc) 14.4 kB
// @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 } )) ) ); } }