From 296ab06d8d74c0db271f4145a36267bdc107105a Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 19 Aug 2026 18:50:51 +0200 Subject: [PATCH 1/8] fix(echarts): place weekly time-axis ticks on the data buckets --- .../src/MixedTimeseries/transformProps.ts | 14 ++ .../src/Timeseries/transformProps.ts | 14 ++ .../plugin-chart-echarts/src/constants.ts | 10 ++ .../plugin-chart-echarts/src/utils/series.ts | 35 +++++ .../MixedTimeseries/transformProps.test.ts | 71 ++++++++++ .../test/Timeseries/transformProps.test.ts | 125 ++++++++++++++++++ 6 files changed, 269 insertions(+) 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 060fedd29406..6681961a7eb4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -73,6 +73,7 @@ import { getLegendProps, getMinAndMaxFromBounds, getOverMaxHiddenFormatter, + getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; import { @@ -757,6 +758,14 @@ export default function transformProps( const { setDataMask = () => {}, onContextMenu } = hooks; const alignTicks = yAxisIndex !== yAxisIndexB; + // Weekly grains: pin the ticks to the buckets. Both queries share the axis. + const temporalTickValues = getTemporalTickValues( + [...rebasedDataA, ...rebasedDataB], + xAxisLabel, + xAxisType, + resolvedTimeGrain, + ); + const echartOptions: EChartsCoreOption = { useUTC: true, grid: { @@ -779,7 +788,12 @@ export default function transformProps( showMinLabel: true, alignMinLabel: 'left', }), + ...(temporalTickValues && { customValues: temporalTickValues }), }, + ...(temporalTickValues && { + axisTick: { customValues: temporalTickValues }, + splitLine: { customValues: temporalTickValues }, + }), minorTick: { show: minorTicks }, minInterval: xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval 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 43e9a2f431bb..c946a549a281 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -88,6 +88,7 @@ import { getHorizontalLegendAvailableWidth, getLegendProps, getMinAndMaxFromBounds, + getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; import { @@ -1242,6 +1243,14 @@ export default function transformProps( })() : xAxisFormatter; + // Weekly grains: pin the ticks to the buckets ECharts would otherwise miss. + const temporalTickValues = getTemporalTickValues( + rebasedData, + xAxisLabel, + xAxisType, + resolvedTimeGrain, + ); + let xAxis: any = { type: xAxisType, name: xAxisTitle, @@ -1273,7 +1282,12 @@ export default function transformProps( showMinLabel: true, alignMinLabel: 'left', }), + ...(temporalTickValues && { customValues: temporalTickValues }), }, + ...(temporalTickValues && { + axisTick: { customValues: temporalTickValues }, + splitLine: { customValues: temporalTickValues }, + }), minorTick: { show: minorTicks }, minInterval: xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index 3044bf10d704..2b94967ae003 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -89,6 +89,16 @@ export const StackControlOptionsWithoutStream: [ [StackControlsValue.Stack, t('Stack')], ]; +// Grains ECharts' time axis cannot tick on; see getTemporalTickValues in +// utils/series. +export const WEEKLY_TIME_GRAINS: ReadonlySet = new Set([ + TimeGranularity.WEEK, + TimeGranularity.WEEK_STARTING_SUNDAY, + TimeGranularity.WEEK_STARTING_MONDAY, + TimeGranularity.WEEK_ENDING_SATURDAY, + TimeGranularity.WEEK_ENDING_SUNDAY, +]); + export const TIMEGRAIN_TO_TIMESTAMP = { [TimeGranularity.HOUR]: 3600 * 1000, [TimeGranularity.DAY]: 3600 * 1000 * 24, 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 cdfaaf78c185..38759986d59f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -42,6 +42,7 @@ import { NULL_STRING, StackControlsValue, TIMESERIES_CONSTANTS, + WEEKLY_TIME_GRAINS, } from '../constants'; import { EchartsTimeseriesSeriesType, @@ -986,6 +987,40 @@ export function getAxisType( return AxisType.Category; } +/** + * Bucket timestamps a temporal axis should tick on, or undefined to let ECharts + * choose. + * + * ECharts generates time ticks from a calendar ladder with no week unit, so for + * weekly data it steps days from the 1st of each month instead: labels drift + * across weekdays and snap to month starts (#17226). Coarser grains already land + * on their data and keep ECharts' calendar-nice labels. + */ +export function getTemporalTickValues( + data: DataRecord[], + xAxisLabel: string, + xAxisType: AxisType, + timeGrain?: string, +): number[] | undefined { + if ( + xAxisType !== AxisType.Time || + !timeGrain || + !WEEKLY_TIME_GRAINS.has(timeGrain) + ) { + return undefined; + } + const values = new Set(); + data.forEach(row => { + const value = row[xAxisLabel]; + const timestamp = + value instanceof Date ? value.getTime() : Number(value ?? NaN); + if (Number.isFinite(timestamp)) { + values.add(timestamp); + } + }); + return values.size ? [...values].sort((a, b) => a - b) : undefined; +} + export function getOverMaxHiddenFormatter( config: { max?: number; 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 d2f8b584ef02..35750d15d466 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 @@ -1352,3 +1352,74 @@ describe('EchartsMixedTimeseries tooltip truncation', () => { expect(html).not.toContain(longSeriesName); }); }); + +describe('weekly x-axis tick alignment', () => { + const WEEK_MS = 7 * 24 * 3600 * 1000; + const MONDAYS = Array.from( + { length: 6 }, + (_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS, + ); + const weeklyLabelMap = { ds: ['ds'], sum__num: ['sum__num'] }; + + const weeklyQuery = (timestamps: number[]) => + createTestQueryData( + timestamps.map((ds, i) => ({ ds, sum__num: 10 + i })), + { + label_map: weeklyLabelMap, + colnames: ['ds', 'sum__num'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ); + + const weeklyChartProps = ( + queryA: number[], + queryB: number[], + overrides: Partial = {}, + ) => + createEchartsTimeseriesTestChartProps< + EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps + >({ + ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS, + defaultQueriesData: [weeklyQuery(queryA), weeklyQuery(queryB)], + formData: { + ...formData, + groupby: [], + groupbyB: [], + timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, + ...overrides, + }, + queriesData: [weeklyQuery(queryA), weeklyQuery(queryB)], + }); + + test('pins ticks, labels and gridlines to the weekly buckets', () => { + const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS)) + .echartOptions as any; + + expect(xAxis.type).toBe(AxisType.Time); + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + expect(xAxis.axisTick.customValues).toEqual(MONDAYS); + expect(xAxis.splitLine.customValues).toEqual(MONDAYS); + }); + + test('covers buckets contributed by either query', () => { + // The two queries share one axis, so a bucket present in only one of them + // still needs a tick. + const { xAxis } = transformProps( + weeklyChartProps(MONDAYS.slice(0, 3), MONDAYS.slice(2)), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + }); + + test('leaves grains ECharts places correctly untouched', () => { + const { xAxis } = transformProps( + weeklyChartProps(MONDAYS, MONDAYS, { + timeGrainSqla: TimeGranularity.MONTH, + }), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toBeUndefined(); + expect(xAxis.axisTick?.customValues).toBeUndefined(); + }); +}); 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 ff44aa7d3b60..a5b4c15ea448 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 @@ -2529,3 +2529,128 @@ describe('EchartsTimeseries tooltip truncation', () => { expect(buildTooltip(undefined, longCategory)).toContain(longCategory); }); }); + +describe('weekly x-axis tick alignment', () => { + // 13 Monday-aligned weekly buckets, the shape produced by a dataset that is + // pre-aggregated to weeks. + const WEEK_MS = 7 * 24 * 3600 * 1000; + const MONDAYS = Array.from( + { length: 13 }, + (_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS, + ); + + const weeklyChartProps = ( + formDataOverrides: Partial = {}, + ) => + createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, + xAxisTimeFormat: '%m-%d', + ...formDataOverrides, + }, + queriesData: [ + createTestQueryData( + MONDAYS.map((__timestamp, i) => ({ __timestamp, sales: 100 + i })), + { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ), + ], + }); + + test('pins ticks, labels and gridlines to the weekly buckets', () => { + const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any; + + expect(xAxis.type).toBe(AxisType.Time); + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + expect(xAxis.axisTick.customValues).toEqual(MONDAYS); + expect(xAxis.splitLine.customValues).toEqual(MONDAYS); + }); + + test.each([ + TimeGranularity.WEEK, + TimeGranularity.WEEK_STARTING_SUNDAY, + TimeGranularity.WEEK_STARTING_MONDAY, + TimeGranularity.WEEK_ENDING_SATURDAY, + TimeGranularity.WEEK_ENDING_SUNDAY, + ])('applies to the %s grain', grain => { + const { xAxis } = transformProps(weeklyChartProps({ timeGrainSqla: grain })) + .echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + }); + + test('a dashboard time-grain override drives the alignment', () => { + const { xAxis } = transformProps( + weeklyChartProps({ + timeGrainSqla: TimeGranularity.DAY, + extraFormData: { time_grain_sqla: TimeGranularity.WEEK }, + }), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + }); + + test('deduplicates and sorts the bucket timestamps', () => { + // A grouped query repeats each bucket once per series, and the rows are + // not necessarily ordered. + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.WEEK, + groupby: ['region'], + }, + queriesData: [ + createTestQueryData( + [ + { __timestamp: MONDAYS[1], region: 'b', sales: 2 }, + { __timestamp: MONDAYS[0], region: 'a', sales: 1 }, + { __timestamp: MONDAYS[1], region: 'a', sales: 3 }, + { __timestamp: MONDAYS[0], region: 'b', sales: 4 }, + ], + { + colnames: ['__timestamp', 'region', 'sales'], + coltypes: [ + GenericDataType.Temporal, + GenericDataType.String, + GenericDataType.Numeric, + ], + }, + ), + ], + }); + const { xAxis } = transformProps(chartProps).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual([MONDAYS[0], MONDAYS[1]]); + }); + + test('leaves grains ECharts places correctly untouched', () => { + ( + [ + TimeGranularity.DAY, + TimeGranularity.MONTH, + TimeGranularity.QUARTER, + TimeGranularity.YEAR, + undefined, + ] as const + ).forEach(grain => { + const { xAxis } = transformProps( + weeklyChartProps({ timeGrainSqla: grain }), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toBeUndefined(); + expect(xAxis.axisTick?.customValues).toBeUndefined(); + }); + }); + + test('leaves a categorical x-axis untouched', () => { + const { xAxis } = transformProps( + weeklyChartProps({ xAxisForceCategorical: true }), + ).echartOptions as any; + + expect(xAxis.type).toBe(AxisType.Category); + expect(xAxis.axisLabel.customValues).toBeUndefined(); + }); +}); From 4d30f6fb6548f583104a41de5ac0342c85aa5fab Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 19 Aug 2026 19:30:05 +0200 Subject: [PATCH 2/8] fix(echarts): keep pinned weekly axis labels thinned and annotation-safe --- .../src/MixedTimeseries/transformProps.ts | 26 +++++-- .../src/Timeseries/transformProps.ts | 26 +++++-- .../MixedTimeseries/transformProps.test.ts | 12 ++- .../test/Timeseries/transformProps.test.ts | 77 ++++++++++++++++++- 4 files changed, 125 insertions(+), 16 deletions(-) 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 6681961a7eb4..3dba312615bd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -759,12 +759,20 @@ export default function transformProps( const alignTicks = yAxisIndex !== yAxisIndexB; // Weekly grains: pin the ticks to the buckets. Both queries share the axis. - const temporalTickValues = getTemporalTickValues( - [...rebasedDataA, ...rebasedDataB], - xAxisLabel, - xAxisType, - resolvedTimeGrain, + // Skipped when a timeseries annotation is shown: it widens the axis past the + // buckets and ECharts clips pinned ticks to the extent, leaving that span bare. + const hasTimeseriesAnnotation = annotationLayers.some( + (layer: AnnotationLayer) => + layer.show && isTimeseriesAnnotationLayer(layer), ); + const temporalTickValues = hasTimeseriesAnnotation + ? undefined + : getTemporalTickValues( + [...rebasedDataA, ...rebasedDataB], + xAxisLabel, + xAxisType, + resolvedTimeGrain, + ); const echartOptions: EChartsCoreOption = { useUTC: true, @@ -778,7 +786,11 @@ export default function transformProps( nameGap: xAxisTitleMarginPx, nameLocation: 'middle', axisLabel: { - hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0), + // Pinned ticks label every bucket, so keep thinning on even when the + // rotation branch would otherwise drop it. + hideOverlap: + !!temporalTickValues || + !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0), formatter: deduplicatedFormatter, rotate: xAxisLabelRotation, interval: xAxisLabelInterval, @@ -790,9 +802,9 @@ export default function transformProps( }), ...(temporalTickValues && { customValues: temporalTickValues }), }, + // Gridlines, when shown, follow axisTick.customValues too. ...(temporalTickValues && { axisTick: { customValues: temporalTickValues }, - splitLine: { customValues: temporalTickValues }, }), minorTick: { show: minorTicks }, minInterval: 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 c946a549a281..e41cba9445b6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -1244,12 +1244,21 @@ export default function transformProps( : xAxisFormatter; // Weekly grains: pin the ticks to the buckets ECharts would otherwise miss. - const temporalTickValues = getTemporalTickValues( - rebasedData, - xAxisLabel, - xAxisType, - resolvedTimeGrain, + // A timeseries annotation contributes its own timestamps and widens the axis + // past the buckets, and ECharts clips pinned ticks to the extent, so that + // span would render bare — leave those charts on ECharts' own ticks. + const hasTimeseriesAnnotation = annotationLayers.some( + (layer: AnnotationLayer) => + layer.show && isTimeseriesAnnotationLayer(layer), ); + const temporalTickValues = hasTimeseriesAnnotation + ? undefined + : getTemporalTickValues( + rebasedData, + xAxisLabel, + xAxisType, + resolvedTimeGrain, + ); let xAxis: any = { type: xAxisType, @@ -1267,7 +1276,10 @@ export default function transformProps( // At 0° rotation, keep hideOverlap to prevent long labels // from overlapping each other, with showMaxLabel to ensure // the last data point label stays visible (#37181). - hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0), + // Pinned ticks label every bucket, which does crowd, so thinning stays on. + hideOverlap: + !!temporalTickValues || + !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0), formatter: deduplicatedFormatter, rotate: xAxisLabelRotation, interval: xAxisLabelInterval, @@ -1284,9 +1296,9 @@ export default function transformProps( }), ...(temporalTickValues && { customValues: temporalTickValues }), }, + // Gridlines, when shown, follow axisTick.customValues too. ...(temporalTickValues && { axisTick: { customValues: temporalTickValues }, - splitLine: { customValues: temporalTickValues }, }), minorTick: { show: minorTicks }, minInterval: 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 35750d15d466..a1f4178ddb69 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 @@ -1398,8 +1398,18 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.type).toBe(AxisType.Time); expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + // Gridlines follow axisTick.customValues, so splitLine needs no own copy. expect(xAxis.axisTick.customValues).toEqual(MONDAYS); - expect(xAxis.splitLine.customValues).toEqual(MONDAYS); + expect(xAxis.splitLine).toBeUndefined(); + }); + + test('keeps label thinning on when the labels are rotated', () => { + const { xAxis } = transformProps( + weeklyChartProps(MONDAYS, MONDAYS, { xAxisLabelRotation: 45 }), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + expect(xAxis.axisLabel.hideOverlap).toBe(true); }); test('covers buckets contributed by either query', () => { 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 a5b4c15ea448..f565b560c32c 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 @@ -17,6 +17,7 @@ * under the License. */ import { + AnnotationData, AnnotationSourceType, AnnotationStyle, AnnotationType, @@ -2541,8 +2542,10 @@ describe('weekly x-axis tick alignment', () => { const weeklyChartProps = ( formDataOverrides: Partial = {}, + annotationData?: AnnotationData, ) => createTestChartProps({ + annotationData, formData: { granularity_sqla: 'ds', timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, @@ -2555,6 +2558,8 @@ describe('weekly x-axis tick alignment', () => { { colnames: ['__timestamp', 'sales'], coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + // transformProps reads annotations off the query, not chartProps. + ...(annotationData && { annotation_data: annotationData }), }, ), ], @@ -2565,8 +2570,78 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.type).toBe(AxisType.Time); expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + // Gridlines follow axisTick.customValues, so splitLine needs no own copy. expect(xAxis.axisTick.customValues).toEqual(MONDAYS); - expect(xAxis.splitLine.customValues).toEqual(MONDAYS); + expect(xAxis.splitLine).toBeUndefined(); + }); + + test('keeps label thinning on when the labels are rotated', () => { + // Rotation normally turns hideOverlap off, but pinned ticks put a label on + // every bucket, so without thinning a multi-year range draws hundreds. + const { xAxis } = transformProps( + weeklyChartProps({ xAxisLabelRotation: 45 }), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + expect(xAxis.axisLabel.hideOverlap).toBe(true); + }); + + test('leaves rotation thinning alone when the ticks are not pinned', () => { + const { xAxis } = transformProps( + weeklyChartProps({ + timeGrainSqla: TimeGranularity.MONTH, + xAxisLabelRotation: 45, + }), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toBeUndefined(); + expect(xAxis.axisLabel.hideOverlap).toBe(false); + }); + + const timeseriesLayer = (show: boolean) => + ({ + name: 'my annotation', + annotationType: AnnotationType.Timeseries, + sourceType: AnnotationSourceType.Line, + style: AnnotationStyle.Solid, + show, + value: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + // The annotation's own timestamps run a year past the last bucket. + const annotationRecords = { + 'my annotation': { + records: [ + { ds: MONDAYS[0], y: 1 }, + { ds: MONDAYS[12] + 52 * WEEK_MS, y: 2 }, + ], + }, + }; + + test('does not pin ticks when a timeseries annotation widens the axis', () => { + // A Time axis takes no min/max, so it stretches to cover the annotation + // while ECharts clips pinned ticks to the extent — that span would be bare. + const { xAxis } = transformProps( + weeklyChartProps( + { annotationLayers: [timeseriesLayer(true)] }, + annotationRecords, + ), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toBeUndefined(); + expect(xAxis.axisTick?.customValues).toBeUndefined(); + }); + + test('still pins ticks for a hidden timeseries annotation', () => { + const { xAxis } = transformProps( + weeklyChartProps( + { annotationLayers: [timeseriesLayer(false)] }, + annotationRecords, + ), + ).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); }); test.each([ From 1eba5c0b7c7bb7cb709bc55bd135f96634f64953 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Tue, 25 Aug 2026 11:53:35 +0200 Subject: [PATCH 3/8] chore: address review comments --- .../src/MixedTimeseries/transformProps.ts | 17 ++- .../src/Timeseries/transformProps.ts | 18 +-- .../plugin-chart-echarts/src/utils/series.ts | 19 ++- .../MixedTimeseries/transformProps.test.ts | 8 +- .../test/Timeseries/transformProps.test.ts | 8 +- .../test/utils/series.test.ts | 110 ++++++++++++++++++ 6 files changed, 158 insertions(+), 22 deletions(-) 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 10990af730ca..5d14b063ff68 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -797,12 +797,17 @@ export default function transformProps( formatter: deduplicatedFormatter, rotate: xAxisLabelRotation, interval: xAxisLabelInterval, - ...(showMaxLabel && { - showMaxLabel: true, - alignMaxLabel: 'right', - showMinLabel: true, - alignMinLabel: 'left', - }), + // Skipped for pinned ticks: the boundary buckets are already real + // ticks there, and showMaxLabel only shields its immediate + // neighbour — hideOverlap can still drop it against a farther + // label on a crowded weekly axis, reopening #39899. + ...(showMaxLabel && + !temporalTickValues && { + showMaxLabel: true, + alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', + }), ...(temporalTickValues && { customValues: temporalTickValues }), }, // Gridlines, when shown, follow axisTick.customValues too. 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 8b496f3b9a15..8e844d7421ce 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -1290,13 +1290,17 @@ export default function transformProps( // and last dates stay visible: hideOverlap can hide the last label, // and a min date that falls between "nice" ticks otherwise renders // no beginning label. Skipped when rotated to avoid phantom labels - // at the axis boundary. - ...(showMaxLabel && { - showMaxLabel: true, - alignMaxLabel: 'right', - showMinLabel: true, - alignMinLabel: 'left', - }), + // at the axis boundary. Also skipped for pinned ticks: the boundary + // buckets are already real ticks there, and showMaxLabel only shields + // its immediate neighbour — hideOverlap can still drop it against a + // farther label on a crowded weekly axis, reopening #39899. + ...(showMaxLabel && + !temporalTickValues && { + showMaxLabel: true, + alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', + }), ...(temporalTickValues && { customValues: temporalTickValues }), }, // Gridlines, when shown, follow axisTick.customValues too. 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 3fa4da70af84..25028f0fa0bd 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -987,6 +987,23 @@ export function getAxisType( return AxisType.Category; } +// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats +// zone-less strings as local time — mismatch would offset the pinned tick. +const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/; + +function parseTemporalString(value: string): number { + const dateOnly = DATE_ONLY_RE.exec(value); + if (dateOnly) { + const [, year, month, day] = dateOnly; + return new Date( + Number(year), + Number(month || 1) - 1, + Number(day || 1), + ).getTime(); + } + return new Date(value).getTime(); +} + /** * Bucket timestamps a temporal axis should tick on, or undefined to let ECharts * choose. @@ -1017,7 +1034,7 @@ export function getTemporalTickValues( value instanceof Date ? value.getTime() : typeof value === 'string' - ? new Date(value).getTime() + ? parseTemporalString(value) : Number(value ?? NaN); if (Number.isFinite(timestamp)) { values.add(timestamp); 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 bfd3dd2faeba..2ec7ad8afe07 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 @@ -1516,13 +1516,13 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.axisLabel.hideOverlap).toBe(true); }); - test('keeps label thinning on at 0° rotation, where showMaxLabel is active', () => { - // The default weekly config: unrotated labels put showMaxLabel in play, - // which must not override the pinned-tick thinning. + test('skips the showMaxLabel override at 0° rotation, unlike unpinned axes', () => { + // Pinned ticks already include the boundary buckets, and showMaxLabel + // can't reliably protect a label under hideOverlap anyway (#39899). const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS)) .echartOptions as any; - expect(xAxis.axisLabel.showMaxLabel).toBe(true); + expect(xAxis.axisLabel.showMaxLabel).toBeUndefined(); expect(xAxis.axisLabel.hideOverlap).toBe(true); }); 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 d5fc17d08b2f..771219bb854f 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 @@ -2610,12 +2610,12 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.splitLine).toBeUndefined(); }); - test('keeps label thinning on at 0° rotation, where showMaxLabel is active', () => { - // The default weekly config: unrotated labels put showMaxLabel in play, - // which must not override the pinned-tick thinning. + test('skips the showMaxLabel override at 0° rotation, unlike unpinned axes', () => { + // Pinned ticks already include the boundary buckets, and showMaxLabel + // can't reliably protect a label under hideOverlap anyway (#39899). const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any; - expect(xAxis.axisLabel.showMaxLabel).toBe(true); + expect(xAxis.axisLabel.showMaxLabel).toBeUndefined(); expect(xAxis.axisLabel.hideOverlap).toBe(true); }); 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 32f16acfe851..74c5f2df7d9b 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 @@ -22,6 +22,7 @@ import { DataRecord, getNumberFormatter, getTimeFormatter, + TimeGranularity, } from '@superset-ui/core'; import { supersetTheme as theme } from '@apache-superset/core/theme'; import { GenericDataType } from '@apache-superset/core/common'; @@ -40,6 +41,7 @@ import { getLegendProps, getOverMaxHiddenFormatter, getMinAndMaxFromBounds, + getTemporalTickValues, sanitizeHtml, sortAndFilterSeries, sortRows, @@ -1705,6 +1707,114 @@ test('getAxisType does not coerce Numeric x-axis to Time regardless of values', ); }); +describe('getTemporalTickValues', () => { + const xAxisLabel = '__timestamp'; + + test('returns undefined for a non-time axis', () => { + const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }]; + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Category, + TimeGranularity.WEEK, + ), + ).toBeUndefined(); + }); + + test('returns undefined when there is no time grain', () => { + const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }]; + expect( + getTemporalTickValues(data, xAxisLabel, AxisType.Time, undefined), + ).toBeUndefined(); + }); + + test('returns undefined for a non-weekly time grain', () => { + const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }]; + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Time, + TimeGranularity.MONTH, + ), + ).toBeUndefined(); + }); + + test('returns sorted, de-duplicated bucket timestamps for numbers and Dates', () => { + const t0 = Date.UTC(2026, 3, 6); + const t1 = Date.UTC(2026, 3, 13); + const data: DataRecord[] = [ + { [xAxisLabel]: t1 }, + { [xAxisLabel]: new Date(t0) }, + { [xAxisLabel]: t0 }, // duplicate of the Date row above + ]; + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Time, + TimeGranularity.WEEK, + ), + ).toEqual([t0, t1]); + }); + + test('parses a zoned ISO string as the instant it names', () => { + const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00.000Z' }]; + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Time, + TimeGranularity.WEEK, + ), + ).toEqual([Date.UTC(2026, 3, 6)]); + }); + + test('parses a zone-less datetime string as local time, matching ECharts', () => { + const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00' }]; + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Time, + TimeGranularity.WEEK, + ), + ).toEqual([new Date(2026, 3, 6, 0, 0, 0).getTime()]); + }); + + test('parses a bare date string as local midnight, matching ECharts rather than native Date', () => { + // `new Date('2026-04-06')` is UTC, but ECharts parses it as local time. + // jest.config.js fixes the test TZ to America/New_York, so they disagree. + const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06' }]; + const localMidnight = new Date(2026, 3, 6).getTime(); + expect(localMidnight).not.toEqual(new Date('2026-04-06').getTime()); + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Time, + TimeGranularity.WEEK, + ), + ).toEqual([localMidnight]); + }); + + test('drops unparseable or nullish values and returns undefined when none remain', () => { + const data: DataRecord[] = [ + { [xAxisLabel]: 'not-a-date' }, + { [xAxisLabel]: null }, + ]; + expect( + getTemporalTickValues( + data, + xAxisLabel, + AxisType.Time, + TimeGranularity.WEEK, + ), + ).toBeUndefined(); + }); +}); + test('getMinAndMaxFromBounds returns empty object when not truncating', () => { expect( getMinAndMaxFromBounds( From 92085abc0d88fc4204d3c89894eb6586ae163070 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Fri, 28 Aug 2026 11:43:04 +0200 Subject: [PATCH 4/8] fix(echarts): cap pinned axis tick marks on long weekly ranges axisLabel.hideOverlap thins displayed labels dynamically, but axisTick.customValues had no such mechanism, so pinning it to every bucket drew an unlabeled comb of tick marks (and matching gridlines) on wide weekly-grain ranges. Co-Authored-By: Claude Sonnet 5 --- .../src/MixedTimeseries/transformProps.ts | 6 ++++-- .../src/Timeseries/transformProps.ts | 6 ++++-- .../plugin-chart-echarts/src/utils/series.ts | 20 +++++++++++++++++++ .../test/utils/series.test.ts | 16 +++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) 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 04f9f56c977e..29836c77c3db 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -73,6 +73,7 @@ import { getLegendProps, getMinAndMaxFromBounds, getOverMaxHiddenFormatter, + capTickMarks, getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; @@ -814,9 +815,10 @@ export default function transformProps( }), ...(temporalTickValues && { customValues: temporalTickValues }), }, - // Gridlines, when shown, follow axisTick.customValues too. + // Gridlines follow axisTick.customValues; cap it so a long weekly + // range doesn't comb. ...(temporalTickValues && { - axisTick: { customValues: temporalTickValues }, + axisTick: { customValues: capTickMarks(temporalTickValues) }, }), minorTick: { show: minorTicks }, minInterval: 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 f5c61aaa6eb5..a5afdc064c2e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -88,6 +88,7 @@ import { getHorizontalLegendAvailableWidth, getLegendProps, getMinAndMaxFromBounds, + capTickMarks, getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; @@ -1305,9 +1306,10 @@ export default function transformProps( }), ...(temporalTickValues && { customValues: temporalTickValues }), }, - // Gridlines, when shown, follow axisTick.customValues too. + // Gridlines follow axisTick.customValues; cap it so a long weekly + // range doesn't comb. ...(temporalTickValues && { - axisTick: { customValues: temporalTickValues }, + axisTick: { customValues: capTickMarks(temporalTickValues) }, }), minorTick: { show: minorTicks }, minInterval: 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 25028f0fa0bd..359b66e65183 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -1043,6 +1043,26 @@ export function getTemporalTickValues( return values.size ? [...values].sort((a, b) => a - b) : undefined; } +// Unlike axisLabel, axisTick has no overlap-based thinning, so pinning it to +// every bucket combs a long weekly range. Downsample evenly, keeping ends. +const MAX_PINNED_AXIS_TICKS = 60; + +export function capTickMarks( + values: number[], + maxTicks: number = MAX_PINNED_AXIS_TICKS, +): number[] { + if (values.length <= maxTicks) { + return values; + } + const step = Math.ceil(values.length / maxTicks); + const capped = values.filter((_, index) => index % step === 0); + const last = values[values.length - 1]; + if (capped[capped.length - 1] !== last) { + capped.push(last); + } + return capped; +} + export function getOverMaxHiddenFormatter( config: { max?: number; 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 74c5f2df7d9b..ad5765c07037 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 @@ -41,6 +41,7 @@ import { getLegendProps, getOverMaxHiddenFormatter, getMinAndMaxFromBounds, + capTickMarks, getTemporalTickValues, sanitizeHtml, sortAndFilterSeries, @@ -1815,6 +1816,21 @@ describe('getTemporalTickValues', () => { }); }); +describe('capTickMarks', () => { + test('returns values unchanged when within the cap', () => { + const values = [1, 2, 3]; + expect(capTickMarks(values, 60)).toEqual(values); + }); + + test('downsamples evenly and always keeps the last value', () => { + const values = Array.from({ length: 261 }, (_, i) => i); + const capped = capTickMarks(values, 60); + expect(capped.length).toBeLessThanOrEqual(60); + expect(capped[0]).toEqual(0); + expect(capped[capped.length - 1]).toEqual(260); + }); +}); + test('getMinAndMaxFromBounds returns empty object when not truncating', () => { expect( getMinAndMaxFromBounds( From 6dad5c93907ab5e6a9f9471f792961672ed3b0f7 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Fri, 28 Aug 2026 15:52:51 +0200 Subject: [PATCH 5/8] fix(echarts): align pinned axis labels with capped ticks, extract shared helper axisLabel.customValues carried the full weekly bucket list while axisTick.customValues (which splitLine also follows) carried the capTickMarks()-downsampled subset. hideOverlap could keep a label at an index capTickMarks had dropped, leaving it with no tick or gridline under it. Both now share one capped set via a new getTemporalAxisTickConfig() helper in series.ts, which also de-dupes the near-identical axis-fragment block between Timeseries and MixedTimeseries. Also keeps showMaxLabel active on pinned (weekly) axes instead of skipping it: it only shields the boundary label's immediate neighbour, but that's strictly better than the no protection pinned axes had before, and matches the guarantee unpinned axes already get (#39899). Co-Authored-By: Claude Sonnet 5 --- .../src/MixedTimeseries/transformProps.ts | 39 ++++---------- .../src/Timeseries/transformProps.ts | 48 ++++------------- .../plugin-chart-echarts/src/utils/series.ts | 52 +++++++++++++++++++ .../MixedTimeseries/transformProps.test.ts | 9 ++-- .../test/Timeseries/transformProps.test.ts | 43 +++++++++++++-- 5 files changed, 114 insertions(+), 77 deletions(-) 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 29836c77c3db..d179f2ba1b47 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -73,7 +73,7 @@ import { getLegendProps, getMinAndMaxFromBounds, getOverMaxHiddenFormatter, - capTickMarks, + getTemporalAxisTickConfig, getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; @@ -791,35 +791,14 @@ export default function transformProps( name: xAxisTitle, nameGap: xAxisTitleMarginPx, nameLocation: 'middle', - axisLabel: { - // Pinned ticks label every bucket, so keep thinning on even when the - // showMaxLabel/rotation branch would otherwise drop it. - hideOverlap: - !!temporalTickValues || - (showMaxLabel - ? false - : !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)), - formatter: deduplicatedFormatter, - rotate: xAxisLabelRotation, - interval: xAxisLabelInterval, - // Skipped for pinned ticks: the boundary buckets are already real - // ticks there, and showMaxLabel only shields its immediate - // neighbour — hideOverlap can still drop it against a farther - // label on a crowded weekly axis, reopening #39899. - ...(showMaxLabel && - !temporalTickValues && { - showMaxLabel: true, - alignMaxLabel: 'right', - showMinLabel: true, - alignMinLabel: 'left', - }), - ...(temporalTickValues && { customValues: temporalTickValues }), - }, - // Gridlines follow axisTick.customValues; cap it so a long weekly - // range doesn't comb. - ...(temporalTickValues && { - axisTick: { customValues: capTickMarks(temporalTickValues) }, - }), + ...getTemporalAxisTickConfig( + temporalTickValues, + showMaxLabel, + xAxisType, + xAxisLabelRotation, + xAxisLabelInterval, + deduplicatedFormatter, + ), minorTick: { show: minorTicks }, minInterval: xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval 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 a5afdc064c2e..c5ad96ab5962 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -88,7 +88,7 @@ import { getHorizontalLegendAvailableWidth, getLegendProps, getMinAndMaxFromBounds, - capTickMarks, + getTemporalAxisTickConfig, getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; @@ -1273,44 +1273,14 @@ export default function transformProps( groupBy.length === 0 && { triggerEvent: true, }), - axisLabel: { - // When rotation is applied on time axes, hideOverlap can - // aggressively hide the last label. Rotated labels already - // have less overlap, so disabling hideOverlap is safe. - // At 0° rotation, also disable hideOverlap when showMaxLabel - // is active so the forced boundary label is never suppressed - // by ECharts' overlap detection (#39899). Pinned ticks label - // every bucket, which does crowd, so thinning always wins there. - hideOverlap: - !!temporalTickValues || - (showMaxLabel - ? false - : !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)), - formatter: deduplicatedFormatter, - rotate: xAxisLabelRotation, - interval: xAxisLabelInterval, - // Force the boundary labels on non-rotated time axes so the first - // and last dates stay visible: hideOverlap can hide the last label, - // and a min date that falls between "nice" ticks otherwise renders - // no beginning label. Skipped when rotated to avoid phantom labels - // at the axis boundary. Also skipped for pinned ticks: the boundary - // buckets are already real ticks there, and showMaxLabel only shields - // its immediate neighbour — hideOverlap can still drop it against a - // farther label on a crowded weekly axis, reopening #39899. - ...(showMaxLabel && - !temporalTickValues && { - showMaxLabel: true, - alignMaxLabel: 'right', - showMinLabel: true, - alignMinLabel: 'left', - }), - ...(temporalTickValues && { customValues: temporalTickValues }), - }, - // Gridlines follow axisTick.customValues; cap it so a long weekly - // range doesn't comb. - ...(temporalTickValues && { - axisTick: { customValues: capTickMarks(temporalTickValues) }, - }), + ...getTemporalAxisTickConfig( + temporalTickValues, + showMaxLabel, + xAxisType, + xAxisLabelRotation, + xAxisLabelInterval, + deduplicatedFormatter, + ), minorTick: { show: minorTicks }, minInterval: xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval 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 359b66e65183..5d567b53d93d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -1063,6 +1063,58 @@ export function capTickMarks( return capped; } +/** + * axisLabel/axisTick fragment for a temporal x-axis, shared by Timeseries and + * MixedTimeseries. When temporalTickValues pins the axis to weekly buckets, + * both axisLabel.customValues (what hideOverlap thins from) and + * axisTick.customValues (what splitLine/gridlines follow) use the same capped + * set, so a label that survives hideOverlap thinning always lands on a real + * tick and gridline rather than a capped-away bucket. + */ +export function getTemporalAxisTickConfig( + temporalTickValues: number[] | undefined, + showMaxLabel: boolean, + xAxisType: AxisType, + xAxisLabelRotation: number, + xAxisLabelInterval: number | string | undefined, + formatter: unknown, +): { + axisLabel: Record; + axisTick?: { customValues: number[] }; +} { + const cappedTickValues = temporalTickValues + ? capTickMarks(temporalTickValues) + : undefined; + return { + axisLabel: { + // Pinned ticks label every bucket, which does crowd, so thinning + // always wins there. + hideOverlap: + !!temporalTickValues || + (showMaxLabel + ? false + : !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)), + formatter, + rotate: xAxisLabelRotation, + interval: xAxisLabelInterval, + // Force the boundary labels so the first and last dates stay visible: + // hideOverlap can hide the last label, and a min date that falls + // between "nice" ticks otherwise renders no beginning label. Applied + // for pinned axes too — showMaxLabel only shields its immediate + // neighbour, so a farther label on a crowded weekly axis can still be + // dropped, but that's strictly better than no shielding at all. + ...(showMaxLabel && { + showMaxLabel: true, + alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', + }), + ...(cappedTickValues && { customValues: cappedTickValues }), + }, + ...(cappedTickValues && { axisTick: { customValues: cappedTickValues } }), + }; +} + export function getOverMaxHiddenFormatter( config: { max?: number; 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 1688df12782e..a7c268021e2b 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 @@ -1569,13 +1569,14 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.axisLabel.hideOverlap).toBe(true); }); - test('skips the showMaxLabel override at 0° rotation, unlike unpinned axes', () => { - // Pinned ticks already include the boundary buckets, and showMaxLabel - // can't reliably protect a label under hideOverlap anyway (#39899). + test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => { + // hideOverlap stays on for pinned ticks (they label every bucket), but + // showMaxLabel still shields the boundary label's immediate neighbour + // so the last bucket isn't silently dropped (#39899). const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS)) .echartOptions as any; - expect(xAxis.axisLabel.showMaxLabel).toBeUndefined(); + expect(xAxis.axisLabel.showMaxLabel).toBe(true); expect(xAxis.axisLabel.hideOverlap).toBe(true); }); 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 ad71cd758ee6..a354e2fa3f83 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 @@ -2751,12 +2751,47 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.splitLine).toBeUndefined(); }); - test('skips the showMaxLabel override at 0° rotation, unlike unpinned axes', () => { - // Pinned ticks already include the boundary buckets, and showMaxLabel - // can't reliably protect a label under hideOverlap anyway (#39899). + test('caps axisLabel.customValues to the same subset as axisTick, not the full bucket set', () => { + // hideOverlap thins whichever set axisLabel.customValues offers it. If + // that set were the full (uncapped) bucket list while axisTick/splitLine + // only kept a downsampled subset, a surviving label could land on a + // bucket with no tick or gridline under it. + const manyMondays = Array.from( + { length: 261 }, + (_, i) => Date.UTC(2021, 0, 4) + i * WEEK_MS, + ); + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, + xAxisTimeFormat: '%m-%d', + }, + queriesData: [ + createTestQueryData( + manyMondays.map((__timestamp, i) => ({ + __timestamp, + sales: 100 + i, + })), + { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ), + ], + }); + const { xAxis } = transformProps(chartProps).echartOptions as any; + + expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length); + expect(xAxis.axisLabel.customValues).toEqual(xAxis.axisTick.customValues); + }); + + test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => { + // hideOverlap stays on for pinned ticks (they label every bucket), but + // showMaxLabel still shields the boundary label's immediate neighbour + // so the last bucket isn't silently dropped (#39899). const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any; - expect(xAxis.axisLabel.showMaxLabel).toBeUndefined(); + expect(xAxis.axisLabel.showMaxLabel).toBe(true); expect(xAxis.axisLabel.hideOverlap).toBe(true); }); From 91b5a7afd2bb629759f5475ca886fe69b36443f3 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Tue, 1 Sep 2026 16:08:49 +0200 Subject: [PATCH 6/8] fix(echarts): only cap pinned axis labels when the axis is non-zoomable customValues never recomputes on dataZoom, so capping axisLabel on a zoomable axis would freeze the visible labels to the pre-zoom subset. On a non-zoomable axis there's no dataZoom to reach a capped-away bucket, so labels are capped to the same subset as axisTick, keeping every surviving label aligned to a real tick and gridline. Co-Authored-By: Claude Sonnet 5 --- .../src/MixedTimeseries/transformProps.ts | 2 + .../src/Timeseries/transformProps.ts | 1 + .../plugin-chart-echarts/src/utils/series.ts | 19 +++--- .../test/Timeseries/transformProps.test.ts | 68 ++++++++++++------- 4 files changed, 59 insertions(+), 31 deletions(-) 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 45a6b5778146..10c8014c97af 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -782,6 +782,8 @@ export default function transformProps( xAxisLabelRotation, xAxisLabelInterval, deduplicatedFormatter, + false, + zoomable, ); const echartOptions: EChartsCoreOption = { 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 c3efae8aea24..427847854e1c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -1265,6 +1265,7 @@ export default function transformProps( xAxisLabelInterval, deduplicatedFormatter, isHorizontal, + zoomable, ); let xAxis: any = { 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 64b1225cabae..4c50d99a0858 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -1089,10 +1089,14 @@ export function capTickMarks( /** * axisLabel/axisTick fragment for a temporal x-axis, shared by Timeseries and * MixedTimeseries. When temporalTickValues pins the axis to weekly buckets, - * both axisLabel.customValues (what hideOverlap thins from) and - * axisTick.customValues (what splitLine/gridlines follow) use the same capped - * set, so a label that survives hideOverlap thinning always lands on a real - * tick and gridline rather than a capped-away bucket. + * axisTick.customValues (what splitLine/gridlines follow) is downsampled to + * avoid combing a long weekly range. axisLabel.customValues (what hideOverlap + * thins from) uses the same capped set on a zoomable axis — zooming lets the + * user reach any bucket, but customValues never recomputes on dataZoom, so a + * capped set there would freeze the visible labels to the pre-zoom subset. + * On a non-zoomable axis the full set is used instead, so a label surviving + * hideOverlap thinning always lands on a real tick and gridline rather than a + * capped-away bucket. */ export function getTemporalAxisTickConfig( temporalTickValues: number[] | undefined, @@ -1102,6 +1106,7 @@ export function getTemporalAxisTickConfig( xAxisLabelInterval: number | string | undefined, formatter: unknown, isHorizontal: boolean = false, + zoomable: boolean = false, ): { axisLabel: Record; axisTick?: { customValues: number[] }; @@ -1109,6 +1114,7 @@ export function getTemporalAxisTickConfig( const cappedTickValues = temporalTickValues ? capTickMarks(temporalTickValues) : undefined; + const labelCustomValues = zoomable ? temporalTickValues : cappedTickValues; return { axisLabel: { // Pinned ticks label every bucket, which does crowd, so thinning @@ -1138,10 +1144,7 @@ export function getTemporalAxisTickConfig( alignMaxLabel: 'right', alignMinLabel: 'left', }), - // Labels keep the full (uncapped) bucket set: hideOverlap thins them - // dynamically at render time, including after a dataZoom, whereas a - // capped set would freeze the visible labels to the pre-zoom subset. - ...(temporalTickValues && { customValues: temporalTickValues }), + ...(labelCustomValues && { customValues: labelCustomValues }), }, ...(cappedTickValues && { axisTick: { customValues: cappedTickValues } }), }; 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 fe4b06801a46..a24ff415857d 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 @@ -2751,33 +2751,55 @@ describe('weekly x-axis tick alignment', () => { expect(xAxis.splitLine).toBeUndefined(); }); - test('caps axisTick.customValues but keeps the full bucket set for axisLabel', () => { - // axisLabel.customValues stays uncapped so hideOverlap keeps thinning it - // dynamically (including after a dataZoom); a capped set would freeze - // the visible labels to the pre-zoom subset. axisTick has no such - // thinning, so it's downsampled to avoid combing a long weekly range. + const manyMondaysChartProps = (overrides: Record = {}) => { const manyMondays = Array.from( { length: 261 }, (_, i) => Date.UTC(2021, 0, 4) + i * WEEK_MS, ); - const chartProps = createTestChartProps({ - formData: { - granularity_sqla: 'ds', - timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, - xAxisTimeFormat: '%m-%d', - }, - queriesData: [ - createTestQueryData( - manyMondays.map((__timestamp, i) => ({ - __timestamp, - sales: 100 + i, - })), - { - colnames: ['__timestamp', 'sales'], - coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], - }, - ), - ], + return { + manyMondays, + chartProps: createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, + xAxisTimeFormat: '%m-%d', + ...overrides, + }, + queriesData: [ + createTestQueryData( + manyMondays.map((__timestamp, i) => ({ + __timestamp, + sales: 100 + i, + })), + { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ), + ], + }), + }; + }; + + test('caps both axisTick and axisLabel customValues on a non-zoomable axis', () => { + // customValues never recomputes, so on a non-zoomable axis (no dataZoom + // to reach hidden buckets) axisLabel is capped to the same subset as + // axisTick: a label surviving hideOverlap thinning then always lands on + // a real tick and gridline rather than a capped-away bucket. + const { manyMondays, chartProps } = manyMondaysChartProps(); + const { xAxis } = transformProps(chartProps).echartOptions as any; + + expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length); + expect(xAxis.axisLabel.customValues).toEqual(xAxis.axisTick.customValues); + }); + + test('keeps the full bucket set for axisLabel on a zoomable axis', () => { + // A capped, uncapped label set would freeze the visible labels to the + // pre-zoom subset since customValues never recomputes on dataZoom, so a + // zoomable axis keeps the full set for axisLabel and lets hideOverlap + // thin it dynamically; only axisTick (no such thinning) stays capped. + const { manyMondays, chartProps } = manyMondaysChartProps({ + zoomable: true, }); const { xAxis } = transformProps(chartProps).echartOptions as any; From eca1d754eb7ae24ab23613ecf3b84718ecf19437 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Tue, 1 Sep 2026 17:17:57 +0200 Subject: [PATCH 7/8] fix(echarts): correct inverted zoomable/non-zoomable description in getTemporalAxisTickConfig comment Co-Authored-By: Claude Sonnet 5 --- .../plugins/plugin-chart-echarts/src/utils/series.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 4c50d99a0858..6ededf12ac3a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -1091,12 +1091,12 @@ export function capTickMarks( * MixedTimeseries. When temporalTickValues pins the axis to weekly buckets, * axisTick.customValues (what splitLine/gridlines follow) is downsampled to * avoid combing a long weekly range. axisLabel.customValues (what hideOverlap - * thins from) uses the same capped set on a zoomable axis — zooming lets the - * user reach any bucket, but customValues never recomputes on dataZoom, so a - * capped set there would freeze the visible labels to the pre-zoom subset. - * On a non-zoomable axis the full set is used instead, so a label surviving - * hideOverlap thinning always lands on a real tick and gridline rather than a - * capped-away bucket. + * thins from) uses the same capped set on a non-zoomable axis, so a label + * surviving hideOverlap thinning always lands on a real tick and gridline + * rather than a capped-away bucket. On a zoomable axis the full set is used + * instead — zooming lets the user reach any bucket, but customValues never + * recomputes on dataZoom, so a capped set there would freeze the visible + * labels to the pre-zoom subset. */ export function getTemporalAxisTickConfig( temporalTickValues: number[] | undefined, From 403148e56838056d048f6f6b944f0962e5054ae5 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Tue, 1 Sep 2026 17:27:37 +0200 Subject: [PATCH 8/8] test(echarts): assert capTickMarks downsampling logic instead of loose bounds Co-Authored-By: Claude Sonnet 5 --- .../test/utils/series.test.ts | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) 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 ad5765c07037..78e88961a008 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 @@ -1822,12 +1822,31 @@ describe('capTickMarks', () => { expect(capTickMarks(values, 60)).toEqual(values); }); - test('downsamples evenly and always keeps the last value', () => { + test('downsamples to every step-th value when the last value already lands on the step', () => { const values = Array.from({ length: 261 }, (_, i) => i); - const capped = capTickMarks(values, 60); - expect(capped.length).toBeLessThanOrEqual(60); - expect(capped[0]).toEqual(0); - expect(capped[capped.length - 1]).toEqual(260); + // step = ceil(261 / 60) = 5, and 260 is already a multiple of 5, so + // nothing needs to be appended for the last bucket. + expect(capTickMarks(values, 60)).toEqual( + Array.from({ length: 53 }, (_, i) => i * 5), + ); + }); + + test('appends the last value when it does not land on the step', () => { + const values = Array.from({ length: 262 }, (_, i) => i); + // step = ceil(262 / 60) = 5, stepping lands on 0..260, and the true last + // value (261) is appended on top since it isn't a multiple of 5. + expect(capTickMarks(values, 60)).toEqual([ + ...Array.from({ length: 53 }, (_, i) => i * 5), + 261, + ]); + }); + + test('maxTicks is not a hard bound once the last value has to be appended', () => { + const values = Array.from({ length: 300 }, (_, i) => i); + // step = ceil(300 / 60) = 5, which already lands on 60 stepped values + // (0..295) plus the appended last value (299), totaling 61 — one over + // maxTicks. Keeping the true last bucket wins over a hard cap. + expect(capTickMarks(values, 60)).toHaveLength(61); }); });