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