kepler.gl
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
617 lines (551 loc) • 20.1 kB
JavaScript
// Copyright (c) 2021 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.
// libraries
import React, {Component, createRef} from 'react';
import PropTypes from 'prop-types';
import MapboxGLMap from 'react-map-gl';
import DeckGL from '@deck.gl/react';
import {createSelector} from 'reselect';
import WebMercatorViewport from 'viewport-mercator-project';
import {errorNotification} from 'utils/notifications-utils';
// components
import MapPopoverFactory from 'components/map/map-popover';
import MapControlFactory from 'components/map/map-control';
import {StyledMapContainer, StyledAttrbution} from 'components/common/styled-components';
import EditorFactory from './editor/editor';
// utils
import {generateMapboxLayers, updateMapboxLayers} from 'layers/mapbox-utils';
import {setLayerBlending} from 'utils/gl-utils';
import {transformRequest} from 'utils/map-style-utils/mapbox-utils';
import {
getLayerHoverProp,
renderDeckGlLayer,
prepareLayersToRender,
prepareLayersForDeck
} from 'utils/layer-utils';
// default-settings
import ThreeDBuildingLayer from 'deckgl-layers/3d-building-layer/3d-building-layer';
import {
FILTER_TYPES,
GEOCODER_LAYER_ID,
THROTTLE_NOTIFICATION_TIME
} from 'constants/default-settings';
import ErrorBoundary from 'components/common/error-boundary';
import {observeDimensions, unobserveDimensions} from '../utils/observe-dimensions';
import {LOCALE_CODES} from 'localization/locales';
/** @type {{[key: string]: React.CSSProperties}} */
const MAP_STYLE = {
container: {
display: 'inline-block',
position: 'relative',
width: '100%',
height: '100%'
},
top: {
position: 'absolute',
top: '0px',
pointerEvents: 'none',
width: '100%',
height: '100%'
}
};
const MAPBOXGL_STYLE_UPDATE = 'style.load';
const MAPBOXGL_RENDER = 'render';
const TRANSITION_DURATION = 0;
export const Attribution = () => (
<StyledAttrbution>
<div className="attrition-logo">
Basemap by:
<a
className="mapboxgl-ctrl-logo"
target="_blank"
rel="noopener noreferrer"
href="https://www.mapbox.com/"
aria-label="Mapbox logo"
/>
</div>
<div className="attrition-link">
<a href="https://kepler.gl/policy/" target="_blank" rel="noopener noreferrer">
© kepler.gl |{' '}
</a>
<a href="https://www.mapbox.com/about/maps/" target="_blank" rel="noopener noreferrer">
© Mapbox |{' '}
</a>
<a href="http://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">
© OpenStreetMap |{' '}
</a>
<a href="https://www.mapbox.com/map-feedback/" target="_blank" rel="noopener noreferrer">
<strong>Improve this map</strong>
</a>
</div>
</StyledAttrbution>
);
MapContainerFactory.deps = [MapPopoverFactory, MapControlFactory, EditorFactory];
export default function MapContainerFactory(MapPopover, MapControl, Editor) {
class MapContainer extends Component {
static propTypes = {
// required
datasets: PropTypes.object,
interactionConfig: PropTypes.object.isRequired,
layerBlending: PropTypes.string.isRequired,
layerOrder: PropTypes.arrayOf(PropTypes.any).isRequired,
layerData: PropTypes.arrayOf(PropTypes.any).isRequired,
layers: PropTypes.arrayOf(PropTypes.any).isRequired,
filters: PropTypes.arrayOf(PropTypes.any).isRequired,
mapState: PropTypes.object.isRequired,
mapControls: PropTypes.object.isRequired,
mapStyle: PropTypes.object.isRequired,
mousePos: PropTypes.object.isRequired,
mapboxApiAccessToken: PropTypes.string.isRequired,
mapboxApiUrl: PropTypes.string,
visStateActions: PropTypes.object.isRequired,
mapStateActions: PropTypes.object.isRequired,
uiStateActions: PropTypes.object.isRequired,
// optional
primary: PropTypes.bool, // primary one will be reporting its size to appState
readOnly: PropTypes.bool,
isExport: PropTypes.bool,
clicked: PropTypes.object,
hoverInfo: PropTypes.object,
mapLayers: PropTypes.object,
onMapToggleLayer: PropTypes.func,
onMapStyleLoaded: PropTypes.func,
onMapRender: PropTypes.func,
getMapboxRef: PropTypes.func,
index: PropTypes.number
};
static defaultProps = {
MapComponent: MapboxGLMap,
deckGlProps: {},
index: 0,
primary: true
};
constructor(props) {
super(props);
this.previousLayers = {
// [layers.id]: mapboxLayerConfig
};
this._deck = null;
this._ref = createRef();
}
componentDidMount() {
observeDimensions(this._ref.current, this._handleResize);
}
componentWillUnmount() {
// unbind mapboxgl event listener
if (this._map) {
this._map.off(MAPBOXGL_STYLE_UPDATE);
this._map.off(MAPBOXGL_RENDER);
}
unobserveDimensions(this._ref.current);
}
_handleResize = dimensions => {
const {primary} = this.props;
if (primary) {
const {mapStateActions} = this.props;
if (dimensions && dimensions.width > 0 && dimensions.height > 0) {
mapStateActions.updateMap(dimensions);
}
}
};
layersSelector = props => props.layers;
layerDataSelector = props => props.layerData;
mapLayersSelector = props => props.mapLayers;
layerOrderSelector = props => props.layerOrder;
layersToRenderSelector = createSelector(
this.layersSelector,
this.layerDataSelector,
this.mapLayersSelector,
prepareLayersToRender
);
layersForDeckSelector = createSelector(
this.layersSelector,
this.layerDataSelector,
prepareLayersForDeck
);
filtersSelector = props => props.filters;
polygonFilters = createSelector(this.filtersSelector, filters =>
filters.filter(f => f.type === FILTER_TYPES.polygon)
);
mapboxLayersSelector = createSelector(
this.layersSelector,
this.layerDataSelector,
this.layerOrderSelector,
this.layersToRenderSelector,
generateMapboxLayers
);
/* component private functions */
_onCloseMapPopover = () => {
this.props.visStateActions.onLayerClick(null);
};
_onLayerSetDomain = (idx, colorDomain) => {
this.props.visStateActions.layerConfigChange(this.props.layers[idx], {
colorDomain
});
};
_handleMapToggleLayer = layerId => {
const {index: mapIndex = 0, visStateActions} = this.props;
visStateActions.toggleLayerForMap(mapIndex, layerId);
};
_onMapboxStyleUpdate = () => {
// force refresh mapboxgl layers
this.previousLayers = {};
this._updateMapboxLayers();
if (typeof this.props.onMapStyleLoaded === 'function') {
this.props.onMapStyleLoaded(this._map);
}
};
_setMapboxMap = mapbox => {
if (!this._map && mapbox) {
this._map = mapbox.getMap();
// i noticed in certain context we don't access the actual map element
if (!this._map) {
return;
}
// bind mapboxgl event listener
this._map.on(MAPBOXGL_STYLE_UPDATE, this._onMapboxStyleUpdate);
this._map.on(MAPBOXGL_RENDER, () => {
if (typeof this.props.onMapRender === 'function') {
this.props.onMapRender(this._map);
}
});
}
if (this.props.getMapboxRef) {
// The parent component can gain access to our MapboxGlMap by
// providing this callback. Note that 'mapbox' will be null when the
// ref is unset (e.g. when a split map is closed).
this.props.getMapboxRef(mapbox, this.props.index);
}
};
_onDeckInitialized(gl) {
if (this.props.onDeckInitialized) {
this.props.onDeckInitialized(this._deck, gl);
}
}
_onBeforeRender = ({gl}) => {
setLayerBlending(gl, this.props.layerBlending);
};
_onDeckError = (error, layer) => {
const errorMessage = `An error in deck.gl: ${error.message} in ${layer.id}`;
const notificationId = `${layer.id}-${error.message}`;
// Throttle error notifications, as React doesn't like too many state changes from here.
this._deckGLErrorsElapsed = this._deckGLErrorsElapsed || {};
const lastShown = this._deckGLErrorsElapsed[notificationId];
if (!lastShown || lastShown < Date.now() - THROTTLE_NOTIFICATION_TIME) {
this._deckGLErrorsElapsed[notificationId] = Date.now();
// Create new error notification or update existing one with same id.
// Update is required to preserve the order of notifications as they probably are going to "jump" based on order of errors.
const {uiStateActions} = this.props;
uiStateActions.addNotification(
errorNotification({
message: errorMessage,
id: notificationId
})
);
}
};
/* component render functions */
/* eslint-disable complexity */
_renderMapPopover(layersToRender) {
// TODO: move this into reducer so it can be tested
const {
mapState,
hoverInfo,
clicked,
datasets,
interactionConfig,
layers,
mousePos: {mousePosition, coordinate, pinned}
} = this.props;
if (!mousePosition || !interactionConfig.tooltip) {
return null;
}
const layerHoverProp = getLayerHoverProp({
interactionConfig,
hoverInfo,
layers,
layersToRender,
datasets
});
const compareMode = interactionConfig.tooltip.config
? interactionConfig.tooltip.config.compareMode
: false;
let pinnedPosition = {};
let layerPinnedProp = null;
if (pinned || clicked) {
// project lnglat to screen so that tooltip follows the object on zoom
const viewport = new WebMercatorViewport(mapState);
const lngLat = clicked ? clicked.lngLat : pinned.coordinate;
pinnedPosition = this._getHoverXY(viewport, lngLat);
layerPinnedProp = getLayerHoverProp({
interactionConfig,
hoverInfo: clicked,
layers,
layersToRender,
datasets
});
if (layerHoverProp && layerPinnedProp) {
layerHoverProp.primaryData = layerPinnedProp.data;
layerHoverProp.compareType = interactionConfig.tooltip.config.compareType;
}
}
const commonProp = {
onClose: this._onCloseMapPopover,
zoom: mapState.zoom,
container: this._deck ? this._deck.canvas : undefined
};
return (
<ErrorBoundary>
{layerPinnedProp && (
<MapPopover
{...pinnedPosition}
{...commonProp}
layerHoverProp={layerPinnedProp}
coordinate={interactionConfig.coordinate.enabled && (pinned || {}).coordinate}
frozen={true}
isBase={compareMode}
/>
)}
{layerHoverProp && (!layerPinnedProp || compareMode) && (
<MapPopover
x={mousePosition[0]}
y={mousePosition[1]}
{...commonProp}
layerHoverProp={layerHoverProp}
frozen={false}
coordinate={interactionConfig.coordinate.enabled && coordinate}
/>
)}
</ErrorBoundary>
);
}
/* eslint-enable complexity */
_getHoverXY(viewport, lngLat) {
const screenCoord = !viewport || !lngLat ? null : viewport.project(lngLat);
return screenCoord && {x: screenCoord[0], y: screenCoord[1]};
}
_renderDeckOverlay(layersForDeck) {
const {
mapState,
mapStyle,
layerData,
layerOrder,
layers,
visStateActions,
mapboxApiAccessToken,
mapboxApiUrl
} = this.props;
// initialise layers from props if exists
let deckGlLayers = this.props.deckGlProps?.layers || [];
// wait until data is ready before render data layers
if (layerData && layerData.length) {
// last layer render first
const dataLayers = layerOrder
.slice()
.reverse()
.filter(idx => layersForDeck[layers[idx].id])
.reduce((overlays, idx) => {
const layerCallbacks = {
onSetLayerDomain: val => this._onLayerSetDomain(idx, val)
};
const layerOverlay = renderDeckGlLayer(this.props, layerCallbacks, idx);
return overlays.concat(layerOverlay || []);
}, []);
deckGlLayers = deckGlLayers.concat(dataLayers);
}
if (mapStyle.visibleLayerGroups['3d building']) {
deckGlLayers.push(
new ThreeDBuildingLayer({
id: '_keplergl_3d-building',
mapboxApiAccessToken,
mapboxApiUrl,
threeDBuildingColor: mapStyle.threeDBuildingColor,
updateTriggers: {
getFillColor: mapStyle.threeDBuildingColor
}
})
);
}
return (
<DeckGL
{...this.props.deckGlProps}
viewState={mapState}
id="default-deckgl-overlay"
layers={deckGlLayers}
onBeforeRender={this._onBeforeRender}
onHover={visStateActions.onLayerHover}
onClick={visStateActions.onLayerClick}
onError={this._onDeckError}
ref={comp => {
if (comp && comp.deck && !this._deck) {
this._deck = comp.deck;
}
}}
onWebGLInitialized={gl => this._onDeckInitialized(gl)}
/>
);
}
_updateMapboxLayers() {
const mapboxLayers = this.mapboxLayersSelector(this.props);
if (!Object.keys(mapboxLayers).length && !Object.keys(this.previousLayers).length) {
return;
}
updateMapboxLayers(this._map, mapboxLayers, this.previousLayers);
this.previousLayers = mapboxLayers;
}
_renderMapboxOverlays() {
if (this._map && this._map.isStyleLoaded()) {
this._updateMapboxLayers();
}
}
_onViewportChange = viewState => {
const {width, height, ...restViewState} = viewState;
const {primary} = this.props;
// react-map-gl sends 0,0 dimensions during initialization
// after we have received proper dimensions from observeDimensions
const next = {
...(width > 0 && height > 0 ? viewState : restViewState),
// enabling transition in two maps may lead to endless update loops
transitionDuration: primary ? TRANSITION_DURATION : 0
};
if (typeof this.props.onViewStateChange === 'function') {
this.props.onViewStateChange(next);
}
this.props.mapStateActions.updateMap(next);
};
_toggleMapControl = panelId => {
const {index, uiStateActions} = this.props;
uiStateActions.toggleMapControl(panelId, index);
};
/* eslint-disable complexity */
_renderMap() {
const {
mapState,
mapStyle,
mapStateActions,
layers,
MapComponent,
datasets,
mapboxApiAccessToken,
mapboxApiUrl,
mapControls,
isExport,
locale,
uiStateActions,
visStateActions,
interactionConfig,
editor,
index,
primary
} = this.props;
const layersToRender = this.layersToRenderSelector(this.props);
const layersForDeck = this.layersForDeckSelector(this.props);
const mapProps = {
...mapState,
width: '100%',
height: '100%',
preserveDrawingBuffer: true,
mapboxApiAccessToken,
mapboxApiUrl,
onViewportChange: this._onViewportChange,
transformRequest
};
const isEdit = (mapControls.mapDraw || {}).active;
const hasGeocoderLayer = layers.find(l => l.id === GEOCODER_LAYER_ID);
const isSplit = Boolean(mapState.isSplit);
return (
<>
<MapControl
datasets={datasets}
availableLocales={Object.keys(LOCALE_CODES)}
dragRotate={mapState.dragRotate}
isSplit={isSplit}
primary={primary}
isExport={isExport}
layers={layers}
layersToRender={layersToRender}
mapIndex={index}
mapControls={mapControls}
readOnly={this.props.readOnly}
scale={mapState.scale || 1}
top={interactionConfig.geocoder && interactionConfig.geocoder.enabled ? 52 : 0}
editor={editor}
locale={locale}
onTogglePerspective={mapStateActions.togglePerspective}
onToggleSplitMap={mapStateActions.toggleSplitMap}
onMapToggleLayer={this._handleMapToggleLayer}
onToggleMapControl={this._toggleMapControl}
onSetEditorMode={visStateActions.setEditorMode}
onSetLocale={uiStateActions.setLocale}
onToggleEditorVisibility={visStateActions.toggleEditorVisibility}
/>
<MapComponent
{...mapProps}
key="bottom"
ref={this._setMapboxMap}
mapStyle={mapStyle.bottomMapStyle}
getCursor={this.props.hoverInfo ? () => 'pointer' : undefined}
onMouseMove={this.props.visStateActions.onMouseMove}
>
{this._renderDeckOverlay(layersForDeck)}
{this._renderMapboxOverlays()}
<Editor
index={index}
datasets={datasets}
editor={editor}
filters={this.polygonFilters(this.props)}
isEnabled={isEdit}
layers={layers}
layersToRender={layersToRender}
onDeleteFeature={visStateActions.deleteFeature}
onSelect={visStateActions.setSelectedFeature}
onUpdate={visStateActions.setFeatures}
onTogglePolygonFilter={visStateActions.setPolygonFilterLayer}
style={{
pointerEvents: isEdit ? 'all' : 'none',
position: 'absolute',
display: editor.visible ? 'block' : 'none'
}}
/>
</MapComponent>
{mapStyle.topMapStyle || hasGeocoderLayer ? (
<div style={MAP_STYLE.top}>
<MapComponent {...mapProps} key="top" mapStyle={mapStyle.topMapStyle}>
{this._renderDeckOverlay({[GEOCODER_LAYER_ID]: true})}
</MapComponent>
</div>
) : null}
{this._renderMapPopover(layersToRender)}
{!isSplit || index === 1 ? <Attribution /> : null}
</>
);
}
render() {
const {mapState, mapStyle} = this.props;
return (
<StyledMapContainer ref={this._ref} style={MAP_STYLE.container} globe={mapState.globe}>
{mapStyle.bottomMapStyle && this._renderMap()}
</StyledMapContainer>
);
}
}
MapContainer.displayName = 'MapContainer';
return MapContainer;
}