@globalfishingwatch/react-map-gl
Version:
A React wrapper for MapboxGL-js and overlay API.
346 lines (302 loc) • 10.4 kB
JavaScript
// @flow
// Copyright (c) 2015 Uber Technologies, Inc.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// 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 * as React from 'react';
import {PureComponent, createRef} from 'react';
import PropTypes from 'prop-types';
import {normalizeStyle} from '../utils/style-utils';
import WebMercatorViewport from 'viewport-mercator-project';
import ResizeObserver from 'resize-observer-polyfill';
import Mapbox from '../mapbox/mapbox';
import mapboxgl from '../utils/mapboxgl';
import {checkVisibilityConstraints} from '../utils/map-constraints';
import {MAPBOX_LIMITS} from '../utils/map-state';
import MapContext, {MapContextProvider} from './map-context';
import type {ViewState} from '../mapbox/mapbox';
/* eslint-disable max-len */
const TOKEN_DOC_URL = 'https://visgl.github.io/react-map-gl/docs/get-started/mapbox-tokens';
const NO_TOKEN_WARNING = 'A valid API access token is required to use Mapbox data';
/* eslint-disable max-len */
function noop() {}
const UNAUTHORIZED_ERROR_CODE = 401;
const CONTAINER_STYLE = {
position: 'absolute',
width: '100%',
height: '100%',
overflow: 'hidden'
};
const propTypes = Object.assign({}, Mapbox.propTypes, {
/** The dimensions of the map **/
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/** Callback when map size changes **/
onResize: PropTypes.func,
/** There are known issues with style diffing. As stopgap, add option to prevent style diffing. */
preventStyleDiffing: PropTypes.bool,
/** Hide invalid token warning even if request fails */
disableTokenWarning: PropTypes.bool,
/** Whether the map is visible */
visible: PropTypes.bool,
/** Custom class name for the map */
className: PropTypes.string,
/** Custom CSS for the container */
style: PropTypes.object,
/** Advanced features */
// Contraints for displaying the map. If not met, then the map is hidden.
// Experimental! May be changed in minor version updates.
visibilityConstraints: PropTypes.object
});
const defaultProps = Object.assign({}, Mapbox.defaultProps, {
preventStyleDiffing: false,
disableTokenWarning: false,
visible: true,
onResize: noop,
className: '',
style: null,
visibilityConstraints: MAPBOX_LIMITS
});
export type StaticMapProps = {
gl?: any,
width: number | string,
height: number | string,
preventStyleDiffing: boolean,
disableTokenWarning: boolean,
visible: boolean,
className: string,
style: any,
visibilityConstraints: any,
children?: any,
onLoad: Function,
onError: Function,
onResize: Function,
mapStyle: any,
visible: boolean,
viewState?: ViewState,
longitude: number,
latitude: number,
zoom: number,
bearing: number,
pitch: number,
altitude?: number
};
type State = {
accessTokenInvalid: boolean
};
export default class StaticMap extends PureComponent<StaticMapProps, State> {
static supported() {
return mapboxgl && mapboxgl.supported();
}
static propTypes: any = propTypes;
static defaultProps: StaticMapProps = defaultProps;
state: State = {
accessTokenInvalid: false
};
componentDidMount() {
if (!StaticMap.supported()) {
return;
}
const {mapStyle} = this.props;
this._mapbox = new Mapbox(
// $FlowFixMe
Object.assign({}, this.props, {
mapboxgl, // Handle to mapbox-gl library
width: this._width,
height: this._height,
container: this._mapboxMapRef.current,
onError: this._mapboxMapError,
mapStyle: normalizeStyle(mapStyle)
})
);
this._map = this._mapbox.getMap();
const resizeObserver = new ResizeObserver(entries => {
if (entries[0].contentRect) {
const {width, height} = entries[0].contentRect;
this._width = width;
this._height = height;
this.props.onResize({width, height});
this.forceUpdate();
}
});
const container = this._mapContainerRef.current;
if (container) {
resizeObserver.observe(container);
}
this._resizeObserver = resizeObserver;
}
componentDidUpdate(prevProps: StaticMapProps) {
if (this._mapbox) {
this._updateMapStyle(prevProps, this.props);
this._updateMapProps(this.props);
}
if (this._context && this._context.setMap && !this._context.map) {
this._context.setMap(this._map);
}
}
componentWillUnmount() {
if (this._mapbox) {
this._mapbox.finalize();
this._mapbox = null;
this._map = null;
}
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
}
_mapbox: any = null;
_map: any = null;
_mapboxMapRef: {current: null | HTMLDivElement} = createRef();
_mapContainerRef: {current: null | HTMLDivElement} = createRef();
_queryParams: any = {};
_width: number = 0;
_height: number = 0;
_resizeObserver: null | ResizeObserver = null;
_context: any = null;
// External apps can access map this way
getMap = () => {
return this._map;
};
/** Uses Mapbox's
* queryRenderedFeatures API to find features at point or in a bounding box.
* https://www.mapbox.com/mapbox-gl-js/api/#Map#queryRenderedFeatures
* To query only some of the layers, set the `interactive` property in the
* layer style to `true`.
* @param {[Number, Number]|[[Number, Number], [Number, Number]]} geometry -
* Point or an array of two points defining the bounding box
* @param {Object} options - query options
*/
queryRenderedFeatures = (geometry: any, options: any = {}) => {
return this._map.queryRenderedFeatures(geometry, options);
};
// Note: needs to be called after render (e.g. in componentDidUpdate)
_updateMapSize(width: number, height: number) {
if (this._width !== width || this._height !== height) {
this._width = width;
this._height = height;
this._updateMapProps(this.props);
}
}
_updateMapStyle(oldProps: StaticMapProps, newProps: StaticMapProps) {
const mapStyle = newProps.mapStyle;
const oldMapStyle = oldProps.mapStyle;
if (mapStyle !== oldMapStyle && mapStyle) {
this._map.setStyle(normalizeStyle(mapStyle), {
diff: !this.props.preventStyleDiffing
});
}
}
_updateMapProps(props: StaticMapProps) {
if (!this._mapbox) {
return;
}
this._mapbox.setProps(
Object.assign({}, props, {
width: this._width,
height: this._height
})
);
}
// Handle map error
_mapboxMapError = (evt: {
error?: {
message: string,
status: number
},
status: number
}) => {
const statusCode = (evt.error && evt.error.status) || evt.status;
if (statusCode === UNAUTHORIZED_ERROR_CODE && !this.state.accessTokenInvalid) {
// Mapbox throws unauthorized error - invalid token
console.error(NO_TOKEN_WARNING); // eslint-disable-line
this.setState({accessTokenInvalid: true});
}
this.props.onError(evt);
};
_renderNoTokenWarning() {
if (this.state.accessTokenInvalid && !this.props.disableTokenWarning) {
const style = {
position: 'absolute',
left: 0,
top: 0
};
return (
<div key="warning" id="no-token-warning" style={style}>
<h3 key="header">NO_TOKEN_WARNING</h3>
<div key="text">For information on setting up your basemap, read</div>
<a key="link" href={TOKEN_DOC_URL}>
Note on Map Tokens
</a>
</div>
);
}
return null;
}
_renderOverlays() {
if (!this._map) {
return null;
}
const {_width: width, _height: height} = this;
this._updateMapSize(width, height);
return (
<MapContext.Consumer>
{interactiveContext => {
this._context = interactiveContext;
const context = {
...interactiveContext,
viewport:
interactiveContext.viewport ||
// $FlowFixMe
new WebMercatorViewport({
...this.props,
...this.props.viewState,
width,
height
}),
map: this._map,
container: interactiveContext.container || this._mapContainerRef.current
};
return (
<MapContextProvider value={context}>
<div key="map-overlays" className="overlays" style={CONTAINER_STYLE}>
{this.props.children}
</div>
</MapContextProvider>
);
}}
</MapContext.Consumer>
);
}
render() {
const {className, width, height, style, visibilityConstraints} = this.props;
const mapContainerStyle = Object.assign({position: 'relative'}, style, {
width,
height
});
const visible =
this.props.visible &&
checkVisibilityConstraints(this.props.viewState || this.props, visibilityConstraints);
const mapStyle = Object.assign({}, CONTAINER_STYLE, {
visibility: visible ? 'inherit' : 'hidden'
});
return (
<div key="map-container" style={mapContainerStyle} ref={this._mapContainerRef}>
<div key="map-mapbox" ref={this._mapboxMapRef} style={mapStyle} className={className} />
{this._renderOverlays()}
{this._renderNoTokenWarning()}
</div>
);
}
}