You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by sb...@apache.org on 2026/08/29 14:40:10 UTC
(superset) branch master updated: feat(plugin-chart-echarts): add Butterfly Chart (#43346)
This is an automated email from the ASF dual-hosted git repository.
SBIN2010 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 96119ffefce feat(plugin-chart-echarts): add Butterfly Chart (#43346)
96119ffefce is described below
commit 96119ffefce6165efa4a655dd4674ec697b819d1
Author: SBIN2010 <Sb...@mail.ru>
AuthorDate: Sat Aug 29 17:40:02 2026 +0300
feat(plugin-chart-echarts): add Butterfly Chart (#43346)
---
.../superset-ui-core/src/chart/types/VizType.ts | 1 +
.../src/Butterfly/Butterfly.tsx | 49 ++++
.../src/Butterfly/buildQuery.ts | 52 ++++
.../src/Butterfly/constants.ts | 29 ++
.../src/Butterfly/controlPanel.tsx | 242 +++++++++++++++++
.../plugin-chart-echarts/src/Butterfly/index.ts | 54 ++++
.../src/Butterfly/transformProps.ts | 298 +++++++++++++++++++++
.../plugin-chart-echarts/src/Butterfly/types.ts | 52 ++++
.../plugins/plugin-chart-echarts/src/index.ts | 2 +
.../test/Butterfly/buildQuery.test.ts | 85 ++++++
.../test/Butterfly/controlPanel.test.ts | 83 ++++++
.../test/Butterfly/transformProps.test.ts | 202 ++++++++++++++
.../src/visualizations/presets/MainPreset.ts | 4 +
13 files changed, 1153 insertions(+)
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts
index cadb4c187ca..fab42d3519e 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts
+++ b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts
@@ -25,6 +25,7 @@ export enum VizType {
BoxPlot = 'box_plot',
Bubble = 'bubble_v2',
Bullet = 'bullet',
+ Butterfly = 'butterfly',
Calendar = 'cal_heatmap',
Cartodiagram = 'cartodiagram',
Chord = 'chord',
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx
new file mode 100644
index 00000000000..0b523313c90
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx
@@ -0,0 +1,49 @@
+/**
+ * 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 Echart from '../components/Echart';
+import { ButterflyTransformedProps } from './types';
+import { EventHandlers } from '../types';
+
+export default function Butterfly(props: ButterflyTransformedProps) {
+ const { height, width, echartOptions, refs, onLegendStateChanged, formData } =
+ props;
+
+ const eventHandlers: EventHandlers = {
+ legendselectchanged: payload => {
+ onLegendStateChanged?.(payload.selected);
+ },
+ legendselectall: payload => {
+ onLegendStateChanged?.(payload.selected);
+ },
+ legendinverseselect: payload => {
+ onLegendStateChanged?.(payload.selected);
+ },
+ };
+
+ return (
+ <Echart
+ refs={refs}
+ height={height}
+ width={width}
+ echartOptions={echartOptions}
+ eventHandlers={eventHandlers}
+ vizType={formData.vizType}
+ />
+ );
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/buildQuery.ts
new file mode 100644
index 00000000000..46adeb8b669
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/buildQuery.ts
@@ -0,0 +1,52 @@
+/**
+ * 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,
+ ensureIsArray,
+ QueryFormData,
+ QueryFormOrderBy,
+} from '@superset-ui/core';
+import { buildSortMetricOrderby } from '@superset-ui/chart-controls';
+
+export default function buildQuery(formData: QueryFormData) {
+ const columns = ensureIsArray(formData.groupby);
+ const baseMetrics = [
+ ...ensureIsArray(formData.left_metric),
+ ...ensureIsArray(formData.right_metric),
+ ];
+
+ const { orderby, metrics } = buildSortMetricOrderby({
+ metrics: baseMetrics,
+ timeseriesLimitMetric: ensureIsArray(formData.orderby)[0],
+ order_desc: formData.order_desc,
+ });
+ const resolvedOrderby: QueryFormOrderBy[] | undefined = orderby.length
+ ? orderby
+ : columns.length
+ ? [[columns[0], true]]
+ : undefined;
+ return buildQueryContext(formData, baseQueryObject => [
+ {
+ ...baseQueryObject,
+ columns,
+ metrics,
+ orderby: resolvedOrderby,
+ },
+ ]);
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/constants.ts
new file mode 100644
index 00000000000..4cf18193dc2
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/constants.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 {
+ DEFAULT_LEGEND_FORM_DATA,
+ DEFAULT_TITLE_FORM_DATA,
+} from '../constants';
+import { defaultXAxis } from '../defaults';
+
+export const DEFAULT_FORM_DATA = {
+ ...DEFAULT_LEGEND_FORM_DATA,
+ ...DEFAULT_TITLE_FORM_DATA,
+ xAxisLabelRotation: defaultXAxis.xAxisLabelRotation,
+};
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/controlPanel.tsx
new file mode 100644
index 00000000000..27d17946723
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/controlPanel.tsx
@@ -0,0 +1,242 @@
+/**
+ * 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 } from '@apache-superset/core/translation';
+import { ensureIsArray } from '@superset-ui/core';
+import {
+ ControlPanelConfig,
+ ControlSubSectionHeader,
+ formatSelectOptions,
+ getStandardizedControls,
+ sections,
+ sharedControls,
+} from '@superset-ui/chart-controls';
+import {
+ legendSection,
+ showValueControl,
+ xAxisLabelRotation,
+} from '../controls';
+import { DEFAULT_FORM_DATA } from './constants';
+
+const { xAxisTitleMargin, yAxisTitleMargin } = DEFAULT_FORM_DATA;
+
+const config: ControlPanelConfig = {
+ controlPanelSections: [
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [
+ ['groupby'],
+ [
+ {
+ name: 'left_metric',
+ config: {
+ ...sharedControls.metric,
+ label: t('Left metric'),
+ description: t(
+ 'Metric displayed on the left side of the butterfly chart',
+ ),
+ },
+ },
+ ],
+ [
+ {
+ name: 'right_metric',
+ config: {
+ ...sharedControls.metric,
+ label: t('Right metric'),
+ description: t(
+ 'Metric displayed on the right side of the butterfly chart',
+ ),
+ },
+ },
+ ],
+ ['adhoc_filters'],
+ ['row_limit'],
+ ['orderby'],
+ [
+ {
+ name: 'order_desc',
+ config: {
+ ...sharedControls.order_desc,
+ visibility: ({ controls }) => Boolean(controls.orderby.value),
+ },
+ },
+ ],
+ ],
+ },
+ {
+ label: t('Chart Options'),
+ expanded: true,
+ controlSetRows: [[showValueControl], ...legendSection],
+ },
+ {
+ label: t('Series settings'),
+ expanded: true,
+ controlSetRows: [
+ [
+ <ControlSubSectionHeader>
+ {t('Left series setting')}
+ </ControlSubSectionHeader>,
+ ],
+ [
+ {
+ name: 'left_color',
+ config: {
+ label: t('Left color'),
+ type: 'ColorPickerControl',
+ default: { r: 84, g: 112, b: 198, a: 1 },
+ renderTrigger: true,
+ description: t('Color for bars on the left side of the chart'),
+ },
+ },
+ {
+ name: 'left_label',
+ config: {
+ label: t('Left label'),
+ type: 'TextControl',
+ renderTrigger: true,
+ description: t(
+ 'Customize the label for the left series in tooltips and legend',
+ ),
+ },
+ },
+ ],
+ [
+ <ControlSubSectionHeader>
+ {t('Right series setting')}
+ </ControlSubSectionHeader>,
+ ],
+ [
+ {
+ name: 'right_color',
+ config: {
+ label: t('Right color'),
+ type: 'ColorPickerControl',
+ default: { r: 145, g: 204, b: 117, a: 1 },
+ renderTrigger: true,
+ description: t('Color for bars on the right side of the chart'),
+ },
+ },
+ {
+ name: 'right_label',
+ config: {
+ label: t('Right label'),
+ type: 'TextControl',
+ renderTrigger: true,
+ description: t(
+ 'Customize the label for the right series in tooltips and legend',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ {
+ label: t('X Axis'),
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'x_axis_label',
+ config: {
+ type: 'TextControl',
+ label: t('X Axis Label'),
+ renderTrigger: true,
+ default: '',
+ },
+ },
+ ],
+ [
+ {
+ name: 'x_axis_title_margin',
+ config: {
+ type: 'SelectControl',
+ freeForm: true,
+ clearable: true,
+ label: t('X Axis title margin'),
+ renderTrigger: true,
+ default: xAxisTitleMargin,
+ choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
+ },
+ },
+ ],
+ ['x_axis_format'],
+ ['currency_format'],
+ ],
+ },
+ {
+ label: t('Y Axis'),
+ expanded: true,
+ controlSetRows: [
+ [
+ {
+ name: 'y_axis_label',
+ config: {
+ type: 'TextControl',
+ label: t('Y Axis Label'),
+ renderTrigger: true,
+ default: '',
+ },
+ },
+ ],
+ [
+ {
+ name: 'y_axis_title_margin',
+ config: {
+ type: 'SelectControl',
+ freeForm: true,
+ clearable: true,
+ label: t('Y Axis title margin'),
+ renderTrigger: true,
+ default: yAxisTitleMargin,
+ choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
+ },
+ },
+ ],
+ [
+ {
+ name: xAxisLabelRotation.name,
+ config: {
+ ...xAxisLabelRotation.config,
+ label: t('Rotate category label'),
+ description: t(
+ 'Input field supports custom rotation. e.g. 30 for 30°',
+ ),
+ },
+ },
+ ],
+ ],
+ },
+ ],
+ controlOverrides: {
+ groupby: {
+ label: t('Categories'),
+ description: t('Dimension used for category labels on the vertical axis'),
+ multi: false,
+ },
+ },
+ formDataOverrides: formData => ({
+ ...formData,
+ groupby: ensureIsArray(getStandardizedControls().shiftColumn()),
+ left_metric: getStandardizedControls().shiftMetric(),
+ right_metric: getStandardizedControls().shiftMetric(),
+ }),
+};
+
+export default config;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/index.ts
new file mode 100644
index 00000000000..db4a35c984d
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/index.ts
@@ -0,0 +1,54 @@
+/**
+ * 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 } from '@apache-superset/core/translation';
+import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+import controlPanel from './controlPanel';
+import transformProps from './transformProps';
+import { EchartsButterflyChartProps, EchartsButterflyFormData } from './types';
+
+export default class EchartsButterflyChartPlugin extends ChartPlugin<
+ EchartsButterflyFormData,
+ EchartsButterflyChartProps
+> {
+ constructor() {
+ super({
+ buildQuery,
+ controlPanel,
+ loadChart: () => import('./Butterfly'),
+ metadata: new ChartMetadata({
+ credits: ['https://echarts.apache.org'],
+ category: t('Comparison'),
+ description: t(
+ 'A butterfly chart compares two metrics across categories using horizontal bars ' +
+ 'that extend left and right from a central axis.',
+ ),
+ name: t('Butterfly Chart'),
+ tags: [
+ t('Categorical'),
+ t('Comparison'),
+ t('ECharts'),
+ t('Multi-Variables'),
+ ],
+ thumbnail: '',
+ }),
+ transformProps,
+ });
+ }
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts
new file mode 100644
index 00000000000..a6e20fa23eb
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts
@@ -0,0 +1,298 @@
+/**
+ * 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 {
+ CurrencyFormatter,
+ ensureIsArray,
+ getColumnLabel,
+ getMetricLabel,
+ getNumberFormatter,
+ NumberFormatter,
+ rgbToHex,
+ tooltipHtml,
+} from '@superset-ui/core';
+import type { ComposeOption } from 'echarts/core';
+import type { BarSeriesOption } from 'echarts/charts';
+import type { CallbackDataParams } from 'echarts/types/src/util/types';
+import { EchartsButterflyChartProps, ButterflyTransformedProps } from './types';
+import { DEFAULT_FORM_DATA } from './constants';
+import { defaultGrid } from '../defaults';
+import { getDefaultTooltip } from '../utils/tooltip';
+import { Refs } from '../types';
+import { NULL_STRING } from '../constants';
+import { getChartPadding, getLegendProps } from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
+import { convertInteger } from '../utils/convertInteger';
+
+type EChartsOption = ComposeOption<BarSeriesOption>;
+
+const LABEL_LEFT = { position: 'left' as const };
+const LABEL_RIGHT = { position: 'right' as const };
+
+function formatCategory(value: unknown): string {
+ if (value == null) {
+ return NULL_STRING;
+ }
+ if (typeof value === 'string' || typeof value === 'number') {
+ return String(value);
+ }
+ return String(value);
+}
+
+function formatTooltip(
+ params: CallbackDataParams[],
+ formatter: NumberFormatter | CurrencyFormatter,
+) {
+ const axisParams = params.filter(
+ param => param.seriesName && typeof param.value === 'number',
+ );
+ if (!axisParams.length) {
+ return '';
+ }
+
+ const title = axisParams[0].name;
+ const rows = axisParams.map(param => [
+ param.seriesName!,
+ formatter(Math.abs(param.value as number)),
+ ]);
+
+ return tooltipHtml(rows, title);
+}
+
+export default function transformProps(
+ chartProps: EchartsButterflyChartProps,
+): ButterflyTransformedProps {
+ const {
+ width,
+ height,
+ formData,
+ legendState,
+ queriesData,
+ hooks,
+ theme,
+ inContextMenu,
+ } = chartProps;
+ const refs: Refs = {};
+ const { data = [] } = queriesData[0];
+ const { setDataMask = () => {}, onContextMenu, onLegendStateChanged } = hooks;
+
+ const {
+ currencyFormat,
+ groupby,
+ leftMetric,
+ rightMetric,
+ leftColor = { r: 84, g: 112, b: 198, a: 1 },
+ rightColor = { r: 145, g: 204, b: 117, a: 1 },
+ leftLabel,
+ rightLabel,
+ xAxisLabel,
+ yAxisLabel,
+ xAxisFormat,
+ xAxisTitleMargin,
+ yAxisTitleMargin,
+ showLegend,
+ legendMargin,
+ legendOrientation,
+ legendType,
+ legendSort,
+ showValue,
+ xAxisLabelRotation,
+ }: EchartsButterflyChartProps['formData'] = {
+ ...DEFAULT_FORM_DATA,
+ ...formData,
+ };
+
+ const groupbyColumn = ensureIsArray(groupby)[0];
+ const categoryLabel = getColumnLabel(groupbyColumn);
+ const leftMetricLabel = leftMetric ? getMetricLabel(leftMetric) : '';
+ const rightMetricLabel = rightMetric ? getMetricLabel(rightMetric) : '';
+ const leftSeriesName = leftLabel || leftMetricLabel;
+ const rightSeriesName = rightLabel || rightMetricLabel;
+
+ const defaultFormatter = currencyFormat?.symbol
+ ? new CurrencyFormatter({ d3Format: xAxisFormat, currency: currencyFormat })
+ : getNumberFormatter(xAxisFormat);
+
+ const categories = data.map(row => formatCategory(row[categoryLabel]));
+ const leftData = data.map(row => {
+ const value = Number(row[leftMetricLabel] ?? 0);
+ return {
+ value: -Math.abs(value),
+ label: LABEL_LEFT,
+ };
+ });
+ const rightData = data.map(row => {
+ const value = Number(row[rightMetricLabel] ?? 0);
+ return {
+ value: Math.abs(value),
+ label: LABEL_RIGHT,
+ };
+ });
+
+ const labelFormatter = (params: CallbackDataParams) => {
+ const value = Math.abs(params.value as number);
+
+ if (value === 0) {
+ return '';
+ }
+
+ return defaultFormatter(value);
+ };
+
+ const series: BarSeriesOption[] = [
+ {
+ name: leftSeriesName,
+ type: 'bar',
+ stack: 'Total',
+ label: {
+ show: showValue,
+ formatter: labelFormatter,
+ color: theme.colorText,
+ },
+ itemStyle: {
+ color: rgbToHex(leftColor.r, leftColor.g, leftColor.b),
+ },
+ data: leftData,
+ },
+ {
+ name: rightSeriesName,
+ type: 'bar',
+ stack: 'Total',
+ label: {
+ show: showValue,
+ formatter: labelFormatter,
+ color: theme.colorText,
+ },
+ itemStyle: {
+ color: rgbToHex(rightColor.r, rightColor.g, rightColor.b),
+ },
+ data: rightData,
+ },
+ ];
+
+ const legendData = [leftSeriesName, rightSeriesName].sort((a, b) => {
+ if (!legendSort) {
+ return 0;
+ }
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
+
+ const legendPadding = getChartPadding(
+ showLegend,
+ legendOrientation,
+ effectiveLegendMargin,
+ undefined,
+ true,
+ );
+
+ const echartOptions: EChartsOption = {
+ grid: {
+ ...defaultGrid,
+ top:
+ theme.sizeUnit * 5 +
+ legendPadding.top +
+ convertInteger(xAxisTitleMargin),
+ bottom: theme.sizeUnit * 5 + legendPadding.bottom,
+ left:
+ theme.sizeUnit * 5 +
+ legendPadding.left +
+ convertInteger(yAxisTitleMargin),
+ right: theme.sizeUnit * 5 + legendPadding.right,
+ },
+ legend: {
+ ...getLegendProps(
+ effectiveLegendType,
+ legendOrientation,
+ showLegend,
+ theme,
+ false,
+ legendState,
+ ),
+ data: legendData,
+ },
+ xAxis: {
+ type: 'value',
+ position: 'top',
+ name: xAxisLabel,
+ nameLocation: 'middle',
+ nameGap: convertInteger(xAxisTitleMargin),
+ nameTextStyle: {
+ padding: [theme.sizeUnit * 4, 0, 0, 0],
+ },
+ splitLine: {
+ lineStyle: {
+ type: 'dashed',
+ },
+ },
+ axisLabel: {
+ formatter: (value: number) => defaultFormatter(Math.abs(value)),
+ },
+ },
+ yAxis: {
+ type: 'category',
+ name: yAxisLabel,
+ nameLocation: 'middle',
+ nameGap: convertInteger(yAxisTitleMargin),
+ nameTextStyle: {
+ padding: [0, theme.sizeUnit * 4, 0, 0],
+ },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ splitLine: { show: false },
+ axisLabel: {
+ rotate: xAxisLabelRotation,
+ },
+ data: categories,
+ },
+ tooltip: {
+ ...getDefaultTooltip(refs),
+ appendToBody: true,
+ trigger: 'axis',
+ axisPointer: { type: 'shadow' },
+ show: !inContextMenu,
+ formatter: (params: CallbackDataParams | CallbackDataParams[]) =>
+ formatTooltip(
+ ensureIsArray(params) as CallbackDataParams[],
+ defaultFormatter,
+ ),
+ },
+ series,
+ };
+
+ return {
+ refs,
+ formData,
+ width,
+ height,
+ echartOptions,
+ setDataMask,
+ onContextMenu,
+ onLegendStateChanged,
+ };
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/types.ts
new file mode 100644
index 00000000000..ac5daebd394
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/types.ts
@@ -0,0 +1,52 @@
+/**
+ * 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,
+ RgbaColor,
+} from '@superset-ui/core';
+import { BaseTransformedProps, LegendFormData, TitleFormData } from '../types';
+
+export type EchartsButterflyFormData = QueryFormData &
+ LegendFormData &
+ TitleFormData & {
+ groupby: QueryFormColumn[];
+ leftMetric: QueryFormMetric;
+ rightMetric: QueryFormMetric;
+ leftColor: RgbaColor;
+ rightColor: RgbaColor;
+ leftLabel?: string;
+ rightLabel?: string;
+ xAxisLabel: string;
+ yAxisLabel: string;
+ xAxisFormat: string;
+ showValue: boolean;
+ xAxisLabelRotation: number;
+ };
+
+export interface EchartsButterflyChartProps extends ChartProps {
+ formData: EchartsButterflyFormData;
+ queriesData: ChartDataResponseResult[];
+}
+
+export type ButterflyTransformedProps =
+ BaseTransformedProps<EchartsButterflyFormData>;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
index cada8312502..e556b1ec98b 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
@@ -46,6 +46,7 @@ export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as EchartsBubbleChartPlugin } from './Bubble';
export { default as EchartsSankeyChartPlugin } from './Sankey';
export { default as EchartsWaterfallChartPlugin } from './Waterfall';
+export { default as EchartsButterflyChartPlugin } from './Butterfly';
export { default as EchartsGanttChartPlugin } from './Gantt';
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
@@ -62,6 +63,7 @@ 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';
+export { default as ButterflyTransformProps } from './Butterfly/transformProps';
export { default as HistogramTransformProps } from './Histogram/transformProps';
export { default as SankeyTransformProps } from './Sankey/transformProps';
export { default as GanttTransformProps } from './Gantt/transformProps';
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/buildQuery.test.ts
new file mode 100644
index 00000000000..18b01d956b6
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/buildQuery.test.ts
@@ -0,0 +1,85 @@
+/**
+ * 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 buildQuery from '../../src/Butterfly/buildQuery';
+
+const formData = {
+ datasource: '1__table',
+ viz_type: 'butterfly',
+ groupby: ['category'],
+ left_metric: 'left_sum',
+ right_metric: 'right_sum',
+};
+
+test('defaults to ordering by the category column', () => {
+ const [query] = buildQuery(formData).queries;
+ expect(query.columns).toEqual(['category']);
+ expect(query.metrics).toEqual(['left_sum', 'right_sum']);
+ expect(query.orderby).toEqual([['category', true]]);
+});
+
+test('wraps the sort metric in a valid orderby tuple', () => {
+ const sortMetric = {
+ expressionType: 'SIMPLE',
+ column: { column_name: 'left_sum' },
+ aggregate: 'SUM',
+ label: 'SUM(left_sum)',
+ };
+ const [query] = buildQuery({
+ ...formData,
+ orderby: sortMetric,
+ order_desc: true,
+ }).queries;
+ expect(query.metrics).toEqual(['left_sum', 'right_sum', sortMetric]);
+ expect(query.orderby).toEqual([[sortMetric, false]]);
+});
+
+test('appends the sort metric when it is not already selected', () => {
+ const sortMetric = {
+ expressionType: 'SIMPLE',
+ column: { column_name: 'count' },
+ aggregate: 'SUM',
+ label: 'SUM(count)',
+ };
+ const [query] = buildQuery({
+ ...formData,
+ orderby: sortMetric,
+ order_desc: false,
+ }).queries;
+ expect(query.metrics).toEqual(['left_sum', 'right_sum', sortMetric]);
+ expect(query.orderby).toEqual([[sortMetric, true]]);
+});
+
+test('leaves orderby unset when no category column is selected', () => {
+ const [query] = buildQuery({
+ ...formData,
+ groupby: [],
+ }).queries;
+ expect(query.columns).toEqual([]);
+ expect(query.metrics).toEqual(['left_sum', 'right_sum']);
+ expect(query.orderby).toBeUndefined();
+});
+
+test('issues no metrics when none are selected', () => {
+ const [query] = buildQuery({
+ datasource: '1__table',
+ viz_type: 'butterfly',
+ groupby: ['category'],
+ }).queries;
+ expect(query.metrics).toEqual([]);
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/controlPanel.test.ts
new file mode 100644
index 00000000000..2c517305df9
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/controlPanel.test.ts
@@ -0,0 +1,83 @@
+/**
+ * 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 { SqlaFormData } from '@superset-ui/core';
+
+const mockShiftMetric = jest
+ .fn()
+ .mockReturnValueOnce('left_sum')
+ .mockReturnValueOnce('right_sum');
+const mockShiftColumn = jest.fn(() => 'category');
+
+jest.mock('@superset-ui/chart-controls', () => {
+ const actual = jest.requireActual('@superset-ui/chart-controls');
+ return {
+ ...actual,
+ getStandardizedControls: jest.fn(() => ({
+ shiftMetric: mockShiftMetric,
+ shiftColumn: mockShiftColumn,
+ })),
+ };
+});
+
+// eslint-disable-next-line import/first
+import controlPanel from '../../src/Butterfly/controlPanel';
+
+const collectControlNames = () => {
+ const names = new Set<string>();
+ controlPanel.controlPanelSections?.forEach(section => {
+ section?.controlSetRows?.forEach(row => {
+ row.forEach(control => {
+ if (typeof control === 'string') {
+ names.add(control);
+ } else if (
+ control &&
+ typeof control === 'object' &&
+ 'name' in control
+ ) {
+ names.add(String(control.name));
+ }
+ });
+ });
+ });
+ return names;
+};
+
+test('exposes left and right metric controls', () => {
+ const controlNames = collectControlNames();
+ expect(controlNames.has('left_metric')).toBe(true);
+ expect(controlNames.has('right_metric')).toBe(true);
+ expect(controlNames.has('groupby')).toBe(true);
+ expect(controlNames.has('orderby')).toBe(true);
+});
+
+test('restricts categories to a single dimension', () => {
+ expect(controlPanel.controlOverrides?.groupby?.multi).toBe(false);
+});
+
+test('maps standardized controls to butterfly metrics', () => {
+ const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData;
+ const formData = controlPanel.formDataOverrides?.(dummyFormData);
+
+ expect(formData?.someProp).toBe('test');
+ expect(formData?.groupby).toEqual(['category']);
+ expect(formData?.left_metric).toBe('left_sum');
+ expect(formData?.right_metric).toBe('right_sum');
+ expect(mockShiftMetric).toHaveBeenCalledTimes(2);
+ expect(mockShiftColumn).toHaveBeenCalledTimes(1);
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/transformProps.test.ts
new file mode 100644
index 00000000000..e80f04aed9b
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/transformProps.test.ts
@@ -0,0 +1,202 @@
+/**
+ * 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 { ChartProps } from '@superset-ui/core';
+import { supersetTheme } from '@apache-superset/core/theme';
+import {
+ EchartsButterflyChartProps,
+ ButterflyTransformedProps,
+} from '../../src/Butterfly/types';
+import transformProps from '../../src/Butterfly/transformProps';
+import { NULL_STRING } from '../../src/constants';
+
+type SeriesDataPoint = { value?: number } | number;
+
+type ButterflyTestSeries = {
+ name?: string;
+ data?: SeriesDataPoint[];
+ itemStyle?: { color?: string };
+ label?: { show?: boolean };
+};
+
+type ButterflyTestEchartOptions = {
+ series?: ButterflyTestSeries[];
+ xAxis?: { name?: string; nameGap?: number };
+ yAxis?: {
+ name?: string;
+ nameGap?: number;
+ data?: string[];
+ axisLabel?: { rotate?: number };
+ };
+ legend?: { orient?: string; data?: string[] };
+ grid?: { left?: number; top?: number };
+ tooltip?: { show?: boolean };
+};
+
+const getEchartOptions = (
+ props: ButterflyTransformedProps,
+): ButterflyTestEchartOptions =>
+ props.echartOptions as ButterflyTestEchartOptions;
+
+const extractSeriesValues = (props: ButterflyTransformedProps) => {
+ const series = getEchartOptions(props).series ?? [];
+ return series.map(item =>
+ (item.data ?? []).map(entry =>
+ typeof entry === 'object' && entry !== null && 'value' in entry
+ ? entry.value
+ : entry,
+ ),
+ );
+};
+
+const extractSeriesNames = (props: ButterflyTransformedProps) => {
+ const series = getEchartOptions(props).series ?? [];
+ return series.map(item => item.name);
+};
+
+const data: Record<string, unknown>[] = [
+ { category: 'A', left_sum: 10, right_sum: 25 },
+ { category: 'B', left_sum: 5, right_sum: 19 },
+];
+
+const formData = {
+ groupby: ['category'],
+ left_metric: 'left_sum',
+ right_metric: 'right_sum',
+ left_color: { r: 84, g: 112, b: 198 },
+ right_color: { r: 145, g: 204, b: 117 },
+ showValue: true,
+ showLegend: true,
+};
+
+const createChartProps = (
+ overrides: Record<string, unknown> = {},
+ queryData: Record<string, unknown>[] = data,
+) =>
+ new ChartProps({
+ formData: { ...formData, ...overrides },
+ width: 800,
+ height: 600,
+ queriesData: [{ data: queryData }],
+ theme: supersetTheme,
+ ...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
+ });
+
+test('transforms chart props into diverging bar series', () => {
+ const transformedProps = transformProps(
+ createChartProps() as unknown as EchartsButterflyChartProps,
+ );
+
+ expect(extractSeriesValues(transformedProps)).toEqual([
+ [-10, -5],
+ [25, 19],
+ ]);
+});
+
+test('uses absolute values for negative right-side metrics', () => {
+ const transformedProps = transformProps(
+ createChartProps({}, [
+ { category: 'A', left_sum: -8, right_sum: -15 },
+ ]) as unknown as EchartsButterflyChartProps,
+ );
+
+ expect(extractSeriesValues(transformedProps)).toEqual([[-8], [15]]);
+});
+
+test('formats null categories and missing metric values', () => {
+ const transformedProps = transformProps(
+ createChartProps({}, [
+ { category: null, left_sum: undefined, right_sum: 7 },
+ ]) as unknown as EchartsButterflyChartProps,
+ );
+ const { yAxis } = getEchartOptions(transformedProps);
+
+ expect(yAxis?.data).toEqual([NULL_STRING]);
+ const [leftValues, rightValues] = extractSeriesValues(transformedProps);
+ expect(Math.abs(leftValues[0] as number)).toBe(0);
+ expect(rightValues).toEqual([7]);
+});
+
+test('applies custom series labels, colors, and axis titles', () => {
+ const transformedProps = transformProps(
+ createChartProps({
+ left_label: 'Left side',
+ right_label: 'Right side',
+ left_color: { r: 255, g: 0, b: 0 },
+ right_color: { r: 0, g: 255, b: 0 },
+ x_axis_label: 'Value axis',
+ y_axis_label: 'Category axis',
+ }) as unknown as EchartsButterflyChartProps,
+ );
+ const { series, xAxis, yAxis } = getEchartOptions(transformedProps);
+
+ expect(extractSeriesNames(transformedProps)).toEqual([
+ 'Left side',
+ 'Right side',
+ ]);
+ expect(series?.[0]?.itemStyle?.color).toBe('#ff0000');
+ expect(series?.[1]?.itemStyle?.color).toBe('#00ff00');
+ expect(xAxis?.name).toBe('Value axis');
+ expect(yAxis?.name).toBe('Category axis');
+});
+
+test('applies legend orientation, sort, and axis margin settings', () => {
+ const transformedProps = transformProps(
+ createChartProps({
+ legendOrientation: 'left',
+ legendSort: 'desc',
+ xAxisLabelRotation: 45,
+ x_axis_title_margin: 60,
+ y_axis_title_margin: 80,
+ }) as unknown as EchartsButterflyChartProps,
+ );
+ const { legend, xAxis, yAxis, grid } = getEchartOptions(transformedProps);
+
+ expect(legend?.orient).toBe('vertical');
+ expect(legend?.data).toEqual(['right_sum', 'left_sum']);
+ expect(xAxis?.nameGap).toBe(60);
+ expect(yAxis?.axisLabel?.rotate).toBe(45);
+ expect(yAxis?.nameGap).toBe(80);
+ expect(grid?.left).toBeGreaterThan(80);
+ expect(grid?.top).toBeGreaterThan(60);
+});
+
+test('hides value labels when showValue is false', () => {
+ const transformedProps = transformProps(
+ createChartProps({
+ showValue: false,
+ }) as unknown as EchartsButterflyChartProps,
+ );
+ const { series } = getEchartOptions(transformedProps);
+
+ expect(series?.[0]?.label?.show).toBe(false);
+ expect(series?.[1]?.label?.show).toBe(false);
+});
+
+test('hides tooltip while the context menu is open', () => {
+ const transformedProps = transformProps(
+ createChartProps({}, data) as unknown as EchartsButterflyChartProps,
+ );
+ const withContextMenu = transformProps({
+ ...createChartProps(),
+ inContextMenu: true,
+ } as unknown as EchartsButterflyChartProps);
+
+ expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
+ expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
+});
diff --git a/superset-frontend/src/visualizations/presets/MainPreset.ts b/superset-frontend/src/visualizations/presets/MainPreset.ts
index b2938c1cfbc..5f2bc285715 100644
--- a/superset-frontend/src/visualizations/presets/MainPreset.ts
+++ b/superset-frontend/src/visualizations/presets/MainPreset.ts
@@ -65,6 +65,7 @@ import {
EchartsRoseChartPlugin,
EchartsTimePivotChartPlugin,
EchartsBulletChartPlugin,
+ EchartsButterflyChartPlugin,
} from '@superset-ui/plugin-chart-echarts';
import {
SelectFilterPlugin,
@@ -163,6 +164,9 @@ export default class MainPreset extends Preset {
new EchartsWaterfallChartPlugin().configure({
key: VizType.Waterfall,
}),
+ new EchartsButterflyChartPlugin().configure({
+ key: VizType.Butterfly,
+ }),
new EchartsHeatmapChartPlugin().configure({ key: VizType.Heatmap }),
new EchartsHistogramChartPlugin().configure({ key: VizType.Histogram }),
new SelectFilterPlugin().configure({ key: FilterPlugins.Select }),