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 }),