mirror of
https://github.com/apache/superset.git
synced 2026-08-30 12:01:19 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dad5c9390 | ||
|
|
1c3aeecbd2 | ||
|
|
3b801354f3 | ||
|
|
92085abc0d | ||
|
|
a5657e04e1 | ||
|
|
e61295d4b1 | ||
|
|
1eba5c0b7c | ||
|
|
94d8a0bf77 | ||
|
|
5a1f6332bf | ||
|
|
4d30f6fb65 | ||
|
|
296ab06d8d |
+26
-14
@@ -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;
|
||||
|
||||
+92
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+270
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user