You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2024/03/28 19:16:24 UTC

(superset) branch master updated: feat: Adds the ECharts Heatmap chart (#25353)

This is an automated email from the ASF dual-hosted git repository.

michaelsmolina 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 546d48adbb feat: Adds the ECharts Heatmap chart (#25353)
546d48adbb is described below

commit 546d48adbb84b1354d6a3d4ae88dbeba0ad14d44
Author: Michael S. Molina <70...@users.noreply.github.com>
AuthorDate: Thu Mar 28 16:16:17 2024 -0300

    feat: Adds the ECharts Heatmap chart (#25353)
---
 .../src/operators/index.ts                         |   1 +
 .../src/operators/{types.ts => rankOperator.ts}    |  15 +-
 .../src/operators/types.ts                         |   2 +-
 .../test/operators/rankOperator.test.ts            |  47 ++++
 .../src/query/types/PostProcessing.ts              |  12 +-
 .../legacy-plugin-chart-heatmap/src/index.js       |   9 +-
 .../plugins/plugin-chart-echarts/package.json      |  35 +--
 .../plugin-chart-echarts/src/Heatmap/Heatmap.tsx}  |  18 +-
 .../plugin-chart-echarts/src/Heatmap/buildQuery.ts |  68 +++++
 .../src/Heatmap/controlPanel.tsx                   | 304 +++++++++++++++++++++
 .../src/Heatmap/images/example1.png                | Bin 0 -> 69070 bytes
 .../src/Heatmap/images/example2.png                | Bin 0 -> 101622 bytes
 .../src/Heatmap/images/example3.png                | Bin 0 -> 76688 bytes
 .../src/Heatmap/images/thumbnail.png               | Bin 0 -> 66135 bytes
 .../src/Heatmap/index.ts}                          |  23 +-
 .../src/Heatmap/transformProps.ts                  | 243 ++++++++++++++++
 .../plugin-chart-echarts/src/Heatmap/types.ts      |  53 ++++
 .../plugins/plugin-chart-echarts/src/index.ts      |   2 +
 .../explore/components/controls/BoundsControl.tsx  |  14 +-
 .../controls/VizTypeControl/VizTypeGallery.tsx     |   1 +
 .../src/visualizations/presets/MainPreset.js       |   2 +
 superset/utils/pandas_postprocessing/__init__.py   |   2 +
 superset/utils/pandas_postprocessing/rank.py       |  40 +++
 23 files changed, 845 insertions(+), 46 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts
index f39d649f88..c7151dafd4 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts
@@ -28,4 +28,5 @@ export { contributionOperator } from './contributionOperator';
 export { prophetOperator } from './prophetOperator';
 export { boxplotOperator } from './boxplotOperator';
 export { flattenOperator } from './flattenOperator';
+export { rankOperator } from './rankOperator';
 export * from './utils';
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rankOperator.ts
similarity index 72%
copy from superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
copy to superset-frontend/packages/superset-ui-chart-controls/src/operators/rankOperator.ts
index 34f632ff8f..2f9da25d32 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rankOperator.ts
@@ -16,8 +16,15 @@
  * specific language governing permissions and limitationsxw
  * under the License.
  */
-import { QueryFormData, QueryObject } from '@superset-ui/core';
+import { PostProcessingRank } from '@superset-ui/core';
+import { PostProcessingFactory } from './types';
 
-export interface PostProcessingFactory<T> {
-  (formData: QueryFormData, queryObject: QueryObject): T;
-}
+/* eslint-disable @typescript-eslint/no-unused-vars */
+export const rankOperator: PostProcessingFactory<PostProcessingRank> = (
+  formData,
+  queryObject,
+  options,
+) => ({
+  operation: 'rank',
+  options,
+});
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
index 34f632ff8f..0c5285a2a1 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
@@ -19,5 +19,5 @@
 import { QueryFormData, QueryObject } from '@superset-ui/core';
 
 export interface PostProcessingFactory<T> {
-  (formData: QueryFormData, queryObject: QueryObject): T;
+  (formData: QueryFormData, queryObject: QueryObject, options?: any): T;
 }
diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/rankOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/rankOperator.test.ts
new file mode 100644
index 0000000000..91d67b59a2
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/rankOperator.test.ts
@@ -0,0 +1,47 @@
+/**
+ * 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 { QueryObject, SqlaFormData } from '@superset-ui/core';
+import { rankOperator } from '@superset-ui/chart-controls';
+
+const formData: SqlaFormData = {
+  x_axis: 'dttm',
+  metrics: ['sales'],
+  groupby: ['department'],
+  time_range: '2015 : 2016',
+  granularity: 'month',
+  datasource: 'foo',
+  viz_type: 'table',
+  truncate_metric: true,
+};
+const queryObject: QueryObject = {
+  is_timeseries: true,
+  metrics: ['sales'],
+  columns: ['department'],
+  time_range: '2015 : 2016',
+  granularity: 'month',
+  post_processing: [],
+};
+
+test('should add rankOperator', () => {
+  const options = { metric: 'sales', group_by: 'department' };
+  expect(rankOperator(formData, queryObject, options)).toEqual({
+    operation: 'rank',
+    options,
+  });
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts
index e32eda6a90..3b40941332 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts
@@ -224,6 +224,15 @@ export type PostProcessingFlatten =
   | _PostProcessingFlatten
   | DefaultPostProcessing;
 
+interface _PostProcessingRank {
+  operation: 'rank';
+  options?: {
+    metric: string;
+    group_by: string | null;
+  };
+}
+export type PostProcessingRank = _PostProcessingRank | DefaultPostProcessing;
+
 /**
  * Parameters for chart data postprocessing.
  * See superset/utils/pandas_processing.py.
@@ -241,7 +250,8 @@ export type PostProcessingRule =
   | PostProcessingSort
   | PostProcessingResample
   | PostProcessingRename
-  | PostProcessingFlatten;
+  | PostProcessingFlatten
+  | PostProcessingRank;
 
 export function isPostProcessingAggregation(
   rule?: PostProcessingRule,
diff --git a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js b/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js
index 43d5b3eda0..3779c0d03e 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js
+++ b/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js
@@ -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, ChartLabel } from '@superset-ui/core';
 import transformProps from './transformProps';
 import transportation from './images/transportation.jpg';
 import channels from './images/channels.jpg';
@@ -35,7 +35,8 @@ const metadata = new ChartMetadata({
     { url: channels, caption: t('Relationships between community channels') },
     { url: employment, caption: t('Employment and education') },
   ],
-  name: t('Heatmap'),
+  label: ChartLabel.DEPRECATED,
+  name: t('Heatmap (legacy)'),
   tags: [
     t('Business'),
     t('Intensity'),
@@ -43,11 +44,15 @@ const metadata = new ChartMetadata({
     t('Density'),
     t('Predictive'),
     t('Single Metric'),
+    t('Deprecated'),
   ],
   thumbnail,
   useLegacyApi: true,
 });
 
+/**
+ * @deprecated in version 4.0.
+ */
 export default class HeatmapChartPlugin extends ChartPlugin {
   constructor() {
     super({
diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json b/superset-frontend/plugins/plugin-chart-echarts/package.json
index c91e52bdf3..6c0e5fa63f 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/package.json
+++ b/superset-frontend/plugins/plugin-chart-echarts/package.json
@@ -2,30 +2,27 @@
   "name": "@superset-ui/plugin-chart-echarts",
   "version": "0.18.25",
   "description": "Superset Chart - Echarts",
-  "sideEffects": false,
-  "main": "lib/index.js",
-  "module": "esm/index.js",
-  "files": [
-    "esm",
-    "lib"
-  ],
-  "repository": {
-    "type": "git",
-    "url": "https://github.com/apache/superset.git",
-    "directory": "superset-frontend/plugins/plugin-chart-echarts"
-  },
   "keywords": [
     "superset"
   ],
-  "author": "Superset",
-  "license": "Apache-2.0",
+  "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-echarts#readme",
   "bugs": {
     "url": "https://github.com/apache/superset/issues"
   },
-  "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-echarts#readme",
-  "publishConfig": {
-    "access": "public"
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/apache/superset.git",
+    "directory": "superset-frontend/plugins/plugin-chart-echarts"
   },
+  "license": "Apache-2.0",
+  "author": "Superset",
+  "sideEffects": false,
+  "main": "lib/index.js",
+  "module": "esm/index.js",
+  "files": [
+    "esm",
+    "lib"
+  ],
   "dependencies": {
     "d3-array": "^1.2.0",
     "echarts": "^5.4.1",
@@ -35,6 +32,10 @@
   "peerDependencies": {
     "@superset-ui/chart-controls": "*",
     "@superset-ui/core": "*",
+    "memoize-one": "*",
     "react": "^16.13.1"
+  },
+  "publishConfig": {
+    "access": "public"
   }
 }
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx
similarity index 63%
copy from superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
copy to superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx
index 34f632ff8f..555b9a63a3 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx
@@ -13,11 +13,21 @@
  * 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 limitationsxw
+ * specific language governing permissions and limitations
  * under the License.
  */
-import { QueryFormData, QueryObject } from '@superset-ui/core';
+import React from 'react';
+import { HeatmapTransformedProps } from './types';
+import Echart from '../components/Echart';
 
-export interface PostProcessingFactory<T> {
-  (formData: QueryFormData, queryObject: QueryObject): T;
+export default function Heatmap(props: HeatmapTransformedProps) {
+  const { height, width, echartOptions, refs } = props;
+  return (
+    <Echart
+      refs={refs}
+      height={height}
+      width={width}
+      echartOptions={echartOptions}
+    />
+  );
 }
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/buildQuery.ts
new file mode 100644
index 0000000000..2d1ee869eb
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/buildQuery.ts
@@ -0,0 +1,68 @@
+/**
+ * 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 {
+  QueryFormColumn,
+  QueryFormOrderBy,
+  buildQueryContext,
+  ensureIsArray,
+  getColumnLabel,
+  getMetricLabel,
+  getXAxisColumn,
+} from '@superset-ui/core';
+import { rankOperator } from '@superset-ui/chart-controls';
+import { HeatmapFormData } from './types';
+
+export default function buildQuery(formData: HeatmapFormData) {
+  const { groupby, normalize_across, sort_x_axis, sort_y_axis, x_axis } =
+    formData;
+  const metric = getMetricLabel(formData.metric);
+  const columns = [
+    ...ensureIsArray(getXAxisColumn(formData)),
+    ...ensureIsArray(groupby),
+  ];
+  const orderby: QueryFormOrderBy[] = [
+    [
+      sort_x_axis.includes('value') ? metric : columns[0],
+      sort_x_axis.includes('asc'),
+    ],
+    [
+      sort_y_axis.includes('value') ? metric : columns[1],
+      sort_y_axis.includes('asc'),
+    ],
+  ];
+  const group_by =
+    normalize_across === 'x'
+      ? getColumnLabel(x_axis)
+      : normalize_across === 'y'
+        ? getColumnLabel(groupby as unknown as QueryFormColumn)
+        : undefined;
+  return buildQueryContext(formData, baseQueryObject => [
+    {
+      ...baseQueryObject,
+      columns,
+      orderby,
+      post_processing: [
+        rankOperator(formData, baseQueryObject, {
+          metric,
+          group_by,
+        }),
+      ],
+    },
+  ]);
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/controlPanel.tsx
new file mode 100644
index 0000000000..28356e6441
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/controlPanel.tsx
@@ -0,0 +1,304 @@
+/**
+ * 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 from 'react';
+import { t, validateNonEmpty } from '@superset-ui/core';
+import {
+  ControlPanelConfig,
+  formatSelectOptionsForRange,
+  getStandardizedControls,
+} from '@superset-ui/chart-controls';
+
+const sortAxisChoices = [
+  ['alpha_asc', t('Axis ascending')],
+  ['alpha_desc', t('Axis descending')],
+  ['value_asc', t('Metric ascending')],
+  ['value_desc', t('Metric descending')],
+];
+
+const config: ControlPanelConfig = {
+  controlPanelSections: [
+    {
+      label: t('Query'),
+      expanded: true,
+      controlSetRows: [
+        ['x_axis'],
+        ['time_grain_sqla'],
+        ['groupby'],
+        ['metric'],
+        ['adhoc_filters'],
+        ['row_limit'],
+        [
+          {
+            name: 'sort_x_axis',
+            config: {
+              type: 'SelectControl',
+              label: t('Sort X Axis'),
+              choices: sortAxisChoices,
+              renderTrigger: false,
+              clearable: false,
+              default: 'alpha_asc',
+            },
+          },
+        ],
+        [
+          {
+            name: 'sort_y_axis',
+            config: {
+              type: 'SelectControl',
+              label: t('Sort Y Axis'),
+              choices: sortAxisChoices,
+              renderTrigger: false,
+              clearable: false,
+              default: 'alpha_asc',
+            },
+          },
+        ],
+        [
+          {
+            name: 'normalize_across',
+            config: {
+              type: 'SelectControl',
+              label: t('Normalize Across'),
+              choices: [
+                ['heatmap', t('heatmap')],
+                ['x', t('x')],
+                ['y', t('y')],
+              ],
+              default: 'heatmap',
+              renderTrigger: false,
+              description: (
+                <>
+                  <div>
+                    {t(
+                      'Color will be shaded based the normalized (0% to 100%) value of a given cell against the other cells in the selected range: ',
+                    )}
+                  </div>
+                  <ul>
+                    <li>{t('x: values are normalized within each column')}</li>
+                    <li>{t('y: values are normalized within each row')}</li>
+                    <li>
+                      {t(
+                        'heatmap: values are normalized across the entire heatmap',
+                      )}
+                    </li>
+                  </ul>
+                </>
+              ),
+            },
+          },
+        ],
+      ],
+    },
+    {
+      label: t('Chart Options'),
+      expanded: true,
+      controlSetRows: [
+        [
+          {
+            name: 'legend_type',
+            config: {
+              type: 'SelectControl',
+              label: t('Legend Type'),
+              renderTrigger: true,
+              choices: [
+                ['continuous', t('Continuous')],
+                ['piecewise', t('Piecewise')],
+              ],
+              default: 'continuous',
+              clearable: false,
+            },
+          },
+        ],
+        ['linear_color_scheme'],
+        [
+          {
+            name: 'xscale_interval',
+            config: {
+              type: 'SelectControl',
+              label: t('XScale Interval'),
+              renderTrigger: true,
+              choices: [[-1, t('Auto')]].concat(
+                formatSelectOptionsForRange(1, 50),
+              ),
+              default: -1,
+              clearable: false,
+              description: t(
+                'Number of steps to take between ticks when displaying the X scale',
+              ),
+            },
+          },
+        ],
+        [
+          {
+            name: 'yscale_interval',
+            config: {
+              type: 'SelectControl',
+              label: t('YScale Interval'),
+              choices: [[-1, t('Auto')]].concat(
+                formatSelectOptionsForRange(1, 50),
+              ),
+              default: -1,
+              clearable: false,
+              renderTrigger: true,
+              description: t(
+                'Number of steps to take between ticks when displaying the Y scale',
+              ),
+            },
+          },
+        ],
+        [
+          {
+            name: 'left_margin',
+            config: {
+              type: 'SelectControl',
+              freeForm: true,
+              clearable: false,
+              label: t('Left Margin'),
+              choices: [
+                ['auto', t('Auto')],
+                [50, '50'],
+                [75, '75'],
+                [100, '100'],
+                [125, '125'],
+                [150, '150'],
+                [200, '200'],
+              ],
+              default: 'auto',
+              renderTrigger: true,
+              description: t(
+                'Left margin, in pixels, allowing for more room for axis labels',
+              ),
+            },
+          },
+        ],
+        [
+          {
+            name: 'bottom_margin',
+            config: {
+              type: 'SelectControl',
+              clearable: false,
+              freeForm: true,
+              label: t('Bottom Margin'),
+              choices: [
+                ['auto', t('Auto')],
+                [50, '50'],
+                [75, '75'],
+                [100, '100'],
+                [125, '125'],
+                [150, '150'],
+                [200, '200'],
+              ],
+              default: 'auto',
+              renderTrigger: true,
+              description: t(
+                'Bottom margin, in pixels, allowing for more room for axis labels',
+              ),
+            },
+          },
+        ],
+        [
+          {
+            name: 'value_bounds',
+            config: {
+              type: 'BoundsControl',
+              label: t('Value bounds'),
+              renderTrigger: true,
+              default: [null, null],
+              description: t('Hard value bounds applied for color coding.'),
+            },
+          },
+        ],
+        ['y_axis_format'],
+        ['x_axis_time_format'],
+        ['currency_format'],
+        [
+          {
+            name: 'show_legend',
+            config: {
+              type: 'CheckboxControl',
+              label: t('Legend'),
+              renderTrigger: true,
+              default: true,
+              description: t('Whether to display the legend (toggles)'),
+            },
+          },
+        ],
+        [
+          {
+            name: 'show_percentage',
+            config: {
+              type: 'CheckboxControl',
+              label: t('Show percentage'),
+              renderTrigger: true,
+              description: t(
+                'Whether to include the percentage in the tooltip',
+              ),
+              default: true,
+            },
+          },
+        ],
+        [
+          {
+            name: 'show_values',
+            config: {
+              type: 'CheckboxControl',
+              label: t('Show Values'),
+              renderTrigger: true,
+              default: false,
+              description: t(
+                'Whether to display the numerical values within the cells',
+              ),
+            },
+          },
+        ],
+        [
+          {
+            name: 'normalized',
+            config: {
+              type: 'CheckboxControl',
+              label: t('Normalized'),
+              renderTrigger: true,
+              description: t(
+                'Whether to apply a normal distribution based on rank on the color scale',
+              ),
+              default: false,
+            },
+          },
+        ],
+      ],
+    },
+  ],
+  controlOverrides: {
+    groupby: {
+      label: t('Y-Axis'),
+      description: t('Dimension to use on y-axis.'),
+      multi: false,
+      validators: [validateNonEmpty],
+    },
+    y_axis_format: {
+      label: t('Value Format'),
+    },
+  },
+  formDataOverrides: formData => ({
+    ...formData,
+    metric: getStandardizedControls().shiftMetric(),
+  }),
+};
+
+export default config;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example1.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example1.png
new file mode 100644
index 0000000000..7d7339b316
Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example1.png differ
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example2.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example2.png
new file mode 100644
index 0000000000..ea4b1ec163
Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example2.png differ
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example3.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example3.png
new file mode 100644
index 0000000000..c161192fa0
Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example3.png differ
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/thumbnail.png
new file mode 100644
index 0000000000..2993ef351f
Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/thumbnail.png differ
diff --git a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/index.ts
similarity index 71%
copy from superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js
copy to superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/index.ts
index 43d5b3eda0..e9b2c75441 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/index.ts
@@ -18,40 +18,35 @@
  */
 import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
 import transformProps from './transformProps';
-import transportation from './images/transportation.jpg';
-import channels from './images/channels.jpg';
-import employment from './images/employment.jpg';
+import buildQuery from './buildQuery';
+import example1 from './images/example1.png';
+import example2 from './images/example2.png';
+import example3 from './images/example3.png';
 import thumbnail from './images/thumbnail.png';
 import controlPanel from './controlPanel';
 
 const metadata = new ChartMetadata({
   category: t('Correlation'),
-  credits: ['http://bl.ocks.org/mbostock/3074470'],
   description: t(
     'Visualize a related metric across pairs of groups. Heatmaps excel at showcasing the correlation or strength between two groups. Color is used to emphasize the strength of the link between each pair of groups.',
   ),
-  exampleGallery: [
-    { url: transportation, caption: t('Sizes of vehicles') },
-    { url: channels, caption: t('Relationships between community channels') },
-    { url: employment, caption: t('Employment and education') },
-  ],
+  exampleGallery: [{ url: example1 }, { url: example2 }, { url: example3 }],
   name: t('Heatmap'),
   tags: [
     t('Business'),
     t('Intensity'),
-    t('Legacy'),
     t('Density'),
-    t('Predictive'),
     t('Single Metric'),
+    t('ECharts'),
   ],
   thumbnail,
-  useLegacyApi: true,
 });
 
-export default class HeatmapChartPlugin extends ChartPlugin {
+export default class EchartsHeatmapChartPlugin extends ChartPlugin {
   constructor() {
     super({
-      loadChart: () => import('./ReactHeatmap'),
+      buildQuery,
+      loadChart: () => import('./Heatmap'),
       metadata,
       transformProps,
       controlPanel,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts
new file mode 100644
index 0000000000..832b868779
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts
@@ -0,0 +1,243 @@
+/**
+ * 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 {
+  GenericDataType,
+  QueryFormColumn,
+  getColumnLabel,
+  getMetricLabel,
+  getSequentialSchemeRegistry,
+  getTimeFormatter,
+  getValueFormatter,
+} from '@superset-ui/core';
+import memoizeOne from 'memoize-one';
+import { maxBy, minBy } from 'lodash';
+import { EChartsOption, HeatmapSeriesOption } from 'echarts';
+import { CallbackDataParams } from 'echarts/types/src/util/types';
+import { HeatmapChartProps, HeatmapTransformedProps } from './types';
+import { getDefaultTooltip } from '../utils/tooltip';
+import { Refs } from '../types';
+import { parseAxisBound } from '../utils/controls';
+import { NULL_STRING } from '../constants';
+
+// Calculated totals per x and y categories plus total
+const calculateTotals = memoizeOne(
+  (
+    data: Record<string, any>[],
+    xAxis: string,
+    groupby: string,
+    metric: string,
+  ) =>
+    data.reduce(
+      (acc, row) => {
+        const value = row[metric];
+        if (typeof value !== 'number') {
+          return acc;
+        }
+        const x = row[xAxis] as string;
+        const y = row[groupby] as string;
+        const xTotal = acc.x[x] || 0;
+        const yTotal = acc.y[y] || 0;
+        return {
+          x: { ...acc.x, [x]: xTotal + value },
+          y: { ...acc.y, [y]: yTotal + value },
+          total: acc.total + value,
+        };
+      },
+      { x: {}, y: {}, total: 0 },
+    ),
+);
+
+export default function transformProps(
+  chartProps: HeatmapChartProps,
+): HeatmapTransformedProps {
+  const refs: Refs = {};
+  const { width, height, formData, queriesData, datasource } = chartProps;
+  const {
+    bottomMargin,
+    xAxis,
+    groupby,
+    linearColorScheme,
+    leftMargin,
+    legendType = 'continuous',
+    metric,
+    normalizeAcross,
+    normalized,
+    showLegend,
+    showPercentage,
+    showValues,
+    xscaleInterval,
+    yscaleInterval,
+    valueBounds,
+    yAxisFormat,
+    xAxisTimeFormat,
+    currencyFormat,
+  } = formData;
+  const metricLabel = getMetricLabel(metric);
+  const xAxisLabel = getColumnLabel(xAxis);
+  // groupby is overridden to be a single value
+  const yAxisLabel = getColumnLabel(groupby as unknown as QueryFormColumn);
+  const { data, colnames, coltypes } = queriesData[0];
+  const { columnFormats = {}, currencyFormats = {} } = datasource;
+  const colorColumn = normalized ? 'rank' : metricLabel;
+  const colors = getSequentialSchemeRegistry().get(linearColorScheme)?.colors;
+  const getAxisFormatter =
+    (colType: GenericDataType) => (value: number | string) => {
+      if (colType === GenericDataType.Temporal) {
+        if (typeof value === 'string') {
+          return getTimeFormatter(xAxisTimeFormat)(Number.parseInt(value, 10));
+        }
+        return getTimeFormatter(xAxisTimeFormat)(value);
+      }
+      return String(value);
+    };
+
+  const xAxisFormatter = getAxisFormatter(coltypes[0]);
+  const yAxisFormatter = getAxisFormatter(coltypes[1]);
+  const valueFormatter = getValueFormatter(
+    metric,
+    currencyFormats,
+    columnFormats,
+    yAxisFormat,
+    currencyFormat,
+  );
+
+  let [min, max] = (valueBounds || []).map(parseAxisBound);
+  if (min === undefined) {
+    min = minBy(data, row => row[colorColumn])?.[colorColumn] as number;
+  }
+  if (max === undefined) {
+    max = maxBy(data, row => row[colorColumn])?.[colorColumn] as number;
+  }
+
+  const series: HeatmapSeriesOption[] = [
+    {
+      name: metricLabel,
+      type: 'heatmap',
+      data: data.map(row =>
+        colnames.map(col => {
+          const value = row[col];
+          if (!value) {
+            return NULL_STRING;
+          }
+          if (typeof value === 'boolean') {
+            return String(value);
+          }
+          return value;
+        }),
+      ),
+      label: {
+        show: showValues,
+        formatter: (params: CallbackDataParams) =>
+          valueFormatter(params.value[2]),
+      },
+    },
+  ];
+
+  const echartOptions: EChartsOption = {
+    grid: {
+      containLabel: true,
+      bottom: bottomMargin,
+      left: leftMargin,
+    },
+    series,
+    tooltip: {
+      ...getDefaultTooltip(refs),
+      formatter: (params: CallbackDataParams) => {
+        const totals = calculateTotals(
+          data,
+          xAxisLabel,
+          yAxisLabel,
+          metricLabel,
+        );
+        const x = params.value[0];
+        const y = params.value[1];
+        const value = params.value[2];
+        const formattedX = xAxisFormatter(x);
+        const formattedY = yAxisFormatter(y);
+        const formattedValue = valueFormatter(value);
+        let percentage = 0;
+        let suffix = 'heatmap';
+        if (typeof value === 'number') {
+          if (normalizeAcross === 'x') {
+            percentage = (value / totals.x[x]) * 100;
+            suffix = formattedX;
+          } else if (normalizeAcross === 'y') {
+            percentage = (value / totals.y[y]) * 100;
+            suffix = formattedY;
+          } else {
+            percentage = (value / totals.total) * 100;
+            suffix = 'heatmap';
+          }
+        }
+        return `
+          <div>
+            <div>${colnames[0]}: <b>${formattedX}</b></div>
+            <div>${colnames[1]}: <b>${formattedY}</b></div>
+            <div>${colnames[2]}: <b>${formattedValue}</b></div>
+            ${
+              showPercentage
+                ? `<div>% (${suffix}): <b>${valueFormatter(
+                    percentage,
+                  )}%</b></div>`
+                : ''
+            }
+          </div>`;
+      },
+    },
+    visualMap: {
+      type: legendType,
+      min,
+      max,
+      calculable: true,
+      orient: 'horizontal',
+      right: 0,
+      top: 0,
+      itemHeight: legendType === 'continuous' ? 300 : 14,
+      itemWidth: 15,
+      formatter: min => valueFormatter(min as number),
+      inRange: {
+        color: colors,
+      },
+      show: showLegend,
+      // By default, ECharts uses the last dimension which is rank
+      dimension: normalized ? 3 : 2,
+    },
+    xAxis: {
+      type: 'category',
+      axisLabel: {
+        formatter: xAxisFormatter,
+        interval: xscaleInterval === -1 ? 'auto' : xscaleInterval - 1,
+      },
+    },
+    yAxis: {
+      type: 'category',
+      axisLabel: {
+        formatter: yAxisFormatter,
+        interval: yscaleInterval === -1 ? 'auto' : yscaleInterval - 1,
+      },
+    },
+  };
+  return {
+    refs,
+    echartOptions,
+    width,
+    height,
+    formData,
+  };
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/types.ts
new file mode 100644
index 0000000000..8ec9847032
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/types.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 {
+  Currency,
+  QueryFormColumn,
+  QueryFormData,
+  QueryFormMetric,
+} from '@superset-ui/core';
+import { BaseChartProps, BaseTransformedProps } from '../types';
+
+export interface HeatmapFormData extends QueryFormData {
+  bottomMargin: string;
+  currencyFormat?: Currency;
+  leftMargin: string;
+  legendType: 'continuous' | 'piecewise';
+  linearColorScheme?: string;
+  metric: QueryFormMetric;
+  normalizeAcross: 'heatmap' | 'x' | 'y';
+  normalized?: boolean;
+  showLegend?: boolean;
+  showPercentage?: boolean;
+  showValues?: boolean;
+  sortXAxis: string;
+  sortYAxis: string;
+  timeFormat?: string;
+  xAxis: QueryFormColumn;
+  xscaleInterval: number;
+  valueBounds: [number | undefined | null, number | undefined | null];
+  yAxisFormat?: string;
+  yscaleInterval: number;
+}
+
+export interface HeatmapChartProps extends BaseChartProps<HeatmapFormData> {
+  formData: HeatmapFormData;
+}
+
+export type HeatmapTransformedProps = BaseTransformedProps<HeatmapFormData>;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
index 2e19203138..f3bee62095 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
@@ -31,6 +31,7 @@ export { default as EchartsGaugeChartPlugin } from './Gauge';
 export { default as EchartsRadarChartPlugin } from './Radar';
 export { default as EchartsFunnelChartPlugin } from './Funnel';
 export { default as EchartsTreeChartPlugin } from './Tree';
+export { default as EchartsHeatmapChartPlugin } from './Heatmap';
 export { default as EchartsTreemapChartPlugin } from './Treemap';
 export {
   BigNumberChartPlugin,
@@ -51,6 +52,7 @@ export { default as RadarTransformProps } from './Radar/transformProps';
 export { default as TimeseriesTransformProps } from './Timeseries/transformProps';
 export { default as TreeTransformProps } from './Tree/transformProps';
 export { default as TreemapTransformProps } from './Treemap/transformProps';
+export { default as HeatmapTransformProps } from './Heatmap/transformProps';
 export { default as SunburstTransformProps } from './Sunburst/transformProps';
 export { default as BubbleTransformProps } from './Bubble/transformProps';
 export { default as WaterfallTransformProps } from './Waterfall/transformProps';
diff --git a/superset-frontend/src/explore/components/controls/BoundsControl.tsx b/superset-frontend/src/explore/components/controls/BoundsControl.tsx
index 97fd26e283..03ab603791 100644
--- a/superset-frontend/src/explore/components/controls/BoundsControl.tsx
+++ b/superset-frontend/src/explore/components/controls/BoundsControl.tsx
@@ -19,7 +19,7 @@
 import React, { useEffect, useRef, useState } from 'react';
 import { InputNumber } from 'src/components/Input';
 import { t, styled } from '@superset-ui/core';
-import { debounce } from 'lodash';
+import { debounce, parseInt } from 'lodash';
 import ControlHeader from 'src/explore/components/ControlHeader';
 
 type ValueType = (number | null)[];
@@ -43,8 +43,16 @@ const MaxInput = styled(InputNumber)`
   margin-left: ${({ theme }) => theme.gridUnit}px;
 `;
 
-const parseNumber = (value: undefined | number | string | null) =>
-  value === null || Number.isNaN(Number(value)) ? null : Number(value);
+const parseNumber = (value: undefined | number | string | null) => {
+  if (
+    value === null ||
+    value === undefined ||
+    (typeof value === 'string' && Number.isNaN(parseInt(value)))
+  ) {
+    return null;
+  }
+  return Number(value);
+};
 
 export default function BoundsControl({
   onChange = () => {},
diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx
index 4905e9a333..ac84bd260c 100644
--- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx
+++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx
@@ -88,6 +88,7 @@ const DEFAULT_ORDER = [
   'time_pivot',
   'deck_arc',
   'heatmap',
+  'heatmap_v2',
   'deck_grid',
   'deck_screengrid',
   'treemap_v2',
diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js
index f77f8c037b..1d447228f9 100644
--- a/superset-frontend/src/visualizations/presets/MainPreset.js
+++ b/superset-frontend/src/visualizations/presets/MainPreset.js
@@ -67,6 +67,7 @@ import {
   EchartsBubbleChartPlugin,
   EchartsWaterfallChartPlugin,
   BigNumberPeriodOverPeriodChartPlugin,
+  EchartsHeatmapChartPlugin,
 } from '@superset-ui/plugin-chart-echarts';
 import {
   SelectFilterPlugin,
@@ -158,6 +159,7 @@ export default class MainPreset extends Preset {
         new EchartsWaterfallChartPlugin().configure({
           key: 'waterfall',
         }),
+        new EchartsHeatmapChartPlugin().configure({ key: 'heatmap_v2' }),
         new SelectFilterPlugin().configure({ key: FilterPlugins.Select }),
         new RangeFilterPlugin().configure({ key: FilterPlugins.Range }),
         new TimeFilterPlugin().configure({ key: FilterPlugins.Time }),
diff --git a/superset/utils/pandas_postprocessing/__init__.py b/superset/utils/pandas_postprocessing/__init__.py
index e66a52f655..6d6c833e64 100644
--- a/superset/utils/pandas_postprocessing/__init__.py
+++ b/superset/utils/pandas_postprocessing/__init__.py
@@ -28,6 +28,7 @@ from superset.utils.pandas_postprocessing.geography import (
 )
 from superset.utils.pandas_postprocessing.pivot import pivot
 from superset.utils.pandas_postprocessing.prophet import prophet
+from superset.utils.pandas_postprocessing.rank import rank
 from superset.utils.pandas_postprocessing.rename import rename
 from superset.utils.pandas_postprocessing.resample import resample
 from superset.utils.pandas_postprocessing.rolling import rolling
@@ -50,6 +51,7 @@ __all__ = [
     "geodetic_parse",
     "pivot",
     "prophet",
+    "rank",
     "rename",
     "resample",
     "rolling",
diff --git a/superset/utils/pandas_postprocessing/rank.py b/superset/utils/pandas_postprocessing/rank.py
new file mode 100644
index 0000000000..7b69e80089
--- /dev/null
+++ b/superset/utils/pandas_postprocessing/rank.py
@@ -0,0 +1,40 @@
+# 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.
+from __future__ import annotations
+
+import pandas as pd
+
+
+def rank(
+    df: pd.DataFrame,
+    metric: str,
+    group_by: str | None = None,
+) -> pd.DataFrame:
+    """
+    Calculates the rank of a metric within a group.
+
+    :param df: N-dimensional DataFrame.
+    :param metric: The metric to rank.
+    :param group_by: The column to group by.
+    :return: a flat DataFrame
+    """
+    if group_by:
+        gb = df.groupby(group_by, group_keys=False)
+        df["rank"] = gb.apply(lambda x: x[metric].rank(pct=True))
+    else:
+        df["rank"] = df[metric].rank(pct=True)
+    return df