UNPKG

kepler.gl

Version:

kepler.gl is a webgl based application to visualize large scale location data in the browser

515 lines (452 loc) 16.6 kB
// 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. import React, {Component, createRef} from 'react'; import Console from 'global/console'; import {bindActionCreators} from 'redux'; import styled, {ThemeProvider, withTheme} from 'styled-components'; import {createSelector} from 'reselect'; import {connect as keplerGlConnect} from 'connect/keplergl-connect'; import {IntlProvider} from 'react-intl'; import {messages} from '../localization'; import {RootContext} from 'components/context'; import * as VisStateActions from 'actions/vis-state-actions'; import * as MapStateActions from 'actions/map-state-actions'; import * as MapStyleActions from 'actions/map-style-actions'; import * as UIStateActions from 'actions/ui-state-actions'; import * as ProviderActions from 'actions/provider-actions'; import { DIMENSIONS, KEPLER_GL_NAME, KEPLER_GL_VERSION, THEME, DEFAULT_MAPBOX_API_URL } from 'constants/default-settings'; import {MISSING_MAPBOX_TOKEN} from 'constants/user-feedbacks'; import SidePanelFactory from './side-panel'; import MapContainerFactory from './map-container'; import MapsLayoutFactory from './maps-layout'; import BottomWidgetFactory from './bottom-widget'; import ModalContainerFactory from './modal-container'; import PlotContainerFactory from './plot-container'; import NotificationPanelFactory from './notification-panel'; import GeoCoderPanelFactory from './geocoder-panel'; import {generateHashId} from 'utils/utils'; import {validateToken} from 'utils/mapbox-utils'; import {mergeMessages} from 'utils/locale-utils'; import {theme as basicTheme, themeLT, themeBS} from 'styles/base'; import {observeDimensions, unobserveDimensions} from '../utils/observe-dimensions'; // Maybe we should think about exporting this or creating a variable // as part of the base.js theme const GlobalStyle = styled.div` font-family: ${props => props.theme.fontFamily}; font-weight: ${props => props.theme.fontWeight}; font-size: ${props => props.theme.fontSize}; line-height: ${props => props.theme.lineHeight}; *, *:before, *:after { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } ul { margin: 0; padding: 0; } li { margin: 0; } a { text-decoration: none; color: ${props => props.theme.labelColor}; } .mapboxgl-ctrl .mapboxgl-ctrl-logo { display: none; } `; const BottomWidgetOuter = styled.div( ({absolute}) => ` ${absolute ? 'position: absolute; bottom: 0; right: 0;' : ''} pointer-events: none; /* prevent padding from blocking input */ & > * { /* all children should allow input */ pointer-events: all; }` ); export const mapFieldsSelector = props => ({ getMapboxRef: props.getMapboxRef, mapboxApiAccessToken: props.mapboxApiAccessToken, mapboxApiUrl: props.mapboxApiUrl, mapState: props.mapState, mapStyle: props.mapStyle, onDeckInitialized: props.onDeckInitialized, onViewStateChange: props.onViewStateChange, deckGlProps: props.deckGlProps, uiStateActions: props.uiStateActions, visStateActions: props.visStateActions, mapStateActions: props.mapStateActions, // visState editor: props.visState.editor, datasets: props.visState.datasets, layers: props.visState.layers, layerOrder: props.visState.layerOrder, layerData: props.visState.layerData, layerBlending: props.visState.layerBlending, filters: props.visState.filters, interactionConfig: props.visState.interactionConfig, hoverInfo: props.visState.hoverInfo, clicked: props.visState.clicked, mousePos: props.visState.mousePos, animationConfig: props.visState.animationConfig, // uiState activeSidePanel: props.uiState.activeSidePanel, mapControls: props.uiState.mapControls, readOnly: props.uiState.readOnly, locale: props.uiState.locale }); export const sidePanelSelector = (props, availableProviders) => ({ appName: props.appName, version: props.version, appWebsite: props.appWebsite, mapStyle: props.mapStyle, onSaveMap: props.onSaveMap, uiState: props.uiState, mapStyleActions: props.mapStyleActions, visStateActions: props.visStateActions, uiStateActions: props.uiStateActions, datasets: props.visState.datasets, filters: props.visState.filters, layers: props.visState.layers, layerOrder: props.visState.layerOrder, layerClasses: props.visState.layerClasses, interactionConfig: props.visState.interactionConfig, mapInfo: props.visState.mapInfo, layerBlending: props.visState.layerBlending, width: props.sidePanelWidth, availableProviders, mapSaved: props.providerState.mapSaved }); export const plotContainerSelector = props => ({ width: props.width, height: props.height, exportImageSetting: props.uiState.exportImage, mapFields: mapFieldsSelector(props), addNotification: props.uiStateActions.addNotification, setExportImageSetting: props.uiStateActions.setExportImageSetting, setExportImageDataUri: props.uiStateActions.setExportImageDataUri, setExportImageError: props.uiStateActions.setExportImageError, splitMaps: props.visState.splitMaps }); export const isSplitSelector = props => props.visState.splitMaps && props.visState.splitMaps.length > 1; export const bottomWidgetSelector = (props, theme) => ({ filters: props.visState.filters, datasets: props.visState.datasets, uiState: props.uiState, layers: props.visState.layers, animationConfig: props.visState.animationConfig, visStateActions: props.visStateActions, toggleModal: props.uiStateActions.toggleModal, sidePanelWidth: props.uiState.readOnly ? 0 : props.sidePanelWidth + theme.sidePanel.margin.left }); export const modalContainerSelector = (props, rootNode) => ({ appName: props.appName, mapStyle: props.mapStyle, visState: props.visState, mapState: props.mapState, uiState: props.uiState, providerState: props.providerState, mapboxApiAccessToken: props.mapboxApiAccessToken, mapboxApiUrl: props.mapboxApiUrl, visStateActions: props.visStateActions, uiStateActions: props.uiStateActions, mapStyleActions: props.mapStyleActions, providerActions: props.providerActions, rootNode, // User defined cloud provider props cloudProviders: props.cloudProviders, onExportToCloudSuccess: props.onExportToCloudSuccess, onLoadCloudMapSuccess: props.onLoadCloudMapSuccess, onLoadCloudMapError: props.onLoadCloudMapError, onExportToCloudError: props.onExportToCloudError }); export const geoCoderPanelSelector = props => ({ isGeocoderEnabled: props.visState.interactionConfig.geocoder.enabled, mapboxApiAccessToken: props.mapboxApiAccessToken, mapState: props.mapState, updateVisData: props.visStateActions.updateVisData, removeDataset: props.visStateActions.removeDataset, updateMap: props.mapStateActions.updateMap }); export const notificationPanelSelector = props => ({ removeNotification: props.uiStateActions.removeNotification, notifications: props.uiState.notifications }); export const DEFAULT_KEPLER_GL_PROPS = { mapStyles: [], mapStylesReplaceDefault: false, mapboxApiUrl: DEFAULT_MAPBOX_API_URL, width: 800, height: 800, appName: KEPLER_GL_NAME, version: KEPLER_GL_VERSION, sidePanelWidth: DIMENSIONS.sidePanel.width, theme: {}, cloudProviders: [], readOnly: false }; KeplerGlFactory.deps = [ BottomWidgetFactory, GeoCoderPanelFactory, MapContainerFactory, MapsLayoutFactory, ModalContainerFactory, SidePanelFactory, PlotContainerFactory, NotificationPanelFactory ]; function KeplerGlFactory( BottomWidget, GeoCoderPanel, MapContainer, MapsLayout, ModalContainer, SidePanel, PlotContainer, NotificationPanel ) { /** @typedef {import('./kepler-gl').UnconnectedKeplerGlProps} KeplerGlProps */ /** @augments React.Component<KeplerGlProps> */ class KeplerGL extends Component { static defaultProps = DEFAULT_KEPLER_GL_PROPS; state = { dimensions: null }; componentDidMount() { this._validateMapboxToken(); this._loadMapStyle(); if (typeof this.props.onKeplerGlInitialized === 'function') { this.props.onKeplerGlInitialized(); } if (this.root.current instanceof HTMLElement) { observeDimensions(this.root.current, this._handleResize); } } componentWillUnmount() { if (this.root.current instanceof HTMLElement) { unobserveDimensions(this.root.current); } } _handleResize = dimensions => { this.setState({dimensions}); }; static contextType = RootContext; root = createRef(); bottomWidgetRef = createRef(); /* selectors */ themeSelector = props => props.theme; availableThemeSelector = createSelector(this.themeSelector, theme => typeof theme === 'object' ? { ...basicTheme, ...theme } : theme === THEME.light ? themeLT : theme === THEME.base ? themeBS : theme ); availableProviders = createSelector( props => props.cloudProviders, providers => Array.isArray(providers) && providers.length ? { hasStorage: providers.some(p => p.hasPrivateStorage()), hasShare: providers.some(p => p.hasSharingUrl()) } : {} ); localeMessagesSelector = createSelector( props => props.localeMessages, customMessages => (customMessages ? mergeMessages(messages, customMessages) : messages) ); /* private methods */ _validateMapboxToken() { const {mapboxApiAccessToken} = this.props; if (!validateToken(mapboxApiAccessToken)) { Console.warn(MISSING_MAPBOX_TOKEN); } } _loadMapStyle = () => { const defaultStyles = Object.values(this.props.mapStyle.mapStyles); // add id to custom map styles if not given const customStyles = (this.props.mapStyles || []).map(ms => ({ ...ms, id: ms.id || generateHashId() })); const allStyles = [...customStyles, ...defaultStyles].reduce( (accu, style) => { const hasStyleObject = style.style && typeof style.style === 'object'; accu[hasStyleObject ? 'toLoad' : 'toRequest'][style.id] = style; return accu; }, {toLoad: {}, toRequest: {}} ); this.props.mapStyleActions.loadMapStyles(allStyles.toLoad); this.props.mapStyleActions.requestMapStyles(allStyles.toRequest); }; render() { const { id, width, height, uiState, visState, // readOnly override readOnly } = this.props; const dimensions = this.state.dimensions || {width, height}; const { splitMaps, // this will store support for split map view is necessary interactionConfig } = visState; const isSplit = isSplitSelector(this.props); const theme = this.availableThemeSelector(this.props); const localeMessages = this.localeMessagesSelector(this.props); const isExportingImage = uiState.exportImage.exporting; const availableProviders = this.availableProviders(this.props); const mapFields = mapFieldsSelector(this.props); const sideFields = sidePanelSelector(this.props, availableProviders); const plotContainerFields = plotContainerSelector(this.props); const bottomWidgetFields = bottomWidgetSelector(this.props, theme); const modalContainerFields = modalContainerSelector(this.props, this.root.current); const geoCoderPanelFields = geoCoderPanelSelector(this.props); const notificationPanelFields = notificationPanelSelector(this.props); const mapContainers = !isSplit ? [<MapContainer primary={true} key={0} index={0} {...mapFields} mapLayers={null} />] : splitMaps.map((settings, index) => ( <MapContainer key={index} index={index} primary={index === 1} {...mapFields} mapLayers={splitMaps[index].layers} /> )); return ( <RootContext.Provider value={this.root}> <IntlProvider locale={uiState.locale} messages={localeMessages[uiState.locale]}> <ThemeProvider theme={theme}> <GlobalStyle className="kepler-gl" id={`kepler-gl__${id}`} style={{ display: 'flex', flexDirection: 'column', position: 'relative', width: `${width}px`, height: `${height}px` }} ref={this.root} > <NotificationPanel {...notificationPanelFields} /> {!uiState.readOnly && !readOnly && <SidePanel {...sideFields} />} <MapsLayout className="maps">{mapContainers}</MapsLayout> {isExportingImage && <PlotContainer {...plotContainerFields} />} {interactionConfig.geocoder.enabled && <GeoCoderPanel {...geoCoderPanelFields} />} <BottomWidgetOuter absolute> <BottomWidget ref={this.bottomWidgetRef} {...bottomWidgetFields} containerW={dimensions.width} /> </BottomWidgetOuter> <ModalContainer {...modalContainerFields} containerW={dimensions.width} containerH={dimensions.height} /> </GlobalStyle> </ThemeProvider> </IntlProvider> </RootContext.Provider> ); } } return keplerGlConnect(mapStateToProps, makeMapDispatchToProps)(withTheme(KeplerGL)); } export function mapStateToProps(state = {}, props) { return { ...props, visState: state.visState, mapStyle: state.mapStyle, mapState: state.mapState, uiState: state.uiState, providerState: state.providerState }; } const defaultUserActions = {}; const getDispatch = (dispatch, props) => dispatch; const getUserActions = (dispatch, props) => props.actions || defaultUserActions; /** @type {() => import('reselect').OutputParametricSelector<any, any, any, any>} */ function makeGetActionCreators() { return createSelector([getDispatch, getUserActions], (dispatch, userActions) => { const [visStateActions, mapStateActions, mapStyleActions, uiStateActions, providerActions] = [ VisStateActions, MapStateActions, MapStyleActions, UIStateActions, ProviderActions ].map(actions => bindActionCreators(mergeActions(actions, userActions), dispatch)); return { visStateActions, mapStateActions, mapStyleActions, uiStateActions, providerActions, dispatch }; }); } function makeMapDispatchToProps() { const getActionCreators = makeGetActionCreators(); const mapDispatchToProps = (dispatch, ownProps) => { const groupedActionCreators = getActionCreators(dispatch, ownProps); return { ...groupedActionCreators, dispatch }; }; return mapDispatchToProps; } /** * Override default kepler.gl actions with user defined actions using the same key */ function mergeActions(actions, userActions) { const overrides = {}; for (const key in userActions) { if (userActions.hasOwnProperty(key) && actions.hasOwnProperty(key)) { overrides[key] = userActions[key]; } } return {...actions, ...overrides}; } export default KeplerGlFactory;