// Copyright (c) 2022 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 MapboxGLMap from 'react-map-gl'; import DeckGL from '@deck.gl/react'; import {createSelector} from 'reselect'; import {OPERATION, Layer} from '@deck.gl/core'; import {SolidPolygonLayer} from '@deck.gl/layers'; import {TileLayer} from '@deck.gl/geo-layers'; import {errorNotification} from 'utils/notifications-utils'; import * as VisStateActions from 'actions/vis-state-actions'; import * as MapStateActions from 'actions/map-state-actions'; import * as UIStateActions from 'actions/ui-state-actions'; // components import MapPopoverFactory from 'components/map/map-popover'; import MapControlFactory from 'components/map/map-control'; import {StyledMapContainer} from 'components/common/styled-components'; import EditorFactory from './editor/editor'; // utils import {generateMapboxLayers} from 'layers/mapbox-utils'; import {setLayerBlending} from 'utils/gl-utils'; import {transformRequest} from 'utils/map-style-utils/mapbox-utils'; import {renderDeckGlLayer, prepareLayersToRender, prepareLayersForDeck} from 'utils/layer-utils'; // default-settings import ThreeDBuildingLayer from 'deckgl-layers/3d-building-layer/3d-building-layer'; import {FILTER_TYPES, MASK_LAYER_ID, THROTTLE_NOTIFICATION_TIME} from 'constants/default-settings'; import {observeDimensions, unobserveDimensions} from '../utils/observe-dimensions'; import {LOCALE_CODES} from 'localization/locales'; import { Datasets, Filter, InteractionConfig, MapControls, MapState, MapStyle, Viewport } from 'reducers'; import {SplitMapLayers} from 'reducers/vis-state-updaters'; import {LayerBaseConfig, VisualChannelDomain} from 'layers/base-layer'; // Performance debugging import {createTracker, createLayerUpdateTracker, renderPerformanceStats} from './performance'; // @ts-expect-error const trackers: {drawCount?: ReturnType, viewportChangeCount?: ReturnType} = {}; const PERF_DEBUG = window?.location?.search?.includes('PERF_DEBUG'); if (PERF_DEBUG) { trackers.drawCount = createTracker('draws'); trackers.viewportChangeCount = createTracker('viewport changes'); createLayerUpdateTracker('state updates', [Layer, TileLayer]); } /** @type {{[key: string]: React.CSSProperties}} */ const MAP_STYLE: {[key: string]: React.CSSProperties} = { container: { display: 'inline-block', position: 'relative', width: '100%', height: '100%' }, top: { position: 'absolute', top: '0px', pointerEvents: 'none', width: '100%', height: '100%' } }; const TRANSITION_DURATION = 0; MapContainerFactory.deps = [MapPopoverFactory, MapControlFactory, EditorFactory]; type MapboxStyle = string | object | undefined; interface MapContainerProps { datasets: Datasets; interactionConfig: InteractionConfig; layerBlending: string; layerOrder: number[]; layerData: any[]; layers: Layer[]; filters: Filter[]; mapState: MapState; mapControls: MapControls; mapStyle: {bottomMapStyle?: MapboxStyle; topMapStyle?: MapboxStyle} & MapStyle; mousePos: any; mapboxApiAccessToken: string; mapboxApiUrl: string; visStateActions: typeof VisStateActions; mapStateActions: typeof MapStateActions; uiStateActions: typeof UIStateActions; // optional primary?: boolean; // primary one will be reporting its size to appState readOnly?: boolean; isExport?: boolean; clicked?: any; hoverInfo?: any; mapLayers?: SplitMapLayers | null; onMapToggleLayer?: Function; onMapStyleLoaded?: Function; onMapRender?: Function; getMapboxRef?: (mapbox?: typeof MapboxGLMap | null, index?: number) => void; index?: number; locale?: any; editor?: any; MapComponent?: typeof MapboxGLMap; deckGlProps?: any; onDeckInitialized?: (a: any, b: any) => void; onViewStateChange?: (viewport: Viewport) => void; generateThumbnail?: Function; popupSettings?: any; maskPolygon?: any; } export default function MapContainerFactory( MapPopover, MapControl, Editor ): React.ComponentType { class MapContainer extends Component { static propTypes = { // required }; static defaultProps = { MapComponent: MapboxGLMap, deckGlProps: {}, index: 0, primary: true }; previousLayers = { // [layers.id]: mapboxLayerConfig }; displayName = 'MapContainer'; _deck: any = null; _map: mapboxgl.Map | null = null; _ref = createRef(); _deckGLErrorsElapsed: {[id: string]: number} = {}; _hasRendered: React.MutableRefObject = createRef(); _mousePosition: React.MutableRefObject = createRef(); constructor(props) { super(props); } componentDidMount() { if (!this._ref.current) { return; } observeDimensions(this._ref.current, this._handleResize); } componentWillUnmount() { unobserveDimensions(this._ref.current as Element); } componentDidUpdate() { const contextLost = (this.state as any)?.contextLost; if (contextLost) { // eslint-disable-next-line react/no-did-update-set-state this.setState({contextLost: false}); } } _handleResize = dimensions => { const {primary, isExport} = this.props; if (primary) { const {mapStateActions} = this.props; if (!isExport && 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; popupSettingsSelector = props => props.popupSettings; layersToRenderSelector = createSelector( this.layersSelector, this.layerDataSelector, this.mapLayersSelector, this.popupSettingsSelector, 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: number, colorDomain: VisualChannelDomain) => { // @ts-expect-error this.props.visStateActions.layerConfigChange(this.props.layers[idx], { colorDomain } as Partial); }; _handleMapToggleLayer = layerId => { const {index: mapIndex = 0, visStateActions} = this.props; visStateActions.toggleLayerForMap(mapIndex, layerId); }; _onDeckInitialized(gl) { if (this.props.onDeckInitialized) { this.props.onDeckInitialized(this._deck, gl); } } _onBeforeRender = ({gl}) => { setLayerBlending(gl, this.props.layerBlending); }; _onDeckError = (error, layer) => { if (error?.message === 'WebGL context is lost') { this.setState({contextLost: true}); return; } 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. 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) { // Overriden in Builder: features/builder/ui/KeplerGl/MapContainer/MapContainer.tsx return null; } /* 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, mapboxApiUrl, maskPolygon } = this.props; const mousePosition = this._mousePosition; trackers.drawCount?.increment(); // 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 (this.props.deckGlProps?.layersOnTop) { deckGlLayers = deckGlLayers.concat(this.props.deckGlProps.layersOnTop); } if (maskPolygon) { deckGlLayers.push( new SolidPolygonLayer({ id: MASK_LAYER_ID, operation: OPERATION.MASK, data: [maskPolygon], dataComparator: (data, oldData) => data[0] === oldData[0], getPolygon: d => d, getFillColor: [255, 255, 255, 255] }) ); } if (mapStyle.visibleLayerGroups['3d building']) { deckGlLayers.push( new (ThreeDBuildingLayer as any)({ id: '_keplergl_3d-building', mapboxApiUrl, threeDBuildingColor: mapStyle.threeDBuildingColor, updateTriggers: { getFillColor: mapStyle.threeDBuildingColor } }) ); } // Limit renders const hasRendered = this._hasRendered; const resetHasRendered = () => (hasRendered.current = false); return ( { // Limit updates (prevents freezes when dev tools open) // @ts-expect-error if (!hasRendered.current && viewState.width > 1 && viewState.height > 1) { hasRendered.current = true; this._onViewportChange(viewState); } }} id="default-deckgl-overlay" layers={deckGlLayers} onBeforeRender={opts => { resetHasRendered(); this._onBeforeRender(opts); }} // onHover={visStateActions.onLayerHover} onHover={info => { visStateActions.onLayerHover(info); mousePosition.current = {mousePosition: info.pixel}; }} onClick={visStateActions.onLayerClick} onDragStart={() => { mousePosition.current = null; }} onError={this._onDeckError} ref={comp => { if (comp && comp.deck && comp.deck !== this._deck) { this._deck = comp.deck; } }} onWebGLInitialized={gl => this._onDeckInitialized(gl)} layerFilter={({layer, viewport}) => { if ( // @ts-expect-error isNaN(layer.props.visibilityByZoom?.min) || // @ts-expect-error isNaN(layer.props.visibilityByZoom?.max) ) { return true; } const currentZoomRange = Math.round(mapState.zoom); return ( // @ts-expect-error layer.props.visibilityByZoom.min <= currentZoomRange && // @ts-expect-error layer.props.visibilityByZoom.max >= currentZoomRange ); }} /> ); } _onViewportChange = viewState => { trackers.viewportChangeCount?.increment(); const {width, height, ...restViewState} = viewState; const {primary, isExport} = 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); } if (!isExport) { this.props.mapStateActions.updateMap(next); } }; _toggleMapControl = panelId => { const {index, uiStateActions} = this.props; uiStateActions.toggleMapControl(panelId, Number(index)); }; /* eslint-disable complexity */ _renderMap() { const { mapState, mapStyle, mapStateActions, layers, MapComponent = MapboxGLMap, datasets, mapControls, isExport, locale, uiStateActions, visStateActions, interactionConfig, editor, index, primary, generateThumbnail } = this.props; const layersToRender = this.layersToRenderSelector(this.props); const layersForDeck = this.layersForDeckSelector(this.props); const mapProps = { ...mapState, width: '100%', height: '100%', preserveDrawingBuffer: true, transformRequest, generateThumbnail }; const isSplit = Boolean(mapState.isSplit); return ( <> {PERF_DEBUG && renderPerformanceStats()} {this._renderDeckOverlay(layersForDeck)} {this._renderMapPopover(layersToRender)} ); } render() { const {mapState, mapStyle} = this.props; const contextLost = (this.state as any)?.contextLost; if (contextLost) { return null; } return ( {mapStyle.bottomMapStyle && this._renderMap()} ); } } return MapContainer; }