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"""