Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ import {
getLegendProps,
getMinAndMaxFromBounds,
getOverMaxHiddenFormatter,
capTickMarks,
getTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
Expand Down Expand Up @@ -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: {
Expand All @@ -774,19 +792,34 @@ export default function transformProps(
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
axisLabel: {
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
// 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,
...(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 follow axisTick.customValues; cap it so a long weekly
// range doesn't comb.
...(temporalTickValues && {
axisTick: { customValues: capTickMarks(temporalTickValues) },
}),
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ import {
getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
capTickMarks,
getTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
Expand Down Expand Up @@ -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(
Comment thread
EnxDev marked this conversation as resolved.
(layer: AnnotationLayer) =>
layer.show && isTimeseriesAnnotationLayer(layer),
);
const temporalTickValues = hasTimeseriesAnnotation
? undefined
: getTemporalTickValues(
rebasedData,
xAxisLabel,
xAxisType,
resolvedTimeGrain,
);

let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
Expand All @@ -1260,25 +1279,38 @@ export default function transformProps(
// 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),
// by ECharts' overlap detection (#39899). Pinned ticks label
// every bucket, which does crowd, so thinning always wins there.
hideOverlap:
!!temporalTickValues ||
Comment thread
EnxDev marked this conversation as resolved.
(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',
}),
// 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) },
}),
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
Expand Down
10 changes: 10 additions & 0 deletions superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
NULL_STRING,
StackControlsValue,
TIMESERIES_CONSTANTS,
WEEKLY_TIME_GRAINS,
} from '../constants';
import {
EchartsTimeseriesSeriesType,
Expand Down Expand Up @@ -986,6 +987,82 @@ 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;
}
Comment thread
EnxDev marked this conversation as resolved.

// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1509,3 +1509,94 @@ 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('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).toBeUndefined();
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();
});
});
Loading
Loading