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 237950626fb..f7d6fe86c81 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -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: 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 11114815338..c30491601c9 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 @@ -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([