fix(Timeseries): dedup x axis labels (#38733)

This commit is contained in:
Mehmet Salih Yavuz
2026-03-27 22:13:02 +03:00
committed by GitHub
parent fc705d94e3
commit f832f9b0d5
2 changed files with 73 additions and 6 deletions

View File

@@ -824,6 +824,33 @@ export default function transformProps(
isHorizontal,
);
// When showMaxLabel is true, ECharts may render a label at the axis
// boundary that formats identically to the last data-point tick (e.g.
// "2005" appears twice with Year grain). Wrap the formatter to suppress
// consecutive duplicate labels.
const showMaxLabel =
xAxisType === AxisType.Time && xAxisLabelRotation === 0;
const deduplicatedFormatter = showMaxLabel
? (() => {
let lastLabel: string | undefined;
const wrapper = (value: number | string) => {
const label =
typeof xAxisFormatter === 'function'
? (xAxisFormatter as Function)(value)
: String(value);
if (label === lastLabel) {
return '';
}
lastLabel = label;
return label;
};
if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
(wrapper as any).id = (xAxisFormatter as any).id;
}
return wrapper;
})()
: xAxisFormatter;
let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
@@ -837,17 +864,16 @@ export default function transformProps(
// from overlapping each other, with showMaxLabel to ensure
// the last data point label stays visible (#37181).
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: xAxisFormatter,
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
// Force last label on non-rotated time axes to prevent
// hideOverlap from hiding it. Skipped when rotated to
// avoid phantom labels at the axis boundary.
...(xAxisType === AxisType.Time &&
xAxisLabelRotation === 0 && {
showMaxLabel: true,
alignMaxLabel: 'right',
}),
...(showMaxLabel && {
showMaxLabel: true,
alignMaxLabel: 'right',
}),
},
minorTick: { show: minorTicks },
minInterval:

View File

@@ -28,7 +28,9 @@ import {
SqlaFormData,
TimeseriesAnnotationLayer,
ChartDataResponseResult,
TimeGranularity,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { EchartsTimeseriesChartProps } from '../../src/types';
import type { SeriesOption } from 'echarts';
import transformProps from '../../src/Timeseries/transformProps';
@@ -1336,6 +1338,45 @@ test('should not apply axis bounds calculation when seriesType is not Bar for ho
expect(xAxisRaw.max).toBeUndefined();
});
test('x-axis formatter deduplicates consecutive identical labels for coarse time grains', () => {
const yearData = [
{ __timestamp: Date.UTC(2003, 0, 1), sales: 100 },
{ __timestamp: Date.UTC(2004, 0, 1), sales: 200 },
{ __timestamp: Date.UTC(2005, 0, 1), sales: 300 },
];
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
time_grain_sqla: TimeGranularity.YEAR,
xAxisTimeFormat: '%Y',
},
queriesData: [
createTestQueryData(yearData, {
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
}),
],
});
const transformedProps = transformProps(chartProps);
const xAxisResult = transformedProps.echartOptions.xAxis as any;
const { formatter } = xAxisResult.axisLabel;
expect(typeof formatter).toBe('function');
expect(xAxisResult.axisLabel.showMaxLabel).toBe(true);
const label1 = formatter(Date.UTC(2003, 0, 1));
const label2 = formatter(Date.UTC(2004, 0, 1));
const label3 = formatter(Date.UTC(2005, 0, 1));
const label4 = formatter(Date.UTC(2005, 6, 1));
expect(label1).toBe('2003');
expect(label2).toBe('2004');
expect(label3).toBe('2005');
expect(label4).toBe('');
});
test('should assign distinct dash patterns for multiple time offsets consistently', () => {
const queriesDataWithMultipleOffsets = [
createTestQueryData([