diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx index 4348bf1561c97..fb150445f4e93 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/CategoricalDeckGLContainer.tsx @@ -24,7 +24,7 @@ */ /* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */ -import React from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { CategoricalColorNamespace, Datasource, @@ -40,7 +40,7 @@ import sandboxedEval from './utils/sandbox'; // eslint-disable-next-line import/extensions import fitViewport, { Viewport } from './utils/fitViewport'; import { - DeckGLContainer, + DeckGLContainerHandle, DeckGLContainerStyledWrapper, } from './DeckGLContainer'; import { Point } from './types'; @@ -83,82 +83,72 @@ export type CategoricalDeckGLContainerProps = { setControlValue: (control: string, value: JsonValue) => void; }; -export type CategoricalDeckGLContainerState = { - formData?: QueryFormData; - viewport: Viewport; - categories: JsonObject; -}; - -export default class CategoricalDeckGLContainer extends React.PureComponent< - CategoricalDeckGLContainerProps, - CategoricalDeckGLContainerState -> { - containerRef = React.createRef(); - - /* - * A Deck.gl container that handles categories. - * - * The container will have an interactive legend, populated from the - * categories present in the data. - */ - constructor(props: CategoricalDeckGLContainerProps) { - super(props); - this.state = this.getStateFromProps(props); - - this.getLayers = this.getLayers.bind(this); - this.toggleCategory = this.toggleCategory.bind(this); - this.showSingleCategory = this.showSingleCategory.bind(this); - } - - UNSAFE_componentWillReceiveProps(nextProps: CategoricalDeckGLContainerProps) { - if (nextProps.payload.form_data !== this.state.formData) { - this.setState({ ...this.getStateFromProps(nextProps) }); - } - } +const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => { + const containerRef = useRef(null); - // eslint-disable-next-line class-methods-use-this - getStateFromProps( - props: CategoricalDeckGLContainerProps, - state?: CategoricalDeckGLContainerState, - ) { - const features = props.payload.data.features || []; - const categories = getCategories(props.formData, features); - - // the state is computed only from the payload; if it hasn't changed, do - // not recompute state since this would reset selections and/or the play - // slider position due to changes in form controls - if (state && props.payload.form_data === state.formData) { - return { ...state, categories }; - } - - const { width, height, formData } = props; - let { viewport } = props; - if (formData.autozoom) { + const getAdjustedViewport = useCallback(() => { + let viewport = { ...props.viewport }; + if (props.formData.autozoom) { viewport = fitViewport(viewport, { - width, - height, - points: props.getPoints(features), + width: props.width, + height: props.height, + points: props.getPoints(props.payload.data.features || []), }); } if (viewport.zoom < 0) { viewport.zoom = 0; } + return viewport; + }, [props]); + + const [categories, setCategories] = useState( + getCategories(props.formData, props.payload.data.features || []), + ); + const [stateFormData, setStateFormData] = useState( + props.payload.form_data, + ); + const [viewport, setViewport] = useState(getAdjustedViewport()); + + useEffect(() => { + if (props.payload.form_data !== stateFormData) { + const features = props.payload.data.features || []; + const categories = getCategories(props.formData, features); + + setViewport(getAdjustedViewport()); + setStateFormData(props.payload.form_data); + setCategories(categories); + } + }, [getAdjustedViewport, props, stateFormData]); - return { - viewport, - selected: [], - lastClick: 0, - formData: props.payload.form_data, - categories, - }; - } + const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { + const { current } = containerRef; + if (current) { + current.setTooltip(tooltip); + } + }, []); + + const addColor = useCallback((data: JsonObject[], fd: QueryFormData) => { + const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; + const colorFn = getScale(fd.color_scheme); + + return data.map(d => { + let color; + if (fd.dimension) { + color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255); + + return { ...d, color }; + } + + return d; + }); + }, []); - getLayers() { - const { getLayer, payload, formData: fd, onAddFilter } = this.props; + const getLayers = useCallback(() => { + const { getLayer, payload, formData: fd, onAddFilter } = props; let features = payload.data.features ? [...payload.data.features] : []; // Add colors from categories or fixed color - features = this.addColor(features, fd); + features = addColor(features, fd); // Apply user defined data mutator if defined if (fd.js_data_mutator) { @@ -167,9 +157,8 @@ export default class CategoricalDeckGLContainer extends React.PureComponent< } // Show only categories selected in the legend - const cats = this.state.categories; if (fd.dimension) { - features = features.filter(d => cats[d.cat_color]?.enabled); + features = features.filter(d => categories[d.cat_color]?.enabled); } const filteredPayload = { @@ -182,88 +171,69 @@ export default class CategoricalDeckGLContainer extends React.PureComponent< fd, filteredPayload, onAddFilter, - this.setTooltip, - this.props.datasource, + setTooltip, + props.datasource, ) as Layer, ]; - } - - // eslint-disable-next-line class-methods-use-this - addColor(data: JsonObject[], fd: QueryFormData) { - const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; - const colorFn = getScale(fd.color_scheme); - - return data.map(d => { - let color; - if (fd.dimension) { - color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255); - - return { ...d, color }; + }, [addColor, categories, props, setTooltip]); + + const toggleCategory = useCallback( + (category: string) => { + const categoryState = categories[category]; + const categoriesExtended = { + ...categories, + [category]: { + ...categoryState, + enabled: !categoryState.enabled, + }, + }; + + // if all categories are disabled, enable all -- similar to nvd3 + if (Object.values(categoriesExtended).every(v => !v.enabled)) { + /* eslint-disable no-param-reassign */ + Object.values(categoriesExtended).forEach(v => { + v.enabled = true; + }); } - - return d; - }); - } - - toggleCategory(category: string) { - const categoryState = this.state.categories[category]; - const categories = { - ...this.state.categories, - [category]: { - ...categoryState, - enabled: !categoryState.enabled, - }, - }; - - // if all categories are disabled, enable all -- similar to nvd3 - if (Object.values(categories).every(v => !v.enabled)) { - /* eslint-disable no-param-reassign */ - Object.values(categories).forEach(v => { - v.enabled = true; + setCategories(categoriesExtended); + }, + [categories], + ); + + const showSingleCategory = useCallback( + (category: string) => { + const modifiedCategories = { ...categories }; + Object.values(modifiedCategories).forEach(v => { + v.enabled = false; }); - } - this.setState({ categories }); - } - - showSingleCategory(category: string) { - const categories = { ...this.state.categories }; - /* eslint-disable no-param-reassign */ - Object.values(categories).forEach(v => { - v.enabled = false; - }); - categories[category].enabled = true; - this.setState({ categories }); - } - - setTooltip = (tooltip: TooltipProps['tooltip']) => { - const { current } = this.containerRef; - if (current) { - current.setTooltip(tooltip); - } - }; + modifiedCategories[category].enabled = true; + setCategories(modifiedCategories); + }, + [categories], + ); + + return ( +
+ + +
+ ); +}; - render() { - return ( -
- - -
- ); - } -} +export default memo(CategoricalDeckGLContainer); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx index 29672febfb154..7b8f61e18b581 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/DeckGLContainer.tsx @@ -20,11 +20,19 @@ * specific language governing permissions and limitations * under the License. */ -import React, { ReactNode } from 'react'; +import React, { + forwardRef, + memo, + ReactNode, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from 'react'; import { isEqual } from 'lodash'; import { StaticMap } from 'react-map-gl'; import DeckGL, { Layer } from 'deck.gl/typed'; -import { JsonObject, JsonValue, styled } from '@superset-ui/core'; +import { JsonObject, JsonValue, styled, usePrevious } from '@superset-ui/core'; import Tooltip, { TooltipProps } from './components/Tooltip'; import 'mapbox-gl/dist/mapbox-gl.css'; import { Viewport } from './utils/fitViewport'; @@ -43,76 +51,57 @@ export type DeckGLContainerProps = { onViewportChange?: (viewport: Viewport) => void; }; -export type DeckGLContainerState = { - lastUpdate: number | null; - viewState: Viewport; - tooltip: TooltipProps['tooltip']; - timer: ReturnType; -}; - -export class DeckGLContainer extends React.Component< - DeckGLContainerProps, - DeckGLContainerState -> { - constructor(props: DeckGLContainerProps) { - super(props); - this.tick = this.tick.bind(this); - this.onViewStateChange = this.onViewStateChange.bind(this); - // This has to be placed after this.tick is bound to this - this.state = { - timer: setInterval(this.tick, TICK), - tooltip: null, - viewState: props.viewport, - lastUpdate: null, - }; - } +export const DeckGLContainer = memo( + forwardRef((props: DeckGLContainerProps, ref) => { + const [tooltip, setTooltip] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [viewState, setViewState] = useState(props.viewport); + const prevViewport = usePrevious(props.viewport); - UNSAFE_componentWillReceiveProps(nextProps: DeckGLContainerProps) { - if (!isEqual(nextProps.viewport, this.props.viewport)) { - this.setState({ viewState: nextProps.viewport }); - } - } + useImperativeHandle(ref, () => ({ setTooltip }), []); - componentWillUnmount() { - clearInterval(this.state.timer); - } + const tick = useCallback(() => { + // Rate limiting updating viewport controls as it triggers lots of renders + if (lastUpdate && Date.now() - lastUpdate > TICK) { + const setCV = props.setControlValue; + if (setCV) { + setCV('viewport', viewState); + } + setLastUpdate(null); + } + }, [lastUpdate, props.setControlValue, viewState]); - onViewStateChange({ viewState }: { viewState: JsonObject }) { - this.setState({ viewState: viewState as Viewport, lastUpdate: Date.now() }); - } + useEffect(() => { + const timer = setInterval(tick, TICK); + return clearInterval(timer); + }, [tick]); - tick() { - // Rate limiting updating viewport controls as it triggers lotsa renders - const { lastUpdate } = this.state; - if (lastUpdate && Date.now() - lastUpdate > TICK) { - const setCV = this.props.setControlValue; - if (setCV) { - setCV('viewport', this.state.viewState); + useEffect(() => { + if (!isEqual(props.viewport, prevViewport)) { + setViewState(props.viewport); } - this.setState({ lastUpdate: null }); - } - } + }, [prevViewport, props.viewport]); - layers() { - // Support for layer factory - if (this.props.layers.some(l => typeof l === 'function')) { - return this.props.layers.map(l => - typeof l === 'function' ? l() : l, - ) as Layer[]; - } - - return this.props.layers as Layer[]; - } + const onViewStateChange = useCallback( + ({ viewState }: { viewState: JsonObject }) => { + setViewState(viewState as Viewport); + setLastUpdate(Date.now()); + }, + [], + ); - setTooltip = (tooltip: TooltipProps['tooltip']) => { - this.setState({ tooltip }); - }; + const layers = useCallback(() => { + // Support for layer factory + if (props.layers.some(l => typeof l === 'function')) { + return props.layers.map(l => + typeof l === 'function' ? l() : l, + ) as Layer[]; + } - render() { - const { children = null, height, width } = this.props; - const { viewState, tooltip } = this.state; + return props.layers as Layer[]; + }, [props.layers]); - const layers = this.layers(); + const { children = null, height, width } = props; return ( <> @@ -121,15 +110,15 @@ export class DeckGLContainer extends React.Component< controller width={width} height={height} - layers={layers} + layers={layers()} viewState={viewState} glOptions={{ preserveDrawingBuffer: true }} - onViewStateChange={this.onViewStateChange} + onViewStateChange={onViewStateChange} > {children} @@ -137,8 +126,8 @@ export class DeckGLContainer extends React.Component< ); - } -} + }), +); export const DeckGLContainerStyledWrapper = styled(DeckGLContainer)` .deckgl-tooltip > div { @@ -146,3 +135,7 @@ export const DeckGLContainerStyledWrapper = styled(DeckGLContainer)` text-overflow: ellipsis; } `; + +export type DeckGLContainerHandle = typeof DeckGLContainer & { + setTooltip: (tooltip: ReactNode) => void; +}; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx index 5cfa02f704561..540b094219cf4 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx @@ -19,7 +19,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { isEqual } from 'lodash'; import { Datasource, @@ -28,11 +28,12 @@ import { JsonValue, QueryFormData, SupersetClient, + usePrevious, } from '@superset-ui/core'; import { Layer } from 'deck.gl/typed'; import { - DeckGLContainer, + DeckGLContainerHandle, DeckGLContainerStyledWrapper, } from '../DeckGLContainer'; import { getExploreLongUrl } from '../utils/explore'; @@ -52,120 +53,97 @@ export type DeckMultiProps = { onSelect: () => void; }; -export type DeckMultiState = { - subSlicesLayers: Record; - viewport?: Viewport; -}; - -class DeckMulti extends React.PureComponent { - containerRef = React.createRef(); - - constructor(props: DeckMultiProps) { - super(props); - this.state = { subSlicesLayers: {} }; - this.onViewportChange = this.onViewportChange.bind(this); - } - - componentDidMount() { - const { formData, payload } = this.props; - this.loadLayers(formData, payload); - } - - UNSAFE_componentWillReceiveProps(nextProps: DeckMultiProps) { - const { formData, payload } = nextProps; - const hasChanges = !isEqual( - this.props.formData.deck_slices, - nextProps.formData.deck_slices, - ); - if (hasChanges) { - this.loadLayers(formData, payload); - } - } - - onViewportChange(viewport: Viewport) { - this.setState({ viewport }); - } - - loadLayers( - formData: QueryFormData, - payload: JsonObject, - viewport?: Viewport, - ) { - this.setState({ subSlicesLayers: {}, viewport }); - payload.data.slices.forEach( - (subslice: { slice_id: number } & JsonObject) => { - // Filters applied to multi_deck are passed down to underlying charts - // note that dashboard contextual information (filter_immune_slices and such) aren't - // taken into consideration here - const filters = [ - ...(subslice.form_data.filters || []), - ...(formData.filters || []), - ...(formData.extra_filters || []), - ]; - const subsliceCopy = { - ...subslice, - form_data: { - ...subslice.form_data, - filters, - }, - }; - - const url = getExploreLongUrl(subsliceCopy.form_data, 'json'); +const DeckMulti = (props: DeckMultiProps) => { + const containerRef = useRef(); - if (url) { - SupersetClient.get({ - endpoint: url, - }) - .then(({ json }) => { - const layer = layerGenerators[subsliceCopy.form_data.viz_type]( - subsliceCopy.form_data, - json, - this.props.onAddFilter, - this.setTooltip, - this.props.datasource, - [], - this.props.onSelect, - ); - this.setState({ - subSlicesLayers: { - ...this.state.subSlicesLayers, - [subsliceCopy.slice_id]: layer, - }, - }); - }) - .catch(() => {}); - } - }, - ); - } + const [viewport, setViewport] = useState(); + const [subSlicesLayers, setSubSlicesLayers] = useState>( + {}, + ); - setTooltip = (tooltip: TooltipProps['tooltip']) => { - const { current } = this.containerRef; + const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { + const { current } = containerRef; if (current) { current.setTooltip(tooltip); } - }; - - render() { - const { payload, formData, setControlValue, height, width } = this.props; - const { subSlicesLayers } = this.state; - - const layers = Object.values(subSlicesLayers); - - return ( - - ); - } -} + }, []); + + const loadLayers = useCallback( + (formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => { + setViewport(viewport); + setSubSlicesLayers({}); + payload.data.slices.forEach( + (subslice: { slice_id: number } & JsonObject) => { + // Filters applied to multi_deck are passed down to underlying charts + // note that dashboard contextual information (filter_immune_slices and such) aren't + // taken into consideration here + const filters = [ + ...(subslice.form_data.filters || []), + ...(formData.filters || []), + ...(formData.extra_filters || []), + ]; + const subsliceCopy = { + ...subslice, + form_data: { + ...subslice.form_data, + filters, + }, + }; + + const url = getExploreLongUrl(subsliceCopy.form_data, 'json'); + + if (url) { + SupersetClient.get({ + endpoint: url, + }) + .then(({ json }) => { + const layer = layerGenerators[subsliceCopy.form_data.viz_type]( + subsliceCopy.form_data, + json, + props.onAddFilter, + setTooltip, + props.datasource, + [], + props.onSelect, + ); + setSubSlicesLayers(subSlicesLayers => ({ + ...subSlicesLayers, + [subsliceCopy.slice_id]: layer, + })); + }) + .catch(() => {}); + } + }, + ); + }, + [props.datasource, props.onAddFilter, props.onSelect, setTooltip], + ); + + const prevDeckSlices = usePrevious(props.formData.deck_slices); + useEffect(() => { + const { formData, payload } = props; + const hasChanges = !isEqual(prevDeckSlices, formData.deck_slices); + if (hasChanges) { + loadLayers(formData, payload); + } + }, [loadLayers, prevDeckSlices, props]); + + const { payload, formData, setControlValue, height, width } = props; + const layers = Object.values(subSlicesLayers); + + return ( + + ); +}; -export default DeckMulti; +export default memo(DeckMulti); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/TooltipRow.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/TooltipRow.tsx index 9d72f719fe645..3e69258556ce1 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/TooltipRow.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/TooltipRow.tsx @@ -23,15 +23,11 @@ type TooltipRowProps = { value: string; }; -export default class TooltipRow extends React.PureComponent { - render() { - const { label, value } = this.props; +const TooltipRow = ({ label, value }: TooltipRowProps) => ( +
+ {label} + {value} +
+); - return ( -
- {label} - {value} -
- ); - } -} +export default TooltipRow; diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/factory.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/factory.tsx index 4ddde91247de8..fb1255a2fdc32 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/factory.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/factory.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { isEqual } from 'lodash'; import { Layer } from 'deck.gl/typed'; import { @@ -24,11 +24,12 @@ import { QueryFormData, JsonObject, HandlerFunction, + usePrevious, } from '@superset-ui/core'; import { DeckGLContainerStyledWrapper, - DeckGLContainer, + DeckGLContainerHandle, } from './DeckGLContainer'; import CategoricalDeckGLContainer from './CategoricalDeckGLContainer'; import fitViewport, { Viewport } from './utils/fitViewport'; @@ -57,91 +58,73 @@ export interface getLayerType { interface getPointsType { (data: JsonObject[]): Point[]; } -type deckGLComponentState = { - viewport: Viewport; - layer: Layer; -}; export function createDeckGLComponent( getLayer: getLayerType, getPoints: getPointsType, -): React.ComponentClass { +) { // Higher order component - class Component extends React.PureComponent< - deckGLComponentProps, - deckGLComponentState - > { - containerRef: React.RefObject = React.createRef(); - - constructor(props: deckGLComponentProps) { - super(props); - + return memo((props: deckGLComponentProps) => { + const containerRef = useRef(); + const prevFormData = usePrevious(props.formData); + const prevPayload = usePrevious(props.payload); + const getAdjustedViewport = () => { const { width, height, formData } = props; - let { viewport } = props; if (formData.autozoom) { - viewport = fitViewport(viewport, { + return fitViewport(props.viewport, { width, height, points: getPoints(props.payload.data.features), }) as Viewport; } + return props.viewport; + }; - this.state = { - viewport, - layer: this.computeLayer(props), - }; - this.onViewportChange = this.onViewportChange.bind(this); - } + const [viewport, setViewport] = useState(getAdjustedViewport()); - UNSAFE_componentWillReceiveProps(nextProps: deckGLComponentProps) { - // Only recompute the layer if anything BUT the viewport has changed - const nextFdNoVP = { ...nextProps.formData, viewport: null }; - const currFdNoVP = { ...this.props.formData, viewport: null }; - if ( - !isEqual(nextFdNoVP, currFdNoVP) || - nextProps.payload !== this.props.payload - ) { - this.setState({ layer: this.computeLayer(nextProps) }); + const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { + const { current } = containerRef; + if (current) { + current?.setTooltip(tooltip); } - } + }, []); - onViewportChange(viewport: Viewport) { - this.setState({ viewport }); - } + const computeLayer = useCallback( + (props: deckGLComponentProps) => { + const { formData, payload, onAddFilter } = props; - computeLayer(props: deckGLComponentProps) { - const { formData, payload, onAddFilter } = props; + return getLayer(formData, payload, onAddFilter, setTooltip) as Layer; + }, + [setTooltip], + ); - return getLayer(formData, payload, onAddFilter, this.setTooltip) as Layer; - } + const [layer, setLayer] = useState(computeLayer(props)); - setTooltip = (tooltip: TooltipProps['tooltip']) => { - const { current } = this.containerRef; - if (current) { - current?.setTooltip(tooltip); + useEffect(() => { + // Only recompute the layer if anything BUT the viewport has changed + const prevFdNoVP = { ...prevFormData, viewport: null }; + const currFdNoVP = { ...props.formData, viewport: null }; + if (!isEqual(prevFdNoVP, currFdNoVP) || prevPayload !== props.payload) { + setLayer(computeLayer(props)); } - }; + }, [computeLayer, prevFormData, prevPayload, props]); - render() { - const { formData, payload, setControlValue, height, width } = this.props; - const { layer, viewport } = this.state; + const { formData, payload, setControlValue, height, width } = props; - return ( - - ); - } - } - return Component; + return ( + + ); + }); } export function createCategoricalDeckGLComponent( diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx index 4aa827e45bea5..c8c9d4863ce9b 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Geojson/Geojson.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; import { GeoJsonLayer } from 'deck.gl/typed'; import geojsonExtent from '@mapbox/geojson-extent'; import { @@ -27,7 +27,7 @@ import { } from '@superset-ui/core'; import { - DeckGLContainer, + DeckGLContainerHandle, DeckGLContainerStyledWrapper, } from '../../DeckGLContainer'; import { hexToRGB } from '../../utils/colors'; @@ -164,21 +164,19 @@ export type DeckGLGeoJsonProps = { width: number; }; -class DeckGLGeoJson extends React.Component { - containerRef = React.createRef(); - - setTooltip = (tooltip: TooltipProps['tooltip']) => { - const { current } = this.containerRef; +const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => { + const containerRef = useRef(); + const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { + const { current } = containerRef; if (current) { current.setTooltip(tooltip); } - }; + }, []); - render() { - const { formData, payload, setControlValue, onAddFilter, height, width } = - this.props; + const { formData, payload, setControlValue, onAddFilter, height, width } = + props; - let { viewport } = this.props; + const viewport: Viewport = useMemo(() => { if (formData.autozoom) { const points = payload?.data?.features?.reduce?.( @@ -194,29 +192,36 @@ class DeckGLGeoJson extends React.Component { ) || []; if (points.length) { - viewport = fitViewport(viewport, { + return fitViewport(props.viewport, { width, height, points, }); } } + return props.viewport; + }, [ + formData.autozoom, + height, + payload?.data?.features, + props.viewport, + width, + ]); - const layer = getLayer(formData, payload, onAddFilter, this.setTooltip); - - return ( - - ); - } -} + const layer = getLayer(formData, payload, onAddFilter, setTooltip); + + return ( + + ); +}; -export default DeckGLGeoJson; +export default memo(DeckGLGeoJson); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx index 627125c398199..460c4a3b51969 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/Polygon.tsx @@ -21,7 +21,7 @@ */ /* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */ -import React from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { HandlerFunction, JsonObject, @@ -41,7 +41,7 @@ import sandboxedEval from '../../utils/sandbox'; import getPointsFromPolygon from '../../utils/getPointsFromPolygon'; import fitViewport, { Viewport } from '../../utils/fitViewport'; import { - DeckGLContainer, + DeckGLContainerHandle, DeckGLContainerStyledWrapper, } from '../../DeckGLContainer'; import { TooltipProps } from '../../components/Tooltip'; @@ -173,145 +173,134 @@ export type DeckGLPolygonProps = { height: number; }; -export type DeckGLPolygonState = { - lastClick: number; - viewport: Viewport; - formData: PolygonFormData; - selected: JsonObject[]; -}; - -class DeckGLPolygon extends React.PureComponent< - DeckGLPolygonProps, - DeckGLPolygonState -> { - containerRef = React.createRef(); - - constructor(props: DeckGLPolygonProps) { - super(props); - - this.state = DeckGLPolygon.getDerivedStateFromProps( - props, - ) as DeckGLPolygonState; - - this.getLayers = this.getLayers.bind(this); - this.onSelect = this.onSelect.bind(this); - } - - static getDerivedStateFromProps( - props: DeckGLPolygonProps, - state?: DeckGLPolygonState, - ) { - const { width, height, formData, payload } = props; - - // the state is computed only from the payload; if it hasn't changed, do - // not recompute state since this would reset selections and/or the play - // slider position due to changes in form controls - if (state && payload.form_data === state.formData) { - return null; - } - - const features = payload.data.features || []; +const DeckGLPolygon = (props: DeckGLPolygonProps) => { + const containerRef = useRef(); - let { viewport } = props; - if (formData.autozoom) { + const getAdjustedViewport = useCallback(() => { + let viewport = { ...props.viewport }; + if (props.formData.autozoom) { + const features = props.payload.data.features || []; viewport = fitViewport(viewport, { - width, - height, + width: props.width, + height: props.height, points: features.flatMap(getPointsFromPolygon), }); } + if (viewport.zoom < 0) { + viewport.zoom = 0; + } + return viewport; + }, [props]); + + const [lastClick, setLastClick] = useState(0); + const [viewport, setViewport] = useState(getAdjustedViewport()); + const [stateFormData, setStateFormData] = useState(props.payload.form_data); + const [selected, setSelected] = useState([]); + + useEffect(() => { + const { payload } = props; + + if (payload.form_data !== stateFormData) { + setViewport(getAdjustedViewport()); + setSelected([]); + setLastClick(0); + setStateFormData(payload.form_data); + } + }, [getAdjustedViewport, props, stateFormData, viewport]); - return { - viewport, - selected: [], - lastClick: 0, - formData: payload.form_data, - }; - } - - onSelect(polygon: JsonObject) { - const { formData, onAddFilter } = this.props; - - const now = new Date().getDate(); - const doubleClick = now - this.state.lastClick <= DOUBLE_CLICK_THRESHOLD; - - // toggle selected polygons - const selected = [...this.state.selected]; - if (doubleClick) { - selected.splice(0, selected.length, polygon); - } else if (formData.toggle_polygons) { - const i = selected.indexOf(polygon); - if (i === -1) { - selected.push(polygon); + const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { + const { current } = containerRef; + if (current) { + current.setTooltip(tooltip); + } + }, []); + + const onSelect = useCallback( + (polygon: JsonObject) => { + const { formData, onAddFilter } = props; + + const now = new Date().getDate(); + const doubleClick = now - lastClick <= DOUBLE_CLICK_THRESHOLD; + + // toggle selected polygons + const selectedCopy = [...selected]; + if (doubleClick) { + selectedCopy.splice(0, selectedCopy.length, polygon); + } else if (formData.toggle_polygons) { + const i = selectedCopy.indexOf(polygon); + if (i === -1) { + selectedCopy.push(polygon); + } else { + selectedCopy.splice(i, 1); + } } else { - selected.splice(i, 1); + selectedCopy.splice(0, 1, polygon); } - } else { - selected.splice(0, 1, polygon); - } - this.setState({ selected, lastClick: now }); - if (formData.table_filter) { - onAddFilter(formData.line_column, selected, false, true); - } - } + setSelected(selectedCopy); + setLastClick(now); + if (formData.table_filter) { + onAddFilter(formData.line_column, selected, false, true); + } + }, + [lastClick, props, selected], + ); - getLayers() { - if (this.props.payload.data.features === undefined) { + const getLayers = useCallback(() => { + if (props.payload.data.features === undefined) { return []; } const layer = getLayer( - this.props.formData, - this.props.payload, - this.props.onAddFilter, - this.setTooltip, - this.state.selected, - this.onSelect, + props.formData, + props.payload, + props.onAddFilter, + setTooltip, + selected, + onSelect, ); return [layer]; - } - - setTooltip = (tooltip: TooltipProps['tooltip']) => { - const { current } = this.containerRef; - if (current) { - current.setTooltip(tooltip); - } - }; - - render() { - const { payload, formData, setControlValue } = this.props; - - const fd = formData; - const metricLabel = fd.metric ? fd.metric.label || fd.metric : null; - const accessor = (d: JsonObject) => d[metricLabel]; - - const buckets = getBuckets(formData, payload.data.features, accessor); + }, [ + onSelect, + props.formData, + props.onAddFilter, + props.payload, + selected, + setTooltip, + ]); + + const { payload, formData, setControlValue } = props; + + const metricLabel = formData.metric + ? formData.metric.label || formData.metric + : null; + const accessor = (d: JsonObject) => d[metricLabel]; - return ( -
- + + + {formData.metric !== null && ( + + )} +
+ ); +}; - {formData.metric !== null && ( - - )} - - ); - } -} - -export default DeckGLPolygon; +export default memo(DeckGLPolygon); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx index 173770c6c1aac..7e47cc9530c04 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Screengrid/Screengrid.tsx @@ -20,7 +20,7 @@ */ /* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */ -import React from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { ScreenGridLayer } from 'deck.gl/typed'; import { JsonObject, JsonValue, QueryFormData, t } from '@superset-ui/core'; import { noop } from 'lodash'; @@ -30,7 +30,7 @@ import TooltipRow from '../../TooltipRow'; // eslint-disable-next-line import/extensions import fitViewport, { Viewport } from '../../utils/fitViewport'; import { - DeckGLContainer, + DeckGLContainerHandle, DeckGLContainerStyledWrapper, } from '../../DeckGLContainer'; import { TooltipProps } from '../../components/Tooltip'; @@ -99,93 +99,63 @@ export type DeckGLScreenGridProps = { onAddFilter: () => void; }; -export type DeckGLScreenGridState = { - viewport: Viewport; - formData: QueryFormData; -}; - -class DeckGLScreenGrid extends React.PureComponent< - DeckGLScreenGridProps, - DeckGLScreenGridState -> { - containerRef = React.createRef(); - - constructor(props: DeckGLScreenGridProps) { - super(props); - - this.state = DeckGLScreenGrid.getDerivedStateFromProps( - props, - ) as DeckGLScreenGridState; - - this.getLayers = this.getLayers.bind(this); - } - - static getDerivedStateFromProps( - props: DeckGLScreenGridProps, - state?: DeckGLScreenGridState, - ) { - // the state is computed only from the payload; if it hasn't changed, do - // not recompute state since this would reset selections and/or the play - // slider position due to changes in form controls - if (state && props.payload.form_data === state.formData) { - return null; - } +const DeckGLScreenGrid = (props: DeckGLScreenGridProps) => { + const containerRef = useRef(); + const getAdjustedViewport = useCallback(() => { const features = props.payload.data.features || []; const { width, height, formData } = props; - let { viewport } = props; if (formData.autozoom) { - viewport = fitViewport(viewport, { + return fitViewport(props.viewport, { width, height, points: getPoints(features), }); } + return props.viewport; + }, [props]); - return { - viewport, - formData: props.payload.form_data as QueryFormData, - }; - } + const [stateFormData, setStateFormData] = useState(props.payload.form_data); + const [viewport, setViewport] = useState(getAdjustedViewport()); - getLayers() { - const layer = getLayer( - this.props.formData, - this.props.payload, - noop, - this.setTooltip, - ); - - return [layer]; - } + useEffect(() => { + if (props.payload.form_data !== stateFormData) { + setViewport(getAdjustedViewport()); + setStateFormData(props.payload.form_data); + } + }, [getAdjustedViewport, props.payload.form_data, stateFormData]); - setTooltip = (tooltip: TooltipProps['tooltip']) => { - const { current } = this.containerRef; + const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => { + const { current } = containerRef; if (current) { current.setTooltip(tooltip); } - }; - - render() { - const { formData, payload, setControlValue } = this.props; - - return ( -
- -
- ); - } -} + }, []); + + const getLayers = useCallback(() => { + const layer = getLayer(props.formData, props.payload, noop, setTooltip); + + return [layer]; + }, [props.formData, props.payload, setTooltip]); + + const { formData, payload, setControlValue } = props; + + return ( +
+ +
+ ); +}; -export default DeckGLScreenGrid; +export default memo(DeckGLScreenGrid);