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/09/29 06:57:39 UTC

(superset) branch master updated: feat(echarts): double-click chart legend to isolate a series (#44562)

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 e7275f15ae0 feat(echarts): double-click chart legend to isolate a series (#44562)
e7275f15ae0 is described below

commit e7275f15ae0c98fbbeb6cbffb2f629998ca91592
Author: İbrahim Ercan <ib...@gmail.com>
AuthorDate: Tue Sep 29 09:57:32 2026 +0300

    feat(echarts): double-click chart legend to isolate a series (#44562)
    
    Co-authored-by: Cursor <cu...@cursor.com>
    Co-authored-by: Evan Rusackas <ev...@rusackas.com>
    Co-authored-by: Evan Rusackas <ev...@preset.io>
    Co-authored-by: Claude Sonnet 5 <no...@anthropic.com>
---
 .../src/Bubble/EchartsBubble.tsx                   |  16 +-
 .../src/Bubble/transformProps.ts                   |  31 ++-
 .../src/Butterfly/Butterfly.tsx                    |  16 +-
 .../src/Butterfly/transformProps.ts                |  11 +-
 .../src/Funnel/EchartsFunnel.tsx                   |  19 +-
 .../src/Funnel/transformProps.ts                   |  15 +-
 .../src/Gantt/EchartsGantt.tsx                     |  23 +-
 .../src/Gantt/transformProps.ts                    |   6 +-
 .../src/Graph/EchartsGraph.tsx                     |   8 +
 .../src/Graph/transformProps.ts                    |  11 +-
 .../src/Histogram/Histogram.tsx                    |  17 +-
 .../src/Histogram/transformProps.ts                |  10 +-
 .../src/MixedTimeseries/EchartsMixedTimeseries.tsx |   9 +
 .../src/MixedTimeseries/transformProps.ts          |  12 +-
 .../plugin-chart-echarts/src/Pie/EchartsPie.tsx    |  19 +-
 .../plugin-chart-echarts/src/Pie/transformProps.ts |  15 +-
 .../src/Radar/EchartsRadar.tsx                     |  19 +-
 .../src/Radar/transformProps.ts                    |  15 +-
 .../src/Timeseries/EchartsTimeseries.tsx           |  19 +-
 .../src/Timeseries/transformProps.ts               |   6 +-
 .../src/Waterfall/EchartsWaterfall.tsx             |  16 +-
 .../src/utils/legendEventHandlers.test.ts          | 275 +++++++++++++++++++++
 .../src/utils/legendEventHandlers.ts               | 131 ++++++++++
 .../plugin-chart-echarts/src/utils/series.ts       |  27 +-
 .../test/Butterfly/Butterfly.test.tsx              |  32 ++-
 .../plugin-chart-echarts/test/utils/series.test.ts |  16 +-
 26 files changed, 708 insertions(+), 86 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx
index adfa0acfe30..36b247e5f50 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx
@@ -18,14 +18,28 @@
  */
 import { BubbleChartTransformedProps } from './types';
 import Echart from '../components/Echart';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function EchartsBubble(props: BubbleChartTransformedProps) {
-  const { height, width, echartOptions, refs, formData } = props;
+  const {
+    height,
+    width,
+    echartOptions,
+    refs,
+    formData,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = props;
+  const eventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
   return (
     <Echart
       height={height}
       width={width}
       echartOptions={echartOptions}
+      eventHandlers={eventHandlers}
       refs={refs}
       vizType={formData.vizType}
     />
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts
index 1efe7b93b73..af131326822 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts
@@ -31,7 +31,11 @@ import {
 import { EchartsBubbleChartProps, EchartsBubbleFormData } from './types';
 import { DEFAULT_FORM_DATA, MINIMUM_BUBBLE_SIZE } from './constants';
 import { defaultGrid } from '../defaults';
-import { getLegendProps, getMinAndMaxFromBounds } from '../utils/series';
+import {
+  getLegendProps,
+  getLegendScrollDataIndex,
+  getMinAndMaxFromBounds,
+} from '../utils/series';
 import { resolveLegendLayout } from '../utils/legendLayout';
 import { Refs } from '../types';
 import { parseAxisBound } from '../utils/controls';
@@ -97,8 +101,17 @@ export function formatTooltip(
 }
 
 export default function transformProps(chartProps: EchartsBubbleChartProps) {
-  const { height, width, hooks, queriesData, formData, inContextMenu, theme } =
-    chartProps;
+  const {
+    height,
+    width,
+    hooks,
+    queriesData,
+    formData,
+    inContextMenu,
+    theme,
+    legendState,
+    legendIndex,
+  } = chartProps;
 
   const { data = [] } = queriesData[0];
   const {
@@ -256,7 +269,10 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
         legendOrientation,
         showLegend,
         theme,
+        false,
+        legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     tooltip: {
@@ -276,7 +292,12 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
     grid: { ...defaultGrid, ...padding },
   };
 
-  const { onContextMenu, setDataMask = () => {} } = hooks;
+  const {
+    onContextMenu,
+    setDataMask = () => {},
+    onLegendStateChanged,
+    onLegendScroll,
+  } = hooks;
 
   return {
     refs,
@@ -285,6 +306,8 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
     echartOptions,
     onContextMenu,
     setDataMask,
+    onLegendStateChanged,
+    onLegendScroll,
     formData,
   };
 }
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx
index 04395bb5afe..7b3580f2489 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/Butterfly.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 import { allEventHandlers, type Event } from '../utils/eventHandlers';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 import Echart from '../components/Echart';
 import { EventHandlers } from '../types';
 import { ButterflyTransformedProps } from './types';
@@ -39,12 +40,18 @@ export default function Butterfly(props: ButterflyTransformedProps) {
     selectedValues,
     refs,
     onLegendStateChanged,
+    onLegendScroll,
     formData,
   } = props;
 
   const { click, contextmenu } = allEventHandlers(props);
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
 
   const eventHandlers: EventHandlers = {
+    ...legendEventHandlers,
     click: (params: ButterflyChartEvent) => {
       click({ name: getCategoryKey(params) });
     },
@@ -54,15 +61,6 @@ export default function Butterfly(props: ButterflyTransformedProps) {
         name: getCategoryKey(params),
       });
     },
-    legendselectchanged: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
-    legendselectall: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
-    legendinverseselect: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
   };
 
   return (
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts
index 6dc025b8450..c0b5cb14c72 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Butterfly/transformProps.ts
@@ -38,6 +38,7 @@ import { OpacityEnum } from '../constants';
 import {
   getChartPadding,
   getLegendProps,
+  getLegendScrollDataIndex,
   getColtypesMapping,
   extractGroupbyLabel,
 } from '../utils/series';
@@ -85,6 +86,7 @@ export default function transformProps(
     height,
     formData,
     legendState,
+    legendIndex,
     queriesData,
     hooks,
     theme,
@@ -94,7 +96,12 @@ export default function transformProps(
   } = chartProps;
   const refs: Refs = {};
   const { data = [] } = queriesData[0];
-  const { setDataMask = () => {}, onContextMenu, onLegendStateChanged } = hooks;
+  const {
+    setDataMask = () => {},
+    onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = hooks;
 
   const {
     currencyFormat,
@@ -282,6 +289,7 @@ export default function transformProps(
         false,
         legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     xAxis: {
@@ -344,6 +352,7 @@ export default function transformProps(
     setDataMask,
     onContextMenu,
     onLegendStateChanged,
+    onLegendScroll,
     groupby: groupbyColumns,
     labelMap,
     selectedValues,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx
index c3b2c81a12a..1204390beb5 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx
@@ -19,12 +19,25 @@
 import { FunnelChartTransformedProps } from './types';
 import Echart from '../components/Echart';
 import { allEventHandlers } from '../utils/eventHandlers';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function EchartsFunnel(props: FunnelChartTransformedProps) {
-  const { height, width, echartOptions, selectedValues, refs, formData } =
-    props;
+  const {
+    height,
+    width,
+    echartOptions,
+    selectedValues,
+    refs,
+    formData,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = props;
 
-  const eventHandlers = allEventHandlers(props);
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
+  const eventHandlers = { ...legendEventHandlers, ...allEventHandlers(props) };
 
   return (
     <Echart
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts
index 89017351f63..c45d6288ce9 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts
@@ -44,6 +44,7 @@ import {
   getChartPadding,
   getColtypesMapping,
   getLegendProps,
+  getLegendScrollDataIndex,
   sanitizeHtml,
 } from '../utils/series';
 import { resolveLegendLayout } from '../utils/legendLayout';
@@ -101,6 +102,8 @@ export default function transformProps(
     theme,
     emitCrossFilters,
     datasource,
+    legendState,
+    legendIndex,
   } = chartProps;
   const data: DataRecord[] = queriesData[0].data || [];
   const detectedCurrency = queriesData[0]?.detected_currency;
@@ -155,7 +158,12 @@ export default function transformProps(
     };
   }, {});
 
-  const { setDataMask = () => {}, onContextMenu } = hooks;
+  const {
+    setDataMask = () => {},
+    onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = hooks;
   const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
   const numberFormatter = getValueFormatter(
     metric,
@@ -327,7 +335,10 @@ export default function transformProps(
         legendOrientation,
         showLegend,
         theme,
+        false,
+        legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     series,
@@ -344,6 +355,8 @@ export default function transformProps(
     groupby,
     selectedValues,
     onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
     refs,
     coltypeMapping,
   };
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx
index 48e7e590022..d1f3803e58c 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx
@@ -22,6 +22,7 @@ import { t } from '@apache-superset/core/translation';
 import Echart from '../components/Echart';
 import { EchartsGanttChartTransformedProps } from './types';
 import { EventHandlers } from '../types';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 const { RadioButtonControl } = sharedControlComponents;
 
@@ -35,6 +36,7 @@ export default function EchartsGantt(props: EchartsGanttChartTransformedProps) {
     formData,
     setControlValue,
     onLegendStateChanged,
+    onLegendScroll,
   } = props;
   const extraControlRef = useRef<HTMLDivElement>(null);
   const [extraHeight, setExtraHeight] = useState(0);
@@ -44,23 +46,10 @@ export default function EchartsGantt(props: EchartsGanttChartTransformedProps) {
     setExtraHeight(updatedHeight);
   }, [formData.showExtraControls]);
 
-  const eventHandlers: EventHandlers = {
-    legendselectchanged: payload => {
-      requestAnimationFrame(() => {
-        onLegendStateChanged?.(payload.selected);
-      });
-    },
-    legendselectall: payload => {
-      requestAnimationFrame(() => {
-        onLegendStateChanged?.(payload.selected);
-      });
-    },
-    legendinverseselect: payload => {
-      requestAnimationFrame(() => {
-        onLegendStateChanged?.(payload.selected);
-      });
-    },
-  };
+  const eventHandlers: EventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
 
   return (
     <>
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts
index 0b66dbcdceb..e24205013d3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts
@@ -47,6 +47,7 @@ import { LegendOrientation, Refs } from '../types';
 import {
   getHorizontalLegendAvailableWidth,
   getLegendProps,
+  getLegendScrollDataIndex,
   groupData,
   measureTextWidth,
 } from '../utils/series';
@@ -126,6 +127,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
     emitCrossFilters,
     datasource,
     legendState,
+    legendIndex,
   } = chartProps;
 
   const {
@@ -157,7 +159,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
     ...formData,
   };
 
-  const { setControlValue, onLegendStateChanged } = hooks;
+  const { setControlValue, onLegendStateChanged, onLegendScroll } = hooks;
 
   const { data = [], colnames = [], coltypes = [] } = queriesData[0];
   const refs: Refs = {};
@@ -459,6 +461,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
         legendState,
         padding,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     grid: {
@@ -534,5 +537,6 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
     refs,
     setControlValue,
     onLegendStateChanged,
+    onLegendScroll,
   };
 }
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx
index b765bb6bc0e..6f2665f9046 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx
@@ -25,6 +25,7 @@ import { EventHandlers } from '../types';
 import Echart from '../components/Echart';
 import { GraphChartTransformedProps } from './types';
 import { formatSeriesName } from '../utils/series';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 type DataRow = {
   source?: string;
@@ -52,6 +53,8 @@ export default function EchartsGraph({
   emitCrossFilters,
   refs,
   coltypeMapping,
+  onLegendStateChanged,
+  onLegendScroll,
 }: GraphChartTransformedProps) {
   const getCrossFilterDataMask = (node: DataRow | undefined) => {
     if (!node?.name || !node?.col) {
@@ -88,7 +91,12 @@ export default function EchartsGraph({
       isCurrentValueSelected: selected.includes(name),
     };
   };
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
   const eventHandlers: EventHandlers = {
+    ...legendEventHandlers,
     click: (e: Event) => {
       if (!emitCrossFilters || !setDataMask) {
         return;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts
index 9fee03ae3d3..8cc02b5049e 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts
@@ -40,6 +40,7 @@ import {
   getChartPadding,
   getColtypesMapping,
   getLegendProps,
+  getLegendScrollDataIndex,
   sanitizeHtml,
 } from '../utils/series';
 import { resolveLegendLayout } from '../utils/legendLayout';
@@ -167,6 +168,8 @@ export default function transformProps(
     filterState,
     emitCrossFilters,
     theme,
+    legendState,
+    legendIndex,
   } = chartProps;
   const data: DataRecord[] = queriesData[0].data || [];
   const coltypeMapping = getColtypesMapping(queriesData[0]);
@@ -373,13 +376,17 @@ export default function transformProps(
         legendOrientation,
         showLegend,
         theme,
+        false,
+        legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     series,
   };
 
-  const { onContextMenu, setDataMask } = hooks;
+  const { onContextMenu, setDataMask, onLegendStateChanged, onLegendScroll } =
+    hooks;
 
   return {
     width,
@@ -388,6 +395,8 @@ export default function transformProps(
     echartOptions,
     onContextMenu,
     setDataMask,
+    onLegendStateChanged,
+    onLegendScroll,
     filterState,
     refs,
     emitCrossFilters,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx
index 4f7b5c220d7..c7f2ef74796 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx
@@ -19,6 +19,7 @@
 import { HistogramTransformedProps } from './types';
 import Echart from '../components/Echart';
 import { EventHandlers } from '../types';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function Histogram(props: HistogramTransformedProps) {
   const {
@@ -27,20 +28,18 @@ export default function Histogram(props: HistogramTransformedProps) {
     echartOptions,
     onFocusedSeries,
     onLegendStateChanged,
+    onLegendScroll,
     refs,
     formData,
   } = props;
 
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
+
   const eventHandlers: EventHandlers = {
-    legendselectchanged: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
-    legendselectall: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
-    legendinverseselect: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
+    ...legendEventHandlers,
     mouseout: () => {
       onFocusedSeries(undefined);
     },
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts
index 9d0e13eadc9..32502365a1c 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts
@@ -31,7 +31,7 @@ import {
 import { HistogramChartProps, HistogramTransformedProps } from './types';
 import { LegendOrientation, LegendType, Refs } from '../types';
 import { defaultGrid, defaultYAxis } from '../defaults';
-import { getLegendProps } from '../utils/series';
+import { getLegendProps, getLegendScrollDataIndex } from '../utils/series';
 import { getDefaultTooltip } from '../utils/tooltip';
 import { getPercentFormatter } from '../utils/formatters';
 
@@ -46,11 +46,12 @@ export default function transformProps(
     height,
     hooks,
     legendState = {},
+    legendIndex,
     queriesData,
     theme,
     width,
   } = chartProps;
-  const { onLegendStateChanged } = hooks;
+  const { onLegendStateChanged, onLegendScroll } = hooks;
   const {
     colorScheme,
     column,
@@ -187,6 +188,10 @@ export default function transformProps(
         false,
         legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(
+        legendIndex,
+        legendOptions.length,
+      ),
       data: legendOptions,
     },
     tooltip: {
@@ -204,5 +209,6 @@ export default function transformProps(
     echartOptions,
     onFocusedSeries,
     onLegendStateChanged,
+    onLegendScroll,
   };
 }
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx
index 33249b624c1..8844d82e576 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx
@@ -30,6 +30,7 @@ import { EchartsMixedTimeseriesChartTransformedProps } from './types';
 import Echart from '../components/Echart';
 import { EventHandlers } from '../types';
 import { formatSeriesName } from '../utils/series';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function EchartsMixedTimeseries({
   height,
@@ -50,6 +51,8 @@ export default function EchartsMixedTimeseries({
   xAxis,
   refs,
   coltypeMapping,
+  onLegendStateChanged,
+  onLegendScroll,
 }: EchartsMixedTimeseriesChartTransformedProps) {
   const isFirstQuery = useCallback(
     (seriesIndex: number) => seriesIndex < seriesBreakdown,
@@ -133,7 +136,13 @@ export default function EchartsMixedTimeseries({
     ],
   );
 
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
+
   const eventHandlers: EventHandlers = {
+    ...legendEventHandlers,
     click: props => {
       const { seriesName, seriesIndex } = props;
       handleChange(seriesName, seriesIndex);
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
index 279b85b9aa8..fcc45140f08 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
@@ -77,6 +77,7 @@ import {
   getColtypesMapping,
   getHorizontalLegendAvailableWidth,
   getLegendProps,
+  getLegendScrollDataIndex,
   getMinAndMaxFromBounds,
   getOverMaxHiddenFormatter,
   getTemporalAxisTickConfig,
@@ -152,6 +153,7 @@ export default function transformProps(
     inContextMenu,
     emitCrossFilters,
     legendState,
+    legendIndex,
   } = chartProps;
 
   let focusedSeries: string | null = null;
@@ -798,7 +800,12 @@ export default function transformProps(
     xAxisTitleMarginPx,
   );
 
-  const { setDataMask = () => {}, onContextMenu } = hooks;
+  const {
+    setDataMask = () => {},
+    onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = hooks;
   const alignTicks = yAxisIndex !== yAxisIndexB;
 
   // Both queries share the axis, so a bucket contributed by either needs a tick.
@@ -997,6 +1004,7 @@ export default function transformProps(
         legendState,
         chartPadding,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
@@ -1059,6 +1067,8 @@ export default function transformProps(
     selectedValues: filterState.selectedValues || [],
     onContextMenu,
     onFocusedSeries,
+    onLegendStateChanged,
+    onLegendScroll,
     xValueFormatter: tooltipFormatter,
     xAxis: {
       label: xAxisLabel,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx
index 3f4d4f27747..0ac75f9cb68 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx
@@ -19,12 +19,25 @@
 import { PieChartTransformedProps } from './types';
 import Echart from '../components/Echart';
 import { allEventHandlers } from '../utils/eventHandlers';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function EchartsPie(props: PieChartTransformedProps) {
-  const { height, width, echartOptions, selectedValues, refs, formData } =
-    props;
+  const {
+    height,
+    width,
+    echartOptions,
+    selectedValues,
+    refs,
+    formData,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = props;
 
-  const eventHandlers = allEventHandlers(props);
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
+  const eventHandlers = { ...legendEventHandlers, ...allEventHandlers(props) };
 
   return (
     <Echart
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
index 10505bdfb37..e7429ff42a6 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
@@ -46,6 +46,7 @@ import {
   getChartPadding,
   getColtypesMapping,
   getLegendProps,
+  getLegendScrollDataIndex,
   sanitizeHtml,
 } from '../utils/series';
 import { resolveLegendLayout } from '../utils/legendLayout';
@@ -241,6 +242,8 @@ export default function transformProps(
     inContextMenu,
     emitCrossFilters,
     datasource,
+    legendState,
+    legendIndex,
   } = chartProps;
   const {
     columnFormats = {},
@@ -367,7 +370,12 @@ export default function transformProps(
     };
   }, {});
 
-  const { setDataMask = () => {}, onContextMenu } = hooks;
+  const {
+    setDataMask = () => {},
+    onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = hooks;
   const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
 
   let totalValue = 0;
@@ -592,7 +600,10 @@ export default function transformProps(
         legendOrientation,
         showLegend,
         theme,
+        false,
+        legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     graphic: showTotal
@@ -630,6 +641,8 @@ export default function transformProps(
     groupby,
     selectedValues,
     onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
     refs,
     emitCrossFilters,
     coltypeMapping,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx
index e97b06000e6..7dd43462ac7 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx
@@ -19,11 +19,24 @@
 import { RadarChartTransformedProps } from './types';
 import Echart from '../components/Echart';
 import { allEventHandlers } from '../utils/eventHandlers';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function EchartsRadar(props: RadarChartTransformedProps) {
-  const { height, width, echartOptions, selectedValues, refs, formData } =
-    props;
-  const eventHandlers = allEventHandlers(props);
+  const {
+    height,
+    width,
+    echartOptions,
+    selectedValues,
+    refs,
+    formData,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = props;
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
+  const eventHandlers = { ...legendEventHandlers, ...allEventHandlers(props) };
 
   return (
     <Echart
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts
index e8b80cfb429..d638b8ef251 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts
@@ -44,6 +44,7 @@ import {
   getChartPadding,
   getColtypesMapping,
   getLegendProps,
+  getLegendScrollDataIndex,
 } from '../utils/series';
 import { resolveLegendLayout } from '../utils/legendLayout';
 import { defaultGrid } from '../defaults';
@@ -103,6 +104,8 @@ export default function transformProps(
     theme,
     inContextMenu,
     emitCrossFilters,
+    legendState,
+    legendIndex,
   } = chartProps;
   const refs: Refs = {};
   const { data = [] } = queriesData[0];
@@ -131,7 +134,12 @@ export default function transformProps(
     ...DEFAULT_RADAR_FORM_DATA,
     ...formData,
   };
-  const { setDataMask = () => {}, onContextMenu } = hooks ?? {};
+  const {
+    setDataMask = () => {},
+    onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
+  } = hooks ?? {};
   const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
   const numberFormatter = getNumberFormatter(numberFormat);
   const denormalizedSeriesValues: SeriesNormalizedMap = {};
@@ -433,7 +441,10 @@ export default function transformProps(
         legendOrientation,
         showLegend,
         theme,
+        false,
+        legendState,
       ),
+      scrollDataIndex: getLegendScrollDataIndex(legendIndex, legendData.length),
       data: legendData,
     },
     series,
@@ -472,6 +483,8 @@ export default function transformProps(
     groupby,
     selectedValues,
     onContextMenu,
+    onLegendStateChanged,
+    onLegendScroll,
     refs,
     coltypeMapping,
   };
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index d8db68da7ba..6cd09280a1e 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -57,6 +57,7 @@ import {
 } from './types';
 import { formatSeriesName } from '../utils/series';
 import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 import { ExtraControls } from '../components/ExtraControls';
 import TimeseriesLegend from './TimeseriesLegend';
 import { TIMESERIES_CONSTANTS } from '../constants';
@@ -540,7 +541,13 @@ export default function EchartsTimeseries({
     [xAxisValueIndex],
   );
 
+  const legendEventHandlers = useLegendEventHandlers(
+    onLegendStateChanged,
+    onLegendScroll,
+  );
+
   const eventHandlers: EventHandlers = {
+    ...legendEventHandlers,
     click: props => {
       // Allow cross-filter by dimensions OR by categorical X-axis (issue #25334)
       if (!hasDimensions && !canCrossFilterByXAxis) {
@@ -586,18 +593,6 @@ export default function EchartsTimeseries({
     mouseover: params => {
       onFocusedSeries(params.seriesName);
     },
-    legendscroll: payload => {
-      onLegendScroll?.(payload.scrollDataIndex);
-    },
-    legendselectchanged: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
-    legendselectall: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
-    legendinverseselect: payload => {
-      onLegendStateChanged?.(payload.selected);
-    },
     contextmenu: async eventParams => {
       if (onContextMenu) {
         eventParams.event.stop();
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
index b007fa0dddd..377adae4dfc 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -93,6 +93,7 @@ import {
   getColtypesMapping,
   getHorizontalLegendAvailableWidth,
   getLegendProps,
+  getLegendScrollDataIndex,
   getMinAndMaxFromBounds,
   getTemporalAxisTickConfig,
   resolveTemporalTickValues,
@@ -1738,7 +1739,10 @@ export default function transformProps(
         legendState,
         padding,
       ),
-      scrollDataIndex: legendIndex || 0,
+      scrollDataIndex: getLegendScrollDataIndex(
+        legendIndex,
+        resolvedLegendData.length,
+      ),
       data: resolvedLegendData,
       // Disable legend selection and buttons when colorByPrimaryAxis is enabled
       ...(usesPrimaryAxisLegend
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx
index 58c86a87841..76031e85cdd 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx
@@ -19,6 +19,7 @@
 import Echart from '../components/Echart';
 import { WaterfallChartTransformedProps } from './types';
 import { EventHandlers } from '../types';
+import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 
 export default function EchartsWaterfall(
   props: WaterfallChartTransformedProps,
@@ -26,17 +27,10 @@ export default function EchartsWaterfall(
   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);
-    },
-  };
+  // The waterfall legend has a fixed set of three entries, so it never
+  // paginates and has no scroll position to preserve.
+  const eventHandlers: EventHandlers =
+    useLegendEventHandlers(onLegendStateChanged);
 
   return (
     <Echart
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendEventHandlers.test.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendEventHandlers.test.ts
new file mode 100644
index 00000000000..01457cc2c3b
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendEventHandlers.test.ts
@@ -0,0 +1,275 @@
+/**
+ * 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 { LegendState } from '@superset-ui/core';
+import { act, renderHook } from '@testing-library/react';
+import {
+  LEGEND_DOUBLE_CLICK_INTERVAL,
+  isolateLegendSeries,
+  useLegendEventHandlers,
+} from './legendEventHandlers';
+
+const ALL_VISIBLE: LegendState = { a: true, b: true, c: true };
+
+beforeEach(() => {
+  jest.useFakeTimers();
+});
+
+afterEach(() => {
+  jest.useRealTimers();
+});
+
+function setup() {
+  const onLegendStateChanged = jest.fn();
+  const onLegendScroll = jest.fn();
+  const { result, unmount } = renderHook(() =>
+    useLegendEventHandlers(onLegendStateChanged, onLegendScroll),
+  );
+  return {
+    onLegendStateChanged,
+    onLegendScroll,
+    unmount,
+    handlers: result.current,
+  };
+}
+
+/** Lets the pending single click time out. */
+function waitForSingleClick() {
+  act(() => {
+    jest.advanceTimersByTime(LEGEND_DOUBLE_CLICK_INTERVAL);
+  });
+}
+
+/** Advances less than the double click window, so a pending click survives. */
+function waitBetweenClicks() {
+  act(() => {
+    jest.advanceTimersByTime(LEGEND_DOUBLE_CLICK_INTERVAL / 2);
+  });
+}
+
+test('isolateLegendSeries hides every series but the given one', () => {
+  expect(isolateLegendSeries(ALL_VISIBLE, 'b')).toEqual({
+    a: false,
+    b: true,
+    c: false,
+  });
+});
+
+test('isolateLegendSeries restores every series when already isolated', () => {
+  expect(isolateLegendSeries({ a: false, b: true, c: false }, 'b')).toEqual(
+    ALL_VISIBLE,
+  );
+});
+
+test('isolateLegendSeries ignores previously hidden series', () => {
+  expect(isolateLegendSeries({ a: true, b: true, c: false }, 'a')).toEqual({
+    a: true,
+    b: false,
+    c: false,
+  });
+});
+
+test('a single legend click is not reported until the double click window closes', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: true, b: false, c: true },
+  });
+
+  // Reporting here would re-render the chart mid gesture
+  expect(onLegendStateChanged).not.toHaveBeenCalled();
+
+  waitForSingleClick();
+
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+  expect(onLegendStateChanged).toHaveBeenCalledWith({
+    a: true,
+    b: false,
+    c: true,
+  });
+});
+
+test('double clicking a legend item isolates it without an intermediate update', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: true, b: false, c: true },
+  });
+  waitBetweenClicks();
+  // The second click flips the item back, so ECharts reports the selection
+  // as it was before the double click started
+  handlers.legendselectchanged({ name: 'b', selected: ALL_VISIBLE });
+
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+  expect(onLegendStateChanged).toHaveBeenCalledWith({
+    a: false,
+    b: true,
+    c: false,
+  });
+
+  // The superseded single click must not fire once the window elapses
+  waitForSingleClick();
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+});
+
+test('double clicking an isolated legend item restores every series', () => {
+  const { onLegendStateChanged, handlers } = setup();
+  const isolated: LegendState = { a: false, b: true, c: false };
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: false, b: false, c: false },
+  });
+  waitBetweenClicks();
+  handlers.legendselectchanged({ name: 'b', selected: isolated });
+
+  expect(onLegendStateChanged).toHaveBeenCalledWith(ALL_VISIBLE);
+});
+
+test('two slow clicks on the same legend item stay two single clicks', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: true, b: false, c: true },
+  });
+  waitForSingleClick();
+  handlers.legendselectchanged({ name: 'b', selected: ALL_VISIBLE });
+  waitForSingleClick();
+
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(2);
+  expect(onLegendStateChanged).toHaveBeenLastCalledWith(ALL_VISIBLE);
+});
+
+test('quick clicks on different legend items report only the newer selection', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectchanged({
+    name: 'a',
+    selected: { a: false, b: true, c: true },
+  });
+  waitBetweenClicks();
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: false, b: false, c: true },
+  });
+  waitForSingleClick();
+
+  // ECharts reports the cumulative selection, so one update covers both clicks
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+  expect(onLegendStateChanged).toHaveBeenCalledWith({
+    a: false,
+    b: false,
+    c: true,
+  });
+});
+
+test('a third quick click on an isolated item toggles it off', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: true, b: false, c: true },
+  });
+  waitBetweenClicks();
+  handlers.legendselectchanged({ name: 'b', selected: ALL_VISIBLE });
+  waitBetweenClicks();
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: false, b: false, c: false },
+  });
+  waitForSingleClick();
+
+  expect(onLegendStateChanged).toHaveBeenLastCalledWith({
+    a: false,
+    b: false,
+    c: false,
+  });
+});
+
+test('the legend selector buttons are reported immediately', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectall({ selected: ALL_VISIBLE });
+  expect(onLegendStateChanged).toHaveBeenLastCalledWith(ALL_VISIBLE);
+
+  handlers.legendinverseselect({
+    selected: { a: false, b: false, c: false },
+  });
+  expect(onLegendStateChanged).toHaveBeenLastCalledWith({
+    a: false,
+    b: false,
+    c: false,
+  });
+});
+
+test('a legend selector click discards a pending legend item click', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: true, b: false, c: true },
+  });
+  handlers.legendselectall({ selected: ALL_VISIBLE });
+  waitForSingleClick();
+
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+  expect(onLegendStateChanged).toHaveBeenCalledWith(ALL_VISIBLE);
+});
+
+test('paging a scrolling legend reports the new scroll position', () => {
+  const { onLegendScroll, handlers } = setup();
+
+  handlers.legendscroll({ scrollDataIndex: 12 });
+
+  expect(onLegendScroll).toHaveBeenCalledWith(12);
+});
+
+test('double clicking still isolates after the legend has been paged', () => {
+  const { onLegendStateChanged, handlers } = setup();
+
+  handlers.legendscroll({ scrollDataIndex: 2 });
+  handlers.legendselectchanged({
+    name: 'c',
+    selected: { a: true, b: true, c: false },
+  });
+  waitBetweenClicks();
+  handlers.legendselectchanged({ name: 'c', selected: ALL_VISIBLE });
+
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+  expect(onLegendStateChanged).toHaveBeenCalledWith({
+    a: false,
+    b: false,
+    c: true,
+  });
+});
+
+test('a pending click is dropped when the chart unmounts', () => {
+  const { onLegendStateChanged, handlers, unmount } = setup();
+
+  handlers.legendselectchanged({
+    name: 'b',
+    selected: { a: true, b: false, c: true },
+  });
+  unmount();
+  waitForSingleClick();
+
+  expect(onLegendStateChanged).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendEventHandlers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendEventHandlers.ts
new file mode 100644
index 00000000000..05d728ad88e
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendEventHandlers.ts
@@ -0,0 +1,131 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useRef } from 'react';
+import { LegendState } from '@superset-ui/core';
+import { EventHandlers } from '../types';
+
+/**
+ * Maximum delay in milliseconds between two clicks on the same legend item for
+ * them to count as a double click.
+ */
+export const LEGEND_DOUBLE_CLICK_INTERVAL = 300;
+
+interface LegendSelectChangedPayload {
+  name: string;
+  selected: LegendState;
+}
+
+interface LegendSelectPayload {
+  selected: LegendState;
+}
+
+interface LegendScrollPayload {
+  scrollDataIndex: number;
+}
+
+interface PendingClick {
+  name: string;
+  timer: ReturnType<typeof setTimeout>;
+}
+
+/**
+ * Builds the legend state that leaves `name` as the only visible series. When
+ * `name` is already the only visible series every series is restored instead,
+ * so double clicking an isolated series undoes the isolation.
+ */
+export function isolateLegendSeries(
+  selected: LegendState,
+  name: string,
+): LegendState {
+  const names = Object.keys(selected);
+  const isIsolated = names.every(key => selected[key] === (key === name));
+  return Object.fromEntries(
+    names.map((key): [string, boolean] => [key, isIsolated || key === name]),
+  );
+}
+
+/**
+ * ECharts legend handlers that keep the chart legend state in sync and add
+ * double click support: double clicking a legend item hides every other
+ * series, double clicking it again brings them all back.
+ *
+ * ECharts has no legend double click event, so a single click is held for
+ * `LEGEND_DOUBLE_CLICK_INTERVAL` before it is reported. Holding it matters for
+ * more than telling the two gestures apart. Reporting the first click straight
+ * away re-renders the chart in the middle of a double click, and a re-render
+ * rebuilds the legend from scratch, which knocks a paginated legend off the
+ * page the user is on and moves the item out from under the pointer before the
+ * second click can land. Deferring means either gesture costs exactly one
+ * re-render, and it happens once the gesture is over.
+ *
+ * Nothing feels slower for it: ECharts applies its own selection change as
+ * soon as the item is clicked, so series hide instantly. The deferred callback
+ * only syncs that selection back into React.
+ */
+export function useLegendEventHandlers(
+  onLegendStateChanged?: (state: LegendState) => void,
+  onLegendScroll?: (scrollDataIndex: number) => void,
+): EventHandlers {
+  const pending = useRef<PendingClick | null>(null);
+
+  const clearPending = useCallback(() => {
+    if (pending.current) {
+      clearTimeout(pending.current.timer);
+      pending.current = null;
+    }
+  }, []);
+
+  useEffect(() => clearPending, [clearPending]);
+
+  return useMemo(() => {
+    const report = ({ selected }: LegendSelectPayload) => {
+      clearPending();
+      onLegendStateChanged?.(selected);
+    };
+
+    return {
+      legendselectchanged: ({ name, selected }: LegendSelectChangedPayload) => {
+        if (pending.current?.name === name) {
+          // Second click on the same item. The two clicks cancel each other
+          // out in ECharts' own legend state, so `selected` still describes
+          // the selection as it was before the double click started.
+          clearPending();
+          onLegendStateChanged?.(isolateLegendSeries(selected, name));
+          return;
+        }
+        // A click on a different item supersedes the one still waiting.
+        // ECharts reports the whole selection every time, so the newer payload
+        // already accounts for the click being dropped.
+        clearPending();
+        pending.current = {
+          name,
+          timer: setTimeout(() => {
+            pending.current = null;
+            onLegendStateChanged?.(selected);
+          }, LEGEND_DOUBLE_CLICK_INTERVAL),
+        };
+      },
+      legendselectall: report,
+      legendinverseselect: report,
+      legendscroll: ({ scrollDataIndex }: LegendScrollPayload) => {
+        onLegendScroll?.(scrollDataIndex);
+      },
+    };
+  }, [clearPending, onLegendScroll, onLegendStateChanged]);
+}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
index 58dd17c5245..aa06dbfa7dc 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
@@ -836,6 +836,21 @@ export function extractGroupbyLabel({
     .join(', ');
 }
 
+/**
+ * ECharts `scrollDataIndex` is the legend entry index of the first visible
+ * item. Dashboard state keeps the last scroll position across re-renders, so
+ * clamp it when the legend has fewer entries after a data refresh.
+ */
+export function getLegendScrollDataIndex(
+  legendIndex: number | undefined,
+  legendItemCount: number,
+): number {
+  if (legendItemCount <= 0) {
+    return 0;
+  }
+  return Math.min(Math.max(legendIndex ?? 0, 0), legendItemCount - 1);
+}
+
 export function getLegendProps(
   type: LegendType,
   orientation: LegendOrientation,
@@ -845,7 +860,8 @@ export function getLegendProps(
   legendState?: LegendState,
   padding?: LegendPaddingType,
 ): LegendComponentOption {
-  const legend: LegendComponentOption = {
+  // `animation` is read by ECharts but missing from its legend option type
+  const legend: LegendComponentOption & { animation?: boolean } = {
     orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
       orientation,
     )
@@ -853,6 +869,15 @@ export function getLegendProps(
       : 'vertical',
     show,
     type,
+    ...(type === LegendType.Scroll
+      ? {
+          // A scrolling legend is rebuilt from its first page on every re-render
+          // and then animated back to `scrollDataIndex`, which reads as the legend
+          // sliding away and returning. Turning the animation off makes it render
+          // on the right page to begin with.
+          animation: false,
+        }
+      : {}),
     selected: legendState ?? {},
     selector: ['all', 'inverse'],
     selectorLabel: {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/Butterfly.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/Butterfly.test.tsx
index 7b27d40b711..555363000dc 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/Butterfly.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Butterfly/Butterfly.test.tsx
@@ -24,6 +24,7 @@ import transformProps from '../../src/Butterfly/transformProps';
 import { EchartsButterflyChartProps } from '../../src/Butterfly/types';
 import Echart from '../../src/components/Echart';
 import { EventHandlers } from '../../src/types';
+import { LEGEND_DOUBLE_CLICK_INTERVAL } from '../../src/utils/legendEventHandlers';
 
 jest.mock('../../src/components/Echart', () => ({
   __esModule: true,
@@ -96,6 +97,10 @@ beforeEach(() => {
   mockedEchart.mockClear();
 });
 
+afterEach(() => {
+  jest.useRealTimers();
+});
+
 test('context menu exposes drill to detail for the selected category', () => {
   const { eventHandlers, onContextMenu } = setup();
 
@@ -181,11 +186,15 @@ test('click clears cross-filter when the category is already selected', () => {
 });
 
 test('legend selection forwards legend state to the chart hook', () => {
+  jest.useFakeTimers();
   const onLegendStateChanged = jest.fn();
   const { eventHandlers } = setup({ onLegendStateChanged });
   const selected = { left_sum: true, right_sum: false };
 
-  eventHandlers.legendselectchanged({ selected });
+  eventHandlers.legendselectchanged({ name: 'right_sum', selected });
+  // A legend item click is held until it can no longer become a double click
+  expect(onLegendStateChanged).not.toHaveBeenCalled();
+  jest.advanceTimersByTime(LEGEND_DOUBLE_CLICK_INTERVAL);
   eventHandlers.legendselectall({ selected });
   eventHandlers.legendinverseselect({ selected });
 
@@ -193,6 +202,27 @@ test('legend selection forwards legend state to the chart hook', () => {
   expect(onLegendStateChanged).toHaveBeenCalledWith(selected);
 });
 
+test('double clicking a legend item isolates its series', () => {
+  jest.useFakeTimers();
+  const onLegendStateChanged = jest.fn();
+  const { eventHandlers } = setup({ onLegendStateChanged });
+
+  eventHandlers.legendselectchanged({
+    name: 'right_sum',
+    selected: { left_sum: true, right_sum: false },
+  });
+  eventHandlers.legendselectchanged({
+    name: 'right_sum',
+    selected: { left_sum: true, right_sum: true },
+  });
+
+  expect(onLegendStateChanged).toHaveBeenCalledTimes(1);
+  expect(onLegendStateChanged).toHaveBeenCalledWith({
+    left_sum: false,
+    right_sum: true,
+  });
+});
+
 test('passes selectedValues through to the chart component', () => {
   const { selectedValues } = setup({
     filterState: { selectedValues: [categoryKeyA] },
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts
index 1b39776b085..c5b0efc4fa3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts
@@ -39,6 +39,7 @@ import {
   getAxisType,
   getChartPadding,
   getLegendProps,
+  getLegendScrollDataIndex,
   getOverMaxHiddenFormatter,
   getMinAndMaxFromBounds,
   capTickMarks,
@@ -133,6 +134,11 @@ const expectedThemeProps = {
   },
 };
 
+const expectedScrollThemeProps = {
+  ...expectedThemeProps,
+  animation: false,
+};
+
 const sortData: DataRecord[] = [
   { my_x_axis: 'abc', x: 1, y: 0, z: 2 },
   { my_x_axis: 'foo', x: null, y: 10, z: 5 },
@@ -1105,6 +1111,12 @@ describe('formatSeriesName', () => {
   });
 });
 
+test('getLegendScrollDataIndex clamps saved scroll position to legend length', () => {
+  expect(getLegendScrollDataIndex(12, 5)).toBe(4);
+  expect(getLegendScrollDataIndex(undefined, 3)).toBe(0);
+  expect(getLegendScrollDataIndex(2, 0)).toBe(0);
+});
+
 describe('getLegendProps', () => {
   test('should return the correct props for scroll type with top orientation without zoom', () => {
     expect(
@@ -1121,7 +1133,7 @@ describe('getLegendProps', () => {
       right: 0,
       orient: 'horizontal',
       type: 'scroll',
-      ...expectedThemeProps,
+      ...expectedScrollThemeProps,
     });
   });
 
@@ -1140,7 +1152,7 @@ describe('getLegendProps', () => {
       right: 90,
       orient: 'horizontal',
       type: 'scroll',
-      ...expectedThemeProps,
+      ...expectedScrollThemeProps,
     });
   });