You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2023/10/10 09:20:47 UTC
[superset] branch master updated: feat: Add Deck.gl Contour Layer (#24154)
This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 512fb9a0bd feat: Add Deck.gl Contour Layer (#24154)
512fb9a0bd is described below
commit 512fb9a0bdd428b94b0c121158b8b15b7631e0fb
Author: Matthew Chiang <36...@users.noreply.github.com>
AuthorDate: Tue Oct 10 04:20:37 2023 -0500
feat: Add Deck.gl Contour Layer (#24154)
---
.../src/layers/Contour/Contour.tsx | 103 ++++++
.../src/layers/Contour/controlPanel.ts | 133 ++++++++
.../src/layers/Contour/images/thumbnail.png | Bin 0 -> 64889 bytes
.../src/layers/Contour/images/thumbnailLarge.png | Bin 0 -> 207560 bytes
.../src/{types.ts => layers/Contour/index.ts} | 30 +-
.../legacy-preset-chart-deckgl/src/preset.ts | 2 +
.../legacy-preset-chart-deckgl/src/types.ts | 6 +
.../legacy-preset-chart-deckgl/types/external.d.ts | 53 +++-
.../controls/ContourControl/ContourOption.tsx | 107 +++++++
.../ContourControl/ContourPopoverControl.tsx | 351 +++++++++++++++++++++
.../ContourControl/ContourPopoverTrigger.tsx | 60 ++++
.../components/controls/ContourControl/index.tsx | 143 +++++++++
.../components/controls/ContourControl/types.ts | 55 ++++
.../DndColumnSelectControl/OptionWrapper.tsx | 11 +-
.../controls/DndColumnSelectControl/types.ts | 1 +
.../components/controls/TextControl/index.tsx | 2 +-
.../src/explore/components/controls/index.js | 2 +
superset/viz.py | 21 ++
18 files changed, 1073 insertions(+), 7 deletions(-)
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx
new file mode 100644
index 0000000000..2c46bd6fc2
--- /dev/null
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/Contour.tsx
@@ -0,0 +1,103 @@
+/**
+ * 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 { ContourLayer } from 'deck.gl';
+import React from 'react';
+import { t } from '@superset-ui/core';
+import { commonLayerProps } from '../common';
+import sandboxedEval from '../../utils/sandbox';
+import { createDeckGLComponent, getLayerType } from '../../factory';
+import { ColorType } from '../../types';
+import TooltipRow from '../../TooltipRow';
+
+function setTooltipContent(o: any) {
+ return (
+ <div className="deckgl-tooltip">
+ <TooltipRow
+ label={t('Centroid (Longitude and Latitude): ')}
+ value={`(${o?.coordinate[0]}, ${o?.coordinate[1]})`}
+ />
+ <TooltipRow
+ label={t('Threshold: ')}
+ value={`${o?.object?.contour?.threshold}`}
+ />
+ </div>
+ );
+}
+export const getLayer: getLayerType<unknown> = function (
+ formData,
+ payload,
+ onAddFilter,
+ setTooltip,
+) {
+ const fd = formData;
+ const {
+ aggregation = 'SUM',
+ js_data_mutator: jsFnMutator,
+ contours: rawContours,
+ cellSize = '200',
+ } = fd;
+ let data = payload.data.features;
+
+ const contours = rawContours?.map(
+ (contour: {
+ color: ColorType;
+ lowerThreshold: number;
+ upperThreshold?: number;
+ strokeWidth?: number;
+ }) => {
+ const { lowerThreshold, upperThreshold, color, strokeWidth } = contour;
+ if (upperThreshold) {
+ // Isoband format
+ return {
+ threshold: [lowerThreshold, upperThreshold],
+ color: [color.r, color.g, color.b],
+ };
+ }
+ // Isoline format
+ return {
+ threshold: lowerThreshold,
+ color: [color.r, color.g, color.b],
+ strokeWidth,
+ };
+ },
+ );
+
+ if (jsFnMutator) {
+ // Applying user defined data mutator if defined
+ const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
+ data = jsFnMutatorFunction(data);
+ }
+
+ return new ContourLayer({
+ id: `contourLayer-${fd.slice_id}`,
+ data,
+ contours,
+ cellSize: Number(cellSize || '200'),
+ aggregation: aggregation.toUpperCase(),
+ getPosition: (d: { position: number[]; weight: number }) => d.position,
+ getWeight: (d: { weight: number }) => d.weight || 0,
+ ...commonLayerProps(fd, setTooltip, setTooltipContent),
+ });
+};
+
+function getPoints(data: any[]) {
+ return data.map(d => d.position);
+}
+
+export default createDeckGLComponent(getLayer, getPoints);
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts
new file mode 100644
index 0000000000..238029aada
--- /dev/null
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/controlPanel.ts
@@ -0,0 +1,133 @@
+/**
+ * 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 {
+ ControlPanelConfig,
+ getStandardizedControls,
+ sections,
+} from '@superset-ui/chart-controls';
+import { t, validateNonEmpty } from '@superset-ui/core';
+import {
+ autozoom,
+ filterNulls,
+ jsColumns,
+ jsDataMutator,
+ jsOnclickHref,
+ jsTooltip,
+ mapboxStyle,
+ spatial,
+ viewport,
+} from '../../utilities/Shared_DeckGL';
+
+const config: ControlPanelConfig = {
+ controlPanelSections: [
+ sections.legacyRegularTime,
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [
+ [spatial],
+ ['row_limit'],
+ ['size'],
+ [filterNulls],
+ ['adhoc_filters'],
+ ],
+ },
+ {
+ label: t('Map'),
+ expanded: true,
+ controlSetRows: [
+ [mapboxStyle, viewport],
+ [autozoom],
+ [
+ {
+ name: 'cellSize',
+ config: {
+ type: 'TextControl',
+ label: t('Cell Size'),
+ default: 300,
+ isInt: true,
+ description: t('The size of each cell in meters'),
+ renderTrigger: true,
+ clearable: false,
+ },
+ },
+ ],
+ [
+ {
+ name: 'aggregation',
+ config: {
+ type: 'SelectControl',
+ label: t('Aggregation'),
+ description: t(
+ 'The function to use when aggregating points into groups',
+ ),
+ default: 'sum',
+ clearable: false,
+ renderTrigger: true,
+ choices: [
+ ['sum', t('sum')],
+ ['min', t('min')],
+ ['max', t('max')],
+ ['mean', t('mean')],
+ ],
+ },
+ },
+ ],
+ [
+ {
+ name: 'contours',
+ config: {
+ type: 'ContourControl',
+ label: t('Contours'),
+ renderTrigger: true,
+ description: t(
+ 'Define contour layers. Isolines represent a collection of line segments that ' +
+ 'serparate the area above and below a given threshold. Isobands represent a ' +
+ 'collection of polygons that fill the are containing values in a given ' +
+ 'threshold range.',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ {
+ label: t('Advanced'),
+ controlSetRows: [
+ [jsColumns],
+ [jsDataMutator],
+ [jsTooltip],
+ [jsOnclickHref],
+ ],
+ },
+ ],
+ controlOverrides: {
+ size: {
+ label: t('Weight'),
+ description: t("Metric used as a weight for the grid's coloring"),
+ validators: [validateNonEmpty],
+ },
+ },
+ formDataOverrides: formData => ({
+ ...formData,
+ size: getStandardizedControls().shiftMetric(),
+ }),
+};
+
+export default config;
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/images/thumbnail.png b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/images/thumbnail.png
new file mode 100644
index 0000000000..eb9b541307
Binary files /dev/null and b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/images/thumbnail.png differ
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/images/thumbnailLarge.png b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/images/thumbnailLarge.png
new file mode 100644
index 0000000000..fef905c95f
Binary files /dev/null and b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/images/thumbnailLarge.png differ
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts
similarity index 50%
copy from superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts
copy to superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts
index 9177da614d..01f14467c7 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Contour/index.ts
@@ -16,8 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
-// range and point actually have different value ranges
-// and also are different concept-wise
+import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
+import transformProps from '../../transformProps';
+import controlPanel from './controlPanel';
+import thumbnail from './images/thumbnail.png';
-export type Range = [number, number];
-export type Point = [number, number];
+const metadata = new ChartMetadata({
+ category: t('Map'),
+ credits: ['https://uber.github.io/deck.gl'],
+ description: t(
+ 'Uses Gaussian Kernel Density Estimation to visualize spatial distribution of data',
+ ),
+ name: t('deck.gl Countour'),
+ thumbnail,
+ useLegacyApi: true,
+ tags: [t('deckGL'), t('Spatial'), t('Comparison'), t('Experimental')],
+});
+
+export default class ContourChartPlugin extends ChartPlugin {
+ constructor() {
+ super({
+ loadChart: () => import('./Contour'),
+ controlPanel,
+ metadata,
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/preset.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/preset.ts
index 2f9a1c6b9e..d6e41c6e9b 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/preset.ts
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/preset.ts
@@ -27,6 +27,7 @@ import PathChartPlugin from './layers/Path';
import PolygonChartPlugin from './layers/Polygon';
import ScatterChartPlugin from './layers/Scatter';
import ScreengridChartPlugin from './layers/Screengrid';
+import ContourChartPlugin from './layers/Contour';
export default class DeckGLChartPreset extends Preset {
constructor() {
@@ -43,6 +44,7 @@ export default class DeckGLChartPreset extends Preset {
new PolygonChartPlugin().configure({ key: 'deck_polygon' }),
new ScatterChartPlugin().configure({ key: 'deck_scatter' }),
new ScreengridChartPlugin().configure({ key: 'deck_screengrid' }),
+ new ContourChartPlugin().configure({ key: 'deck_contour' }),
],
});
}
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts
index 9177da614d..aadd859d77 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/types.ts
@@ -21,3 +21,9 @@
export type Range = [number, number];
export type Point = [number, number];
+export interface ColorType {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/types/external.d.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/types/external.d.ts
index 9543307b69..7093762a5c 100644
--- a/superset-frontend/plugins/legacy-preset-chart-deckgl/types/external.d.ts
+++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/types/external.d.ts
@@ -17,4 +17,55 @@
* under the License.
*/
-declare module '*.png';
+declare module 'deck.gl' {
+ import { Layer, LayerProps } from '@deck.gl/core';
+
+ interface HeatmapLayerProps<T extends object = any> extends LayerProps<T> {
+ id?: string;
+ data?: T[];
+ getPosition?: (d: T) => number[] | null | undefined;
+ getWeight?: (d: T) => number | null | undefined;
+ radiusPixels?: number;
+ colorRange?: number[][];
+ threshold?: number;
+ intensity?: number;
+ aggregation?: string;
+ }
+
+ interface ContourLayerProps<T extends object = any> extends LayerProps<T> {
+ id?: string;
+ data?: T[];
+ getPosition?: (d: T) => number[] | null | undefined;
+ getWeight?: (d: T) => number | null | undefined;
+ contours: {
+ color?: ColorType | undefined;
+ lowerThreshold?: any | undefined;
+ upperThreshold?: any | undefined;
+ strokeWidth?: any | undefined;
+ zIndex?: any | undefined;
+ };
+ cellSize: number;
+ colorRange?: number[][];
+ intensity?: number;
+ aggregation?: string;
+ }
+
+ export class HeatmapLayer<T extends object = any> extends Layer<
+ T,
+ HeatmapLayerProps<T>
+ > {
+ constructor(props: HeatmapLayerProps<T>);
+ }
+
+ export class ContourLayer<T extends object = any> extends Layer<
+ T,
+ ContourLayerProps<T>
+ > {
+ constructor(props: ContourLayerProps<T>);
+ }
+}
+
+declare module '*.png' {
+ const value: any;
+ export default value;
+}
diff --git a/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.tsx b/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.tsx
new file mode 100644
index 0000000000..8bc2669038
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.tsx
@@ -0,0 +1,107 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with work for additional information
+ * regarding copyright ownership. The ASF licenses file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use 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 React from 'react';
+import { styled, t } from '@superset-ui/core';
+import { ContourOptionProps } from './types';
+import ContourPopoverTrigger from './ContourPopoverTrigger';
+import OptionWrapper from '../DndColumnSelectControl/OptionWrapper';
+
+const StyledOptionWrapper = styled(OptionWrapper)`
+ max-width: 100%;
+ min-width: 100%;
+`;
+
+const StyledListItem = styled.li`
+ display: flex;
+ align-items: center;
+`;
+
+const ColorPatch = styled.div<{ formattedColor: string }>`
+ background-color: ${({ formattedColor }) => formattedColor};
+ height: ${({ theme }) => theme.gridUnit}px;
+ width: ${({ theme }) => theme.gridUnit}px;
+ margin: 0 ${({ theme }) => theme.gridUnit}px;
+`;
+
+const ContourOption = ({
+ contour,
+ index,
+ saveContour,
+ onClose,
+ onShift,
+}: ContourOptionProps) => {
+ const { lowerThreshold, upperThreshold, color, strokeWidth } = contour;
+
+ const isIsoband = upperThreshold;
+
+ const formattedColor = color
+ ? `rgba(${color.r}, ${color.g}, ${color.b}, 1)`
+ : 'undefined';
+
+ const formatIsoline = (threshold: number, width: number) =>
+ `${t('Threshold')}: ${threshold}, ${t('color')}: ${formattedColor}, ${t(
+ 'stroke width',
+ )}: ${width}`;
+
+ const formatIsoband = (threshold: number[]) =>
+ `${t('Threshold')}: [${threshold[0]}, ${
+ threshold[1]
+ }], color: ${formattedColor}`;
+
+ const displayString = isIsoband
+ ? formatIsoband([lowerThreshold || -1, upperThreshold])
+ : formatIsoline(lowerThreshold || -1, strokeWidth);
+
+ const overlay = (
+ <div className="contour-tooltip-overlay">
+ <StyledListItem>
+ {t('Threshold: ')}
+ {isIsoband
+ ? `[${lowerThreshold}, ${upperThreshold}]`
+ : `${lowerThreshold}`}
+ </StyledListItem>
+ <StyledListItem>
+ {t('Color: ')}
+ <ColorPatch formattedColor={formattedColor} /> {formattedColor}
+ </StyledListItem>
+ {!isIsoband && (
+ <StyledListItem>{`${t(
+ 'Stroke Width:',
+ )} ${strokeWidth}`}</StyledListItem>
+ )}
+ </div>
+ );
+
+ return (
+ <ContourPopoverTrigger saveContour={saveContour} value={contour}>
+ <StyledOptionWrapper
+ index={index}
+ label={displayString}
+ type="ContourOption"
+ withCaret
+ clickClose={onClose}
+ onShiftOptions={onShift}
+ tooltipOverlay={overlay}
+ />
+ </ContourPopoverTrigger>
+ );
+};
+
+export default ContourOption;
diff --git a/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.tsx b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.tsx
new file mode 100644
index 0000000000..ebcf472221
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.tsx
@@ -0,0 +1,351 @@
+/**
+ * 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 React, { useState, useEffect } from 'react';
+import { Row, Col } from 'src/components';
+import Button from 'src/components/Button';
+import Tabs from 'src/components/Tabs';
+import { legacyValidateInteger, styled, t } from '@superset-ui/core';
+import ControlHeader from '../../ControlHeader';
+import TextControl from '../TextControl';
+import ColorPickerControl from '../ColorPickerControl';
+import {
+ ContourPopoverControlProps,
+ ColorType,
+ ContourType,
+ ErrorMapType,
+} from './types';
+
+enum CONTOUR_TYPES {
+ Isoline = 'ISOLINE',
+ Isoband = 'ISOBAND',
+}
+
+const ContourActionsContainer = styled.div`
+ margin-top: ${({ theme }) => theme.gridUnit * 2}px;
+`;
+
+const StyledRow = styled(Row)`
+ width: 100%;
+ gap: ${({ theme }) => theme.gridUnit * 2}px;
+`;
+
+const isIsoband = (contour: ContourType) => {
+ if (Object.keys(contour).length < 4) {
+ return false;
+ }
+ return contour.upperThreshold && contour.lowerThreshold;
+};
+
+const getTabKey = (contour: ContourType | undefined) =>
+ contour && isIsoband(contour) ? CONTOUR_TYPES.Isoband : CONTOUR_TYPES.Isoline;
+
+const determineErrorMap = (tab: string, contour: ContourType) => {
+ const errorMap: ErrorMapType = {
+ lowerThreshold: [],
+ upperThreshold: [],
+ strokeWidth: [],
+ color: [],
+ };
+ // Isoline and Isoband validation
+ const lowerThresholdError = legacyValidateInteger(contour.lowerThreshold);
+ if (lowerThresholdError) errorMap.lowerThreshold.push(lowerThresholdError);
+
+ // Isoline only validation
+ if (tab === CONTOUR_TYPES.Isoline) {
+ const strokeWidthError = legacyValidateInteger(contour.strokeWidth);
+ if (strokeWidthError) errorMap.strokeWidth.push(strokeWidthError);
+ }
+
+ // Isoband only validation
+ if (tab === CONTOUR_TYPES.Isoband) {
+ const upperThresholdError = legacyValidateInteger(contour.upperThreshold);
+ if (upperThresholdError) errorMap.upperThreshold.push(upperThresholdError);
+ if (
+ !upperThresholdError &&
+ !lowerThresholdError &&
+ contour.upperThreshold &&
+ contour.lowerThreshold
+ ) {
+ const lower = parseFloat(contour.lowerThreshold);
+ const upper = parseFloat(contour.upperThreshold);
+ if (lower >= upper) {
+ errorMap.lowerThreshold.push(
+ t('Lower threshold must be lower than upper threshold'),
+ );
+ errorMap.upperThreshold.push(
+ t('Upper threshold must be greater than lower threshold'),
+ );
+ }
+ }
+ }
+ return errorMap;
+};
+
+const convertContourToNumeric = (contour: ContourType) => {
+ const formattedContour = { ...contour };
+ const numericKeys = ['lowerThreshold', 'upperThreshold', 'strokeWidth'];
+ numericKeys.forEach(key => {
+ formattedContour[key] = Number(formattedContour[key]);
+ });
+ return formattedContour;
+};
+
+const formatIsoline = (contour: ContourType) => ({
+ color: contour.color,
+ lowerThreshold: contour.lowerThreshold,
+ upperThreshold: undefined,
+ strokeWidth: contour.strokeWidth,
+});
+
+const formatIsoband = (contour: ContourType) => ({
+ color: contour.color,
+ lowerThreshold: contour.lowerThreshold,
+ upperThreshold: contour.upperThreshold,
+ strokeWidth: undefined,
+});
+
+const DEFAULT_CONTOUR = {
+ lowerThreshold: undefined,
+ upperThreshold: undefined,
+ color: undefined,
+ strokeWidth: undefined,
+};
+
+const ContourPopoverControl = ({
+ value: initialValue,
+ onSave,
+ onClose,
+}: ContourPopoverControlProps) => {
+ const [currentTab, setCurrentTab] = useState(getTabKey(initialValue));
+ const [contour, setContour] = useState(initialValue || DEFAULT_CONTOUR);
+ const [validationErrors, setValidationErrors] = useState(
+ determineErrorMap(getTabKey(initialValue), initialValue || DEFAULT_CONTOUR),
+ );
+ const [isComplete, setIsComplete] = useState(false);
+
+ useEffect(() => {
+ const isIsoband = currentTab === CONTOUR_TYPES.Isoband;
+ const validLower =
+ Boolean(contour.lowerThreshold) || contour.lowerThreshold === 0;
+ const validUpper =
+ Boolean(contour.upperThreshold) || contour.upperThreshold === 0;
+ const validStrokeWidth =
+ Boolean(contour.strokeWidth) || contour.strokeWidth === 0;
+ const validColor =
+ typeof contour.color === 'object' &&
+ 'r' in contour.color &&
+ typeof contour.color.r === 'number' &&
+ 'g' in contour.color &&
+ typeof contour.color.g === 'number' &&
+ 'b' in contour.color &&
+ typeof contour.color.b === 'number' &&
+ 'a' in contour.color &&
+ typeof contour.color.a === 'number';
+
+ const errors = determineErrorMap(currentTab, contour);
+ if (errors !== validationErrors) setValidationErrors(errors);
+
+ const sectionIsComplete = isIsoband
+ ? validLower && validUpper && validColor
+ : validLower && validColor && validStrokeWidth;
+
+ if (sectionIsComplete !== isComplete) setIsComplete(sectionIsComplete);
+ }, [contour, currentTab]);
+
+ const onTabChange = (activeKey: any) => {
+ setCurrentTab(activeKey);
+ };
+
+ const updateStrokeWidth = (value: number | string) => {
+ const newContour = { ...contour };
+ newContour.strokeWidth = value;
+ setContour(newContour);
+ };
+
+ const updateColor = (rgb: ColorType) => {
+ const newContour = { ...contour };
+ newContour.color = { ...rgb, a: 100 };
+ setContour(newContour);
+ };
+
+ const updateLowerThreshold = (value: number | string) => {
+ const newContour = { ...contour };
+ newContour.lowerThreshold = value;
+ setContour(newContour);
+ };
+
+ const updateUpperThreshold = (value: number | string) => {
+ const newContour = { ...contour };
+ newContour.upperThreshold = value;
+ setContour(newContour);
+ };
+
+ const containsErrors = () => {
+ const keys = Object.keys(validationErrors);
+ return keys.some(key => validationErrors[key].length > 0);
+ };
+
+ const handleSave = () => {
+ if (isComplete && onSave) {
+ const newContour =
+ currentTab === CONTOUR_TYPES.Isoline
+ ? formatIsoline(contour)
+ : formatIsoband(contour);
+ onSave(convertContourToNumeric(newContour));
+ if (onClose) onClose();
+ }
+ };
+
+ return (
+ <>
+ <Tabs
+ id="contour-edit-tabs"
+ onChange={onTabChange}
+ defaultActiveKey={getTabKey(initialValue)}
+ >
+ <Tabs.TabPane
+ className="adhoc-filter-edit-tab"
+ key={CONTOUR_TYPES.Isoline}
+ tab={t('Isoline')}
+ >
+ <div key={CONTOUR_TYPES.Isoline} className="isoline-popover-section">
+ <StyledRow>
+ <Col flex="1">
+ <ControlHeader
+ name="isoline-threshold"
+ label={t('Threshold')}
+ description={t(
+ 'Defines the value that determines the boundary between different regions or levels in the data ',
+ )}
+ validationErrors={validationErrors.lowerThreshold}
+ hovered
+ />
+ <TextControl
+ value={contour.lowerThreshold}
+ onChange={updateLowerThreshold}
+ />
+ </Col>
+ </StyledRow>
+ <StyledRow>
+ <Col flex="1">
+ <ControlHeader
+ name="isoline-stroke-width"
+ label={t('Stroke Width')}
+ description={t('The width of the Isoline in pixels')}
+ validationErrors={validationErrors.strokeWidth}
+ hovered
+ />
+ <TextControl
+ value={contour.strokeWidth || ''}
+ onChange={updateStrokeWidth}
+ />
+ </Col>
+ <Col flex="1">
+ <ControlHeader
+ name="isoline-color"
+ label={t('Color')}
+ description={t('The color of the isoline')}
+ validationErrors={validationErrors.color}
+ hovered
+ />
+ <ColorPickerControl
+ value={typeof contour === 'object' && contour?.color}
+ onChange={updateColor}
+ />
+ </Col>
+ </StyledRow>
+ </div>
+ </Tabs.TabPane>
+ <Tabs.TabPane
+ className="adhoc-filter-edit-tab"
+ key={CONTOUR_TYPES.Isoband}
+ tab={t('Isoband')}
+ >
+ <div key={CONTOUR_TYPES.Isoband} className="isoline-popover-section">
+ <StyledRow>
+ <Col flex="1">
+ <ControlHeader
+ name="isoband-threshold-lower"
+ label={t('Lower Threshold')}
+ description={t(
+ 'The lower limit of the threshold range of the Isoband',
+ )}
+ validationErrors={validationErrors.lowerThreshold}
+ hovered
+ />
+ <TextControl
+ value={contour.lowerThreshold || ''}
+ onChange={updateLowerThreshold}
+ />
+ </Col>
+ <Col flex="1">
+ <ControlHeader
+ name="isoband-threshold-upper"
+ label={t('Upper Threshold')}
+ description={t(
+ 'The upper limit of the threshold range of the Isoband',
+ )}
+ validationErrors={validationErrors.upperThreshold}
+ hovered
+ />
+ <TextControl
+ value={contour.upperThreshold || ''}
+ onChange={updateUpperThreshold}
+ />
+ </Col>
+ </StyledRow>
+ <StyledRow>
+ <Col flex="1">
+ <ControlHeader
+ name="isoband-color"
+ label={t('Color')}
+ description={t('The color of the isoband')}
+ validationErrors={validationErrors.color}
+ hovered
+ />
+ <ColorPickerControl
+ value={contour?.color}
+ onChange={updateColor}
+ />
+ </Col>
+ </StyledRow>
+ </div>
+ </Tabs.TabPane>
+ </Tabs>
+ <ContourActionsContainer>
+ <Button buttonSize="small" onClick={onClose} cta>
+ {t('Close')}
+ </Button>
+ <Button
+ data-test="adhoc-filter-edit-popover-save-button"
+ disabled={!isComplete || containsErrors()}
+ buttonStyle="primary"
+ buttonSize="small"
+ className="m-r-5"
+ onClick={handleSave}
+ cta
+ >
+ {t('Save')}
+ </Button>
+ </ContourActionsContainer>
+ </>
+ );
+};
+
+export default ContourPopoverControl;
diff --git a/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.tsx
new file mode 100644
index 0000000000..b989731fc0
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.tsx
@@ -0,0 +1,60 @@
+/**
+ * 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 React, { useState } from 'react';
+import ContourPopoverControl from './ContourPopoverControl';
+import ControlPopover from '../ControlPopover/ControlPopover';
+import { ContourPopoverTriggerProps } from './types';
+
+const ContourPopoverTrigger = ({
+ value: initialValue,
+ saveContour,
+ isControlled,
+ visible: controlledVisibility,
+ toggleVisibility,
+ ...props
+}: ContourPopoverTriggerProps) => {
+ const [isVisible, setIsVisible] = useState(false);
+
+ const visible = isControlled ? controlledVisibility : isVisible;
+ const setVisibility =
+ isControlled && toggleVisibility ? toggleVisibility : setIsVisible;
+
+ const popoverContent = (
+ <ContourPopoverControl
+ value={initialValue}
+ onSave={saveContour}
+ onClose={() => setVisibility(false)}
+ />
+ );
+
+ return (
+ <ControlPopover
+ trigger="click"
+ content={popoverContent}
+ defaultVisible={visible}
+ visible={visible}
+ onVisibleChange={setVisibility}
+ destroyTooltipOnHide
+ >
+ {props.children}
+ </ControlPopover>
+ );
+};
+
+export default ContourPopoverTrigger;
diff --git a/superset-frontend/src/explore/components/controls/ContourControl/index.tsx b/superset-frontend/src/explore/components/controls/ContourControl/index.tsx
new file mode 100644
index 0000000000..09d1ef825d
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ContourControl/index.tsx
@@ -0,0 +1,143 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with work for additional information
+ * regarding copyright ownership. The ASF licenses file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use 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 React, { useState, useEffect } from 'react';
+import { styled, t } from '@superset-ui/core';
+import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
+import ContourPopoverTrigger from './ContourPopoverTrigger';
+import ContourOption from './ContourOption';
+import { ContourType, ContourControlProps } from './types';
+
+const DEFAULT_CONTOURS: ContourType[] = [
+ {
+ lowerThreshold: 4,
+ color: { r: 255, g: 0, b: 255, a: 100 },
+ strokeWidth: 1,
+ zIndex: 0,
+ },
+ {
+ lowerThreshold: 5,
+ color: { r: 0, g: 255, b: 0, a: 100 },
+ strokeWidth: 2,
+ zIndex: 1,
+ },
+ {
+ lowerThreshold: 6,
+ upperThreshold: 10,
+ color: { r: 0, g: 0, b: 255, a: 100 },
+ zIndex: 2,
+ },
+];
+
+const NewContourFormatPlaceholder = styled('div')`
+ position: relative;
+ width: calc(100% - ${({ theme }) => theme.gridUnit}px);
+ bottom: ${({ theme }) => theme.gridUnit * 4}px;
+ left: 0;
+`;
+
+const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
+ const [popoverVisible, setPopoverVisible] = useState(false);
+ const [contours, setContours] = useState<ContourType[]>(
+ props?.value ? props?.value : DEFAULT_CONTOURS,
+ );
+
+ useEffect(() => {
+ // add z-index to contours
+ const newContours = contours.map((contour, index) => ({
+ ...contour,
+ zIndex: (index + 1) * 10,
+ }));
+ onChange?.(newContours);
+ }, [onChange, contours]);
+
+ const togglePopover = (visible: boolean) => {
+ setPopoverVisible(visible);
+ };
+
+ const handleClickGhostButton = () => {
+ togglePopover(true);
+ };
+
+ const saveContour = (contour: ContourType) => {
+ setContours([...contours, contour]);
+ togglePopover(false);
+ };
+
+ const removeContour = (index: number) => {
+ const newContours = [...contours];
+ newContours.splice(index, 1);
+ setContours(newContours);
+ };
+
+ const onShiftContour = (hoverIndex: number, dragIndex: number) => {
+ const newContours = [...contours];
+ [newContours[hoverIndex], newContours[dragIndex]] = [
+ newContours[dragIndex],
+ newContours[hoverIndex],
+ ];
+ setContours(newContours);
+ };
+
+ const editContour = (contour: ContourType, index: number) => {
+ const newContours = [...contours];
+ newContours[index] = contour;
+ setContours(newContours);
+ };
+
+ const valuesRenderer = () =>
+ contours.map((contour, index) => (
+ <ContourOption
+ key={index}
+ saveContour={(newContour: ContourType) =>
+ editContour(newContour, index)
+ }
+ contour={contour}
+ index={index}
+ onClose={removeContour}
+ onShift={onShiftContour}
+ />
+ ));
+
+ const ghostButtonText = t('Click to add a contour');
+
+ return (
+ <>
+ <DndSelectLabel
+ onDrop={() => {}}
+ canDrop={() => true}
+ valuesRenderer={valuesRenderer}
+ accept={[]}
+ ghostButtonText={ghostButtonText}
+ onClickGhostButton={handleClickGhostButton}
+ {...props}
+ />
+ <ContourPopoverTrigger
+ saveContour={saveContour}
+ isControlled
+ visible={popoverVisible}
+ toggleVisibility={setPopoverVisible}
+ >
+ <NewContourFormatPlaceholder />
+ </ContourPopoverTrigger>
+ </>
+ );
+};
+
+export default ContourControl;
diff --git a/superset-frontend/src/explore/components/controls/ContourControl/types.ts b/superset-frontend/src/explore/components/controls/ContourControl/types.ts
new file mode 100644
index 0000000000..aab03df380
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ContourControl/types.ts
@@ -0,0 +1,55 @@
+import { OptionValueType } from 'src/explore/components/controls/DndColumnSelectControl/types';
+import { ControlComponentProps } from 'src/explore/components/Control';
+
+export interface ColorType {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+export interface ContourType extends OptionValueType {
+ color?: ColorType | undefined;
+ lowerThreshold?: any | undefined;
+ upperThreshold?: any | undefined;
+ strokeWidth?: any | undefined;
+}
+
+export interface ErrorMapType {
+ lowerThreshold: string[];
+ upperThreshold: string[];
+ strokeWidth: string[];
+ color: string[];
+}
+
+export interface ContourControlProps
+ extends ControlComponentProps<OptionValueType[]> {
+ contours?: {};
+}
+
+export interface ContourPopoverTriggerProps {
+ description?: string;
+ hovered?: boolean;
+ value?: ContourType;
+ children?: React.ReactNode;
+ saveContour: (contour: ContourType) => void;
+ isControlled?: boolean;
+ visible?: boolean;
+ toggleVisibility?: (visibility: boolean) => void;
+}
+
+export interface ContourPopoverControlProps {
+ description?: string;
+ hovered?: boolean;
+ value?: ContourType;
+ onSave?: (contour: ContourType) => void;
+ onClose?: () => void;
+}
+
+export interface ContourOptionProps {
+ contour: ContourType;
+ index: number;
+ saveContour: (contour: ContourType) => void;
+ onClose: (index: number) => void;
+ onShift: (hoverIndex: number, dragIndex: number) => void;
+}
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx
index e227228832..6cf5c188cb 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.tsx
@@ -59,6 +59,7 @@ export default function OptionWrapper(
isExtra,
datasourceWarningMessage,
canDelete = true,
+ tooltipOverlay,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
@@ -123,12 +124,20 @@ export default function OptionWrapper(
(!isDragging &&
labelRef &&
labelRef.current &&
- labelRef.current.scrollWidth > labelRef.current.clientWidth);
+ labelRef.current.scrollWidth > labelRef.current.clientWidth) ||
+ (!isDragging && tooltipOverlay);
const LabelContent = () => {
if (!shouldShowTooltip) {
return <span>{label}</span>;
}
+ if (tooltipOverlay) {
+ return (
+ <Tooltip overlay={tooltipOverlay}>
+ <span>{label}</span>
+ </Tooltip>
+ );
+ }
return (
<Tooltip title={tooltipTitle || label}>
<span>{label}</span>
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts
index 9f445a607d..32724863c8 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/types.ts
@@ -32,6 +32,7 @@ export interface OptionProps {
isExtra?: boolean;
datasourceWarningMessage?: string;
canDelete?: boolean;
+ tooltipOverlay?: ReactNode;
}
export interface OptionItemInterface {
diff --git a/superset-frontend/src/explore/components/controls/TextControl/index.tsx b/superset-frontend/src/explore/components/controls/TextControl/index.tsx
index 38877575d9..f15bd95c1b 100644
--- a/superset-frontend/src/explore/components/controls/TextControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/TextControl/index.tsx
@@ -30,7 +30,7 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
disabled?: boolean;
isFloat?: boolean;
isInt?: boolean;
- onChange?: (value: T, errors: any) => {};
+ onChange?: (value: T, errors: any) => void;
onFocus?: () => {};
placeholder?: string;
value?: T | null;
diff --git a/superset-frontend/src/explore/components/controls/index.js b/superset-frontend/src/explore/components/controls/index.js
index 725077cc8f..cba3c27f55 100644
--- a/superset-frontend/src/explore/components/controls/index.js
+++ b/superset-frontend/src/explore/components/controls/index.js
@@ -40,6 +40,7 @@ import MetricsControl from './MetricControl/MetricsControl';
import AdhocFilterControl from './FilterControl/AdhocFilterControl';
import FilterBoxItemControl from './FilterBoxItemControl';
import ConditionalFormattingControl from './ConditionalFormattingControl';
+import ContourControl from './ContourControl';
import DndColumnSelectControl, {
DndColumnSelect,
DndFilterSelect,
@@ -80,6 +81,7 @@ const controlMap = {
FilterBoxItemControl,
ConditionalFormattingControl,
XAxisSortControl,
+ ContourControl,
...sharedControlComponents,
};
export default controlMap;
diff --git a/superset/viz.py b/superset/viz.py
index df77cfadea..2e697a77be 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -2411,6 +2411,27 @@ class DeckHeatmap(BaseDeckGLViz):
return super().get_data(df)
+class DeckContour(BaseDeckGLViz):
+
+ """deck.gl's ContourLayer"""
+
+ viz_type = "deck_contour"
+ verbose_name = _("Deck.gl - Contour")
+ spatial_control_keys = ["spatial"]
+
+ def get_properties(self, data: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "position": data.get("spatial"),
+ "weight": (data.get(self.metric_label) if self.metric_label else None) or 1,
+ }
+
+ def get_data(self, df: pd.DataFrame) -> VizData:
+ self.metric_label = ( # pylint: disable=attribute-defined-outside-init
+ utils.get_metric_name(self.metric) if self.metric else None
+ )
+ return super().get_data(df)
+
+
class DeckGeoJson(BaseDeckGLViz):
"""deck.gl's GeoJSONLayer"""