mirror of
https://github.com/apache/superset.git
synced 2026-08-19 22:51:16 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d30f6fb65 | ||
|
|
296ab06d8d |
+27
-1
@@ -73,6 +73,7 @@ import {
|
||||
getLegendProps,
|
||||
getMinAndMaxFromBounds,
|
||||
getOverMaxHiddenFormatter,
|
||||
getTemporalTickValues,
|
||||
} from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import {
|
||||
@@ -757,6 +758,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: {
|
||||
@@ -769,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,
|
||||
@@ -779,7 +800,12 @@ export default function transformProps(
|
||||
showMinLabel: true,
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
...(temporalTickValues && { customValues: temporalTickValues }),
|
||||
},
|
||||
// Gridlines, when shown, follow axisTick.customValues too.
|
||||
...(temporalTickValues && {
|
||||
axisTick: { customValues: temporalTickValues },
|
||||
}),
|
||||
minorTick: { show: minorTicks },
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
getHorizontalLegendAvailableWidth,
|
||||
getLegendProps,
|
||||
getMinAndMaxFromBounds,
|
||||
getTemporalTickValues,
|
||||
} from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import {
|
||||
@@ -1242,6 +1243,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,
|
||||
@@ -1258,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,
|
||||
@@ -1273,7 +1294,12 @@ export default function transformProps(
|
||||
showMinLabel: true,
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
...(temporalTickValues && { customValues: temporalTickValues }),
|
||||
},
|
||||
// Gridlines, when shown, follow axisTick.customValues too.
|
||||
...(temporalTickValues && {
|
||||
axisTick: { customValues: temporalTickValues },
|
||||
}),
|
||||
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,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<number>();
|
||||
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;
|
||||
|
||||
+81
@@ -1352,3 +1352,84 @@ 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('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();
|
||||
});
|
||||
});
|
||||
|
||||
+200
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
AnnotationData,
|
||||
AnnotationSourceType,
|
||||
AnnotationStyle,
|
||||
AnnotationType,
|
||||
@@ -2529,3 +2530,202 @@ 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<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('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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user