Compare commits

...
Author SHA1 Message Date
Enzo MartellucciandClaude Sonnet 5 6dad5c9390 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 <noreply@anthropic.com>
2026-08-28 15:52:51 +02:00
Enzo Martellucci 1c3aeecbd2 Merge branch 'master' into enxdev/fix/echarts 2026-08-28 15:45:02 +02:00
Enzo Martellucci 3b801354f3 Merge branch 'master' into enxdev/fix/echarts 2026-08-28 11:43:39 +02:00
Enzo MartellucciandClaude Sonnet 5 92085abc0d 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 <noreply@anthropic.com>
2026-08-28 11:43:04 +02:00
Enzo Martellucci a5657e04e1 Merge branch 'master' into enxdev/fix/echarts 2026-08-28 11:38:36 +02:00
Enzo Martellucci e61295d4b1 Merge branch 'master' into enxdev/fix/echarts 2026-08-25 11:53:55 +02:00
Enzo Martellucci 1eba5c0b7c chore: address review comments 2026-08-25 11:53:35 +02:00
Enzo Martellucci 94d8a0bf77 Merge branch 'master' into enxdev/fix/echarts 2026-08-24 17:48:15 +02:00
Enzo Martellucci 5a1f6332bf Merge branch 'master' into enxdev/fix/echarts
# Conflicts:
#	superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
#	superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
#	superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts
2026-08-24 17:00:52 +02:00
Enzo Martellucci 4d30f6fb65 fix(echarts): keep pinned weekly axis labels thinned and annotation-safe 2026-08-19 19:30:05 +02:00
Enzo Martellucci 296ab06d8d fix(echarts): place weekly time-axis ticks on the data buckets 2026-08-19 18:50:51 +02:00
7 changed files with 680 additions and 39 deletions
@@ -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
@@ -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
@@ -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<string> = 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,
@@ -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<number>();
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<string, unknown>;
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;
@@ -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<EchartsMixedTimeseriesFormData> = {},
) =>
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();
});
});
@@ -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<EchartsTimeseriesFormData> = {},
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 = '<span style="background-color:#1f77b4;"></span>';
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
@@ -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(