UNPKG

kepler.gl.geoiq

Version:

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

1,004 lines (914 loc) 30.8 kB
// Copyright (c) 2023 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} from 'react'; import PropTypes from 'prop-types'; import MapboxGLMap from 'react-map-gl'; import DeckGL from 'deck.gl'; import {createSelector} from 'reselect'; import WebMercatorViewport from 'viewport-mercator-project'; import geoViewport from '@mapbox/geo-viewport'; import { dashboardConnection, removeDashboardConnection, addDatasetSocket, removeDatasetSocket, listenDataUpdates } from 'socket/api'; // import GL from 'luma.gl/constants'; // import {registerShaderModules, setParameters} from 'luma.gl'; // import pickingModule from 'shaderlib/picking-module'; // import brushingModule from 'shaderlib/brushing-module'; // import GeoJSON from 'geojson'; // components import MapPopoverFactory from 'components/map/map-popover'; import MapControlFactory from 'components/map/map-control'; import {StyledMapContainer} from 'components/common/styled-components'; import Editor from './editor/editor'; // utils import {generateMapboxLayers, updateMapboxLayers} from 'layers/mapbox-utils'; import {OVERLAY_TYPE} from 'layers/base-layer'; import {onWebGLInitialized, setLayerBlending} from 'utils/gl-utils'; import {transformRequest} from 'utils/map-style-utils/mapbox-utils'; // default-settings import ThreeDBuildingLayer from 'deckgl-layers/3d-building-layer/3d-building-layer'; import {FILTER_TYPES} from 'utils/filter-utils'; import {LAYER_BLENDINGS} from 'constants/default-settings'; // import ThreeDBuildingLayer from '../deckgl-layers/3d-building-layer/3d-building-layer'; // import continuousColorLegend from 'react-vis/dist/legends/continuous-color-legend'; const MAP_STYLE = { container: { display: 'inline-block', position: 'relative' }, top: { position: 'absolute', top: '0px', pointerEvents: 'none' } }; const MAPBOXGL_STYLE_UPDATE = 'style.load'; const MAPBOXGL_RENDER = 'render'; const TRANSITION_DURATION = 0; MapContainerFactory.deps = [MapPopoverFactory, MapControlFactory]; export default function MapContainerFactory(MapPopover, MapControl) { 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, uiState: 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 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: {} }; constructor(props) { super(props); this.state = { nextMapState: null, listenToSocket: false }; this.previousLayers = { // [layers.id]: mapboxLayerConfig }; // dashboardConnection(); } inDebounce = 0; // componentDidMount() { // } componentWillUnmount() { // unbind mapboxgl event listener if (this._map) { this._map.off(MAPBOXGL_STYLE_UPDATE); this._map.off(MAPBOXGL_RENDER); } removeDashboardConnection(this.props); } componentWillReceiveProps(nextProps) { const { mapState, layers, visStateActions, datasets, auth, project } = nextProps; let {listenToSocket} = this.state; const datasetKeys = Object.keys(datasets); const currentDatasetKeys = Object.keys(this.props.datasets); if (currentDatasetKeys.length === 0 && datasetKeys.length > 0) { listenToSocket = true; } if (currentDatasetKeys.length < datasetKeys.length) { const filteredDatasetKeys = datasetKeys.filter( dk => currentDatasetKeys.findIndex(cd => cd === dk) === -1 ); const datasetIdsToAdd = filteredDatasetKeys.reduce((accu, dk) => { if (datasets[dk].isLiveDataset === true) { accu.push(datasets[dk].datasetId); } return accu; }, []); addDatasetSocket( {datasetIdsToAdd, listenToSocket, ...nextProps}, this.updateSocketLayer ); // this.setState({listenToSocket: false}); // listenDataUpdates(this.updateSocketLayer); } else if (currentDatasetKeys.length > datasetKeys.length) { const filteredDatasetKeys = currentDatasetKeys.filter( cd => datasetKeys.findIndex(dk => dk === cd) === -1 ); const datasetIdsToRemove = filteredDatasetKeys.reduce((accu, dk) => { if (this.props.datasets[dk].isLiveDataset === true) { accu.push(this.props.datasets[dk].datasetId); } return accu; }, []); removeDatasetSocket(datasetIdsToRemove, nextProps.auth); } // } // console.log( // 'before condition inside componentWillRecieveProps', // updatingDataset // ); // if (datasetKeys.length && updatingDataset === false) { // // helps in updating datasets insi de a dashboad without checking updateTime // datasetKeys.map(dk => { // if (datasets[dk].isLiveDataset === true) { // this.setState({updatingDataset: true}); // dashboardConnection(nextProps); // } // }); // } // if (updatingDataset === true) { // dashboardConnection(nextProps); // this.setState({updatingDataset: false}); // } if (mapState) { this.setState({nextMapState: nextProps.mapState}); } layers.map(layer => { const {config} = layer; const { apiCallRequest, legendApiCallRequest, colorField, dataId } = config; const {filters} = nextProps; const currentFilters = this.props.filters; if (currentFilters.length > filters.length) { if (filters.length === 0) { visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } filters.map(filter => { if (filter.dataId.includes(dataId)) { if ( layer.type === 'backendPoint' || layer.type === 'backendGeojson' ) { visStateActions.layerConfigChange(layer, { legendApiCallRequest: true }); } else { visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } } }); } if (filters.length && currentFilters.length) { filters.map((f, i) => { if (f.dataId.includes(dataId)) { if ( (currentFilters[i] && JSON.stringify(f.value) != JSON.stringify(currentFilters[i].value)) || (currentFilters[i] && currentFilters[i].dataId.length !== f.dataId.length) ) { if ( layer.type === 'backendPoint' || layer.type === 'backendGeojson' ) { visStateActions.layerConfigChange(layer, { legendApiCallRequest: true }); } else { visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } } } else if ( currentFilters[i] && currentFilters[i].dataId.includes(dataId) ) { if ( layer.type === 'backendPoint' || layer.type === 'backendGeojson' ) { visStateActions.layerConfigChange(layer, { legendApiCallRequest: true }); } else { visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } } }); } if (filters.length && currentFilters.length === 0) { filters.map((f, i) => { if (f.dataId.includes(dataId)) { if (f.dataId.length) { if ( layer.type === 'backendPoint' || layer.type === 'backendGeojson' ) { visStateActions.layerConfigChange(layer, { legendApiCallRequest: true }); } else { visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } } } }); } if ( colorField && legendApiCallRequest && legendApiCallRequest === true ) { const result = layer.axiosLegendAPICall( datasets, filters, auth, project ); result.then(function(result) { visStateActions.layerConfigChange(layer, { legend: result, apiCallRequest: true, legendApiCallRequest: false }); }); } // if ( // layer.type === 'point' && // (!legendApiCallRequest || legendApiCallRequest === false) // ) { // nextProps.visStateActions.layerConfigChange(layer, { // legendApiCallRequest: true // }); // } if ( layer.type === 'dynamic' || layer.type === 'geoHash' || layer.type === 'backendPoint' || layer.type === 'backendGeojson' ) { if ( !_.isEqual(this.props.mapState, mapState) // && // nextProps.layers // nextProps.widget.config.bounds ) { nextProps.visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } } if ( layer && config && apiCallRequest === true // columns[Object.keys(columns)[0]].fieldIdx !== -1 ) { visStateActions.layerConfigChange(layer, { apiCallRequest: false, apiCallLoader: true }); clearTimeout(layer.inDebounce); layer.inDebounce = setTimeout(() => { const result = layer.axiosApiCall( datasets, this.getViewport(mapState), mapState.zoom, nextProps.filters, auth, project ); if (result) { result.then(function(result) { visStateActions.layerConfigChange(layer, { apiCallLoader: false }); visStateActions.updateLayerData(layer, result); }); } clearTimeout(layer.inDebounce); layer.inDebounce = 0; }, 300); } }); } updateSocketLayer = socketData => { const {datasets, layers, widgets, visStateActions} = this.props; const datasetKeys = Object.keys(datasets); const updateDatasetIds = datasetKeys.filter( d => datasets[d].datasetId === socketData.dsId ); updateDatasetIds.map(dataId => visStateActions.updateDataset(dataId, socketData.updatedAt) ); layers.map(layer => { if (updateDatasetIds.indexOf(layer.config.dataId) > -1) { visStateActions.layerConfigChange(layer, { apiCallRequest: true }); } }); widgets.map(widget => { if (updateDatasetIds.indexOf(widget.config.dataId) > -1) { this.props.widgetConfigChange(widget, { apiCallRequest: true }); } }); }; getViewport(mapState) { const { longitude, latitude, height, width, zoom, pitch, bearing } = mapState; // let boundingBox = geoViewport.bounds([longitude, latitude], zoom, [ // width, // height // ]); const viewport = new WebMercatorViewport({ width, height, longitude, latitude, zoom, pitch, bearing }); const cUL = viewport.unproject([0, 0]); const cUR = viewport.unproject([width, 0]); const cLR = viewport.unproject([width, height]); const cLL = viewport.unproject([0, height]); // let latRange = [...new Set([cLL[1], cUR[1], cLR[1], cUL[1]])].sort( // (a, b) => a - b // ); // let lngRange = [...new Set([cLL[0], cUR[0], cLR[0], cUL[0]])].sort( // (a, b) => a - b // ); // return [lngRange[0], lngRange[1], latRange[0], latRange[1]]; return {type: 'Polygon', coordinates: [[cUL, cUR, cLR, cLL, cUL]]}; } layersSelector = props => props.layers; layerDataSelector = props => props.layerData; mapLayersSelector = props => props.mapLayers; layerOrderSelector = props => props.layerOrder; layersToRenderSelector = createSelector( this.layersSelector, this.layerDataSelector, this.mapLayersSelector, // {[id]: true \ false} (layers, layerData, mapLayers) => layers.reduce( (accu, layer, idx) => ({ ...accu, [layer.id]: layer.shouldRenderLayer(layerData[idx]) && this._isVisibleMapLayer(layer, mapLayers) }), {} ) ); 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 */ _isVisibleMapLayer(layer, mapLayers) { // if layer.id is not in mapLayers, don't render it return !mapLayers || (mapLayers && mapLayers[layer.id]); } _onCloseMapPopover = () => { this.props.visStateActions.onLayerClick(null); }; _onLayerSetDomain = (idx, colorDomain) => { this.props.visStateActions.layerConfigChange(this.props.layers[idx], { colorDomain }); }; _onWebGLInitialized = onWebGLInitialized; _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); } }; _onBeforeRender = ({gl}) => { setLayerBlending(gl, this.props.layerBlending); }; /* 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}, auth, project } = this.props; if (!mousePosition) { return null; } // if clicked something, ignore hover behavior const objectInfo = clicked || hoverInfo; let layerHoverProp = null; let position = {x: mousePosition[0], y: mousePosition[1]}; if ( interactionConfig.tooltip.enabled && objectInfo && objectInfo.picked ) { // if anything hovered const {object, layer: overlay} = objectInfo; // deckgl layer to kepler-gl layer const layer = layers[overlay.props.idx]; if (layer.getHoverData && layersToRender[layer.id]) { // if layer is visible and have hovered data const { config: {dataId} } = layer; const {allData, fields} = datasets[dataId]; var fieldsToShow = interactionConfig.tooltip.config.fieldsToShow[dataId]; if (layer.meta.Point === true) { const result = layer.getHoverData( object, auth, datasets, fieldsToShow ); result.then(re => { layerHoverProp = { data: re, fields, fieldsToShow, layer, datasets, auth }; }); } else { var data = layer.getHoverData(object, auth, datasets, fieldsToShow); if (layer.name === 'MVT') { data = Object.values(object.properties); data = [object, ...data]; fieldsToShow = Object.keys(object.properties); } layerHoverProp = { data, fields, fieldsToShow, layer, datasets, auth }; } } } 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; position = this._getHoverXY(viewport, lngLat); } // var data, fieldsToShow; // const {allData, fields} = datasets[dataId]; // // const {x, y} = this._getHoverXY(viewport, lngLat) || objectInfo; // // const {viewport} = overlay.context; // if (layer.name == 'MVT') { // const { // config: {dataId} // } = layer; // // const {allData, fields} = datasets[dataId]; // const fieldKeys = Object.keys(object.properties); // // let fields = []; // // fields.push({ // // format: '', // // id: '_geojson', // // name: '_geojson', // // tableFieldIndex: 1, // // type: 'geojson' // // }); // // fields.push({ // // format: '', // // id: 'population', // // name: 'Population', // // tableFieldIndex: 2, // // type: 'integer' // // }); // data = Object.values(object.properties); // data = [object, ...data]; // fieldsToShow = fieldKeys; // // layerHoverProp = { // // data, // // fields, // // fieldsToShow, // // layer // // }; // } else { // data = layer.getHoverData(object, allData); // // project lnglat to screen so that tooltip follows the object on zoom // } // const popoverProps = { // data, // fields, // fieldsToShow: fieldsToShow // ? fieldsToShow // : interactionConfig.tooltip.config.fieldsToShow[dataId], // layer, // isVisible: true, // x, // y, // freezed: Boolean(clicked), // onClose: this._onCloseMapPopover, // mapState // }; // console.log('PopoverProps', popoverProps); return ( <div> <MapPopover {...position} layerHoverProp={layerHoverProp} coordinate={ interactionConfig.coordinate.enabled && ((pinned || {}).coordinate || coordinate) } freezed={Boolean(clicked || pinned)} onClose={this._onCloseMapPopover} mapW={mapState.width} mapH={mapState.height} /> </div> ); } /* eslint-enable complexity */ _getHoverXY(viewport, lngLat) { const screenCoord = !viewport || !lngLat ? null : viewport.project(lngLat); return screenCoord && {x: screenCoord[0], y: screenCoord[1]}; } _renderLayer = (overlays, idx) => { const { layers, layerData, hoverInfo, clicked, mapState, interactionConfig, mousePos, animationConfig, visStateActions, datasets, filters } = this.props; const {nextMapState} = this.state; const layer = layers[idx]; const data = layerData[idx]; const layerInteraction = { mousePosition: mousePos.mousePosition, wrapLongitude: true }; const objectHovered = clicked || hoverInfo; const layerCallbacks = { onSetLayerDomain: val => this._onLayerSetDomain(idx, val) }; // Layer is Layer class // const layerOverlay = layer.renderLayer({ // data, // idx, // layerInteraction, // objectHovered, // mapState, // interactionConfig, // layerCallbacks, // animationConfig // }); let layerOverlay = []; layerOverlay = layer.renderLayer({ data, idx, layerInteraction, objectHovered, mapState, interactionConfig, layerCallbacks, animationConfig, datasets, filters, loadEDLinkData: (data, dataId) => visStateActions.loadEDLinkData(data, dataId), nextMapState }); return overlays.concat(layerOverlay || []); }; _renderDeckOverlay(layersToRender) { const { mapState, mapStyle, layerData, layerOrder, layers, visStateActions, mapboxApiAccessToken, mapboxApiUrl, uiState } = this.props; let deckGlLayers = []; // wait until data is ready before render data layers if (layerData && layerData.length) { // last layer render first deckGlLayers = layerOrder .slice() .reverse() .filter( idx => layers[idx].overlayType === OVERLAY_TYPE.deckgl && layersToRender[layers[idx].id] ) .reduce(this._renderLayer, []); } if (mapStyle.visibleLayerGroups['3d building']) { deckGlLayers.push( new ThreeDBuildingLayer({ id: '_keplergl_3d-building', mapboxApiAccessToken, mapboxApiUrl, threeDBuildingColor: mapStyle.threeDBuildingColor, updateTriggers: { getFillColor: mapStyle.threeDBuildingColor } }) ); } // const isEdit = uiState.mapControls.mapDraw.active; return ( <DeckGL {...this.props.deckGlProps} viewState={mapState} id="default-deckgl-overlay" layers={deckGlLayers} onWebGLInitialized={this._onWebGLInitialized} onBeforeRender={this._onBeforeRender} onHover={visStateActions.onLayerHover} onClick={visStateActions.onLayerClick} // style={{zIndex: isEdit ? -1 : 0}} /> ); } _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 => { if (typeof this.props.onViewStateChange === 'function') { this.props.onViewStateChange(viewState); } this.props.mapStateActions.updateMap(viewState); }; render() { const { mapState, mapStyle, mapStateActions, mapLayers, layers, MapComponent, datasets, mapboxApiAccessToken, mapboxApiUrl, uiState, uiStateActions, visStateActions, editor, index, auth, project } = this.props; const {longitude, latitude, height, width, zoom} = mapState; // const boundingBox = geoViewport.bounds([longitude, latitude], zoom, [ // width, // height // ]); // console.log('bounding box inside map-container', boundingBox); const layersToRender = this.layersToRenderSelector(this.props); if (!mapStyle.bottomMapStyle) { // style not yet loaded return <div />; } const mapProps = { ...mapState, preserveDrawingBuffer: true, mapboxApiAccessToken, mapboxApiUrl, onViewportChange: this._onViewportChange, transformRequest }; const isEdit = uiState.mapControls.mapDraw.active; return ( <StyledMapContainer sidePanel={uiState.activeSidePanel !== null} style={MAP_STYLE.container} > <MapControl datasets={datasets} dragRotate={mapState.dragRotate} isSplit={Boolean(mapLayers)} isExport={this.props.isExport} layers={layers} layersToRender={layersToRender} mapIndex={index} mapControls={uiState.mapControls} readOnly={this.props.readOnly} scale={mapState.scale || 1} top={0} editor={editor} onTogglePerspective={mapStateActions.togglePerspective} onToggleSplitMap={mapStateActions.toggleSplitMap} onMapToggleLayer={this._handleMapToggleLayer} onToggleMapControl={uiStateActions.toggleMapControl} onSetEditorMode={visStateActions.setEditorMode} onToggleEditorVisibility={visStateActions.toggleEditorVisibility} fitBounds={mapStateActions.fitBounds} /> <MapComponent {...mapProps} key="bottom" ref={this._setMapboxMap} mapStyle={mapStyle.bottomMapStyle} getCursor={this.props.hoverInfo ? () => 'pointer' : undefined} transitionDuration={TRANSITION_DURATION} onMouseMove={this.props.visStateActions.onMouseMove} attributionControl={false} > {this._renderDeckOverlay(layersToRender)} {this._renderMapboxOverlays(layersToRender)} {/* By placing the editor in this map we have to perform fewer checks for css zIndex and fewer updates when we switch from edit to read mode */} <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' }} auth={auth} project={project} mapState={mapState} /> </MapComponent> {mapStyle.topMapStyle && ( <div style={MAP_STYLE.top}> <MapComponent {...mapProps} key="top" mapStyle={mapStyle.topMapStyle} attributionControl={false} /> </div> )} {this._renderMapPopover(layersToRender)} </StyledMapContainer> ); } } MapContainer.displayName = 'MapContainer'; return MapContainer; }