react-map-gl/src/components/static-map.js

487 lines
16 KiB
JavaScript

// 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 React, {PropTypes, PureComponent, createElement} from 'react';
import autobind from '../utils/autobind';
import {getAccessToken} from '../utils/access-token';
import {getInteractiveLayerIds} from '../utils/style-utils';
import diffStyles from '../utils/diff-styles';
import Immutable from 'immutable';
import isBrowser from '../utils/is-browser';
let mapboxgl = null;
let Point = null;
if (isBrowser) {
mapboxgl = require('mapbox-gl');
({Point} = mapboxgl);
}
function noop() {}
const propTypes = {
/** Mapbox API access token for mapbox-gl-js. Required when using Mapbox vector tiles/styles. */
mapboxApiAccessToken: PropTypes.string,
/** Mapbox WebGL context creation option. Useful when you want to export the canvas as a PNG. */
preserveDrawingBuffer: PropTypes.bool,
/** Show attribution control or not. */
attributionControl: PropTypes.bool,
/** The Mapbox style. A string url or a MapboxGL style Immutable.Map object. */
mapStyle: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(Immutable.Map)
]),
/** There are known issues with style diffing. As stopgap, add option to prevent style diffing. */
preventStyleDiffing: PropTypes.bool,
/** 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,
/** Altitude of the viewport camera. Default 1.5 "screen heights" */
// Note: Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
altitude: React.PropTypes.number,
/** The width of the map. */
width: PropTypes.number.isRequired,
/** The height of the map. */
height: PropTypes.number.isRequired,
/**
* `onChangeViewport` callback is fired when the user interacted with the
* map. The object passed to the callback contains `latitude`,
* `longitude` and `zoom` and additional state information.
*/
onChangeViewport: PropTypes.func,
/**
* Called when a feature is hovered over. Uses Mapbox's
* queryRenderedFeatures API to find features under the pointer:
* 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`. See Mapbox's style spec
* https://www.mapbox.com/mapbox-gl-style-spec/#layer-interactive
* If no interactive layers are found (e.g. using Mapbox's default styles),
* will fall back to query all layers.
* @callback
* @param {array} features - The array of features the mouse is over.
*/
onHoverFeatures: PropTypes.func,
/**
* Set to false to enable onHoverFeatures to be called regardless if
* there is an actual feature at x, y. This is useful to emulate
* "mouse-out" behaviors on features.
* Defaults to TRUE
*/
ignoreEmptyFeatures: PropTypes.bool,
/**
* Called when a feature is clicked on. Uses Mapbox's
* queryRenderedFeatures API to find features under the pointer:
* 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`. See Mapbox's style spec
* https://www.mapbox.com/mapbox-gl-style-spec/#layer-interactive
* If no interactive layers are found (e.g. using Mapbox's default styles),
* will fall back to query all layers.
*/
onClickFeatures: PropTypes.func,
/**
* Called when the map is clicked. The handler is called with the clicked
* coordinates (https://www.mapbox.com/mapbox-gl-js/api/#LngLat) and the
* screen coordinates (https://www.mapbox.com/mapbox-gl-js/api/#PointLike).
*/
onClick: PropTypes.func,
/** Radius to detect features around a clicked point. Defaults to 15. */
clickRadius: PropTypes.number
};
const defaultProps = {
mapStyle: 'mapbox://styles/mapbox/light-v8',
onChangeViewport: null,
mapboxApiAccessToken: getAccessToken(),
preserveDrawingBuffer: false,
attributionControl: true,
ignoreEmptyFeatures: true,
bearing: 0,
pitch: 0,
altitude: 1.5,
clickRadius: 15
};
export default class StaticMap extends PureComponent {
static supported() {
return mapboxgl && mapboxgl.supported();
}
constructor(props) {
super(props);
this.state = {
isSupported: mapboxgl && mapboxgl.supported(),
isDragging: false,
isHovering: false,
startDragLngLat: null,
startBearing: null,
startPitch: null
};
this._queryParams = {};
mapboxgl.accessToken = props.mapboxApiAccessToken;
if (!this.state.isSupported) {
this.componentDidMount = noop;
this.componentWillReceiveProps = noop;
this.componentDidUpdate = noop;
}
autobind(this);
}
componentDidMount() {
if (!mapboxgl) {
return;
}
const mapStyle = Immutable.Map.isMap(this.props.mapStyle) ?
this.props.mapStyle.toJS() :
this.props.mapStyle;
const map = new mapboxgl.Map({
container: this.refs.mapboxMap,
center: [this.props.longitude, this.props.latitude],
zoom: this.props.zoom,
pitch: this.props.pitch,
bearing: this.props.bearing,
style: mapStyle,
interactive: false,
preserveDrawingBuffer: this.props.preserveDrawingBuffer
});
// Disable outline style
const canvas = map.getCanvas();
if (canvas) {
canvas.style.outline = 'none';
}
this._map = map;
this._updateMapViewport({}, this.props);
// this._callOnChangeViewport(map.transform);
this._updateQueryParams(mapStyle);
}
componentWillReceiveProps(newProps) {
if (!mapboxgl) {
return;
}
this._updateStateFromProps(this.props, newProps);
this._updateMapViewport(this.props, newProps);
this._updateMapStyle(this.props, newProps);
// Save width/height so that we can check them in componentDidUpdate
this.setState({
width: this.props.width,
height: this.props.height
});
}
componentDidUpdate() {
if (!mapboxgl) {
return;
}
// Since Mapbox's map.resize() reads size from DOM
// we must wait to read size until after render (i.e. here in "didUpdate")
this._updateMapSize(this.state, this.props);
}
componentWillUnmount() {
if (!mapboxgl) {
return;
}
if (this._map) {
this._map.remove();
}
}
// External apps can access map this way
_getMap() {
return this._map;
}
_updateStateFromProps(oldProps, newProps) {
mapboxgl.accessToken = newProps.mapboxApiAccessToken;
const {startDragLngLat} = newProps;
this.setState({
startDragLngLat: startDragLngLat && startDragLngLat.slice()
});
}
// Hover and click only query layers whose interactive property is true
// If no interactivity is specified, query all layers
_updateQueryParams(mapStyle) {
const interactiveLayerIds = getInteractiveLayerIds(mapStyle);
this._queryParams = interactiveLayerIds.length === 0 ? {} :
{layers: interactiveLayerIds};
}
// Update a source in the map style
_updateSource(map, update) {
const newSource = update.source.toJS();
if (newSource.type === 'geojson') {
const oldSource = map.getSource(update.id);
if (oldSource.type === 'geojson') {
// update data if no other GeoJSONSource options were changed
const oldOpts = oldSource.workerOptions;
if (
(newSource.maxzoom === undefined ||
newSource.maxzoom === oldOpts.geojsonVtOptions.maxZoom) &&
(newSource.buffer === undefined ||
newSource.buffer === oldOpts.geojsonVtOptions.buffer) &&
(newSource.tolerance === undefined ||
newSource.tolerance === oldOpts.geojsonVtOptions.tolerance) &&
(newSource.cluster === undefined ||
newSource.cluster === oldOpts.cluster) &&
(newSource.clusterRadius === undefined ||
newSource.clusterRadius === oldOpts.superclusterOptions.radius) &&
(newSource.clusterMaxZoom === undefined ||
newSource.clusterMaxZoom === oldOpts.superclusterOptions.maxZoom)
) {
oldSource.setData(newSource.data);
return;
}
}
}
map.removeSource(update.id);
map.addSource(update.id, newSource);
}
// Individually update the maps source and layers that have changed if all
// other style props haven't changed. This prevents flicking of the map when
// styles only change sources or layers.
/* eslint-disable max-statements, complexity */
_setDiffStyle(prevStyle, nextStyle) {
const prevKeysMap = prevStyle && styleKeysMap(prevStyle) || {};
const nextKeysMap = styleKeysMap(nextStyle);
function styleKeysMap(style) {
return style.map(() => true).delete('layers').delete('sources').toJS();
}
function propsOtherThanLayersOrSourcesDiffer() {
const prevKeysList = Object.keys(prevKeysMap);
const nextKeysList = Object.keys(nextKeysMap);
if (prevKeysList.length !== nextKeysList.length) {
return true;
}
// `nextStyle` and `prevStyle` should not have the same set of props.
if (nextKeysList.some(
key => prevStyle.get(key) !== nextStyle.get(key)
// But the value of one of those props is different.
)) {
return true;
}
return false;
}
const map = this._map;
if (!prevStyle || propsOtherThanLayersOrSourcesDiffer()) {
map.setStyle(nextStyle.toJS());
return;
}
const {sourcesDiff, layersDiff} = diffStyles(prevStyle, nextStyle);
// TODO: It's rather difficult to determine style diffing in the presence
// of refs. For now, if any style update has a ref, fallback to no diffing.
// We can come back to this case if there's a solid usecase.
if (layersDiff.updates.some(node => node.layer.get('ref'))) {
map.setStyle(nextStyle.toJS());
return;
}
for (const enter of sourcesDiff.enter) {
map.addSource(enter.id, enter.source.toJS());
}
for (const update of sourcesDiff.update) {
this._updateSource(map, update);
}
for (const exit of sourcesDiff.exit) {
map.removeSource(exit.id);
}
for (const exit of layersDiff.exiting) {
if (map.style.getLayer(exit.id)) {
map.removeLayer(exit.id);
}
}
for (const update of layersDiff.updates) {
if (!update.enter) {
// This is an old layer that needs to be updated. Remove the old layer
// with the same id and add it back again.
map.removeLayer(update.id);
}
map.addLayer(update.layer.toJS(), update.before);
}
}
/* eslint-enable max-statements, complexity */
_updateMapStyle(oldProps, newProps) {
const mapStyle = newProps.mapStyle;
const oldMapStyle = oldProps.mapStyle;
if (mapStyle !== oldMapStyle) {
if (Immutable.Map.isMap(mapStyle)) {
if (this.props.preventStyleDiffing) {
this._map.setStyle(mapStyle.toJS());
} else {
this._setDiffStyle(oldMapStyle, mapStyle);
}
} else {
this._map.setStyle(mapStyle);
}
this._updateQueryParams(mapStyle);
}
}
_updateMapViewport(oldProps, newProps) {
const viewportChanged =
newProps.latitude !== oldProps.latitude ||
newProps.longitude !== oldProps.longitude ||
newProps.zoom !== oldProps.zoom ||
newProps.pitch !== oldProps.pitch ||
newProps.zoom !== oldProps.bearing ||
newProps.altitude !== oldProps.altitude;
if (viewportChanged) {
this._map.jumpTo({
center: [newProps.longitude, newProps.latitude],
zoom: newProps.zoom,
bearing: newProps.bearing,
pitch: newProps.pitch
});
// TODO - jumpTo doesn't handle altitude
if (newProps.altitude !== oldProps.altitude) {
this._map.transform.altitude = newProps.altitude;
}
}
}
// Note: needs to be called after render (e.g. in componentDidUpdate)
_updateMapSize(oldProps, newProps) {
const sizeChanged =
oldProps.width !== newProps.width || oldProps.height !== newProps.height;
if (sizeChanged) {
this._map.resize();
// this._callOnChangeViewport(this._map.transform);
}
}
_getFeatures({pos, radius}) {
let features;
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 = this._map.queryRenderedFeatures(bbox, this._queryParams);
} else {
const point = new Point(...pos);
features = this._map.queryRenderedFeatures(point, this._queryParams);
}
return features;
}
// HOVER AND CLICK
_onMouseMove({pos}) {
if (this.props.onHover) {
const latLong = this.props.unproject(pos);
this.props.onHover(latLong, pos);
}
if (this.props.onHoverFeatures) {
const features = this._getFeatures({pos});
if (!features.length && this.props.ignoreEmptyFeatures) {
return;
}
this.setState({isHovering: features.length > 0});
this.props.onHoverFeatures(features);
}
}
_onMouseClick(event) {
const pos = [event.clientX, event.clientY];
if (this.props.onClick) {
const latLong = this.props.unproject(pos);
// TODO - Do we really want to expose a mapbox "Point" in our interface?
// const point = new Point(...pos);
this.props.onClick(latLong, pos);
}
if (this.props.onClickFeatures) {
const features = this._getFeatures({pos, radius: this.props.clickRadius});
if (!features.length && this.props.ignoreEmptyFeatures) {
return;
}
this.props.onClickFeatures(features);
}
}
render() {
const {className, width, height, style} = this.props;
const mapContainerStyle = Object.assign({}, style, {width, height, position: 'relative'});
const mapStyle = Object.assign({}, style, {width, height});
const overlayContainerStyle = {position: 'absolute', left: 0, top: 0};
// Note: a static map still handles clicks and hover events
return (
createElement('div', {
key: 'map-container',
style: mapContainerStyle,
onMouseMove: this._onMouseMove,
onClick: this._onMouseClick,
children: [
createElement('div', {
key: 'map-mapbox',
ref: 'mapboxMap',
style: mapStyle,
className
}),
createElement('div', {
key: 'map-overlays',
className: 'overlays',
style: overlayContainerStyle,
children: this.props.children
})
]
})
);
}
}
StaticMap.displayName = 'StaticMap';
StaticMap.propTypes = propTypes;
StaticMap.defaultProps = defaultProps;