feat(deckgl): add cross-filters to deck.gl charts (#33789)

This commit is contained in:
Damian Pendrak
2025-07-07 15:23:27 +02:00
committed by GitHub
parent 6adfd33e3a
commit 0fc4119728
34 changed files with 1558 additions and 276 deletions

View File

@@ -14976,6 +14976,13 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/ngeohash": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz",
"integrity": "sha512-A90x3HMwE1yXbWCnd0ztHzv8rAQPjwTzX2diYI/6OrWm/3oairDaehw5WPWJFgZ+8+J/OuF99IbipmMa2le6tQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz",
@@ -38972,6 +38979,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/ngeohash": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
"license": "MIT",
"engines": {
"node": ">=v0.2.0"
}
},
"node_modules/nise": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz",
@@ -59828,6 +59844,7 @@
"d3-scale": "^3.0.0",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",
"underscore": "^1.13.7",
"urijs": "^1.19.11",
@@ -59835,6 +59852,7 @@
},
"devDependencies": {
"@types/mapbox__geojson-extent": "^1.0.3",
"@types/ngeohash": "^0.6.8",
"@types/underscore": "^1.13.0",
"@types/urijs": "^1.19.25"
},

View File

@@ -55,7 +55,11 @@ export enum AppSection {
Embedded = 'EMBEDDED',
}
export type FilterState = { value?: any; [key: string]: any };
export type FilterState = {
value?: any;
customColumnLabel?: string;
[key: string]: any;
};
export type DataMask = {
extraFormData?: ExtraFormData;

View File

@@ -38,6 +38,7 @@
"d3-scale": "^3.0.0",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",
"underscore": "^1.13.7",
"urijs": "^1.19.11",
@@ -45,6 +46,7 @@
},
"devDependencies": {
"@types/mapbox__geojson-extent": "^1.0.3",
"@types/ngeohash": "^0.6.8",
"@types/underscore": "^1.13.0",
"@types/urijs": "^1.19.25"
},

View File

@@ -28,10 +28,12 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
CategoricalColorNamespace,
Datasource,
FilterState,
HandlerFunction,
JsonObject,
JsonValue,
QueryFormData,
SetDataMaskHook,
} from '@superset-ui/core';
import type { Layer } from '@deck.gl/core';
import Legend from './components/Legend';
@@ -44,7 +46,7 @@ import {
DeckGLContainerStyledWrapper,
} from './DeckGLContainer';
import { Point } from './types';
import { getLayerType } from './factory';
import { GetLayerType } from './factory';
import { TooltipProps } from './components/Tooltip';
const { getScale } = CategoricalColorNamespace;
@@ -78,10 +80,14 @@ export type CategoricalDeckGLContainerProps = {
height: number;
width: number;
viewport: Viewport;
getLayer: getLayerType<unknown>;
getLayer: GetLayerType<unknown>;
payload: JsonObject;
onAddFilter?: HandlerFunction;
setControlValue: (control: string, value: JsonValue) => void;
filterState: FilterState;
setDataMask: SetDataMaskHook;
onContextMenu: HandlerFunction;
emitCrossFilters: boolean;
};
const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
@@ -146,7 +152,16 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
}, []);
const getLayers = useCallback(() => {
const { getLayer, payload, formData: fd, onAddFilter } = props;
const {
getLayer,
payload,
formData: fd,
onAddFilter,
onContextMenu,
filterState,
setDataMask,
emitCrossFilters,
} = props;
let features = payload.data.features ? [...payload.data.features] : [];
// Add colors from categories or fixed color
@@ -169,13 +184,17 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
};
return [
getLayer(
fd,
filteredPayload,
getLayer({
formData: fd,
payload: filteredPayload,
onAddFilter,
setTooltip,
props.datasource,
) as Layer,
datasource: props.datasource,
onContextMenu,
filterState,
setDataMask,
emitCrossFilters,
}) as Layer,
];
}, [addColor, categories, props, setTooltip]);

View File

