From dee926b23dd705838580ba95c7136794cea9deb0 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Wed, 1 Apr 2026 15:16:23 -0300 Subject: [PATCH 1/2] fix(echarts): fix stacked horizontal bar chart clipping and duplicate x-axis labels --- .../src/Timeseries/transformProps.ts | 23 ++++- .../Timeseries/Bar/transformProps.test.ts | 92 +++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) 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 6966be78a3ef..7f2234afa2e7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -659,7 +659,10 @@ export default function transformProps( for (const s of series) { if (s.id) { const columnsArr = labelMap[s.id]; - (s as any).stack = columnsArr[idxSelectedDimension]; + const dimensionValue = columnsArr?.[idxSelectedDimension]; + if (dimensionValue !== undefined) { + (s as any).stack = dimensionValue; + } } } } @@ -682,9 +685,21 @@ export default function transformProps( // For horizontal bar charts, set max/min from calculated data bounds if (shouldCalculateDataBounds) { - // Set max to actual data max to avoid gaps and ensure labels are visible - if (dataMax !== undefined && yAxisMax === undefined) { - yAxisMax = dataMax; + // For stacked charts, use the max stacked total to avoid clipping bars; + // for non-stacked charts, use the individual series max. + const effectiveDataMax = stack + ? Math.max( + ...sortedTotalValues.filter( + (v): v is number => typeof v === 'number' && !Number.isNaN(v), + ), + ) + : dataMax; + if ( + effectiveDataMax !== undefined && + Number.isFinite(effectiveDataMax) && + yAxisMax === undefined + ) { + yAxisMax = effectiveDataMax; } // Set min to actual data min for diverging bars if (dataMin !== undefined && yAxisMin === undefined && dataMin < 0) { 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 4d87320849f4..80e434294c13 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 @@ -24,6 +24,7 @@ import { } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { supersetTheme } from '@apache-superset/core/theme'; +import { StackControlsValue } from '../../../src/constants'; import type { GridComponentOption, LegendComponentOption, @@ -727,6 +728,97 @@ describe('Bar Chart X-axis Time Formatting', () => { }); }); + describe('Horizontal stacked bar chart axis bounds', () => { + // Dataset where each series max = 4 but stacked total max = 8 + const stackedData: ChartDataResponseResult[] = [ + createTestQueryData( + [ + { team: 'Team A', High: 2, Low: 2, Medium: 4 }, + { team: 'Team B', High: null, Low: null, Medium: 3 }, + { team: 'Team C', High: null, Low: null, Medium: 1 }, + ], + { + colnames: ['team', 'High', 'Low', 'Medium'], + coltypes: [ + GenericDataType.String, + GenericDataType.Numeric, + GenericDataType.Numeric, + GenericDataType.Numeric, + ], + }, + ), + ]; + + const horizontalStackedFormData: EchartsTimeseriesFormData = { + ...(baseFormData as EchartsTimeseriesFormData), + x_axis: 'team', + metric: ['High', 'Low', 'Medium'], + groupby: [], + orientation: OrientationType.Horizontal, + seriesType: EchartsTimeseriesSeriesType.Bar, + stack: StackControlsValue.Stack, + truncateYAxis: true, + }; + + test('xAxis.max uses stacked total, not individual series max', () => { + // Individual series max = 4 (Medium), stacked total for Team A = 8 + // Without the fix, xAxis.max would be 4, clipping bars and duplicating labels + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsTimeseriesFormData, + EchartsTimeseriesChartProps + >({ + defaultFormData: horizontalStackedFormData, + defaultVizType: 'echarts_timeseries_bar', + defaultQueriesData: stackedData, + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as any; + + // xAxis.max must be >= stacked total (8), not capped at individual series max (4) + expect(xAxis.max).toBeGreaterThanOrEqual(8); + }); + + test('xAxis.max is not set to individual series max when stacking', () => { + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsTimeseriesFormData, + EchartsTimeseriesChartProps + >({ + defaultFormData: horizontalStackedFormData, + defaultVizType: 'echarts_timeseries_bar', + defaultQueriesData: stackedData, + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as any; + + // 4 is the individual series max — the axis should not be clipped there + expect(xAxis.max).not.toBe(4); + }); + + test('non-stacked horizontal bar chart still uses individual series max', () => { + const nonStackedFormData: EchartsTimeseriesFormData = { + ...horizontalStackedFormData, + stack: null, + }; + + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsTimeseriesFormData, + EchartsTimeseriesChartProps + >({ + defaultFormData: nonStackedFormData, + defaultVizType: 'echarts_timeseries_bar', + defaultQueriesData: stackedData, + }); + + const { echartOptions } = transformProps(chartProps); + const xAxis = echartOptions.xAxis as any; + + // Without stacking, xAxis.max should be based on individual series values + expect(xAxis.max).toBe(4); + }); + }); + describe('Legend layout regressions', () => { const getBottomLegendLayout = ( chartWidth: number, From a781bcdf00f8e742627b25d8089bf2c346803efa Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Wed, 1 Apr 2026 15:29:07 -0300 Subject: [PATCH 2/2] Address codeant suggestion --- .../src/Timeseries/transformProps.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 7f2234afa2e7..114c374d659f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -685,14 +685,17 @@ export default function transformProps( // For horizontal bar charts, set max/min from calculated data bounds if (shouldCalculateDataBounds) { - // For stacked charts, use the max stacked total to avoid clipping bars; - // for non-stacked charts, use the individual series max. + // For stacked charts, clamp against the per-row stacked total to avoid + // clipping bars. Also keep dataMax so that mixed-sign stacks (where + // positive and negative values cancel in the algebraic row sum) cannot + // produce an axis max smaller than the largest individual positive segment. + const stackedTotalMax = Math.max( + ...sortedTotalValues.filter( + (v): v is number => typeof v === 'number' && !Number.isNaN(v), + ), + ); const effectiveDataMax = stack - ? Math.max( - ...sortedTotalValues.filter( - (v): v is number => typeof v === 'number' && !Number.isNaN(v), - ), - ) + ? Math.max(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax) : dataMax; if ( effectiveDataMax !== undefined &&