You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/26 16:34:00 UTC
(superset) 08/15: fix(echarts): prevent plain legend clipping in dashboards (#38675)
This is an automated email from the ASF dual-hosted git repository.
michaelsmolina pushed a commit to branch 6.1
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1e7d781354e96d8edfb04caa3d8e69b716f8316a
Author: Richard Fogaca Nienkotter <63...@users.noreply.github.com>
AuthorDate: Wed Mar 25 09:38:31 2026 -0300
fix(echarts): prevent plain legend clipping in dashboards (#38675)
(cherry picked from commit 12aca720746d70a13e0f76c24380ef4bdb69118f)
---
.../src/Bubble/transformProps.ts | 29 +-
.../src/Funnel/transformProps.ts | 29 +-
.../src/Gantt/transformProps.ts | 48 ++-
.../src/Graph/transformProps.ts | 29 +-
.../src/MixedTimeseries/transformProps.ts | 64 +++-
.../plugin-chart-echarts/src/Pie/transformProps.ts | 33 +-
.../src/Radar/transformProps.ts | 31 +-
.../src/Timeseries/transformProps.ts | 128 +++++--
.../plugin-chart-echarts/src/utils/legendLayout.ts | 56 ++++
.../plugin-chart-echarts/src/utils/series.ts | 372 ++++++++++++++++++++-
.../test/Gantt/transformProps.test.ts | 65 ++++
.../test/MixedTimeseries/transformProps.test.ts | 63 ++++
.../test/Pie/transformProps.test.ts | 46 +++
.../test/Timeseries/Bar/transformProps.test.ts | 274 ++++++++++++++-
.../test/Timeseries/transformProps.test.ts | 37 +-
.../plugin-chart-echarts/test/utils/series.test.ts | 312 ++++++++++++++++-
16 files changed, 1514 insertions(+), 102 deletions(-)
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 ec671b94893..1f11498a358 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts
@@ -31,6 +31,7 @@ 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 { resolveLegendLayout } from '../utils/legendLayout';
import { Refs } from '../types';
import { parseAxisBound } from '../utils/controls';
import { getDefaultTooltip } from '../utils/tooltip';
@@ -172,6 +173,20 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
const xAxisFormatter = getNumberFormatter(xAxisFormat);
const yAxisFormatter = getNumberFormatter(yAxisFormat);
const tooltipSizeFormatter = getNumberFormatter(tooltipSizeFormat);
+ const legendData = Array.from(legends).sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
const [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);
@@ -181,7 +196,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
legendOrientation,
true,
false,
- legendMargin,
+ effectiveLegendMargin,
true,
'Left',
convertInteger(yAxisTitleMargin),
@@ -230,11 +245,13 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
type: logYAxis ? AxisType.Log : AxisType.Value,
},
legend: {
- ...getLegendProps(legendType, legendOrientation, showLegend, theme),
- data: Array.from(legends).sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }),
+ ...getLegendProps(
+ effectiveLegendType,
+ legendOrientation,
+ showLegend,
+ theme,
+ ),
+ data: legendData,
},
tooltip: {
show: !inContextMenu,
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 58c0a697402..87189c355a3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts
@@ -46,6 +46,7 @@ import {
getLegendProps,
sanitizeHtml,
} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import { defaultGrid } from '../defaults';
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
@@ -241,11 +242,25 @@ export default function transformProps(
textBorderColor: theme.colorBgBase,
textBorderWidth: 1,
};
+ const legendData = keys.sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
const series: FunnelSeriesOption[] = [
{
type: VizType.Funnel,
- ...getChartPadding(showLegend, legendOrientation, legendMargin),
+ ...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
animation: true,
minSize: '0%',
maxSize: '100%',
@@ -298,11 +313,13 @@ export default function transformProps(
},
},
legend: {
- ...getLegendProps(legendType, legendOrientation, showLegend, theme),
- data: keys.sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }),
+ ...getLegendProps(
+ effectiveLegendType,
+ legendOrientation,
+ showLegend,
+ theme,
+ ),
+ data: legendData,
},
series,
};
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 997cbab1397..1a9b4d565a1 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts
@@ -43,8 +43,13 @@ import {
EchartsGanttFormData,
} from './types';
import { DEFAULT_FORM_DATA, TIMESERIES_CONSTANTS } from '../constants';
-import { Refs } from '../types';
-import { getLegendProps, groupData } from '../utils/series';
+import { LegendOrientation, Refs } from '../types';
+import {
+ getHorizontalLegendAvailableWidth,
+ getLegendProps,
+ groupData,
+} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import {
getTooltipTimeFormatter,
getXAxisFormatter,
@@ -246,7 +251,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
}
const padding = getPadding(
- showLegend && seriesMap.size > 1,
+ showLegend,
legendOrientation,
false,
zoomable,
@@ -345,6 +350,40 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
if (!legendSort) return 0;
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
});
+ const { legendLayout, effectiveLegendType } = resolveLegendLayout({
+ availableWidth:
+ legendOrientation === LegendOrientation.Top ||
+ legendOrientation === LegendOrientation.Bottom
+ ? getHorizontalLegendAvailableWidth({
+ chartWidth: width,
+ orientation: legendOrientation,
+ padding,
+ zoomable,
+ })
+ : undefined,
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
+ if (legendLayout.effectiveMargin !== undefined) {
+ const adjustedPadding = getPadding(
+ showLegend,
+ legendOrientation,
+ false,
+ zoomable,
+ legendLayout.effectiveMargin,
+ !!xAxisTitle,
+ 'Left',
+ convertInteger(yAxisTitleMargin),
+ convertInteger(xAxisTitleMargin),
+ );
+ Object.assign(padding, adjustedPadding);
+ }
const tooltipFormatterMap = {
[GenericDataType.Numeric]: tooltipValuesFormatter,
@@ -374,12 +413,13 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
},
legend: {
...getLegendProps(
- legendType,
+ effectiveLegendType,
legendOrientation,
showLegend,
theme,
zoomable,
legendState,
+ padding,
),
data: legendData,
},
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 8204351f30f..9fee03ae3d3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts
@@ -42,6 +42,7 @@ import {
getLegendProps,
sanitizeHtml,
} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
@@ -298,6 +299,20 @@ export default function transformProps(
});
const categoryList = [...categories];
+ const legendData = categoryList.sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
const series: GraphSeriesOption[] = [
{
zoom: DEFAULT_GRAPH_SERIES_OPTION.zoom,
@@ -324,7 +339,7 @@ export default function transformProps(
edgeSymbol: parseEdgeSymbol(edgeSymbol),
edgeSymbolSize: baseEdgeWidth * 2,
selectedMode,
- ...getChartPadding(showLegend, legendOrientation, legendMargin),
+ ...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
animation: DEFAULT_GRAPH_SERIES_OPTION.animation,
label: {
...DEFAULT_GRAPH_SERIES_OPTION.label,
@@ -353,11 +368,13 @@ export default function transformProps(
},
},
legend: {
- ...getLegendProps(legendType, legendOrientation, showLegend, theme),
- data: categoryList.sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }),
+ ...getLegendProps(
+ effectiveLegendType,
+ legendOrientation,
+ showLegend,
+ theme,
+ ),
+ data: legendData,
},
series,
};
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 f4db0afc3d9..b1655e38d1d 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
@@ -55,6 +55,7 @@ import {
import {
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
+ LegendOrientation,
Refs,
} from '../types';
import { parseAxisBound } from '../utils/controls';
@@ -67,10 +68,12 @@ import {
extractTooltipKeys,
getAxisType,
getColtypesMapping,
+ getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
getOverMaxHiddenFormatter,
} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import {
extractAnnotationLabels,
getAnnotationData,
@@ -583,13 +586,57 @@ export default function transformProps(
convertInteger(yAxisTitleMargin) !== 0;
const addXAxisTitleOffset =
!!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0;
+ const baseChartPadding = getPadding(
+ showLegend,
+ legendOrientation,
+ addYAxisTitleOffset,
+ zoomable,
+ legendMargin,
+ addXAxisTitleOffset,
+ yAxisTitlePosition,
+ convertInteger(yAxisTitleMargin),
+ convertInteger(xAxisTitleMargin),
+ );
+ const legendData = series
+ .filter(
+ entry =>
+ extractForecastSeriesContext((entry.name || '') as string).type ===
+ ForecastSeriesEnum.Observation,
+ )
+ .map(entry => entry.name)
+ .filter((name): name is string => Boolean(name))
+ .concat(extractAnnotationLabels(annotationLayers))
+ .sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ availableWidth:
+ legendOrientation === LegendOrientation.Top ||
+ legendOrientation === LegendOrientation.Bottom
+ ? getHorizontalLegendAvailableWidth({
+ chartWidth: width,
+ orientation: legendOrientation,
+ padding: baseChartPadding,
+ zoomable,
+ })
+ : undefined,
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
const chartPadding = getPadding(
showLegend,
legendOrientation,
addYAxisTitleOffset,
zoomable,
- legendMargin,
+ effectiveLegendMargin,
addXAxisTitleOffset,
yAxisTitlePosition,
convertInteger(yAxisTitleMargin),
@@ -753,7 +800,7 @@ export default function transformProps(
},
legend: {
...getLegendProps(
- legendType,
+ effectiveLegendType,
legendOrientation,
showLegend,
theme,
@@ -761,18 +808,7 @@ export default function transformProps(
legendState,
chartPadding,
),
- data: series
- .filter(
- entry =>
- extractForecastSeriesContext((entry.name || '') as string).type ===
- ForecastSeriesEnum.Observation,
- )
- .map(entry => entry.id || entry.name || '')
- .concat(extractAnnotationLabels(annotationLayers))
- .sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }),
+ data: legendData,
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
toolbox: {
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 3a66e61910e..6a355f6e753 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
@@ -48,6 +48,7 @@ import {
getLegendProps,
sanitizeHtml,
} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import { defaultGrid } from '../defaults';
import { convertInteger } from '../utils/convertInteger';
import { getDefaultTooltip } from '../utils/tooltip';
@@ -380,11 +381,27 @@ export default function transformProps(
show: showLabels,
color: theme.colorText,
};
+ const legendData = transformedData
+ .map(datum => datum.name)
+ .sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
const chartPadding = getChartPadding(
showLegend,
legendOrientation,
- legendMargin,
+ effectiveLegendMargin,
);
const series: PieSeriesOption[] = [
@@ -444,13 +461,13 @@ export default function transformProps(
},
},
legend: {
- ...getLegendProps(legendType, legendOrientation, showLegend, theme),
- data: transformedData
- .map(datum => datum.name)
- .sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }),
+ ...getLegendProps(
+ effectiveLegendType,
+ legendOrientation,
+ showLegend,
+ theme,
+ ),
+ data: legendData,
},
graphic: showTotal
? {
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 142bc51de45..a906ee9b177 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts
@@ -45,6 +45,7 @@ import {
getColtypesMapping,
getLegendProps,
} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import { defaultGrid } from '../defaults';
import { Refs } from '../types';
import { getDefaultTooltip } from '../utils/tooltip';
@@ -313,11 +314,27 @@ export default function transformProps(
min,
};
});
+ const legendData = Array.from(columnsLabelMap.keys()).sort(
+ (a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ },
+ );
+ const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
+ chartHeight: height,
+ chartWidth: width,
+ legendItems: legendData,
+ legendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ theme,
+ type: legendType,
+ });
const series: RadarSeriesOption[] = [
{
type: 'radar',
- ...getChartPadding(showLegend, legendOrientation, legendMargin),
+ ...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
animation: false,
emphasis: {
label: {
@@ -354,11 +371,13 @@ export default function transformProps(
formatter: NormalizedTooltipFormater,
},
legend: {
- ...getLegendProps(legendType, legendOrientation, showLegend, theme),
- data: Array.from(columnsLabelMap.keys()).sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
- }),
+ ...getLegendProps(
+ effectiveLegendType,
+ legendOrientation,
+ showLegend,
+ theme,
+ ),
+ data: legendData,
},
series,
radar: {
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 f7d90cc3f5a..e53070ceb81 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -63,7 +63,13 @@ import {
TimeseriesChartTransformedProps,
} from './types';
import { DEFAULT_FORM_DATA } from './constants';
-import { ForecastSeriesEnum, ForecastValue, Refs } from '../types';
+import {
+ ForecastSeriesEnum,
+ ForecastValue,
+ LegendOrientation,
+ LegendType,
+ Refs,
+} from '../types';
import { parseAxisBound } from '../utils/controls';
import {
calculateLowerLogTick,
@@ -74,9 +80,11 @@ import {
extractTooltipKeys,
getAxisType,
getColtypesMapping,
+ getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
} from '../utils/series';
+import { resolveLegendLayout } from '../utils/legendLayout';
import {
extractAnnotationLabels,
getAnnotationData,
@@ -647,29 +655,10 @@ export default function transformProps(
onLegendScroll,
} = hooks;
- const addYAxisLabelOffset =
- !!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0;
- const addXAxisLabelOffset =
- !!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0;
- const padding = getPadding(
- showLegend,
- legendOrientation,
- addYAxisLabelOffset,
- zoomable,
- legendMargin,
- addXAxisLabelOffset,
- yAxisTitlePosition,
- convertInteger(yAxisTitleMargin),
- convertInteger(xAxisTitleMargin),
- isHorizontal,
- );
-
const legendData =
colorByPrimaryAxis && groupBy.length === 0 && series.length > 0
- ? // When colorByPrimaryAxis is enabled, show only primary axis values (deduped + filtered)
- (() => {
+ ? (() => {
const firstSeries = series[0];
- // For horizontal charts the category is at index 1, for vertical at index 0
const primaryAxisIndex = isHorizontal ? 1 : 0;
if (firstSeries && Array.isArray(firstSeries.data)) {
const names = (firstSeries.data as any[])
@@ -692,8 +681,7 @@ export default function transformProps(
}
return [];
})()
- : // Otherwise show original series names
- rawSeries
+ : rawSeries
.filter(
entry =>
extractForecastSeriesContext(entry.name || '').type ===
@@ -701,6 +689,84 @@ export default function transformProps(
)
.map(entry => entry.name || '')
.concat(extractAnnotationLabels(annotationLayers));
+ const addYAxisLabelOffset =
+ !!yAxisTitle && convertInteger(yAxisTitleMargin) !== 0;
+ const addXAxisLabelOffset =
+ !!xAxisTitle && convertInteger(xAxisTitleMargin) !== 0;
+
+ const sortedLegendData = [...legendData].sort((a: string, b: string) => {
+ if (!legendSort) return 0;
+ return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
+ });
+ const colorByPrimaryAxisLegendData = legendData.map(name => ({
+ name,
+ icon: 'roundRect',
+ }));
+ const getLegendLayout = (candidateLegendMargin?: string | number | null) => {
+ const padding = getPadding(
+ showLegend,
+ legendOrientation,
+ addYAxisLabelOffset,
+ zoomable,
+ candidateLegendMargin,
+ addXAxisLabelOffset,
+ yAxisTitlePosition,
+ convertInteger(yAxisTitleMargin),
+ convertInteger(xAxisTitleMargin),
+ isHorizontal,
+ );
+
+ return resolveLegendLayout({
+ availableWidth:
+ legendOrientation === LegendOrientation.Top ||
+ legendOrientation === LegendOrientation.Bottom
+ ? getHorizontalLegendAvailableWidth({
+ chartWidth: width,
+ orientation: legendOrientation,
+ padding,
+ zoomable,
+ })
+ : undefined,
+ chartHeight: height,
+ chartWidth: width,
+ legendItems:
+ colorByPrimaryAxis && groupBy.length === 0
+ ? colorByPrimaryAxisLegendData
+ : sortedLegendData,
+ legendMargin: candidateLegendMargin,
+ orientation: legendOrientation,
+ show: showLegend,
+ showSelectors: !(colorByPrimaryAxis && groupBy.length === 0),
+ theme,
+ type: legendType,
+ });
+ };
+ const initialLegendLayout = getLegendLayout(legendMargin);
+ const legendLayout =
+ isHorizontal &&
+ legendOrientation === LegendOrientation.Bottom &&
+ initialLegendLayout.effectiveLegendType === LegendType.Plain
+ ? getLegendLayout(initialLegendLayout.effectiveLegendMargin)
+ : initialLegendLayout;
+ const { effectiveLegendType } = legendLayout;
+ const effectiveLegendMargin =
+ isHorizontal &&
+ legendOrientation === LegendOrientation.Bottom &&
+ legendLayout.effectiveLegendType === LegendType.Scroll
+ ? legendMargin
+ : legendLayout.effectiveLegendMargin;
+ const padding = getPadding(
+ showLegend,
+ legendOrientation,
+ addYAxisLabelOffset,
+ zoomable,
+ effectiveLegendMargin,
+ addXAxisLabelOffset,
+ yAxisTitlePosition,
+ convertInteger(yAxisTitleMargin),
+ convertInteger(xAxisTitleMargin),
+ isHorizontal,
+ );
let xAxis: any = {
type: xAxisType,
@@ -910,7 +976,7 @@ export default function transformProps(
},
legend: {
...getLegendProps(
- legendType,
+ effectiveLegendType,
legendOrientation,
showLegend,
theme,
@@ -921,18 +987,8 @@ export default function transformProps(
scrollDataIndex: legendIndex || 0,
data:
colorByPrimaryAxis && groupBy.length === 0
- ? // When colorByPrimaryAxis, configure legend items with roundRect icons
- legendData.map(name => ({
- name,
- icon: 'roundRect',
- }))
- : // Otherwise use normal legend data
- legendData.sort((a: string, b: string) => {
- if (!legendSort) return 0;
- return legendSort === 'asc'
- ? a.localeCompare(b)
- : b.localeCompare(a);
- }),
+ ? colorByPrimaryAxisLegendData
+ : sortedLegendData,
// Disable legend selection and buttons when colorByPrimaryAxis is enabled
...(colorByPrimaryAxis && groupBy.length === 0
? {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendLayout.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendLayout.ts
new file mode 100644
index 00000000000..c4a8740e0c1
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/legendLayout.ts
@@ -0,0 +1,56 @@
+/**
+ * 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 type { SupersetTheme } from '@apache-superset/core/theme';
+import { LegendOrientation, LegendType } from '../types';
+import { getLegendLayoutResult, LegendLayoutResult } from './series';
+
+type LegendDataItem =
+ | string
+ | number
+ | null
+ | undefined
+ | { name?: string | number | null };
+
+export type ResolvedLegendLayout = {
+ effectiveLegendMargin?: string | number | null;
+ effectiveLegendType: LegendType;
+ legendLayout: LegendLayoutResult;
+};
+
+export function resolveLegendLayout(args: {
+ availableHeight?: number;
+ availableWidth?: number;
+ chartHeight: number;
+ chartWidth: number;
+ legendItems?: LegendDataItem[];
+ legendMargin?: string | number | null;
+ orientation: LegendOrientation;
+ show: boolean;
+ showSelectors?: boolean;
+ theme: SupersetTheme;
+ type: LegendType;
+}): ResolvedLegendLayout {
+ const legendLayout = getLegendLayoutResult(args);
+
+ return {
+ effectiveLegendMargin: legendLayout.effectiveMargin ?? args.legendMargin,
+ effectiveLegendType: legendLayout.effectiveType,
+ legendLayout,
+ };
+}
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 882612ec33f..8e243167474 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
@@ -55,6 +55,364 @@ function isDefined<T>(value: T | undefined | null): boolean {
return value !== undefined && value !== null;
}
+const DEFAULT_LEGEND_ITEM_GAP = 10;
+const DEFAULT_LEGEND_ICON_WIDTH = 25;
+const LEGEND_ICON_LABEL_GAP = 5;
+const LEGEND_HORIZONTAL_SIDE_GUTTER = 16;
+const LEGEND_HORIZONTAL_ROW_HEIGHT = 24;
+const LEGEND_HORIZONTAL_MAX_ROWS = 2;
+const LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO = 0.25;
+const LEGEND_VERTICAL_SIDE_GUTTER = 16;
+const LEGEND_VERTICAL_ROW_HEIGHT = 24;
+const LEGEND_VERTICAL_MAX_WIDTH_RATIO = 0.4;
+const LEGEND_SELECTOR_GAP = 10;
+const LEGEND_MARGIN_GUTTER = 45;
+// ECharts does not expose pre-render measurements for plain legends, so these
+// values intentionally overestimate selector space to avoid clipping.
+const ESTIMATED_LEGEND_SELECTOR_WIDTH = 112;
+const LEGEND_TEXT_WIDTH_CACHE = new Map<string, number>();
+
+type LegendDataItem =
+ | string
+ | number
+ | null
+ | undefined
+ | { name?: string | number | null };
+
+export type LegendLayoutResult = {
+ effectiveMargin?: number;
+ effectiveType: LegendType;
+};
+
+const SCROLL_LEGEND_LAYOUT: LegendLayoutResult = {
+ effectiveType: LegendType.Scroll,
+};
+
+function getLegendLabel(item: LegendDataItem): string {
+ if (typeof item === 'string' || typeof item === 'number') {
+ return String(item);
+ }
+
+ if (item?.name === undefined || item.name === null) {
+ return '';
+ }
+
+ return String(item.name);
+}
+
+function measureLegendTextWidth(text: string, theme: SupersetTheme): number {
+ const cacheKey = `${theme.fontFamily}:${theme.fontSizeSM}:${text}`;
+ const cachedWidth = LEGEND_TEXT_WIDTH_CACHE.get(cacheKey);
+ if (cachedWidth !== undefined) {
+ return cachedWidth;
+ }
+
+ let width = text.length * theme.fontSizeSM * 0.62;
+
+ if (typeof document !== 'undefined') {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ if (context) {
+ context.font = `${theme.fontSizeSM}px ${theme.fontFamily}`;
+ ({ width } = context.measureText(text));
+ }
+ }
+
+ LEGEND_TEXT_WIDTH_CACHE.set(cacheKey, width);
+ return width;
+}
+
+function hasLegendLabel(item: LegendDataItem): boolean {
+ if (item === null || item === undefined) {
+ return false;
+ }
+
+ if (typeof item === 'object') {
+ return item.name !== null && item.name !== undefined;
+ }
+
+ return true;
+}
+
+function getLegendLabels(items: LegendDataItem[]): string[] {
+ return items.filter(hasLegendLabel).map(getLegendLabel);
+}
+
+function getLegendItemWidths(labels: string[], theme: SupersetTheme): number[] {
+ return labels.map(
+ label =>
+ DEFAULT_LEGEND_ICON_WIDTH +
+ LEGEND_ICON_LABEL_GAP +
+ measureLegendTextWidth(label, theme),
+ );
+}
+
+function estimateHorizontalLegendRows(
+ labels: string[],
+ chartWidth: number,
+ showSelectors: boolean,
+ theme: SupersetTheme,
+): number {
+ const availableWidth = Math.max(
+ chartWidth - LEGEND_HORIZONTAL_SIDE_GUTTER,
+ 0,
+ );
+ if (availableWidth === 0) {
+ return Infinity;
+ }
+
+ const legendItemWidths = getLegendItemWidths(labels, theme);
+
+ if (legendItemWidths.length === 0) {
+ if (showSelectors && ESTIMATED_LEGEND_SELECTOR_WIDTH > availableWidth) {
+ return Infinity;
+ }
+ return 1;
+ }
+
+ let rows = 1;
+ let rowWidth = 0;
+
+ for (const itemWidth of legendItemWidths) {
+ if (itemWidth > availableWidth) {
+ return Infinity;
+ }
+
+ const nextWidth =
+ rowWidth === 0
+ ? itemWidth
+ : rowWidth + DEFAULT_LEGEND_ITEM_GAP + itemWidth;
+ if (rowWidth > 0 && nextWidth > availableWidth) {
+ rows += 1;
+ rowWidth = itemWidth;
+ } else {
+ rowWidth = nextWidth;
+ }
+ }
+
+ if (showSelectors) {
+ if (ESTIMATED_LEGEND_SELECTOR_WIDTH > availableWidth) {
+ return Infinity;
+ }
+ const selectorWidth =
+ rowWidth === 0
+ ? ESTIMATED_LEGEND_SELECTOR_WIDTH
+ : rowWidth + LEGEND_SELECTOR_GAP + ESTIMATED_LEGEND_SELECTOR_WIDTH;
+ if (selectorWidth > availableWidth) {
+ rows += 1;
+ }
+ }
+
+ return rows;
+}
+
+export function getHorizontalLegendAvailableWidth({
+ chartWidth,
+ orientation,
+ padding,
+ zoomable = false,
+}: {
+ chartWidth: number;
+ orientation: LegendOrientation.Top | LegendOrientation.Bottom;
+ padding?: LegendPaddingType;
+ zoomable?: boolean;
+}): number {
+ let availableWidth = chartWidth - (padding?.left ?? 0);
+
+ if (orientation === LegendOrientation.Top && zoomable) {
+ availableWidth -= TIMESERIES_CONSTANTS.legendTopRightOffset;
+ }
+
+ return Math.max(availableWidth, 0);
+}
+
+function getLongestLegendLabelWidth(
+ labels: string[],
+ theme: SupersetTheme,
+): number {
+ return labels.reduce(
+ (maxWidth, label) =>
+ Math.max(maxWidth, measureLegendTextWidth(label, theme)),
+ 0,
+ );
+}
+
+function isHorizontalLegendOrientation(
+ orientation: LegendOrientation,
+): orientation is LegendOrientation.Top | LegendOrientation.Bottom {
+ return (
+ orientation === LegendOrientation.Top ||
+ orientation === LegendOrientation.Bottom
+ );
+}
+
+function getHorizontalPlainLegendLayout({
+ availableHeight,
+ availableWidth,
+ currentMargin,
+ legendLabels,
+ orientation,
+ showSelectors,
+ theme,
+}: {
+ availableHeight: number;
+ availableWidth: number;
+ currentMargin: number;
+ legendLabels: string[];
+ orientation: LegendOrientation.Top | LegendOrientation.Bottom;
+ showSelectors: boolean;
+ theme: SupersetTheme;
+}): LegendLayoutResult {
+ const rowCount = estimateHorizontalLegendRows(
+ legendLabels,
+ availableWidth,
+ showSelectors,
+ theme,
+ );
+ const requiredMargin =
+ defaultLegendPadding[orientation] +
+ Math.max(0, rowCount - 1) * LEGEND_HORIZONTAL_ROW_HEIGHT;
+ const maxLegendHeight =
+ availableHeight > 0
+ ? availableHeight * LEGEND_HORIZONTAL_MAX_HEIGHT_RATIO
+ : Infinity;
+
+ if (
+ !Number.isFinite(rowCount) ||
+ rowCount > LEGEND_HORIZONTAL_MAX_ROWS ||
+ requiredMargin > maxLegendHeight
+ ) {
+ return SCROLL_LEGEND_LAYOUT;
+ }
+
+ return {
+ effectiveMargin: Math.max(currentMargin, requiredMargin),
+ effectiveType: LegendType.Plain,
+ };
+}
+
+function getVerticalPlainLegendLayout({
+ availableHeight,
+ availableWidth,
+ currentMargin,
+ legendLabels,
+ showSelectors,
+ theme,
+}: {
+ availableHeight: number;
+ availableWidth: number;
+ currentMargin: number;
+ legendLabels: string[];
+ showSelectors: boolean;
+ theme: SupersetTheme;
+}): LegendLayoutResult {
+ if (legendLabels.length === 0) {
+ return {
+ effectiveMargin: currentMargin,
+ effectiveType: LegendType.Plain,
+ };
+ }
+
+ const selectorHeight = showSelectors
+ ? LEGEND_VERTICAL_ROW_HEIGHT + LEGEND_SELECTOR_GAP
+ : 0;
+ const effectiveAvailableHeight = Math.max(
+ availableHeight - LEGEND_VERTICAL_SIDE_GUTTER - selectorHeight,
+ 0,
+ );
+ const rowsPerColumn = Math.floor(
+ (effectiveAvailableHeight + DEFAULT_LEGEND_ITEM_GAP) /
+ (LEGEND_VERTICAL_ROW_HEIGHT + DEFAULT_LEGEND_ITEM_GAP),
+ );
+ const requiredSelectorMargin = showSelectors
+ ? ESTIMATED_LEGEND_SELECTOR_WIDTH + LEGEND_VERTICAL_SIDE_GUTTER
+ : 0;
+ const requiredMargin = Math.ceil(
+ Math.max(
+ getLongestLegendLabelWidth(legendLabels, theme) + LEGEND_MARGIN_GUTTER,
+ requiredSelectorMargin,
+ ),
+ );
+ const maxLegendWidth =
+ availableWidth > 0
+ ? availableWidth * LEGEND_VERTICAL_MAX_WIDTH_RATIO
+ : Infinity;
+
+ if (
+ rowsPerColumn <= 0 ||
+ legendLabels.length > rowsPerColumn ||
+ requiredMargin > maxLegendWidth
+ ) {
+ return SCROLL_LEGEND_LAYOUT;
+ }
+
+ return {
+ effectiveMargin: Math.max(currentMargin, requiredMargin),
+ effectiveType: LegendType.Plain,
+ };
+}
+
+export function getLegendLayoutResult({
+ availableHeight,
+ availableWidth,
+ chartHeight,
+ chartWidth,
+ legendItems = [],
+ legendMargin,
+ orientation,
+ show,
+ showSelectors = true,
+ theme,
+ type,
+}: {
+ // Raw chart dimensions. Use availableWidth/availableHeight when other chart
+ // UI elements reserve legend space before ECharts lays out the legend.
+ availableHeight?: number;
+ availableWidth?: number;
+ chartHeight: number;
+ chartWidth: number;
+ legendItems?: LegendDataItem[];
+ legendMargin?: string | number | null;
+ orientation: LegendOrientation;
+ show: boolean;
+ showSelectors?: boolean;
+ theme: SupersetTheme;
+ type: LegendType;
+}): LegendLayoutResult {
+ if (!show || type !== LegendType.Plain) {
+ return { effectiveType: type };
+ }
+
+ const resolvedLegendMargin =
+ typeof legendMargin === 'number'
+ ? legendMargin
+ : defaultLegendPadding[orientation];
+ const legendLabels = getLegendLabels(legendItems);
+ const resolvedAvailableWidth = availableWidth ?? chartWidth;
+ const resolvedAvailableHeight = availableHeight ?? chartHeight;
+
+ if (isHorizontalLegendOrientation(orientation)) {
+ return getHorizontalPlainLegendLayout({
+ availableHeight: resolvedAvailableHeight,
+ availableWidth: resolvedAvailableWidth,
+ currentMargin: resolvedLegendMargin,
+ legendLabels,
+ orientation,
+ showSelectors,
+ theme,
+ });
+ }
+
+ return getVerticalPlainLegendLayout({
+ availableHeight: resolvedAvailableHeight,
+ availableWidth: resolvedAvailableWidth,
+ currentMargin: resolvedLegendMargin,
+ legendLabels,
+ showSelectors,
+ theme,
+ });
+}
+
export function extractDataTotalValues(
data: DataRecord[],
opts: {
@@ -438,12 +796,6 @@ export function getLegendProps(
legendState?: LegendState,
padding?: LegendPaddingType,
): LegendComponentOption {
- const isHorizontal =
- orientation === LegendOrientation.Top ||
- orientation === LegendOrientation.Bottom;
-
- const effectiveType =
- type === LegendType.Scroll || !isHorizontal ? type : LegendType.Scroll;
const legend: LegendComponentOption = {
orient: [LegendOrientation.Top, LegendOrientation.Bottom].includes(
orientation,
@@ -451,7 +803,7 @@ export function getLegendProps(
? 'horizontal'
: 'vertical',
show,
- type: effectiveType,
+ type,
selected: legendState ?? {},
selector: ['all', 'inverse'],
selectorLabel: {
@@ -491,10 +843,16 @@ export function getLegendProps(
if (padding?.left) {
legend.left = padding.left;
}
+ if (type === LegendType.Plain) {
+ legend.right = 0;
+ }
break;
case LegendOrientation.Top:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
+ if (type === LegendType.Plain && padding?.left) {
+ legend.left = padding.left;
+ }
break;
default:
legend.top = 0;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts
index fd0545d5e99..f260959e4d2 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Gantt/transformProps.test.ts
@@ -303,4 +303,69 @@ describe('legend sorting', () => {
const legendData = (result.echartOptions.legend as any).data;
expect(legendData).toEqual(['series value 2', 'series value 1']);
});
+
+ test('falls back to scroll for plain legends with an overlong legend item', () => {
+ const props = new ChartProps({
+ ...chartPropsConfig,
+ width: 320,
+ formData: {
+ ...formData,
+ legendType: LegendType.Plain,
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ startTime: Date.UTC(2025, 1, 1, 13, 0, 0),
+ endTime: Date.UTC(2025, 1, 1, 14, 0, 0),
+ 'Y Axis': 'first',
+ tooltip_column: 'tooltip value 1',
+ series:
+ 'This is a ridiculously long legend label that should switch to scroll',
+ },
+ {
+ startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
+ endTime: Date.UTC(2025, 1, 1, 20, 0, 0),
+ 'Y Axis': 'second',
+ tooltip_column: 'tooltip value 2',
+ series: 'short label',
+ },
+ ],
+ colnames: [
+ 'startTime',
+ 'endTime',
+ 'Y Axis',
+ 'tooltip_column',
+ 'series',
+ ],
+ },
+ ],
+ });
+
+ const result = transformProps(props as EchartsGanttChartProps);
+
+ expect((result.echartOptions.legend as any).type).toBe(LegendType.Scroll);
+ });
+
+ test('keeps legend visibility driven by showLegend for single-series charts', () => {
+ const props = new ChartProps({
+ ...chartPropsConfig,
+ queriesData: [
+ {
+ data: [queriesData[0].data[0]],
+ colnames: [
+ 'startTime',
+ 'endTime',
+ 'Y Axis',
+ 'tooltip_column',
+ 'series',
+ ],
+ },
+ ],
+ });
+
+ const result = transformProps(props as EchartsGanttChartProps);
+
+ expect((result.echartOptions.legend as any).show).toBe(true);
+ });
});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts
index e8707d0c8c2..81b3f367084 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts
@@ -19,8 +19,10 @@
import {
AnnotationStyle,
AnnotationType,
+ AnnotationSourceType,
DataRecord,
FormulaAnnotationLayer,
+ IntervalAnnotationLayer,
VizType,
ChartDataResponseResult,
} from '@superset-ui/core';
@@ -366,6 +368,67 @@ test('legend margin: right orientation sets grid.right correctly', () => {
expect((transformed.echartOptions.grid as any).right).toEqual(270);
});
+test('should exclude unnamed annotation helper series from legend data', () => {
+ const interval: IntervalAnnotationLayer = {
+ annotationType: AnnotationType.Interval,
+ name: 'My Interval',
+ show: true,
+ showLabel: true,
+ sourceType: AnnotationSourceType.Table,
+ titleColumn: '',
+ timeColumn: 'start',
+ intervalEndColumn: 'end',
+ descriptionColumns: [],
+ style: AnnotationStyle.Dashed,
+ value: 2,
+ };
+
+ const annotationData = {
+ 'My Interval': {
+ columns: ['start', 'end', 'title'],
+ records: [
+ {
+ start: 2000,
+ end: 3000,
+ title: 'My Title',
+ },
+ ],
+ },
+ };
+
+ const chartProps = createEchartsTimeseriesTestChartProps<
+ EchartsMixedTimeseriesFormData,
+ EchartsMixedTimeseriesProps
+ >({
+ ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
+ defaultQueriesData: [],
+ formData: {
+ ...formData,
+ annotationLayers: [interval],
+ showLegend: true,
+ showQueryIdentifiers: true,
+ },
+ queriesData: [
+ createTestQueryData(defaultQueryRows, {
+ label_map: defaultLabelMap,
+ annotation_data: annotationData,
+ }),
+ createTestQueryData(defaultQueryRows, {
+ label_map: defaultLabelMap,
+ annotation_data: annotationData,
+ }),
+ ],
+ });
+ const transformed = transformProps(chartProps);
+
+ expect((transformed.echartOptions.legend as any).data).toEqual([
+ 'sum__num (Query A), girl',
+ 'sum__num (Query A), boy',
+ 'sum__num (Query B), girl',
+ 'sum__num (Query B), boy',
+ ]);
+});
+
test('should add a formula annotation when X-axis column has dataset-level label', () => {
const formula: FormulaAnnotationLayer = {
name: 'My Formula',
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
index 78b35ddbc65..5f2c508562c 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
@@ -29,6 +29,7 @@ import type {
} from 'echarts/types/src/util/types';
import transformProps, { parseParams } from '../../src/Pie/transformProps';
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
+import { LegendOrientation, LegendType } from '../../src/types';
describe('Pie transformProps', () => {
const formData: SqlaFormData = {
@@ -84,6 +85,51 @@ describe('Pie transformProps', () => {
}),
);
});
+
+ test('falls back to scroll for plain legends with overlong labels', () => {
+ const longLegendChartProps = new ChartProps({
+ formData: {
+ colorScheme: 'bnbColors',
+ datasource: '3__table',
+ granularity_sqla: 'ds',
+ metric: 'sum__num',
+ groupby: ['category'],
+ viz_type: 'pie',
+ legendType: LegendType.Plain,
+ legendOrientation: LegendOrientation.Top,
+ showLegend: true,
+ } as SqlaFormData,
+ width: 320,
+ height: 600,
+ queriesData: [
+ {
+ data: [
+ {
+ category: 'This is a very long pie legend label one',
+ sum__num: 10,
+ },
+ {
+ category: 'This is a very long pie legend label two',
+ sum__num: 20,
+ },
+ {
+ category: 'This is a very long pie legend label three',
+ sum__num: 30,
+ },
+ ],
+ },
+ ],
+ theme: supersetTheme,
+ });
+
+ const transformed = transformProps(
+ longLegendChartProps as EchartsPieChartProps,
+ );
+
+ expect((transformed.echartOptions.legend as any).type).toBe(
+ LegendType.Scroll,
+ );
+ });
});
describe('formatPieLabel', () => {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts
index ec5bdac5a8c..4d87320849f 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Bar/transformProps.test.ts
@@ -16,12 +16,62 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ChartProps, SqlaFormData } from '@superset-ui/core';
+import {
+ ChartDataResponseResult,
+ ChartProps,
+ DataRecord,
+ SqlaFormData,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
-import { EchartsTimeseriesChartProps } from '../../../src/types';
+import type {
+ GridComponentOption,
+ LegendComponentOption,
+} from 'echarts/components';
+import {
+ EchartsTimeseriesChartProps,
+ LegendOrientation,
+ LegendType,
+} from '../../../src/types';
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
-import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types';
+import {
+ EchartsTimeseriesFormData,
+ OrientationType,
+ EchartsTimeseriesSeriesType,
+} from '../../../src/Timeseries/types';
+import { getPadding } from '../../../src/Timeseries/transformers';
+import {
+ getHorizontalLegendAvailableWidth,
+ getLegendLayoutResult,
+} from '../../../src/utils/series';
+import { createEchartsTimeseriesTestChartProps } from '../../helpers';
+
+function createTestQueryData(
+ data: DataRecord[],
+ overrides?: Partial<ChartDataResponseResult>,
+): ChartDataResponseResult {
+ return {
+ annotation_data: null,
+ cache_key: null,
+ cache_timeout: null,
+ cached_dttm: null,
+ queried_dttm: null,
+ data,
+ colnames: [],
+ coltypes: [],
+ error: null,
+ is_cached: false,
+ query: '',
+ rowcount: data.length,
+ sql_rowcount: data.length,
+ stacktrace: null,
+ status: 'success',
+ from_dttm: null,
+ to_dttm: null,
+ ...overrides,
+ };
+}
describe('Bar Chart X-axis Time Formatting', () => {
const baseFormData: SqlaFormData = {
@@ -570,6 +620,48 @@ describe('Bar Chart X-axis Time Formatting', () => {
expect(legendData).toContain('C');
});
+ test('should preserve source order for color-by-primary-axis legends when label sorting is enabled', () => {
+ const unsortedCategoricalData = [
+ {
+ data: [
+ { category: 'Zulu', value: 100 },
+ { category: 'Alpha', value: 150 },
+ { category: 'Mike', value: 200 },
+ ],
+ colnames: ['category', 'value'],
+ coltypes: ['STRING', 'BIGINT'],
+ },
+ ];
+
+ const formData = {
+ ...baseFormData,
+ colorByPrimaryAxis: true,
+ groupby: [],
+ legendSort: 'asc',
+ x_axis: 'category',
+ metric: 'value',
+ };
+
+ const chartProps = new ChartProps({
+ ...baseChartPropsConfig,
+ queriesData: unsortedCategoricalData,
+ formData,
+ });
+
+ const transformedProps = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+
+ const legend = transformedProps.echartOptions.legend as {
+ data: { name: string }[];
+ };
+ expect(legend.data.map(item => item.name)).toEqual([
+ 'Zulu',
+ 'Alpha',
+ 'Mike',
+ ]);
+ });
+
test('should deduplicate legend entries when x-axis has repeated values', () => {
const repeatedData = [
{
@@ -634,4 +726,180 @@ describe('Bar Chart X-axis Time Formatting', () => {
expect(hiddenSeries.length).toBe(3);
});
});
+
+ describe('Legend layout regressions', () => {
+ const getBottomLegendLayout = (
+ chartWidth: number,
+ legendItems: string[],
+ legendMargin?: string | number | null,
+ ) =>
+ getLegendLayoutResult({
+ availableWidth: getHorizontalLegendAvailableWidth({
+ chartWidth,
+ orientation: LegendOrientation.Bottom,
+ padding: getPadding(
+ true,
+ LegendOrientation.Bottom,
+ false,
+ false,
+ legendMargin,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ true,
+ ),
+ }),
+ chartHeight: baseChartPropsConfig.height,
+ chartWidth,
+ legendItems,
+ legendMargin,
+ orientation: LegendOrientation.Bottom,
+ show: true,
+ theme: supersetTheme,
+ type: LegendType.Plain,
+ });
+
+ test('should fall back to scroll for horizontal bottom legends after margin expansion reduces available width', () => {
+ const legendLabels = [
+ 'This is a long sales legend',
+ 'This is a long marketing legend',
+ 'This is a long operations legend',
+ ];
+ const longLegendData: ChartDataResponseResult[] = [
+ createTestQueryData(
+ [
+ {
+ [legendLabels[0]]: 100,
+ [legendLabels[1]]: null,
+ [legendLabels[2]]: null,
+ __timestamp: 1609459200000,
+ },
+ {
+ [legendLabels[0]]: null,
+ [legendLabels[1]]: 150,
+ [legendLabels[2]]: null,
+ __timestamp: 1612137600000,
+ },
+ {
+ [legendLabels[0]]: null,
+ [legendLabels[1]]: null,
+ [legendLabels[2]]: 200,
+ __timestamp: 1614556800000,
+ },
+ ],
+ {
+ colnames: [...legendLabels, '__timestamp'],
+ coltypes: [
+ GenericDataType.Numeric,
+ GenericDataType.Numeric,
+ GenericDataType.Numeric,
+ GenericDataType.Temporal,
+ ],
+ },
+ ),
+ ];
+ const regressionFormData: EchartsTimeseriesFormData = {
+ ...(baseFormData as EchartsTimeseriesFormData),
+ metric: legendLabels,
+ orientation: OrientationType.Horizontal,
+ legendOrientation: LegendOrientation.Bottom,
+ legendType: LegendType.Plain,
+ showLegend: true,
+ };
+ const baselineChartProps = createEchartsTimeseriesTestChartProps<
+ EchartsTimeseriesFormData,
+ EchartsTimeseriesChartProps
+ >({
+ defaultFormData: regressionFormData,
+ defaultVizType: 'echarts_timeseries_bar',
+ defaultQueriesData: longLegendData,
+ width: baseChartPropsConfig.width,
+ height: baseChartPropsConfig.height,
+ });
+ const baselineTransformed = transformProps(baselineChartProps);
+ const legendItems = (
+ (baselineTransformed.echartOptions.legend as LegendComponentOption)
+ .data as Array<string | { name: string }>
+ ).map(item => (typeof item === 'string' ? item : item.name));
+ let chartWidth: number | undefined;
+ let expandedLegendMargin: number | null = null;
+
+ for (let width = 300; width <= 700; width += 1) {
+ const initialLayout = getBottomLegendLayout(width, legendItems, null);
+
+ if (initialLayout.effectiveType !== LegendType.Plain) {
+ continue;
+ }
+
+ const refinedLayout = getBottomLegendLayout(
+ width,
+ legendItems,
+ initialLayout.effectiveMargin ?? null,
+ );
+
+ if (refinedLayout.effectiveType === LegendType.Scroll) {
+ chartWidth = width;
+ expandedLegendMargin = initialLayout.effectiveMargin ?? null;
+ break;
+ }
+ }
+
+ expect(chartWidth).toBeDefined();
+ expect(expandedLegendMargin).not.toBeNull();
+ const resolvedChartWidth = chartWidth ?? baseChartPropsConfig.width;
+
+ const chartProps = createEchartsTimeseriesTestChartProps<
+ EchartsTimeseriesFormData,
+ EchartsTimeseriesChartProps
+ >({
+ defaultFormData: regressionFormData,
+ defaultVizType: 'echarts_timeseries_bar',
+ defaultQueriesData: longLegendData,
+ width: resolvedChartWidth,
+ height: baseChartPropsConfig.height,
+ });
+
+ const transformedProps = transformProps(chartProps);
+ const legend = transformedProps.echartOptions
+ .legend as LegendComponentOption;
+ const grid = transformedProps.echartOptions.grid as GridComponentOption;
+ const expectedPadding = getPadding(
+ true,
+ LegendOrientation.Bottom,
+ false,
+ false,
+ null,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ true,
+ );
+ [expectedPadding.bottom, expectedPadding.left] = [
+ expectedPadding.left,
+ expectedPadding.bottom,
+ ];
+ const expandedPadding = getPadding(
+ true,
+ LegendOrientation.Bottom,
+ false,
+ false,
+ expandedLegendMargin,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ true,
+ );
+ [expandedPadding.bottom, expandedPadding.left] = [
+ expandedPadding.left,
+ expandedPadding.bottom,
+ ];
+
+ expect(legend.type).toBe(LegendType.Scroll);
+ expect(grid.bottom).toBe(expectedPadding.bottom);
+ expect(grid.bottom).not.toBe(expandedPadding.bottom);
+ });
+ });
});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
index 9020c811871..9e147a4f245 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
@@ -37,7 +37,8 @@ import {
OrientationType,
EchartsTimeseriesFormData,
} from '../../src/Timeseries/types';
-import { StackControlsValue } from '../../src/constants';
+import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
+import { LegendOrientation, LegendType } from '../../src/types';
import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
import { createEchartsTimeseriesTestChartProps } from '../helpers';
import { BASE_TIMESTAMP, createTestData } from './helpers';
@@ -898,6 +899,40 @@ describe('legend sorting', () => {
'Boston',
]);
});
+
+ test('falls back to scroll for zoomable top legends when toolbox space reduces available width', () => {
+ const narrowLegendData = [
+ createTestQueryData(
+ createTestData(
+ [
+ {
+ Alpha: 1,
+ Beta: 2,
+ Gamma: 3,
+ },
+ ],
+ { intervalMs: 300000000 },
+ ),
+ ),
+ ];
+ const chartProps = createTestChartProps({
+ width: 190 + TIMESERIES_CONSTANTS.legendTopRightOffset,
+ formData: {
+ ...formData,
+ legendType: LegendType.Plain,
+ legendOrientation: LegendOrientation.Top,
+ showLegend: true,
+ zoomable: true,
+ },
+ queriesData: narrowLegendData,
+ });
+
+ const transformed = transformProps(chartProps);
+
+ expect((transformed.echartOptions.legend as any).type).toBe(
+ LegendType.Scroll,
+ );
+ });
});
const timeCompareFormData: SqlaFormData = {
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 88c58f34837..f50b1933d5d 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
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { SortSeriesType } from '@superset-ui/chart-controls';
+import { LegendPaddingType, SortSeriesType } from '@superset-ui/chart-controls';
import {
AxisType,
DataRecord,
@@ -51,6 +51,71 @@ import {
import { defaultLegendPadding } from '../../src/defaults';
import { NULL_STRING } from '../../src/constants';
+const {
+ getHorizontalLegendAvailableWidth,
+ getLegendLayoutResult,
+}: {
+ getHorizontalLegendAvailableWidth: (args: {
+ chartWidth: number;
+ orientation: LegendOrientation.Top | LegendOrientation.Bottom;
+ padding?: LegendPaddingType;
+ zoomable?: boolean;
+ }) => number;
+ getLegendLayoutResult: (args: {
+ availableHeight?: number;
+ availableWidth?: number;
+ chartHeight: number;
+ chartWidth: number;
+ legendItems?: (
+ | string
+ | number
+ | null
+ | undefined
+ | { name?: string | number | null }
+ )[];
+ legendMargin?: string | number | null;
+ orientation: LegendOrientation;
+ show: boolean;
+ showSelectors?: boolean;
+ theme: typeof theme;
+ type: LegendType;
+ }) => {
+ effectiveMargin?: number;
+ effectiveType: LegendType;
+ };
+} = require('../../src/utils/series');
+
+const {
+ resolveLegendLayout,
+}: {
+ resolveLegendLayout: (args: {
+ availableHeight?: number;
+ availableWidth?: number;
+ chartHeight: number;
+ chartWidth: number;
+ legendItems?: (
+ | string
+ | number
+ | null
+ | undefined
+ | { name?: string | number | null }
+ )[];
+ legendMargin?: string | number | null;
+ orientation: LegendOrientation;
+ show: boolean;
+ showSelectors?: boolean;
+ theme: typeof theme;
+ type: LegendType;
+ }) => {
+ effectiveLegendMargin?: string | number | null;
+ effectiveLegendType: LegendType;
+ legendLayout: {
+ effectiveMargin?: number;
+ effectiveType: LegendType;
+ };
+ };
+} = require('../../src/utils/legendLayout');
+
const expectedThemeProps = {
selector: ['all', 'inverse'],
selected: {},
@@ -891,19 +956,20 @@ describe('getLegendProps', () => {
});
});
- test('should default plain legends to scroll for bottom orientation', () => {
+ test('should return the correct props for plain type with bottom orientation', () => {
expect(
getLegendProps(LegendType.Plain, LegendOrientation.Bottom, false, theme),
).toEqual({
show: false,
bottom: 0,
+ right: 0,
orient: 'horizontal',
- type: 'scroll',
+ type: 'plain',
...expectedThemeProps,
});
});
- test('should default plain legends to scroll for top orientation', () => {
+ test('should return the correct props for plain type with top orientation', () => {
expect(
getLegendProps(LegendType.Plain, LegendOrientation.Top, false, theme),
).toEqual({
@@ -911,12 +977,248 @@ describe('getLegendProps', () => {
top: 0,
right: 0,
orient: 'horizontal',
- type: 'scroll',
+ type: 'plain',
...expectedThemeProps,
});
});
});
+test('getLegendLayoutResult keeps plain horizontal legends when they fit within two rows', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 800,
+ legendItems: ['Alpha', 'Beta', 'Gamma', 'Delta'],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveMargin: defaultLegendPadding[LegendOrientation.Top],
+ effectiveType: LegendType.Plain,
+ });
+});
+
+test('getLegendLayoutResult adds extra margin for wrapped plain horizontal legends', () => {
+ const layout = getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 640,
+ legendItems: [
+ 'This is a long legend label',
+ 'Another long legend label',
+ 'Third long legend label',
+ ],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ });
+
+ expect(layout).toMatchObject({
+ effectiveType: LegendType.Plain,
+ });
+ expect(layout.effectiveMargin).toBeGreaterThan(
+ defaultLegendPadding[LegendOrientation.Top],
+ );
+});
+
+test('getLegendLayoutResult falls back to scroll when horizontal plain legends exceed two rows', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 240,
+ legendItems: [
+ 'This is a long legend label',
+ 'Another long legend label',
+ 'Third long legend label',
+ ],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('getLegendLayoutResult falls back to scroll when a single horizontal plain legend item exceeds available width', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 260,
+ legendItems: [
+ 'This is a ridiculously long legend label that should not fit on one line',
+ ],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('getLegendLayoutResult falls back to scroll when reserved horizontal width reduces plain legend capacity', () => {
+ const availableWidth = getHorizontalLegendAvailableWidth({
+ chartWidth: 265,
+ orientation: LegendOrientation.Top,
+ padding: { left: 20 },
+ zoomable: true,
+ });
+
+ expect(
+ getLegendLayoutResult({
+ availableWidth,
+ chartHeight: 400,
+ chartWidth: 265,
+ legendItems: ['Alpha', 'Beta', 'Gamma'],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('getLegendLayoutResult falls back to scroll when horizontal legend selectors alone exceed available width', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 95,
+ legendItems: ['A'],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('getLegendLayoutResult keeps plain vertical legends when they fit within a single column', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 800,
+ legendItems: ['Alpha', 'Beta', 'Gamma'],
+ legendMargin: null,
+ orientation: LegendOrientation.Left,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveMargin: defaultLegendPadding[LegendOrientation.Left],
+ effectiveType: LegendType.Plain,
+ });
+});
+
+test('getLegendLayoutResult adds extra margin for wide vertical plain legends', () => {
+ const layout = getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 800,
+ legendItems: ['This is a very long legend label'],
+ legendMargin: null,
+ orientation: LegendOrientation.Left,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ });
+
+ expect(layout).toMatchObject({
+ effectiveType: LegendType.Plain,
+ });
+ expect(layout.effectiveMargin).toBeGreaterThan(
+ defaultLegendPadding[LegendOrientation.Left],
+ );
+});
+
+test('getLegendLayoutResult falls back to scroll when vertical plain legends exceed one column', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 160,
+ chartWidth: 800,
+ legendItems: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'],
+ legendMargin: null,
+ orientation: LegendOrientation.Left,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('getLegendLayoutResult falls back to scroll when vertical plain legend selectors exceed available width', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 300,
+ legendItems: ['A', 'B', 'C'],
+ legendMargin: null,
+ orientation: LegendOrientation.Left,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('getLegendLayoutResult counts empty-string legend labels when estimating layout', () => {
+ expect(
+ getLegendLayoutResult({
+ chartHeight: 400,
+ chartWidth: 116,
+ legendItems: ['', 'A', 'B', 'C', 'D'],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ showSelectors: false,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveType: LegendType.Scroll,
+ });
+});
+
+test('resolveLegendLayout returns both raw and effective legend layout values', () => {
+ expect(
+ resolveLegendLayout({
+ chartHeight: 400,
+ chartWidth: 800,
+ legendItems: ['Alpha', 'Beta'],
+ legendMargin: null,
+ orientation: LegendOrientation.Top,
+ show: true,
+ theme,
+ type: LegendType.Plain,
+ }),
+ ).toEqual({
+ effectiveLegendMargin: defaultLegendPadding[LegendOrientation.Top],
+ effectiveLegendType: LegendType.Plain,
+ legendLayout: {
+ effectiveMargin: defaultLegendPadding[LegendOrientation.Top],
+ effectiveType: LegendType.Plain,
+ },
+ });
+});
+
describe('getChartPadding', () => {
test('should handle top default', () => {
expect(getChartPadding(true, LegendOrientation.Top)).toEqual({