@@ -24,10 +24,12 @@ import {
forwardRef,
memo,
ReactNode,
MouseEvent,
useCallback,
useEffect,
useImperativeHandle,
useState,
useRef,
} from 'react';
import { isEqual } from 'lodash';
import { StaticMap } from 'react-map-gl';
@@ -58,6 +60,14 @@ export const DeckGLContainer = memo(
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
const [viewState, setViewState] = useState(props.viewport);
const prevViewport = usePrevious(props.viewport);
const glContextRef = useRef<WebGL2RenderingContext | null>(null);
useEffect(
() => () => {
glContextRef.current?.getExtension('WEBGL_lose_context')?.loseContext();
},
[],
);
useImperativeHandle(ref, () => ({ setTooltip }), []);
@@ -106,7 +116,13 @@ export const DeckGLContainer = memo(
return (
<>
<div style={{ position: 'relative', width, height }}>
<div
style={{ position: 'relative', width, height }}
onContextMenu={(e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DeckGL
controller
width={width}
@@ -114,6 +130,9 @@ export const DeckGLContainer = memo(
layers={layers()}
viewState={viewState}
onViewStateChange={onViewStateChange}
onAfterRender={context => {
glContextRef.current = context.gl;
}}
>
<StaticMap
preserveDrawingBuffer

View File

@@ -25,6 +25,11 @@ import {
JsonObject,
HandlerFunction,
usePrevious,
SetDataMaskHook,
DataMask,
FilterState,
JsonValue,
ContextMenuFilters,
} from '@superset-ui/core';
import {
@@ -36,37 +41,56 @@ import fitViewport, { Viewport } from './utils/fitViewport';
import { Point } from './types';
import { TooltipProps } from './components/Tooltip';
type deckGLComponentProps = {
type DeckGLComponentProps = {
datasource: Datasource;
formData: QueryFormData;
height: number;
onAddFilter: HandlerFunction;
onContextMenu: HandlerFunction;
payload: JsonObject;
setControlValue: () => void;
viewport: Viewport;
width: number;
filterState: FilterState;
setDataMask: SetDataMaskHook;
emitCrossFilters: boolean;
};
export interface getLayerType<T> {
(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: HandlerFunction | undefined,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
datasource?: Datasource,
): T;
export interface GetLayerTypeParams {
formData: QueryFormData;
payload: JsonObject;
onAddFilter?: HandlerFunction;
setTooltip: (tooltip: TooltipProps['tooltip']) => void;
setDataMask?: (dataMask: DataMask) => void;
onContextMenu?: (
clientX: number,
clientY: number,
filters?: ContextMenuFilters,
) => void;
datasource?: Datasource;
filterState?: FilterState;
selected?: JsonObject[];
onSelect?: (value: JsonValue) => void;
emitCrossFilters?: boolean;
}
interface getPointsType {
export interface GetLayerType<T> {
(params: GetLayerTypeParams): T;
}
interface GetPointsType {
(data: JsonObject[]): Point[];
}
export function createDeckGLComponent(
getLayer: getLayerType<unknown>,
getPoints: getPointsType,
getLayer: GetLayerType<unknown>,
getPoints: GetPointsType,
) {
// Higher order component
return memo((props: deckGLComponentProps) => {
return memo((props: DeckGLComponentProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
const prevFormData = usePrevious(props.formData);
const prevFilterState = usePrevious(props.filterState);
const prevPayload = usePrevious(props.payload);
const getAdjustedViewport = () => {
const { width, height, formData } = props;
@@ -90,10 +114,27 @@ export function createDeckGLComponent(
}, []);
const computeLayer = useCallback(
(props: deckGLComponentProps) => {
const { formData, payload, onAddFilter } = props;
(props: DeckGLComponentProps) => {
const {
formData,
payload,
onAddFilter,
filterState,
setDataMask,
onContextMenu,
emitCrossFilters,
} = props;
return getLayer(formData, payload, onAddFilter, setTooltip) as Layer;
return getLayer({
formData,
payload,
onAddFilter,
setTooltip,
setDataMask,
onContextMenu,
filterState,
emitCrossFilters,
}) as Layer;
},
[setTooltip],
);
@@ -102,12 +143,20 @@ export function createDeckGLComponent(
useEffect(() => {
// Only recompute the layer if anything BUT the viewport has changed
const prevFdNoVP = { ...prevFormData, viewport: null };
const currFdNoVP = { ...props.formData, viewport: null };
const prevFdNoVP = {
...prevFormData,
...prevFilterState,
viewport: null,
};
const currFdNoVP = {
...props.formData,
...props.filterState,
viewport: null,
};
if (!isEqual(prevFdNoVP, currFdNoVP) || prevPayload !== props.payload) {
setLayer(computeLayer(props));
}
}, [computeLayer, prevFormData, prevPayload, props]);
}, [computeLayer, prevFormData, prevFilterState, prevPayload, props]);
const { formData, payload, setControlValue, height, width } = props;
@@ -128,10 +177,10 @@ export function createDeckGLComponent(
}
export function createCategoricalDeckGLComponent(
getLayer: getLayerType<Layer>,
getPoints: getPointsType,
getLayer: GetLayerType<Layer>,
getPoints: GetPointsType,
) {
return function Component(props: deckGLComponentProps) {
return function Component(props: DeckGLComponentProps) {
const {
datasource,
formData,
@@ -140,6 +189,10 @@ export function createCategoricalDeckGLComponent(
setControlValue,
viewport,
width,
setDataMask,
filterState,
onContextMenu,
emitCrossFilters,
} = props;
return (
@@ -154,6 +207,10 @@ export function createCategoricalDeckGLComponent(
getPoints={getPoints}
width={width}
height={height}
setDataMask={setDataMask}
onContextMenu={onContextMenu}
filterState={filterState}
emitCrossFilters={emitCrossFilters}
/>
);
};

View File

@@ -17,16 +17,10 @@
* under the License.
*/
import { ArcLayer } from '@deck.gl/layers';
import {
HandlerFunction,
JsonObject,
QueryFormData,
t,
} from '@superset-ui/core';
import { JsonObject, QueryFormData, t } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import { createCategoricalDeckGLComponent } from '../../factory';
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
export function getPoints(data: JsonObject[]) {
@@ -60,12 +54,16 @@ function setTooltipContent(formData: QueryFormData) {
);
}
export function getLayer(
fd: QueryFormData,
payload: JsonObject,
onAddFilter: HandlerFunction,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
export const getLayer: GetLayerType<ArcLayer> = function ({
formData,
payload,
setTooltip,
filterState,
setDataMask,
onContextMenu,
emitCrossFilters,
}) {
const fd = formData;
const data = payload.data.features;
const sc = fd.color_picker;
const tc = fd.target_color_picker;
@@ -78,8 +76,16 @@ export function getLayer(
d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
id: `path-layer-${fd.slice_id}` as const,
getWidth: fd.stroke_width ? fd.stroke_width : 3,
...commonLayerProps(fd, setTooltip, setTooltipContent(fd)),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent: setTooltipContent(fd),
onContextMenu,
setDataMask,
filterState,
emitCrossFilters,
}),
});
}
};
export default createCategoricalDeckGLComponent(getLayer, getPoints);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -25,6 +25,11 @@ import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
category: t('Map'),
credits: ['https://uber.github.io/deck.gl'],
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillBy,
Behavior.DrillToDetail,
],
description: t(
'Plot the distance (like flight paths) between origin and destination.',
),

View File

@@ -21,7 +21,7 @@ import { Position } from '@deck.gl/core';
import { t } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { createDeckGLComponent, getLayerType } from '../../factory';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import { ColorType } from '../../types';
import TooltipRow from '../../TooltipRow';
@@ -39,12 +39,15 @@ function setTooltipContent(o: any) {
</div>
);
}
export const getLayer: getLayerType<unknown> = function (
export const getLayer: GetLayerType<ContourLayer> = function ({
formData,
payload,
onAddFilter,
filterState,
setDataMask,
onContextMenu,
setTooltip,
) {
emitCrossFilters,
}) {
const fd = formData;
const {
aggregation = 'SUM',
@@ -93,7 +96,15 @@ export const getLayer: getLayerType<unknown> = function (
getPosition: (d: { position: number[]; weight: number }) =>
d.position as Position,
getWeight: (d: { weight: number }) => d.weight || 0,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
onContextMenu,
setDataMask,
filterState,
emitCrossFilters,
}),
});
};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import transformProps from '../../transformProps';
import controlPanel from './controlPanel';
import thumbnail from './images/thumbnail.png';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
thumbnail,
useLegacyApi: true,
tags: [t('deckGL'), t('Spatial'), t('Comparison')],
behaviors: [Behavior.InteractiveChart],
});
export default class ContourChartPlugin extends ChartPlugin {

View File

@@ -23,10 +23,12 @@ import { GeoJsonLayer } from '@deck.gl/layers';
import { Feature, Geometry, GeoJsonProperties } from 'geojson';
import geojsonExtent from '@mapbox/geojson-extent';
import {
FilterState,
HandlerFunction,
JsonObject,
JsonValue,
QueryFormData,
SetDataMaskHook,
} from '@superset-ui/core';
import {
@@ -40,6 +42,7 @@ import TooltipRow from '../../TooltipRow';
import fitViewport, { Viewport } from '../../utils/fitViewport';
import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
import { GetLayerType } from '../../factory';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -119,12 +122,15 @@ function setTooltipContent(o: JsonObject) {
const getFillColor = (feature: JsonObject) => feature?.properties?.fillColor;
const getLineColor = (feature: JsonObject) => feature?.properties?.strokeColor;
export function getLayer(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: HandlerFunction,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
filterState,
setDataMask,
payload,
setTooltip,
emitCrossFilters,
}) {
const fd = formData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
@@ -159,9 +165,17 @@ export function getLayer(
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
lineWidthUnits: fd.line_width_unit,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setDataMask,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
}
};
export type DeckGLGeoJsonProps = {
formData: QueryFormData;
@@ -171,6 +185,9 @@ export type DeckGLGeoJsonProps = {
onAddFilter: HandlerFunction;
height: number;
width: number;
filterState: FilterState;
onContextMenu: HandlerFunction;
setDataMask: SetDataMaskHook;
};
export function getPoints(data: Point[]) {
@@ -217,7 +234,15 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
width,
]);
const layer = getLayer(formData, payload, onAddFilter, setTooltip);
const layer = getLayer({
onContextMenu: props.onContextMenu,
filterState: props.filterState,
setDataMask: props.setDataMask,
setTooltip,
onAddFilter,
payload,
formData,
});
return (
<DeckGLContainerStyledWrapper

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
thumbnail,
useLegacyApi: true,
tags: [t('deckGL'), t('2D')],
behaviors: [Behavior.InteractiveChart],
});
export default class GeojsonChartPlugin extends ChartPlugin {

View File

@@ -18,19 +18,13 @@
*/
import { Color } from '@deck.gl/core';
import { GridLayer } from '@deck.gl/aggregation-layers';
import {
t,
CategoricalColorNamespace,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
import { commonLayerProps, getAggFunc } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { createDeckGLComponent } from '../../factory';
import { createDeckGLComponent, GetLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { TooltipProps } from '../../components/Tooltip';
function setTooltipContent(o: JsonObject) {
return (
@@ -49,12 +43,15 @@ function setTooltipContent(o: JsonObject) {
);
}
export function getLayer(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: () => void,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
export const getLayer: GetLayerType<GridLayer> = function ({
formData,
payload,
setTooltip,
setDataMask,
onContextMenu,
filterState,
emitCrossFilters,
}) {
const fd = formData;
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
@@ -82,9 +79,17 @@ export function getLayer(
getElevationValue: aggFunc,
// @ts-ignore
getColorValue: aggFunc,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setDataMask,
setTooltip,
setTooltipContent,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
}
};
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
exampleGallery: [{ url: example }],
useLegacyApi: true,
tags: [t('deckGL'), t('3D'), t('Comparison')],
behaviors: [Behavior.InteractiveChart],
});
export default class GridChartPlugin extends ChartPlugin {

View File

@@ -22,7 +22,7 @@ import { t, getSequentialSchemeRegistry, JsonObject } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { createDeckGLComponent, getLayerType } from '../../factory';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
function setTooltipContent(o: JsonObject) {
@@ -35,12 +35,15 @@ function setTooltipContent(o: JsonObject) {
</div>
);
}
export const getLayer: getLayerType<unknown> = (
export const getLayer: GetLayerType<HeatmapLayer> = ({
formData,
payload,
onAddFilter,
onContextMenu,
filterState,
setDataMask,
setTooltip,
) => {
payload,
emitCrossFilters,
}) => {
const fd = formData;
const {
intensity = 1,
@@ -75,7 +78,15 @@ export const getLayer: getLayerType<unknown> = (
getPosition: (d: { position: Position; weight: number }) => d.position,
getWeight: (d: { position: number[]; weight: number }) =>
d.weight ? d.weight : 1,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setDataMask,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import transformProps from '../../transformProps';
import controlPanel from './controlPanel';
import thumbnail from './images/thumbnail.png';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
thumbnail,
useLegacyApi: true,
tags: [t('deckGL'), t('Spatial'), t('Comparison')],
behaviors: [Behavior.InteractiveChart],
});
export default class HeatmapChartPlugin extends ChartPlugin {

View File

@@ -18,19 +18,13 @@
*/
import { Color } from '@deck.gl/core';
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import {
t,
CategoricalColorNamespace,
QueryFormData,
JsonObject,
} from '@superset-ui/core';
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
import { commonLayerProps, getAggFunc } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { hexToRGB } from '../../utils/colors';
import { createDeckGLComponent } from '../../factory';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { TooltipProps } from '../../components/Tooltip';
function setTooltipContent(o: JsonObject) {
return (
@@ -48,12 +42,15 @@ function setTooltipContent(o: JsonObject) {
);
}
export function getLayer(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: () => void,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
export const getLayer: GetLayerType<HexagonLayer> = function ({
formData,
payload,
setTooltip,
onContextMenu,
filterState,
setDataMask,
emitCrossFilters,
}) {
const fd = formData;
const appliedScheme = fd.color_scheme;
const colorScale = CategoricalColorNamespace.getScale(appliedScheme);
@@ -80,9 +77,17 @@ export function getLayer(
getElevationValue: aggFunc,
// @ts-ignore
getColorValue: aggFunc,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setDataMask,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
}
};
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
thumbnail,
useLegacyApi: true,
tags: [t('deckGL'), t('3D'), t('Geo'), t('Comparison')],
behaviors: [Behavior.InteractiveChart],
});
export default class HexChartPlugin extends ChartPlugin {

View File

@@ -18,12 +18,11 @@
* under the License.
*/
import { PathLayer } from '@deck.gl/layers';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { JsonObject } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { createDeckGLComponent } from '../../factory';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
function setTooltipContent(o: JsonObject) {
@@ -42,12 +41,15 @@ function setTooltipContent(o: JsonObject) {
);
}
export function getLayer(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: () => void,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
export const getLayer: GetLayerType<PathLayer> = function ({
formData,
payload,
onContextMenu,
filterState,
setDataMask,
setTooltip,
emitCrossFilters,
}) {
const fd = formData;
const c = fd.color_picker;
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
@@ -72,9 +74,17 @@ export function getLayer(
rounded: true,
widthScale: 1,
widthUnits: fd.line_width_unit,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setDataMask,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
}
};
export function getPoints(data: JsonObject[]) {
let points: Point[] = [];

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -31,6 +31,7 @@ const metadata = new ChartMetadata({
exampleGallery: [{ url: example }],
useLegacyApi: true,
tags: [t('deckGL'), t('Web')],
behaviors: [Behavior.InteractiveChart],
});
export default class PathChartPlugin extends ChartPlugin {

View File

@@ -23,10 +23,14 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
ContextMenuFilters,
ensureIsArray,
FilterState,
HandlerFunction,
JsonObject,
JsonValue,
QueryFormData,
SetDataMaskHook,
t,
} from '@superset-ui/core';
@@ -45,6 +49,7 @@ import {
DeckGLContainerStyledWrapper,
} from '../../DeckGLContainer';
import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType } from '../../factory';
const DOUBLE_CLICK_THRESHOLD = 250; // milliseconds
@@ -90,15 +95,18 @@ function setTooltipContent(formData: PolygonFormData) {
};
}
export function getLayer(
formData: PolygonFormData,
payload: JsonObject,
onAddFilter: HandlerFunction,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
selected: JsonObject[],
onSelect: (value: JsonValue) => void,
) {
const fd = formData;
export const getLayer: GetLayerType<PolygonLayer> = function ({
formData,
payload,
setTooltip,
filterState,
setDataMask,
onContextMenu,
onSelect,
selected,
emitCrossFilters,
}) {
const fd = formData as PolygonFormData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
let data = [...payload.data.features];
@@ -125,7 +133,7 @@ export function getLayer(
number,
number,
]) || [0, 0, 0, 0];
if (selected.length > 0 && !selected.includes(d[fd.line_column])) {
if (!ensureIsArray(selected).includes(d[fd.line_column])) {
baseColor[3] /= 2;
}
@@ -153,9 +161,18 @@ export function getLayer(
getElevation: (d: any) => getElevation(d, colorScaler),
elevationScale: fd.multiplier,
fp64: true,
...commonLayerProps(fd, setTooltip, tooltipContentGenerator, onSelect),
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent: tooltipContentGenerator,
onSelect,
filterState,
onContextMenu,
setDataMask,
emitCrossFilters,
}),
});
}
};
export type PolygonFormData = QueryFormData & {
break_points: string[];
@@ -171,6 +188,14 @@ export type DeckGLPolygonProps = {
onAddFilter: HandlerFunction;
width: number;
height: number;
onContextMenu?: (
clientX: number,
clientY: number,
filters?: ContextMenuFilters,
) => void;
setDataMask?: SetDataMaskHook;
filterState?: FilterState;
emitCrossFilters?: boolean;
};
export function getPoints(data: JsonObject[]) {
@@ -251,28 +276,35 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
);
const getLayers = useCallback(() => {
const {
formData,
payload,
onAddFilter,
onContextMenu,
setDataMask,
filterState,
emitCrossFilters,
} = props;
if (props.payload.data.features === undefined) {
return [];
}
const layer = getLayer(
props.formData,
props.payload,
props.onAddFilter,
const layer = getLayer({
formData,
payload,
onAddFilter,
setTooltip,
selected,
onSelect,
);
onContextMenu,
setDataMask,
filterState,
emitCrossFilters,
});
return [layer];
}, [
onSelect,
props.formData,
props.onAddFilter,
props.payload,
selected,
setTooltip,
]);
}, [onSelect, selected, setTooltip, props]);
const { payload, formData, setControlValue } = props;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
exampleGallery: [{ url: example }],
useLegacyApi: true,
tags: [t('deckGL'), t('3D'), t('Multi-Dimensions'), t('Geo')],
behaviors: [Behavior.InteractiveChart],
});
export default class PolygonChartPlugin extends ChartPlugin {

View File

@@ -18,17 +18,15 @@
*/
import { ScatterplotLayer } from '@deck.gl/layers';
import {
Datasource,
getMetricLabel,
JsonObject,
QueryFormData,
t,
} from '@superset-ui/core';
import { commonLayerProps } from '../common';
import { createCategoricalDeckGLComponent } from '../../factory';
import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { unitToRadius } from '../../utils/geo';
import { TooltipProps } from '../../components/Tooltip';
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
@@ -64,13 +62,16 @@ function setTooltipContent(
};
}
export function getLayer(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: () => void,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
datasource: Datasource,
) {
export const getLayer: GetLayerType<ScatterplotLayer> = function ({
formData,
payload,
setTooltip,
setDataMask,
filterState,
onContextMenu,
datasource,
emitCrossFilters,
}) {
const fd = formData;
const dataWithRadius = payload.data.features.map((d: JsonObject) => {
let radius = unitToRadius(fd.point_unit, d.radius) || 10;
@@ -95,12 +96,16 @@ export function getLayer(
radiusMinPixels: Number(fd.min_radius) || undefined,
radiusMaxPixels: Number(fd.max_radius) || undefined,
stroked: false,
...commonLayerProps(
fd,
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent(fd, datasource?.verboseMap),
),
setTooltipContent: setTooltipContent(fd, datasource?.verboseMap),
setDataMask,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
}
};
export default createCategoricalDeckGLComponent(getLayer, getPoints);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -41,6 +41,7 @@ const metadata = new ChartMetadata({
t('Intensity'),
t('Density'),
],
behaviors: [Behavior.InteractiveChart],
});
export default class ScatterChartPlugin extends ChartPlugin {

View File

@@ -18,21 +18,13 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
import { JsonObject, JsonValue, QueryFormData, t } from '@superset-ui/core';
import { JsonObject, t } from '@superset-ui/core';
import sandboxedEval from '../../utils/sandbox';
import { commonLayerProps } from '../common';
import TooltipRow from '../../TooltipRow';
// eslint-disable-next-line import/extensions
import fitViewport, { Viewport } from '../../utils/fitViewport';
import {
DeckGLContainerHandle,
DeckGLContainerStyledWrapper,
} from '../../DeckGLContainer';
import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType, createDeckGLComponent } from '../../factory';
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
@@ -55,12 +47,15 @@ function setTooltipContent(o: JsonObject) {
);
}
export function getLayer(
formData: QueryFormData,
payload: JsonObject,
onAddFilter: () => void,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
) {
export const getLayer: GetLayerType<ScreenGridLayer> = function ({
formData,
setDataMask,
filterState,
onContextMenu,
payload,
setTooltip,
emitCrossFilters,
}) {
const fd = formData;
const c = fd.color_picker;
let data = payload.data.features.map((d: JsonObject) => ({
@@ -84,77 +79,16 @@ export function getLayer(
maxColor: [c.r, c.g, c.b, 255 * c.a],
outline: false,
getWeight: (d: any) => d.weight || 0,
...commonLayerProps(fd, setTooltip, setTooltipContent),
...commonLayerProps({
formData: fd,
setDataMask,
setTooltip,
setTooltipContent,
filterState,
onContextMenu,
emitCrossFilters,
}),
});
}
export type DeckGLScreenGridProps = {
formData: QueryFormData;
payload: JsonObject;
setControlValue: (control: string, value: JsonValue) => void;
viewport: Viewport;
width: number;
height: number;
onAddFilter: () => void;
};
const DeckGLScreenGrid = (props: DeckGLScreenGridProps) => {
const containerRef = useRef<DeckGLContainerHandle>();
const getAdjustedViewport = useCallback(() => {
const features = props.payload.data.features || [];
const { width, height, formData } = props;
if (formData.autozoom) {
return fitViewport(props.viewport, {
width,
height,
points: getPoints(features),
});
}
return props.viewport;
}, [props]);
const [stateFormData, setStateFormData] = useState(props.payload.form_data);
const [viewport, setViewport] = useState(getAdjustedViewport());
useEffect(() => {
if (props.payload.form_data !== stateFormData) {
setViewport(getAdjustedViewport());
setStateFormData(props.payload.form_data);
}
}, [getAdjustedViewport, props.payload.form_data, stateFormData]);
const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => {
const { current } = containerRef;
if (current) {
current.setTooltip(tooltip);
}
}, []);
const getLayers = useCallback(() => {
const layer = getLayer(props.formData, props.payload, () => {}, setTooltip);
return [layer];
}, [props.formData, props.payload, setTooltip]);
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);
export default createDeckGLComponent(getLayer, getPoints);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import thumbnail from './images/thumbnail.png';
import example from './images/example.png';
import transformProps from '../../transformProps';
@@ -33,6 +33,7 @@ const metadata = new ChartMetadata({
exampleGallery: [{ url: example }],
useLegacyApi: true,
tags: [t('deckGL'), t('Comparison'), t('Intensity'), t('Density')],
behaviors: [Behavior.InteractiveChart],
});
export default class ScreengridChartPlugin extends ChartPlugin {

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { PickingInfo } from '@deck.gl/core';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { getAggFunc, commonLayerProps } from './common';
@@ -99,22 +100,22 @@ describe('commonLayerProps', () => {
...partialformData,
js_tooltip: 'tooltip => tooltip.content',
} as QueryFormData;
const props = commonLayerProps(
const props = commonLayerProps({
formData,
mockSetTooltip,
mockSetTooltipContent,
);
setTooltip: mockSetTooltip,
setTooltipContent: mockSetTooltipContent,
});
expect(props.pickable).toBe(true);
expect(props.onHover).toBeDefined();
});
it('calls onHover and sets tooltip', () => {
const formData = { ...partialformData, js_tooltip: null } as QueryFormData;
const props = commonLayerProps(
const props = commonLayerProps({
formData,
mockSetTooltip,
mockSetTooltipContent,
);
setTooltip: mockSetTooltip,
setTooltipContent: mockSetTooltipContent,
});
const mockObject = { picked: true, x: 10, y: 20 };
props.onHover?.(mockObject);
@@ -131,15 +132,30 @@ describe('commonLayerProps', () => {
table_filter: true,
line_column: 'name',
} as QueryFormData;
const props = commonLayerProps(
const props = commonLayerProps({
formData,
mockSetTooltip,
mockSetTooltipContent,
mockOnSelect,
);
setTooltip: mockSetTooltip,
setTooltipContent: mockSetTooltipContent,
onSelect: mockOnSelect,
});
const mockObject = { object: { name: 'John Doe' } };
props.onClick?.(mockObject);
const pickingData = {
color: [],
index: 1,
coordinate: [-122.40138935788005, 37.77785781376027],
devicePixel: [345, 428],
pixel: [172, 116.484375],
pixelRatio: 2,
picked: true,
sourceLayer: {},
viewport: { zoom: 10 },
layer: {},
x: 172,
y: 116.484375,
object: { name: 'John Doe' },
} as unknown as PickingInfo;
props.onClick?.(pickingData, {});
expect(mockOnSelect).toHaveBeenCalledWith('John Doe');
});
});

View File

@@ -28,16 +28,38 @@ import {
variance as d3variance,
deviation as d3deviation,
} from 'd3-array';
import { JsonObject, JsonValue, QueryFormData } from '@superset-ui/core';
import {
FilterState,
HandlerFunction,
JsonObject,
JsonValue,
QueryFormData,
SetDataMaskHook,
} from '@superset-ui/core';
import { Layer, PickingInfo } from '@deck.gl/core';
import sandboxedEval from '../utils/sandbox';
import { TooltipProps } from '../components/Tooltip';
import { getCrossFilterDataMask } from '../utils/crossFiltersDataMask';
export function commonLayerProps(
formData: QueryFormData,
setTooltip: (tooltip: TooltipProps['tooltip']) => void,
setTooltipContent: (content: JsonObject) => ReactNode,
onSelect?: (value: JsonValue) => void,
) {
export function commonLayerProps({
formData,
setDataMask,
setTooltip,
setTooltipContent,
onSelect,
onContextMenu,
filterState,
emitCrossFilters,
}: {
formData: QueryFormData;
setDataMask?: SetDataMaskHook;
setTooltip: (tooltip: TooltipProps['tooltip']) => void;
setTooltipContent: (content: JsonObject) => ReactNode;
onSelect?: (value: JsonValue) => void;
filterState?: FilterState;
onContextMenu?: HandlerFunction;
emitCrossFilters?: boolean;
}) {
const fd = formData;
let onHover;
let tooltipContentGenerator = setTooltipContent;
@@ -58,6 +80,7 @@ export function commonLayerProps(
return true;
};
}
let onClick;
if (fd.js_onclick_href) {
onClick = (o: any) => {
@@ -70,12 +93,30 @@ export function commonLayerProps(
onSelect(o.object[fd.line_column]);
return true;
};
} else if (emitCrossFilters) {
onClick = (data: PickingInfo, event: any) => {
const crossFilters = getCrossFilterDataMask({
data,
filterState,
formData,
});
if (event.leftButton && setDataMask !== undefined && crossFilters) {
setDataMask(crossFilters.dataMask);
} else if (event.rightButton && onContextMenu !== undefined) {
onContextMenu(event.center.x, event.center.y, {
drillToDetail: [],
crossFilter: crossFilters,
drillBy: {},
});
}
};
}
return {
onClick,
onClick: onClick as Layer['onClick'],
onHover,
pickable: Boolean(onHover),
pickable: Boolean(onHover || onClick),
};
}

View File

@@ -22,22 +22,39 @@ import { ChartProps } from '@superset-ui/core';
const NOOP = () => {};
export default function transformProps(chartProps: ChartProps) {
const { datasource, height, hooks, queriesData, rawFormData, width } =
chartProps;
const { onAddFilter = NOOP, setControlValue = NOOP } = hooks;
const {
datasource,
height,
hooks,
queriesData,
rawFormData,
width,
filterState,
emitCrossFilters,
} = chartProps;
const {
onAddFilter = NOOP,
onContextMenu = NOOP,
setControlValue = NOOP,
setDataMask = NOOP,
} = hooks;
return {
datasource,
emitCrossFilters,
formData: rawFormData,
height,
onAddFilter,
onContextMenu,
payload: queriesData[0],
setControlValue,
filterState,
viewport: {
...rawFormData.viewport,
height,
width,
},
width,
setDataMask,
};
}

View File

@@ -0,0 +1,462 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { TimeGranularity } from '@superset-ui/core';
import { PickingInfo } from '@deck.gl/core';
import {
getCrossFilterDataMask,
LayerFormData,
SpatialData,
} from './crossFiltersDataMask';
const formData: LayerFormData = {
metric: 'value',
colorPicker: {
r: 0,
g: 122,
b: 135,
a: 1,
},
compareLag: 1,
timeGrainSqla: TimeGranularity.QUARTER,
granularitySqla: 'ds',
compareSuffix: 'over last quarter',
viz_type: 'deck_grid',
yAxisFormat: '.3s',
datasource: 'test_datasource',
};
const pickingData = {
color: [],
index: 1,
coordinate: [-122.40138935788005, 37.77785781376027],
devicePixel: [345, 428],
pixel: [172, 116.484375],
pixelRatio: 2,
picked: true,
sourceLayer: {},
viewport: { zoom: 10 },
layer: {},
x: 172,
y: 116.484375,
} as unknown as PickingInfo;
describe('getCrossFilterDataMask', () => {
it('handles latlong type', () => {
const latlongFormData = {
...formData,
spatial: {
latCol: 'LAT',
lonCol: 'LON',
type: 'latlong',
} as SpatialData,
};
const latlongPickingData = {
...pickingData,
object: {
col: 14,
row: 34,
colorValue: 1369,
elevationValue: 1369,
count: 5,
pointIndices: [2, 1425, 4107, 4410, 4737],
points: [
{
position: [-122.4205965, 37.8054735],
weight: 1349,
},
{
position: [-122.4215375, 37.8058583],
weight: 8,
},
],
},
};
const dataMask = getCrossFilterDataMask({
formData: latlongFormData,
data: latlongPickingData,
filterState: {},
});
const expected = {
dataMask: {
extraFormData: {
filters: [
{
col: 'LON',
op: '==',
val: -122.4205965,
},
{
col: 'LAT',
op: '==',
val: 37.8054735,
},
],
},
filterState: {
value: [-122.4205965, 37.8054735],
customColumnLabel: 'LON, LAT',
},
},
isCurrentValueSelected: false,
};
expect(dataMask).toStrictEqual(expected);
});
it('handles latlong type with active filters', () => {
const latlongFormData = {
...formData,
spatial: {
latCol: 'LAT',
lonCol: 'LON',
type: 'latlong',
} as SpatialData,
};
const latlongPickingData = {
...pickingData,
object: {
col: 14,
row: 34,
colorValue: 1369,
elevationValue: 1369,
count: 5,
pointIndices: [2, 1425, 4107, 4410, 4737],
points: [
{
position: [-122.4205965, 37.8054735],
weight: 1349,
},
{
position: [-122.4215375, 37.8058583],
weight: 8,
},
],
},
};
const dataMask = getCrossFilterDataMask({
formData: latlongFormData,
data: latlongPickingData,
filterState: { value: [-122.4205965, 37.8054735] },
});
const expected = {
dataMask: {
extraFormData: {
filters: [],
},
filterState: {
value: null,
},
},
isCurrentValueSelected: true,
};
expect(dataMask).toStrictEqual(expected);
});
it('handles delimited type', () => {
const delimitedFormData = {
...formData,
spatial: {
lonlatCol: 'LONLAT',
delimiter: ',',
type: 'delimited',
} as SpatialData,
};
const delimitedPickingData = {
...pickingData,
object: {
points: [
{
position: [-122.4205965, 37.8054735],
weight: 1349,
},
{
position: [-122.4215375, 37.8058583],
weight: 8,
},
],
},
};
const dataMask = getCrossFilterDataMask({
formData: delimitedFormData,
data: delimitedPickingData,
filterState: {},
});
const expected = {
dataMask: {
extraFormData: {
filters: [
{
col: 'LONLAT',
op: '==',
val: `-122.4205965,37.8054735`,
},
],
},
filterState: {
value: [`-122.4205965,37.8054735`],
},
},
isCurrentValueSelected: false,
};
expect(dataMask).toStrictEqual(expected);
});
it('handles delimited type with reversed lon/lat', () => {
const delimitedFormData = {
...formData,
spatial: {
lonlatCol: 'LONLAT',
delimiter: ',',
type: 'delimited',
reverseCheckbox: true,
} as SpatialData,
};
const delimitedPickingData = {
...pickingData,
object: {
points: [
{
position: [-122.4205965, 37.8054735],
weight: 1349,
},
{
position: [-122.4215375, 37.8058583],
weight: 8,
},
],
},
};
const dataMask = getCrossFilterDataMask({
formData: delimitedFormData,
data: delimitedPickingData,
filterState: {},
});
const expected = {
dataMask: {
extraFormData: {
filters: [
{
col: 'LONLAT',
op: '==',
val: `37.8054735,-122.4205965`,
},
],
},
filterState: {
value: [`37.8054735,-122.4205965`],
},
},
isCurrentValueSelected: false,
};
expect(dataMask).toStrictEqual(expected);
});
it('handles geohash type', () => {
const geohashFormData = {
...formData,
spatial: {
geohashCol: 'geohash',
reverseCheckbox: false,
type: 'geohash',
} as SpatialData,
};
const geohashPickingData = {
...pickingData,
object: {
points: [
{
position: [-122.42059646174312, 37.805473459884524],
weight: 1349,
},
],
},
};
const dataMask = getCrossFilterDataMask({
formData: geohashFormData,
data: geohashPickingData,
filterState: {},
});
const expected = {
dataMask: {
extraFormData: {
filters: [
{
col: 'geohash',
op: '==',
val: `9q8zn620c751`,
},
],
},
filterState: {
value: ['9q8zn620c751'],
},
},
isCurrentValueSelected: false,
};
expect(dataMask).toStrictEqual(expected);
});
it('handles start and end postions (Arc Chart)', () => {
const arcFormData = {
...formData,
start_spatial: {
geohashCol: 'geohash',
reverseCheckbox: false,
type: 'geohash',
} as SpatialData,
end_spatial: {
latCol: 'LAT_DEST',
lonCol: 'LON_DEST',
type: 'latlong',
} as SpatialData,
};
const arkPickingData = {
...pickingData,
object: {
sourcePosition: [-122.42059646174312, 37.805473459884524],
targetPosition: [-122.4215375, 37.8058583],
},
};
const dataMask = getCrossFilterDataMask({
formData: arcFormData,
data: arkPickingData,
filterState: {},
});
const expected = {
dataMask: {
extraFormData: {
filters: [
{
col: 'geohash',
op: '==',
val: `9q8zn620c751`,
},
{
col: 'LON_DEST',
op: '==',
val: -122.4215375,
},
{
col: 'LAT_DEST',
op: '==',
val: 37.8058583,
},
],
},
filterState: {
value: [['9q8zn620c751'], [-122.4215375, 37.8058583]],
customColumnLabel: 'Start geohash end LAT_DEST, LON_DEST',
},
},
isCurrentValueSelected: false,
};
expect(dataMask).toStrictEqual(expected);
});
it('handles Charts with GPU aggregation', () => {
const latlongGPUFormData = {
...formData,
spatial: {
latCol: 'LAT',
lonCol: 'LON',
type: 'latlong',
} as SpatialData,
};
const latlongGPUPickingData = {
...pickingData,
object: {
col: 14,
row: 34,
colorValue: 1369,
elevationValue: 1369,
count: 5,
pointIndices: [2, 1425, 4107, 4410, 4737],
},
};
const dataMask = getCrossFilterDataMask({
formData: latlongGPUFormData,
data: latlongGPUPickingData,
filterState: {},
});
const expected = {
dataMask: {
extraFormData: {
filters: [
{
col: 'LON',
op: '>=',
val: -122.41076435788005,
},
{
col: 'LAT',
op: '>=',
val: 37.76848281376027,
},
{
col: 'LON',
op: '<=',
val: -122.39201435788004,
},
{
col: 'LAT',
op: '<=',
val: 37.78723281376027,
},
],
},
filterState: {
value: [
[-122.41076435788005, 37.76848281376027],
[-122.39201435788004, 37.78723281376027],
],
customColumnLabel: 'From LON, LAT to LON, LAT',
},
},
isCurrentValueSelected: false,
};
expect(dataMask).toStrictEqual(expected);
});
});

View File

@@ -0,0 +1,422 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PickingInfo, Viewport } from '@deck.gl/core';
import {
ContextMenuFilters,
FilterState,
QueryObjectFilterClause,
SqlaFormData,
} from '@superset-ui/core';
import ngeohash from 'ngeohash';
const GEOHASH_PRECISION = 12;
const VIEWPORT_BUFFER_FACTOR = 0.3;
const ZOOM_DIVISOR = 2;
export const spatialTypes = {
latlong: 'latlong',
delimited: 'delimited',
geohash: 'geohash',
} as const;
type SpatialType = (typeof spatialTypes)[keyof typeof spatialTypes];
export type SpatialData = {
latCol?: string;
lonCol?: string;
lonlatCol?: string;
reverseCheckbox?: boolean;
delimiter?: string;
type: SpatialType;
geohashCol?: string;
line_column?: string;
};
export interface LayerFormData extends SqlaFormData {
start_spatial?: SpatialData;
end_spatial?: SpatialData;
spatial?: SpatialData;
line_column?: string;
geojson?: string;
}
export interface FilterResult {
filters: QueryObjectFilterClause[];
values: FilterState;
customColumnLabel?: string;
}
export interface PositionBounds {
from: [number, number];
to: [number, number];
}
export interface ValidatedPickingData {
position?: [number, number];
positionBounds?: PositionBounds;
sourcePosition?: [number, number];
targetPosition?: [number, number];
path?: string;
geometry?: any;
}
const getFiltersBySpatialType = ({
position,
positionBounds,
spatialData,
}: {
position: [number, number];
spatialData: SpatialData;
positionBounds?: PositionBounds;
}) => {
const {
lonCol,
latCol,
lonlatCol,
geohashCol,
reverseCheckbox,
type,
delimiter,
} = spatialData;
let values: any[] = [];
let filters: QueryObjectFilterClause[] = [];
let customColumnLabel;
if (!position && !positionBounds)
throw new Error('Position of picked data is required');
switch (type) {
case spatialTypes.latlong: {
if (lonCol != null && latCol != null) {
const cols = [lonCol, latCol];
if (position) {
values = position;
customColumnLabel = cols.join(', ');
filters = [
...cols.map(
(col, index) =>
({
col,
op: '==',
val: position[index],
}) as QueryObjectFilterClause,
),
];
} else if (positionBounds) {
values = [positionBounds.from, positionBounds.to];
customColumnLabel = `From ${lonCol}, ${latCol} to ${lonCol}, ${latCol}`;
filters = [
...cols.map(
(col, index) =>
({
col,
op: '>=',
val: positionBounds.from[index],
}) as QueryObjectFilterClause,
),
...cols.map(
(col, index) =>
({
col,
op: '<=',
val: positionBounds.to[index],
}) as QueryObjectFilterClause,
),
];
}
}
break;
}
case spatialTypes.delimited: {
const col = lonlatCol ?? geohashCol;
if (!col) throw new Error('Column is required');
const val = (reverseCheckbox ? position.reverse() : position).join(
delimiter,
);
values = [val];
filters = [
{
col,
op: '==',
val,
},
];
break;
}
case spatialTypes.geohash: {
const col = lonlatCol ?? geohashCol;
if (!col) throw new Error('Column is required');
const [lon, lat] = position;
const val = ngeohash.encode(lat, lon, GEOHASH_PRECISION);
values = [val];
filters = [
{
col,
op: '==',
val,
},
];
break;
}
default: {
values = [];
}
}
return {
filters,
values,
customColumnLabel,
};
};
const calculatePickedPositionBounds = ({
pickedCoordinates,
viewport,
}: {
pickedCoordinates: number[];
viewport: Viewport;
}): PositionBounds => {
const buffer =
VIEWPORT_BUFFER_FACTOR / Math.pow(2, viewport.zoom / ZOOM_DIVISOR);
return {
from: [pickedCoordinates[0] - buffer, pickedCoordinates[1] - buffer],
to: [pickedCoordinates[0] + buffer, pickedCoordinates[1] + buffer],
};
};
const getSpatialColumnLabel = ({
latCol,
lonCol,
geohashCol,
line_column,
}: {
latCol?: string;
lonCol?: string;
geohashCol?: string;
line_column?: string;
}) => {
if (latCol && lonCol) {
return `${latCol}, ${lonCol}`;
}
if (geohashCol) {
return geohashCol;
}
if (line_column) {
return line_column;
}
return '';
};
const getStartEndSpatialFilters = ({
formData,
data,
}: {
formData: LayerFormData;
data: PickingInfo;
}): FilterResult => {
const sourcePosition: [number, number] = data.object?.sourcePosition;
const targetPosition: [number, number] = data.object?.targetPosition;
if (!sourcePosition || !targetPosition)
throw new Error('Position of picked data is required');
if (!formData.start_spatial || !formData.end_spatial)
throw new Error('Spatial data is required');
const customColumnLabel = `Start ${getSpatialColumnLabel(formData.start_spatial)} end ${getSpatialColumnLabel(formData.end_spatial)}`;
const startSpatialFilters = getFiltersBySpatialType({
position: sourcePosition,
spatialData: formData.start_spatial,
});
const endSpatialFilters = getFiltersBySpatialType({
position: targetPosition,
spatialData: formData.end_spatial,
});
if (!startSpatialFilters || !endSpatialFilters)
throw new Error('Failed to generate filters');
return {
values: [startSpatialFilters.values, endSpatialFilters.values],
filters: [
...(startSpatialFilters.filters || []),
...(endSpatialFilters.filters || []),
],
customColumnLabel,
};
};
const getSpatialFilters = ({
formData,
data,
}: {
formData: LayerFormData;
data: PickingInfo;
}): FilterResult => {
const position = (data.object?.points?.[0]?.position ||
data.object?.position) as [number, number];
let positionBounds: PositionBounds | undefined;
if (!position && data.coordinate && data.viewport) {
const pickedPositionBounds = calculatePickedPositionBounds({
pickedCoordinates: data.coordinate,
viewport: data.viewport,
});
positionBounds = pickedPositionBounds;
}
if (!formData.spatial) throw new Error('Spatial data is required');
return getFiltersBySpatialType({
position,
positionBounds,
spatialData: formData.spatial,
});
};
const getLineColumnFilters = ({
formData,
data,
}: {
formData: LayerFormData;
data: PickingInfo;
}): FilterResult => {
const path = (data?.object?.path || data.object?.polygon) as string;
const val = JSON.stringify(path);
if (!formData.line_column) throw new Error('Line column is required');
if (!path) throw new Error('Position of picked data is required');
return {
values: [val],
filters: [
{
col: {
expressionType: 'SQL',
sqlExpression: `REPLACE(${formData.line_column}, ' ', '')`,
label: formData.line_column,
},
op: '==',
val,
},
],
};
};
const getGeojsonFilters = ({
formData,
data,
}: {
formData: LayerFormData;
data: PickingInfo;
}): FilterResult => {
const geometry = data.object?.geometry?.coordinates;
if (!geometry) throw new Error('Position of picked data is required');
const val = `%${JSON.stringify(geometry)}%`;
return {
values: [val],
filters: [
{
col: {
expressionType: 'SQL',
sqlExpression: `REPLACE(${formData.geojson}, ' ', '')`,
label: formData.geojson,
},
op: 'LIKE',
val,
},
],
};
};
export const getCrossFilterDataMask = ({
data,
filterState,
formData,
}: {
data: PickingInfo;
filterState?: FilterState;
formData: LayerFormData;
}) => {
let values: FilterState['value'] = [];
let filters: QueryObjectFilterClause[] = [];
let customColumnLabel: string | undefined;
if (formData.start_spatial && formData.end_spatial) {
const result = getStartEndSpatialFilters({ formData, data });
({ values, filters, customColumnLabel } = result);
} else if (formData.spatial?.type) {
const result = getSpatialFilters({ formData, data });
({ values, filters, customColumnLabel } = result);
} else if (formData.line_column) {
const result = getLineColumnFilters({ formData, data });
({ values, filters, customColumnLabel } = result);
} else if (formData.geojson) {
const result = getGeojsonFilters({ formData, data });
({ values, filters, customColumnLabel } = result);
} else {
throw new Error('No valid spatial configuration found in form data');
}
const isSelected =
values &&
filterState?.value?.every(
(val: string, i: number) => val.toString() === values[i].toString(),
);
if (isSelected) {
values = [];
}
return {
dataMask: {
extraFormData: {
filters: values.length ? filters : [],
},
filterState: {
value: values.length ? values : null,
...(customColumnLabel && values.length ? { customColumnLabel } : {}),
},
},
isCurrentValueSelected: isSelected || false,
} as ContextMenuFilters['crossFilter'];
};

View File

@@ -66,7 +66,8 @@ const CrossFilterTag = (props: {
useCSSTextTruncation<HTMLSpanElement>();
const [valueRef, valueIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
const columnLabel = getColumnLabel(filter.column ?? '');
const columnLabel =
filter.customColumnLabel || getColumnLabel(filter.column ?? '');
return (
<StyledTag

View File

@@ -0,0 +1,143 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { getCrossFilterIndicator } from './selectors';
describe('getCrossFilterIndicator', () => {
const chartId = 123;
const chartLayoutItems = [
{
id: 'chart-123',
type: CHART_TYPE,
children: [],
parents: ['ROOT_ID'],
meta: {
chartId,
sliceName: 'Test Chart',
uuid: 'uuid-123',
height: 10,
width: 10,
},
},
];
it('returns correct indicator with label from filterState.label', () => {
const dataMask = {
filterState: { label: 'foo', value: 'bar' },
extraFormData: {},
};
const result = getCrossFilterIndicator(chartId, dataMask, chartLayoutItems);
expect(result).toEqual({
column: undefined,
name: 'Test Chart',
path: ['ROOT_ID', 'chart-123'],
value: 'foo',
});
});
it('returns correct indicator with label from filterState.value', () => {
const dataMask = {
filterState: { value: ['bar', 'baz'] },
extraFormData: {},
};
const result = getCrossFilterIndicator(chartId, dataMask, chartLayoutItems);
expect(result).toEqual({
column: undefined,
name: 'Test Chart',
path: ['ROOT_ID', 'chart-123'],
value: 'bar, baz',
});
});
it('returns correct indicator with column and customColumnLabel', () => {
const dataMask = {
filterState: {
value: 'valA',
filters: { col: 'col' },
customColumnLabel: 'label',
},
extraFormData: {},
};
const result = getCrossFilterIndicator(chartId, dataMask, chartLayoutItems);
expect(result).toEqual({
column: 'col',
name: 'Test Chart',
path: ['ROOT_ID', 'chart-123'],
value: 'valA',
customColumnLabel: 'label',
});
});
it('returns correct indicator with column from extraFormData.filters', () => {
const filterClause = { col: 'colB', op: 'IS NOT NULL' as const };
const dataMask = {
filterState: { value: 'valB' },
extraFormData: { filters: [filterClause] },
};
const result = getCrossFilterIndicator(chartId, dataMask, chartLayoutItems);
expect(result).toEqual({
column: 'colB',
name: 'Test Chart',
path: ['ROOT_ID', 'chart-123'],
value: 'valB',
});
});
it('returns correct indicator with column from filterState.filters', () => {
const dataMask = {
filterState: { value: 'valC', filters: { colC: 'something' } },
extraFormData: {},
};
const result = getCrossFilterIndicator(chartId, dataMask, chartLayoutItems);
expect(result).toEqual({
column: 'colC',
name: 'Test Chart',
path: ['ROOT_ID', 'chart-123'],
value: 'valC',
});
});
it('returns empty name and path if chartLayoutItem is not found', () => {
const dataMask = {
filterState: { value: 'valD' },
extraFormData: {},
};
const result = getCrossFilterIndicator(999, dataMask, chartLayoutItems);
expect(result).toEqual({
column: undefined,
name: '',
path: [''],
value: 'valD',
});
});
it('returns null value if no label or value in filterState', () => {
const dataMask = {
filterState: {},
extraFormData: {},
};
const result = getCrossFilterIndicator(chartId, dataMask, chartLayoutItems);
expect(result).toEqual({
column: undefined,
name: 'Test Chart',
path: ['ROOT_ID', 'chart-123'],
value: null,
});
});
});

View File

@@ -157,6 +157,7 @@ export type Indicator = {
value?: any;
status?: IndicatorStatus;
path?: string[];
customColumnLabel?: string;
};
export type CrossFilterIndicator = Indicator & { emitterId: number };
@@ -170,6 +171,7 @@ export const getCrossFilterIndicator = (
const filters = dataMask?.extraFormData?.filters;
const label = extractLabel(filterState);
const filtersState = filterState?.filters;
const customColumnLabel = filterState?.customColumnLabel;
const column =
filters?.[0]?.col || (filtersState && Object.keys(filtersState)[0]);
@@ -185,6 +187,7 @@ export const getCrossFilterIndicator = (
'',
path: [...(chartLayoutItem?.parents ?? []), chartLayoutItem?.id || ''],
value: label,
customColumnLabel,
};
return filterObject;
};