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 d16d60cd583c..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,6 +73,8 @@ import { getLegendProps, getMinAndMaxFromBounds, getOverMaxHiddenFormatter, + getTemporalAxisTickConfig, + getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; import { @@ -762,6 +764,22 @@ 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. + // 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, grid: { @@ -773,20 +791,14 @@ export default function transformProps( name: xAxisTitle, nameGap: xAxisTitleMarginPx, nameLocation: 'middle', - axisLabel: { - hideOverlap: showMaxLabel - ? false - : !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0), - formatter: deduplicatedFormatter, - rotate: xAxisLabelRotation, - interval: xAxisLabelInterval, - ...(showMaxLabel && { - showMaxLabel: true, - alignMaxLabel: 'right', - showMinLabel: true, - alignMinLabel: 'left', - }), - }, + ...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 9ac71b429032..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,6 +88,8 @@ import { getHorizontalLegendAvailableWidth, getLegendProps, getMinAndMaxFromBounds, + getTemporalAxisTickConfig, + getTemporalTickValues, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; import { @@ -1245,6 +1247,23 @@ export default function transformProps( })() : xAxisFormatter; + // Weekly grains: pin the ticks to the buckets ECharts would otherwise miss. + // 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, name: xAxisTitle, @@ -1254,31 +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). - hideOverlap: 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. - ...(showMaxLabel && { - showMaxLabel: true, - alignMaxLabel: 'right', - showMinLabel: true, - alignMinLabel: 'left', - }), - }, + ...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/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..5d567b53d93d 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,134 @@ 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. + * + * 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 = + // eslint-disable-next-line no-nested-ternary + value instanceof Date + ? value.getTime() + : typeof value === 'string' + ? parseTemporalString(value) + : Number(value ?? NaN); + if (Number.isFinite(timestamp)) { + values.add(timestamp); + } + }); + 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; +} + +/** + * 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 42fb4d1ca2cc..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 @@ -1509,3 +1509,95 @@ 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); + // Gridlines follow axisTick.customValues, so splitLine needs no own copy. + expect(xAxis.axisTick.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('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).toBe(true); + expect(xAxis.axisLabel.hideOverlap).toBe(true); + }); + + 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 137a900a1344..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 @@ -17,6 +17,7 @@ * under the License. */ import { + AnnotationData, AnnotationSourceType, AnnotationStyle, AnnotationType, @@ -2706,6 +2707,275 @@ describe('EchartsTimeseries tooltip truncation', () => { }); }); +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 = {}, + annotationData?: AnnotationData, + ) => + createTestChartProps({ + annotationData, + 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], + // transformProps reads annotations off the query, not chartProps. + ...(annotationData && { annotation_data: annotationData }), + }, + ), + ], + }); + + 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); + // Gridlines follow axisTick.customValues, so splitLine needs no own copy. + expect(xAxis.axisTick.customValues).toEqual(MONDAYS); + expect(xAxis.splitLine).toBeUndefined(); + }); + + 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).toBe(true); + expect(xAxis.axisLabel.hideOverlap).toBe(true); + }); + + test('pins ticks when the bucket column holds ISO date strings', () => { + // A dataset can arrive with __timestamp serialized as an ISO string + // rather than a Date/epoch-ms value. + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY, + }, + queriesData: [ + createTestQueryData( + MONDAYS.map((__timestamp, i) => ({ + __timestamp: new Date(__timestamp).toISOString(), + sales: 100 + i, + })), + { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }, + ), + ], + }); + const { xAxis } = transformProps(chartProps).echartOptions as any; + + expect(xAxis.axisLabel.customValues).toEqual(MONDAYS); + }); + + 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([ + 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(); + }); +}); + describe('tooltip for metrics whose labels end in forecast suffixes', () => { const marker = ''; const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper']; 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..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 @@ -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,8 @@ import { getLegendProps, getOverMaxHiddenFormatter, getMinAndMaxFromBounds, + capTickMarks, + getTemporalTickValues, sanitizeHtml, sortAndFilterSeries, sortRows, @@ -1705,6 +1708,129 @@ 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(); + }); +}); + +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(