fix(echarts): fix stacked horizontal bar chart clipping and duplicate x-axis labels (#39012)

(cherry picked from commit 022342839a)
This commit is contained in:
Michael S. Molina
2026-04-01 15:50:08 -03:00
committed by Michael S. Molina
parent b1eb6ac7c9
commit bf399b9f97
3 changed files with 117 additions and 5 deletions

View File

@@ -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,24 @@ 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, 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(dataMax ?? Number.NEGATIVE_INFINITY, stackedTotalMax)
: 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) {

View File

@@ -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,