diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
new file mode 100644
index 0000000000000..08f381b15d323
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
@@ -0,0 +1,126 @@
+/**
+ * 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, { useCallback } from 'react';
+import { BinaryQueryObjectFilterClause } from '@superset-ui/core';
+import { SunburstTransformedProps } from './types';
+import Echart from '../components/Echart';
+import { EventHandlers, TreePathInfo } from '../types';
+
+export const extractTreePathInfo = (treePathInfo: TreePathInfo[] | undefined) =>
+ (treePathInfo ?? [])
+ .map(pathInfo => pathInfo?.name || '')
+ .filter(path => path !== '');
+
+export default function EchartsSunburst(props: SunburstTransformedProps) {
+ const {
+ height,
+ width,
+ echartOptions,
+ setDataMask,
+ labelMap,
+ selectedValues,
+ formData,
+ onContextMenu,
+ refs,
+ } = props;
+
+ const { emitFilter, columns } = formData;
+
+ const handleChange = useCallback(
+ (values: string[]) => {
+ if (!emitFilter) {
+ return;
+ }
+
+ const labels = values.map(value => labelMap[value]);
+
+ setDataMask({
+ extraFormData: {
+ filters:
+ values.length === 0 || !columns
+ ? []
+ : columns.map((col, idx) => {
+ const val = labels.map(v => v[idx]);
+ if (val === null || val === undefined)
+ return {
+ col,
+ op: 'IS NULL',
+ };
+ return {
+ col,
+ op: 'IN',
+ val: val as (string | number | boolean)[],
+ };
+ }),
+ },
+ filterState: {
+ value: labels.length ? labels : null,
+ selectedValues: values.length ? values : null,
+ },
+ });
+ },
+ [emitFilter, setDataMask, columns, labelMap],
+ );
+
+ const eventHandlers: EventHandlers = {
+ click: props => {
+ const { treePathInfo } = props;
+ const treePath = extractTreePathInfo(treePathInfo);
+ const name = treePath.join(',');
+ const values = Object.values(selectedValues);
+ if (values.includes(name)) {
+ handleChange(values.filter(v => v !== name));
+ } else {
+ handleChange([name]);
+ }
+ },
+ contextmenu: eventParams => {
+ if (onContextMenu) {
+ eventParams.event.stop();
+ const treePath = extractTreePathInfo(eventParams.treePathInfo);
+ if (treePath.length > 0) {
+ const pointerEvent = eventParams.event.event;
+ const filters: BinaryQueryObjectFilterClause[] = [];
+ if (columns) {
+ treePath.forEach((path, i) =>
+ filters.push({
+ col: columns[i],
+ op: '==',
+ val: path,
+ formattedVal: path,
+ }),
+ );
+ }
+ onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
+ }
+ }
+ },
+ };
+
+ return (
+
+ );
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/buildQuery.ts
new file mode 100644
index 0000000000000..8b47fb5e725cc
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/buildQuery.ts
@@ -0,0 +1,29 @@
+/**
+ * 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 { buildQueryContext, QueryFormData } from '@superset-ui/core';
+
+export default function buildQuery(formData: QueryFormData) {
+ const { metric, sort_by_metric } = formData;
+ return buildQueryContext(formData, baseQueryObject => [
+ {
+ ...baseQueryObject,
+ ...(sort_by_metric && { orderby: [[metric, false]] }),
+ },
+ ]);
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/controlPanel.tsx
new file mode 100644
index 0000000000000..9c4c5ae5bc46c
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/controlPanel.tsx
@@ -0,0 +1,207 @@
+/**
+ * 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 } from '@superset-ui/core';
+import {
+ ControlPanelConfig,
+ ControlPanelsContainerProps,
+ D3_FORMAT_DOCS,
+ D3_FORMAT_OPTIONS,
+ D3_TIME_FORMAT_OPTIONS,
+ emitFilterControl,
+ getStandardizedControls,
+ sections,
+} from '@superset-ui/chart-controls';
+import { DEFAULT_FORM_DATA } from './types';
+
+const { labelType, numberFormat, showLabels } = DEFAULT_FORM_DATA;
+
+const config: ControlPanelConfig = {
+ controlPanelSections: [
+ sections.legacyRegularTime,
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [
+ ['columns'],
+ ['metric'],
+ ['secondary_metric'],
+ ['adhoc_filters'],
+ emitFilterControl,
+ ['row_limit'],
+ [
+ {
+ name: 'sort_by_metric',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Sort by metric'),
+ description: t(
+ 'Whether to sort results by the selected metric in descending order.',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ {
+ label: t('Chart Options'),
+ expanded: true,
+ controlSetRows: [
+ ['color_scheme'],
+ ['linear_color_scheme'],
+ [
{t('Labels')}
],
+ [
+ {
+ name: 'show_labels',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Show Labels'),
+ renderTrigger: true,
+ default: showLabels,
+ description: t('Whether to display the labels.'),
+ },
+ },
+ ],
+ [
+ {
+ name: 'show_labels_threshold',
+ config: {
+ type: 'TextControl',
+ label: t('Percentage threshold'),
+ renderTrigger: true,
+ isFloat: true,
+ default: 5,
+ description: t(
+ 'Minimum threshold in percentage points for showing labels.',
+ ),
+ },
+ },
+ ],
+ [
+ {
+ name: 'show_total',
+ config: {
+ type: 'CheckboxControl',
+ label: t('Show Total'),
+ default: false,
+ renderTrigger: true,
+ description: t('Whether to display the aggregate count'),
+ },
+ },
+ ],
+ [
+ {
+ name: 'label_type',
+ config: {
+ type: 'SelectControl',
+ label: t('Label Type'),
+ default: labelType,
+ renderTrigger: true,
+ choices: [
+ ['key', t('Category Name')],
+ ['value', t('Value')],
+ ['key_value', t('Category and Value')],
+ ],
+ description: t('What should be shown on the label?'),
+ },
+ },
+ ],
+ [
+ {
+ name: 'number_format',
+ config: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: t('Number format'),
+ renderTrigger: true,
+ default: numberFormat,
+ choices: D3_FORMAT_OPTIONS,
+ description: `${t(
+ 'D3 format syntax: https://github.com/d3/d3-format',
+ )} ${t('Only applies when "Label Type" is set to show values.')}`,
+ },
+ },
+ ],
+ [
+ {
+ name: 'date_format',
+ config: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: t('Date format'),
+ renderTrigger: true,
+ choices: D3_TIME_FORMAT_OPTIONS,
+ default: 'smart_date',
+ description: D3_FORMAT_DOCS,
+ },
+ },
+ ],
+ ],
+ },
+ ],
+ controlOverrides: {
+ metric: {
+ label: t('Primary Metric'),
+ description: t(
+ 'The primary metric is used to define the arc segment sizes',
+ ),
+ },
+ secondary_metric: {
+ label: t('Secondary Metric'),
+ default: null,
+ description: t(
+ '[optional] this secondary metric is used to ' +
+ 'define the color as a ratio against the primary metric. ' +
+ 'When omitted, the color is categorical and based on labels',
+ ),
+ },
+ color_scheme: {
+ description: t(
+ 'When only a primary metric is provided, a categorical color scale is used.',
+ ),
+ visibility: ({ controls }: ControlPanelsContainerProps) =>
+ Boolean(
+ !controls?.secondary_metric?.value ||
+ controls?.secondary_metric?.value === controls?.metric.value,
+ ),
+ },
+ linear_color_scheme: {
+ description: t(
+ 'When a secondary metric is provided, a linear color scale is used.',
+ ),
+ visibility: ({ controls }: ControlPanelsContainerProps) =>
+ Boolean(
+ controls?.secondary_metric?.value &&
+ controls?.secondary_metric?.value !== controls?.metric.value,
+ ),
+ },
+ groupby: {
+ label: t('Hierarchy'),
+ description: t('This defines the level of the hierarchy'),
+ },
+ },
+ formDataOverrides: formData => ({
+ ...formData,
+ groupby: getStandardizedControls().popAllColumns(),
+ metric: getStandardizedControls().shiftMetric(),
+ secondary_metric: getStandardizedControls().shiftMetric(),
+ }),
+};
+
+export default config;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/images/thumbnail.png
new file mode 100644
index 0000000000000..7afef30bd4e6e
Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/images/thumbnail.png differ
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts
new file mode 100644
index 0000000000000..fe75a7916fefd
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts
@@ -0,0 +1,51 @@
+/**
+ * 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 { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
+import transformProps from './transformProps';
+import thumbnail from './images/thumbnail.png';
+import controlPanel from './controlPanel';
+import buildQuery from './buildQuery';
+
+export default class EchartsSunburstChartPlugin extends ChartPlugin {
+ constructor() {
+ super({
+ buildQuery,
+ controlPanel,
+ loadChart: () => import('./EchartsSunburst'),
+ metadata: new ChartMetadata({
+ behaviors: [Behavior.INTERACTIVE_CHART],
+ category: t('Part of a Whole'),
+ credits: ['https://echarts.apache.org'],
+ description: t(
+ 'Uses circles to visualize the flow of data through different stages of a system. Hover over individual paths in the visualization to understand the stages a value took. Useful for multi-stage, multi-group visualizing funnels and pipelines.',
+ ),
+ exampleGallery: [],
+ name: t('Sunburst Chart v2'),
+ tags: [
+ t('ECharts'),
+ t('Aesthetic'),
+ t('Multi-Levels'),
+ t('Proportional'),
+ ],
+ thumbnail,
+ }),
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
new file mode 100644
index 0000000000000..4677e1af694c8
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
@@ -0,0 +1,362 @@
+/**
+ * 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 {
+ CategoricalColorNamespace,
+ getColumnLabel,
+ getMetricLabel,
+ getNumberFormatter,
+ getSequentialSchemeRegistry,
+ getTimeFormatter,
+ NumberFormats,
+ NumberFormatter,
+ t,
+} from '@superset-ui/core';
+import { EChartsCoreOption } from 'echarts';
+import { SunburstSeriesNodeItemOption } from 'echarts/types/src/chart/sunburst/SunburstSeries';
+import { CallbackDataParams } from 'echarts/types/src/util/types';
+import { OpacityEnum } from '../constants';
+import { defaultGrid, defaultTooltip } from '../defaults';
+import { Refs } from '../types';
+import { formatSeriesName, getColtypesMapping } from '../utils/series';
+import { treeBuilder, TreeNode } from '../utils/treeBuilder';
+import {
+ EchartsSunburstChartProps,
+ EchartsSunburstLabelType,
+ SunburstTransformedProps,
+} from './types';
+
+export function getLinearDomain(
+ treeData: TreeNode[],
+ callback: (treeNode: TreeNode) => number,
+) {
+ let min = 0;
+ let max = 0;
+ let temp = null;
+ function traverse(tree: TreeNode[]) {
+ tree.forEach(treeNode => {
+ if (treeNode.children?.length) {
+ traverse(treeNode.children);
+ }
+ temp = callback(treeNode);
+ if (temp !== null) {
+ if (min > temp) min = temp;
+ if (max < temp) max = temp;
+ }
+ });
+ }
+ traverse(treeData);
+ return [min, max];
+}
+
+export function formatLabel({
+ params,
+ labelType,
+ numberFormatter,
+}: {
+ params: CallbackDataParams;
+ labelType: EchartsSunburstLabelType;
+ numberFormatter: NumberFormatter;
+}): string {
+ const { name = '', value } = params;
+ const formattedValue = numberFormatter(value as number);
+
+ switch (labelType) {
+ case EchartsSunburstLabelType.Key:
+ return name;
+ case EchartsSunburstLabelType.Value:
+ return formattedValue;
+ case EchartsSunburstLabelType.KeyValue:
+ return `${name}: ${formattedValue}`;
+ default:
+ return name;
+ }
+}
+
+export function formatTooltip({
+ params,
+ numberFormatter,
+ colorByCategory,
+ totalValue,
+ metricLabel,
+ secondaryMetricLabel,
+}: {
+ params: CallbackDataParams & {
+ treePathInfo: {
+ name: string;
+ dataIndex: number;
+ value: number;
+ }[];
+ };
+ numberFormatter: NumberFormatter;
+ colorByCategory: boolean;
+ totalValue: number;
+ metricLabel: string;
+ secondaryMetricLabel?: string;
+}): string {
+ const { data, treePathInfo = [] } = params;
+ treePathInfo.shift();
+ const node = data as TreeNode;
+ const formattedValue = numberFormatter(node.value);
+ const formattedSecondaryValue = numberFormatter(node.secondaryValue);
+
+ const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
+ const compareValuePercentage = percentFormatter(
+ node.secondaryValue / node.value,
+ );
+ const absolutePercentage = percentFormatter(node.value / totalValue);
+ const parentNode = treePathInfo[treePathInfo.length - 1];
+ const result = [
+ `${absolutePercentage} of total
`,
+ ];
+ if (parentNode) {
+ const conditionalPercentage = percentFormatter(
+ node.value / parentNode.value,
+ );
+ result.push(`
+
+ ${conditionalPercentage} of parent
+
`);
+ }
+ result.push(
+ `
+ ${metricLabel}: ${formattedValue}${
+ colorByCategory
+ ? ''
+ : `, ${secondaryMetricLabel}: ${formattedSecondaryValue}`
+ }
+
`,
+ colorByCategory
+ ? ''
+ : `
+ ${metricLabel}/${secondaryMetricLabel}: ${compareValuePercentage}
+
`,
+ );
+ return result.join('\n');
+}
+
+export default function transformProps(
+ chartProps: EchartsSunburstChartProps,
+): SunburstTransformedProps {
+ const {
+ formData,
+ height,
+ hooks,
+ filterState,
+ queriesData,
+ width,
+ theme,
+ inContextMenu,
+ } = chartProps;
+ const { data = [] } = queriesData[0];
+ const coltypeMapping = getColtypesMapping(queriesData[0]);
+ const {
+ groupby = [],
+ columns = [],
+ metric = '',
+ secondaryMetric = '',
+ colorScheme,
+ linearColorScheme,
+ labelType,
+ numberFormat,
+ dateFormat,
+ showLabels,
+ showLabelsThreshold,
+ showTotal,
+ sliceId,
+ emitFilter,
+ } = formData;
+ const refs: Refs = {};
+ const numberFormatter = getNumberFormatter(numberFormat);
+ const formatter = (params: CallbackDataParams) =>
+ formatLabel({
+ params,
+ numberFormatter,
+ labelType,
+ });
+ const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6;
+ const padding = {
+ top: theme.gridUnit * 3,
+ right: theme.gridUnit,
+ bottom: theme.gridUnit * 3,
+ left: theme.gridUnit,
+ };
+ const containerWidth = width;
+ const containerHeight = height;
+ const visWidth = containerWidth - padding.left - padding.right;
+ const visHeight = containerHeight - padding.top - padding.bottom;
+ const radius = Math.min(visWidth, visHeight) / 2;
+ const { setDataMask = () => {}, onContextMenu } = hooks;
+ const columnsLabelMap = new Map();
+ const metricLabel = getMetricLabel(metric);
+ const secondaryMetricLabel = secondaryMetric
+ ? getMetricLabel(secondaryMetric)
+ : undefined;
+ const columnLabels = columns.map(getColumnLabel);
+ const treeData = treeBuilder(
+ data,
+ columnLabels,
+ metricLabel,
+ secondaryMetricLabel,
+ );
+ const totalValue = treeData.reduce(
+ (result, treeNode) => result + treeNode.value,
+ 0,
+ );
+ const totalSecondaryValue = treeData.reduce(
+ (result, treeNode) => result + treeNode.secondaryValue,
+ 0,
+ );
+
+ const categoricalColorScale = CategoricalColorNamespace.getScale(
+ colorScheme as string,
+ );
+ let linearColorScale: any;
+ let colorByCategory = true;
+ if (secondaryMetric && metric !== secondaryMetric) {
+ const domain = getLinearDomain(
+ treeData,
+ node => node.secondaryValue / node.value,
+ );
+ colorByCategory = false;
+ linearColorScale = getSequentialSchemeRegistry()
+ ?.get(linearColorScheme)
+ ?.createLinearScale(domain);
+ }
+
+ // add a base color to keep feature parity
+ if (colorByCategory) {
+ categoricalColorScale(metricLabel, sliceId);
+ } else {
+ linearColorScale(totalSecondaryValue / totalValue);
+ }
+
+ const traverse = (treeNodes: TreeNode[], path: string[]) =>
+ treeNodes.map(treeNode => {
+ const { name: nodeName, value, secondaryValue, groupBy } = treeNode;
+ let name = formatSeriesName(nodeName, {
+ numberFormatter,
+ timeFormatter: getTimeFormatter(dateFormat),
+ ...(coltypeMapping[groupBy] && {
+ coltype: coltypeMapping[groupBy],
+ }),
+ });
+ const newPath = path.concat(name);
+ let item: SunburstSeriesNodeItemOption = {
+ name,
+ value,
+ // @ts-ignore
+ secondaryValue,
+ itemStyle: {
+ color: colorByCategory
+ ? categoricalColorScale(name, sliceId)
+ : linearColorScale(secondaryValue / value),
+ },
+ };
+ if (treeNode.children?.length) {
+ item.children = traverse(treeNode.children, newPath);
+ } else {
+ name = newPath.join(',');
+ }
+ columnsLabelMap.set(name, newPath);
+ if (filterState.selectedValues?.[0]?.includes(name) === false) {
+ item = {
+ ...item,
+ itemStyle: {
+ ...item.itemStyle,
+ opacity: OpacityEnum.SemiTransparent,
+ },
+ label: {
+ color: `rgba(0, 0, 0, ${OpacityEnum.SemiTransparent})`,
+ },
+ };
+ }
+ return item;
+ });
+
+ const echartOptions: EChartsCoreOption = {
+ grid: {
+ ...defaultGrid,
+ },
+ tooltip: {
+ ...defaultTooltip,
+ show: !inContextMenu,
+ trigger: 'item',
+ formatter: (params: any) =>
+ formatTooltip({
+ params,
+ numberFormatter,
+ colorByCategory,
+ totalValue,
+ metricLabel,
+ secondaryMetricLabel,
+ }),
+ },
+ series: [
+ {
+ type: 'sunburst',
+ ...padding,
+ nodeClick: false,
+ emphasis: {
+ focus: 'ancestor',
+ label: {
+ show: showLabels,
+ },
+ },
+ label: {
+ width: (radius * 0.6) / (columns.length || 1),
+ show: showLabels,
+ formatter,
+ color: theme.colors.grayscale.dark2,
+ minAngle: minShowLabelAngle,
+ overflow: 'breakAll',
+ },
+ radius: [radius * 0.3, radius],
+ data: traverse(treeData, []),
+ },
+ ],
+ graphic: showTotal
+ ? {
+ type: 'text',
+ top: 'center',
+ left: 'center',
+ style: {
+ text: t('Total: %s', numberFormatter(totalValue)),
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ z: 10,
+ }
+ : null,
+ };
+
+ return {
+ formData,
+ width,
+ height,
+ echartOptions,
+ setDataMask,
+ emitFilter,
+ labelMap: Object.fromEntries(columnsLabelMap),
+ groupby,
+ selectedValues: filterState.selectedValues || [],
+ onContextMenu,
+ refs,
+ };
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
new file mode 100644
index 0000000000000..fdd834c94e6d6
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 {
+ ChartDataResponseResult,
+ ChartProps,
+ QueryFormColumn,
+ QueryFormData,
+ QueryFormMetric,
+} from '@superset-ui/core';
+import {
+ BaseTransformedProps,
+ ContextMenuTransformedProps,
+ CrossFilterTransformedProps,
+} from '../types';
+
+export type EchartsSunburstFormData = QueryFormData & {
+ groupby: QueryFormColumn[];
+ metric: QueryFormMetric;
+ secondaryMetric?: QueryFormMetric;
+ colorScheme?: string;
+ linearColorScheme?: string;
+ emitFilter: boolean;
+};
+
+export enum EchartsSunburstLabelType {
+ Key = 'key',
+ Value = 'value',
+ KeyValue = 'key_value',
+}
+
+export const DEFAULT_FORM_DATA: Partial = {
+ groupby: [],
+ numberFormat: 'SMART_NUMBER',
+ labelType: EchartsSunburstLabelType.Key,
+ showLabels: false,
+ dateFormat: 'smart_date',
+ emitFilter: false,
+};
+
+export interface EchartsSunburstChartProps
+ extends ChartProps {
+ formData: EchartsSunburstFormData;
+ queriesData: ChartDataResponseResult[];
+}
+
+export type SunburstTransformedProps =
+ BaseTransformedProps &
+ ContextMenuTransformedProps &
+ CrossFilterTransformedProps;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts
index 080565229ac26..2cc660f35f5a6 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { TreePathInfo } from './types';
+import { TreePathInfo } from '../types';
export const COLOR_SATURATION = [0.7, 0.4];
export const LABEL_FONTSIZE = 11;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts
index 5c4b4cd936176..eb1b64ef30065 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts
@@ -18,7 +18,6 @@
*/
import {
CategoricalColorNamespace,
- DataRecord,
getColumnLabel,
getMetricLabel,
getNumberFormatter,
@@ -26,7 +25,6 @@ import {
NumberFormats,
NumberFormatter,
} from '@superset-ui/core';
-import { groupBy, isNumber, transform } from 'lodash';
import { TreemapSeriesNodeItemOption } from 'echarts/types/src/chart/treemap/TreemapSeries';
import { EChartsCoreOption, TreemapSeriesOption } from 'echarts';
import {
@@ -49,6 +47,7 @@ import {
import { OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
+import { treeBuilder, TreeNode } from '../utils/treeBuilder';
export function formatLabel({
params,
@@ -151,97 +150,58 @@ export default function transformProps(
});
const columnsLabelMap = new Map();
-
- const transformer = (
- data: DataRecord[],
- groupbyLabels: string[],
- metric: string,
- depth: number,
- path: string[],
- ): TreemapSeriesNodeItemOption[] => {
- const [currGroupby, ...restGroupby] = groupbyLabels;
- const currGrouping = groupBy(data, currGroupby);
- if (!restGroupby.length) {
- return transform(
- currGrouping,
- (result, value, key) => {
- (value ?? []).forEach(datum => {
- const name = formatSeriesName(key, {
- numberFormatter,
- timeFormatter: getTimeFormatter(dateFormat),
- ...(coltypeMapping[currGroupby] && {
- coltype: coltypeMapping[currGroupby],
- }),
- });
- const item: TreemapSeriesNodeItemOption = {
- name,
- value: isNumber(datum[metric]) ? (datum[metric] as number) : 0,
- };
- const joinedName = path.concat(name).join(',');
- // map(joined_name: [columnLabel_1, columnLabel_2, ...])
- columnsLabelMap.set(joinedName, path.concat(name));
- if (
- filterState.selectedValues &&
- !filterState.selectedValues.includes(joinedName)
- ) {
- item.itemStyle = {
- colorAlpha: OpacityEnum.SemiTransparent,
- };
- item.label = {
- color: `rgba(0, 0, 0, ${OpacityEnum.SemiTransparent})`,
- };
- }
- result.push(item);
- });
- },
- [] as TreemapSeriesNodeItemOption[],
- );
- }
- const sortedData = transform(
- currGrouping,
- (result, value, key) => {
- const name = formatSeriesName(key, {
- numberFormatter,
- timeFormatter: getTimeFormatter(dateFormat),
- ...(coltypeMapping[currGroupby] && {
- coltype: coltypeMapping[currGroupby],
- }),
- });
- const children = transformer(
- value,
- restGroupby,
- metric,
- depth + 1,
- path.concat(name),
- );
- result.push({
- name,
- children,
- value: children.reduce(
- (prev, cur) => prev + (cur.value as number),
- 0,
- ),
- });
- result.sort((a, b) => (b.value as number) - (a.value as number));
- },
- [] as TreemapSeriesNodeItemOption[],
- );
- // sort according to the area and then take the color value in order
- return sortedData.map(child => ({
- ...child,
- colorSaturation: COLOR_SATURATION,
- itemStyle: {
- borderColor: BORDER_COLOR,
- color: colorFn(`${child.name}`, sliceId),
- borderWidth: BORDER_WIDTH,
- gapWidth: GAP_WIDTH,
- },
- }));
- };
-
const metricLabel = getMetricLabel(metric);
const groupbyLabels = groupby.map(getColumnLabel);
- const initialDepth = 1;
+ const treeData = treeBuilder(data, groupbyLabels, metricLabel);
+ const traverse = (treeNodes: TreeNode[], path: string[]) =>
+ treeNodes.map(treeNode => {
+ const { name: nodeName, value, groupBy } = treeNode;
+ const name = formatSeriesName(nodeName, {
+ numberFormatter,
+ timeFormatter: getTimeFormatter(dateFormat),
+ ...(coltypeMapping[groupBy] && {
+ coltype: coltypeMapping[groupBy],
+ }),
+ });
+ const newPath = path.concat(name);
+ let item: TreemapSeriesNodeItemOption = {
+ name,
+ value,
+ };
+ if (treeNode.children?.length) {
+ item = {
+ ...item,
+ children: traverse(treeNode.children, newPath),
+ colorSaturation: COLOR_SATURATION,
+ itemStyle: {
+ borderColor: BORDER_COLOR,
+ color: colorFn(name, sliceId),
+ borderWidth: BORDER_WIDTH,
+ gapWidth: GAP_WIDTH,
+ },
+ };
+ } else {
+ const joinedName = newPath.join(',');
+ // map(joined_name: [columnLabel_1, columnLabel_2, ...])
+ columnsLabelMap.set(joinedName, newPath);
+ if (
+ filterState.selectedValues &&
+ !filterState.selectedValues.includes(joinedName)
+ ) {
+ item = {
+ ...item,
+ itemStyle: {
+ colorAlpha: OpacityEnum.SemiTransparent,
+ },
+ label: {
+ color: `rgba(0, 0, 0, ${OpacityEnum.SemiTransparent})`,
+ },
+ };
+ }
+ }
+ return item;
+ });
+
const transformedData: TreemapSeriesNodeItemOption[] = [
{
name: metricLabel,
@@ -255,7 +215,7 @@ export default function transformProps(
upperLabel: {
show: false,
},
- children: transformer(data, groupbyLabels, metricLabel, initialDepth, []),
+ children: traverse(treeData, []),
},
];
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts
index c318b2ac2a366..8d41b9d2e40bf 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts
@@ -29,6 +29,7 @@ import {
ContextMenuTransformedProps,
CrossFilterTransformedProps,
LabelPositionEnum,
+ TreePathInfo,
} from '../types';
export type EchartsTreemapFormData = QueryFormData & {
@@ -67,12 +68,6 @@ export const DEFAULT_FORM_DATA: Partial = {
dateFormat: 'smart_date',
emitFilter: false,
};
-
-export interface TreePathInfo {
- name: string;
- dataIndex: number;
- value: number | number[];
-}
export interface TreemapSeriesCallbackDataParams extends CallbackDataParams {
treePathInfo?: TreePathInfo[];
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
index 9890eb4c13e89..0301f265b051a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
@@ -33,6 +33,7 @@ export { default as EchartsFunnelChartPlugin } from './Funnel';
export { default as EchartsTreeChartPlugin } from './Tree';
export { default as EchartsTreemapChartPlugin } from './Treemap';
export { BigNumberChartPlugin, BigNumberTotalChartPlugin } from './BigNumber';
+export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
export { default as FunnelTransformProps } from './Funnel/transformProps';
@@ -44,6 +45,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 SunburstTransformProps } from './Sunburst/transformProps';
export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants';
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
index 57ed645839992..5bd56eb8ed0d4 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
@@ -22,6 +22,7 @@ import {
ChartDataResponseResult,
ChartProps,
HandlerFunction,
+ PlainObject,
QueryFormColumn,
SetDataMaskHook,
} from '@superset-ui/core';
@@ -111,7 +112,7 @@ export enum LabelPositionEnum {
InsideBottomRight = 'insideBottomRight',
}
-export interface BaseChartProps extends ChartProps {
+export interface BaseChartProps extends ChartProps {
queriesData: ChartDataResponseResult[];
}
@@ -155,4 +156,10 @@ export interface TitleFormData {
export type StackType = boolean | null | Partial;
+export interface TreePathInfo {
+ name: string;
+ dataIndex: number;
+ value: number | number[];
+}
+
export * from './Timeseries/types';
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/treeBuilder.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/treeBuilder.ts
new file mode 100644
index 0000000000000..32e0416a6b849
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/treeBuilder.ts
@@ -0,0 +1,87 @@
+/**
+ * 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 { DataRecord } from '@superset-ui/core';
+import _ from 'lodash';
+
+export type TreeNode = {
+ name: string;
+ value: number;
+ secondaryValue: number;
+ groupBy: string;
+ children?: TreeNode[];
+};
+
+export function treeBuilder(
+ data: DataRecord[],
+ groupBy: string[],
+ metric: string,
+ secondaryMetric?: string,
+): TreeNode[] {
+ const [curGroupBy, ...restGroupby] = groupBy;
+ const curData = _.groupBy(data, curGroupBy);
+ return _.transform(
+ curData,
+ (result, value, name) => {
+ if (!restGroupby.length) {
+ (value ?? []).forEach(datum => {
+ const metricValue = getMetricValue(datum, metric);
+ const secondaryValue = secondaryMetric
+ ? getMetricValue(datum, secondaryMetric)
+ : metricValue;
+ const item = {
+ name,
+ value: metricValue,
+ secondaryValue,
+ groupBy: curGroupBy,
+ };
+ result.push(item);
+ });
+ } else {
+ const children = treeBuilder(
+ value,
+ restGroupby,
+ metric,
+ secondaryMetric,
+ );
+ const metricValue = children.reduce(
+ (prev, cur) => prev + (cur.value as number),
+ 0,
+ );
+ const secondaryValue = secondaryMetric
+ ? children.reduce(
+ (prev, cur) => prev + (cur.secondaryValue as number),
+ 0,
+ )
+ : metricValue;
+ result.push({
+ name,
+ children,
+ value: metricValue,
+ secondaryValue,
+ groupBy: curGroupBy,
+ });
+ }
+ },
+ [] as TreeNode[],
+ );
+}
+
+function getMetricValue(datum: DataRecord, metric: string) {
+ return _.isNumber(datum[metric]) ? (datum[metric] as number) : 0;
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/treeBuilder.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/treeBuilder.test.ts
new file mode 100644
index 0000000000000..b91349a895358
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/treeBuilder.test.ts
@@ -0,0 +1,274 @@
+/**
+ * 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 { treeBuilder } from '../../src/utils/treeBuilder';
+
+describe('test treeBuilder', () => {
+ const data = [
+ {
+ foo: 'a-1',
+ bar: 'a',
+ count: 2,
+ count2: 3,
+ },
+ {
+ foo: 'a-2',
+ bar: 'a',
+ count: 2,
+ count2: 3,
+ },
+ {
+ foo: 'b-1',
+ bar: 'b',
+ count: 2,
+ count2: 3,
+ },
+ {
+ foo: 'b-2',
+ bar: 'b',
+ count: 2,
+ count2: 3,
+ },
+ {
+ foo: 'c-1',
+ bar: 'c',
+ count: 2,
+ count2: 3,
+ },
+ {
+ foo: 'c-2',
+ bar: 'c',
+ count: 2,
+ count2: 3,
+ },
+ {
+ foo: 'd-1',
+ bar: 'd',
+ count: 2,
+ count2: 3,
+ },
+ ];
+ it('should build tree as expected', () => {
+ const tree = treeBuilder(data, ['foo', 'bar'], 'count');
+ expect(tree).toEqual([
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'a',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'a-1',
+ secondaryValue: 2,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'a',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'a-2',
+ secondaryValue: 2,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'b',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'b-1',
+ secondaryValue: 2,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'b',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'b-2',
+ secondaryValue: 2,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'c',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'c-1',
+ secondaryValue: 2,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'c',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'c-2',
+ secondaryValue: 2,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'd',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'd-1',
+ secondaryValue: 2,
+ value: 2,
+ },
+ ]);
+ });
+
+ it('should build tree with secondaryValue as expected', () => {
+ const tree = treeBuilder(data, ['foo', 'bar'], 'count', 'count2');
+ expect(tree).toEqual([
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'a',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'a-1',
+ secondaryValue: 3,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'a',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'a-2',
+ secondaryValue: 3,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'b',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'b-1',
+ secondaryValue: 3,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'b',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'b-2',
+ secondaryValue: 3,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'c',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'c-1',
+ secondaryValue: 3,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'c',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'c-2',
+ secondaryValue: 3,
+ value: 2,
+ },
+ {
+ children: [
+ {
+ groupBy: 'bar',
+ name: 'd',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ],
+ groupBy: 'foo',
+ name: 'd-1',
+ secondaryValue: 3,
+ value: 2,
+ },
+ ]);
+ });
+});