chore: Convert deckgl class components to functional (#25177)

This commit is contained in:
Kamil Gabryjelski
2023-09-07 13:28:09 +02:00
committed by GitHub
parent 749274e635
commit 09e9cb484b
8 changed files with 514 additions and 630 deletions

View File

@@ -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,113 +83,51 @@ export type CategoricalDeckGLContainerProps = {
setControlValue: (control: string, value: JsonValue) => void;
};
export type CategoricalDeckGLContainerState = {
formData?: QueryFormData;
viewport: Viewport;
categories: JsonObject;
};
const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
const containerRef = useRef<DeckGLContainerHandle>(null);
export default class CategoricalDeckGLContainer extends React.PureComponent<
CategoricalDeckGLContainerProps,
CategoricalDeckGLContainerState
> {
containerRef = React.createRef<DeckGLContainer>();
/*
* 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) });
}
}
// 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]);
return {
viewport,
selected: [],
lastClick: 0,
formData: props.payload.form_data,
categories,
};
}
const [categories, setCategories] = useState<JsonObject>(
getCategories(props.formData, props.payload.data.features || []),
);
const [stateFormData, setStateFormData] = useState<JsonObject>(
props.payload.form_data,
);
const [viewport, setViewport] = useState(getAdjustedViewport());
getLayers() {
const { getLayer, payload, formData: fd, onAddFilter } = this.props;
let features = payload.data.features ? [...payload.data.features] : [];
useEffect(() => {
if (props.payload.form_data !== stateFormData) {
const features = props.payload.data.features || [];
const categories = getCategories(props.formData, features);
// Add colors from categories or fixed color
features = this.addColor(features, fd);
// Apply user defined data mutator if defined
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
features = jsFnMutator(features);
setViewport(getAdjustedViewport());
setStateFormData(props.payload.form_data);
setCategories(categories);
}
}, [getAdjustedViewport, props, stateFormData]);
// Show only categories selected in the legend
const cats = this.state.categories;
if (fd.dimension) {
features = features.filter(d => cats[d.cat_color]?.enabled);
const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => {
const { current } = containerRef;
if (current) {
current.setTooltip(tooltip);
}
}, []);
const filteredPayload = {
...payload,
data: { ...payload.data, features },
};
return [
getLayer(
fd,
filteredPayload,
onAddFilter,
this.setTooltip,
this.props.datasource,
) as Layer,
];
}
// eslint-disable-next-line class-methods-use-this
addColor(data: JsonObject[], fd: QueryFormData) {
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);
@@ -203,67 +141,99 @@ export default class CategoricalDeckGLContainer extends React.PureComponent<
return d;
});
}
}, []);
toggleCategory(category: string) {
const categoryState = this.state.categories[category];
const categories = {
...this.state.categories,
[category]: {
...categoryState,
enabled: !categoryState.enabled,
},
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 = addColor(features, fd);
// Apply user defined data mutator if defined
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
features = jsFnMutator(features);
}
// Show only categories selected in the legend
if (fd.dimension) {
features = features.filter(d => categories[d.cat_color]?.enabled);
}
const filteredPayload = {
...payload,
data: { ...payload.data, features },
};
// 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;
return [
getLayer(
fd,
filteredPayload,
onAddFilter,
setTooltip,
props.datasource,
) as Layer,
];
}, [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;
});
}
setCategories(categoriesExtended);
},
[categories],
);
const showSingleCategory = useCallback(
(category: string) => {
const modifiedCategories = { ...categories };
Object.values(modifiedCategories).forEach(v => {
v.enabled = false;
});
}
this.setState({ categories });
}
modifiedCategories[category].enabled = true;
setCategories(modifiedCategories);
},
[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 });
}
return (
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={containerRef}
viewport={viewport}
layers={getLayers()}
setControlValue={props.setControlValue}
mapStyle={props.formData.mapbox_style}
mapboxApiAccessToken={props.mapboxApiKey}
width={props.width}
height={props.height}
/>
<Legend
forceCategorical
categories={categories}
format={props.formData.legend_format}
position={props.formData.legend_position}
showSingleCategory={showSingleCategory}
toggleCategory={toggleCategory}
/>
</div>
);
};
setTooltip = (tooltip: TooltipProps['tooltip']) => {
const { current } = this.containerRef;
if (current) {
current.setTooltip(tooltip);
}
};
render() {
return (
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={this.containerRef}
viewport={this.state.viewport}
layers={this.getLayers()}
setControlValue={this.props.setControlValue}
mapStyle={this.props.formData.mapbox_style}
mapboxApiAccessToken={this.props.mapboxApiKey}
width={this.props.width}
height={this.props.height}
/>
<Legend
forceCategorical
categories={this.state.categories}
format={this.props.formData.legend_format}
position={this.props.formData.legend_position}
showSingleCategory={this.showSingleCategory}
toggleCategory={this.toggleCategory}
/>
</div>
);
}
}
export default memo(CategoricalDeckGLContainer);

View File

@@ -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<typeof setInterval>;
};
export const DeckGLContainer = memo(
forwardRef((props: DeckGLContainerProps, ref) => {
const [tooltip, setTooltip] = useState<TooltipProps['tooltip']>(null);
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
const [viewState, setViewState] = useState(props.viewport);
const prevViewport = usePrevious(props.viewport);
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,
};
}
useImperativeHandle(ref, () => ({ setTooltip }), []);
UNSAFE_componentWillReceiveProps(nextProps: DeckGLContainerProps) {
if (!isEqual(nextProps.viewport, this.props.viewport)) {
this.setState({ viewState: nextProps.viewport });
}
}
componentWillUnmount() {
clearInterval(this.state.timer);
}
onViewStateChange({ viewState }: { viewState: JsonObject }) {
this.setState({ viewState: viewState as Viewport, lastUpdate: Date.now() });
}
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);
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);
}
this.setState({ lastUpdate: null });
}
}
}, [lastUpdate, props.setControlValue, viewState]);
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[];
}
useEffect(() => {
const timer = setInterval(tick, TICK);
return clearInterval(timer);
}, [tick]);
return this.props.layers as Layer[];
}
useEffect(() => {
if (!isEqual(props.viewport, prevViewport)) {
setViewState(props.viewport);
}
}, [prevViewport, props.viewport]);
setTooltip = (tooltip: TooltipProps['tooltip']) => {
this.setState({ tooltip });
};
const onViewStateChange = useCallback(
({ viewState }: { viewState: JsonObject }) => {
setViewState(viewState as Viewport);
setLastUpdate(Date.now());
},
[],
);
render() {
const { children = null, height, width } = this.props;
const { viewState, tooltip } = this.state;
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[];
}
const layers = this.layers();
return props.layers as Layer[];
}, [props.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}
>
<StaticMap
preserveDrawingBuffer
mapStyle={this.props.mapStyle || 'light'}
mapboxApiAccessToken={this.props.mapboxApiAccessToken}
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
</DeckGL>
{children}
@@ -137,8 +126,8 @@ export class DeckGLContainer extends React.Component<
<Tooltip tooltip={tooltip} />
</>
);
}
}
}),
);
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;
};

View File

@@ -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<number, Layer>;
viewport?: Viewport;
};
const DeckMulti = (props: DeckMultiProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
class DeckMulti extends React.PureComponent<DeckMultiProps, DeckMultiState> {
containerRef = React.createRef<DeckGLContainer>();
const [viewport, setViewport] = useState<Viewport>();
const [subSlicesLayers, setSubSlicesLayers] = useState<Record<number, Layer>>(
{},
);
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');
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(() => {});
}
},
);
}
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 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 layers = Object.values(subSlicesLayers);
const url = getExploreLongUrl(subsliceCopy.form_data, 'json');
return (
<DeckGLContainerStyledWrapper
ref={this.containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={this.state.viewport || this.props.viewport}
layers={layers}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
onViewportChange={this.onViewportChange}
height={height}
width={width}
/>
);
}
}
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],
);
export default DeckMulti;
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 (
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport || props.viewport}
layers={layers}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
onViewportChange={setViewport}
height={height}
width={width}
/>
);
};
export default memo(DeckMulti);

View File

@@ -23,15 +23,11 @@ type TooltipRowProps = {
value: string;
};
export default class TooltipRow extends React.PureComponent<TooltipRowProps> {
render() {
const { label, value } = this.props;
const TooltipRow = ({ label, value }: TooltipRowProps) => (
<div>
{label}
<strong>{value}</strong>
</div>
);
return (
<div>
{label}
<strong>{value}</strong>
</div>
);
}
}
export default TooltipRow;

View File

@@ -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<T> {
interface getPointsType {
(data: JsonObject[]): Point[];
}
type deckGLComponentState = {
viewport: Viewport;
layer: Layer;
};
export function createDeckGLComponent(
getLayer: getLayerType<unknown>,
getPoints: getPointsType,
): React.ComponentClass<deckGLComponentProps> {
) {
// Higher order component
class Component extends React.PureComponent<
deckGLComponentProps,
deckGLComponentState
> {
containerRef: React.RefObject<DeckGLContainer> = React.createRef();
constructor(props: deckGLComponentProps) {
super(props);
return memo((props: deckGLComponentProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
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) });
}
}
onViewportChange(viewport: Viewport) {
this.setState({ viewport });
}
computeLayer(props: deckGLComponentProps) {
const { formData, payload, onAddFilter } = props;
return getLayer(formData, payload, onAddFilter, this.setTooltip) as Layer;
}
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, height, width } = this.props;
const { layer, viewport } = this.state;
const computeLayer = useCallback(
(props: deckGLComponentProps) => {
const { formData, payload, onAddFilter } = props;
return (
<DeckGLContainerStyledWrapper
ref={this.containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
width={width}
height={height}
onViewportChange={this.onViewportChange}
/>
);
}
}
return Component;
return getLayer(formData, payload, onAddFilter, setTooltip) as Layer;
},
[setTooltip],
);
const [layer, setLayer] = useState(computeLayer(props));
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]);
const { formData, payload, setControlValue, height, width } = props;
return (
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
width={width}
height={height}
onViewportChange={setViewport}
/>
);
});
}
export function createCategoricalDeckGLComponent(

View File

@@ -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<DeckGLGeoJsonProps> {
containerRef = React.createRef<DeckGLContainer>();
setTooltip = (tooltip: TooltipProps['tooltip']) => {
const { current } = this.containerRef;
const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
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<DeckGLGeoJsonProps> {
) || [];
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);
const layer = getLayer(formData, payload, onAddFilter, setTooltip);
return (
<DeckGLContainerStyledWrapper
ref={this.containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
height={height}
width={width}
/>
);
}
}
return (
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
height={height}
width={width}
/>
);
};
export default DeckGLGeoJson;
export default memo(DeckGLGeoJson);

View File

@@ -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[];
};
const DeckGLPolygon = (props: DeckGLPolygonProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
class DeckGLPolygon extends React.PureComponent<
DeckGLPolygonProps,
DeckGLPolygonState
> {
containerRef = React.createRef<DeckGLContainer>();
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 || [];
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]);
return {
viewport,
selected: [],
lastClick: 0,
formData: payload.form_data,
};
}
const [lastClick, setLastClick] = useState(0);
const [viewport, setViewport] = useState(getAdjustedViewport());
const [stateFormData, setStateFormData] = useState(props.payload.form_data);
const [selected, setSelected] = useState<JsonObject[]>([]);
onSelect(polygon: JsonObject) {
const { formData, onAddFilter } = this.props;
useEffect(() => {
const { payload } = props;
const now = new Date().getDate();
const doubleClick = now - this.state.lastClick <= DOUBLE_CLICK_THRESHOLD;
if (payload.form_data !== stateFormData) {
setViewport(getAdjustedViewport());
setSelected([]);
setLastClick(0);
setStateFormData(payload.form_data);
}
}, [getAdjustedViewport, props, stateFormData, viewport]);
// 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];
}
}, [
onSelect,
props.formData,
props.onAddFilter,
props.payload,
selected,
setTooltip,
]);
setTooltip = (tooltip: TooltipProps['tooltip']) => {
const { current } = this.containerRef;
if (current) {
current.setTooltip(tooltip);
}
};
const { payload, formData, setControlValue } = props;
render() {
const { payload, formData, setControlValue } = this.props;
const metricLabel = formData.metric
? formData.metric.label || formData.metric
: null;
const accessor = (d: JsonObject) => d[metricLabel];
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);
const buckets = getBuckets(formData, payload.data.features, accessor);
return (
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={containerRef}
viewport={viewport}
layers={getLayers()}
setControlValue={setControlValue}
mapStyle={formData.mapbox_style}
mapboxApiAccessToken={payload.data.mapboxApiKey}
width={props.width}
height={props.height}
/>
return (
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={this.containerRef}
viewport={this.state.viewport}
layers={this.getLayers()}
setControlValue={setControlValue}
mapStyle={formData.mapbox_style}
mapboxApiAccessToken={payload.data.mapboxApiKey}
width={this.props.width}
height={this.props.height}
{formData.metric !== null && (
<Legend
categories={buckets}
position={formData.legend_position}
format={formData.legend_format}
/>
)}
</div>
);
};
{formData.metric !== null && (
<Legend
categories={buckets}
position={formData.legend_position}
format={formData.legend_format}
/>
)}
</div>
);
}
}
export default DeckGLPolygon;
export default memo(DeckGLPolygon);

View File

@@ -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<DeckGLContainer>();
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<DeckGLContainerHandle>();
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,
);
useEffect(() => {
if (props.payload.form_data !== stateFormData) {
setViewport(getAdjustedViewport());
setStateFormData(props.payload.form_data);
}
}, [getAdjustedViewport, props.payload.form_data, stateFormData]);
return [layer];
}
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;
const getLayers = useCallback(() => {
const layer = getLayer(props.formData, props.payload, noop, setTooltip);
return (
<div>
<DeckGLContainerStyledWrapper
ref={this.containerRef}
viewport={this.state.viewport}
layers={this.getLayers()}
setControlValue={setControlValue}
mapStyle={formData.mapbox_style}
mapboxApiAccessToken={payload.data.mapboxApiKey}
width={this.props.width}
height={this.props.height}
/>
</div>
);
}
}
return [layer];
}, [props.formData, props.payload, setTooltip]);
export default DeckGLScreenGrid;
const { formData, payload, setControlValue } = props;
return (
<div>
<DeckGLContainerStyledWrapper
ref={containerRef}
viewport={viewport}
layers={getLayers()}
setControlValue={setControlValue}
mapStyle={formData.mapbox_style}
mapboxApiAccessToken={payload.data.mapboxApiKey}
width={props.width}
height={props.height}
/>
</div>
);
};
export default memo(DeckGLScreenGrid);