You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/08/29 03:00:24 UTC
(superset) branch sc-119084-xaxis-label-overlap created (now 246dc831197)
This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a change to branch sc-119084-xaxis-label-overlap
in repository https://gitbox.apache.org/repos/asf/superset.git
at 246dc831197 fix(echarts): thin overlapping x-axis time labels on timeseries charts
This branch includes the following new commits:
new 246dc831197 fix(echarts): thin overlapping x-axis time labels on timeseries charts
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: fix(echarts): thin overlapping x-axis time labels on timeseries charts
Posted by jl...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch sc-119084-xaxis-label-overlap
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 246dc831197958f97ccf7becc1959e98828efbcb
Author: sadpandajoe <jc...@gmail.com>
AuthorDate: Sat Aug 29 03:00:10 2026 +0000
fix(echarts): thin overlapping x-axis time labels on timeseries charts
When showMaxLabel is active (the default for most timeseries charts),
axisLabel.hideOverlap must stay off to keep the forced boundary label
visible (#39899), which also disabled ECharts' own overlap suppression
for every other tick. Dense/closely-spaced time data would then render
with all labels overlapping.
Extend the x-axis label formatter to estimate each label's pixel
footprint against the available per-tick spacing and blank labels that
would visually collide, while always keeping the forced axis boundary
labels visible. Applies to both Timeseries and MixedTimeseries charts.
Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
.../src/MixedTimeseries/transformProps.ts | 50 ++++------
.../src/Timeseries/transformProps.ts | 50 +++-------
.../plugins/plugin-chart-echarts/src/constants.ts | 6 ++
.../plugin-chart-echarts/src/utils/formatters.ts | 102 +++++++++++++++++++++
.../test/MixedTimeseries/transformProps.test.ts | 52 +++++++++++
.../test/Timeseries/transformers.test.ts | 49 ++++++++++
6 files changed, 241 insertions(+), 68 deletions(-)
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 8a9bae65b6e..d26a4f029b8 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
@@ -100,7 +100,9 @@ import {
import { TIMEGRAIN_TO_TIMESTAMP, TIMESERIES_CONSTANTS } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
+ createSpacedXAxisFormatter,
getTooltipTimeFormatter,
+ getXAxisDomain,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
@@ -659,44 +661,26 @@ export default function transformProps(
? getXAxisFormatter(xAxisTimeFormat, resolvedTimeGrain)
: String;
+ // hideOverlap must stay off so the forced boundary label from showMaxLabel
+ // is never suppressed (#39899). The formatter itself dedupes consecutive
+ // identical labels and thins out labels that would otherwise visually
+ // collide, since hideOverlap can no longer do that for us.
const showMaxLabel =
xAxisType === AxisType.Time &&
xAxisLabelRotation === 0 &&
!!resolvedTimeGrain;
const deduplicatedFormatter = showMaxLabel
- ? (() => {
- let lastLabel: string | undefined;
- let lastValue: number | undefined;
- const wrapper = (value: number | string) => {
- // ECharts formats the labels in repeated ascending passes. Reset the
- // dedup state when the sequence restarts so a forced boundary label
- // (e.g. the min date) isn't blanked by the previous pass's last label
- // when both format identically (e.g. a May-to-May range).
- if (
- typeof value === 'number' &&
- lastValue !== undefined &&
- value <= lastValue
- ) {
- lastLabel = undefined;
- }
- if (typeof value === 'number') {
- lastValue = value;
- }
- const label =
- typeof xAxisFormatter === 'function'
- ? (xAxisFormatter as Function)(value)
- : String(value);
- if (label === lastLabel) {
- return '';
- }
- lastLabel = label;
- return label;
- };
- if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
- (wrapper as any).id = (xAxisFormatter as any).id;
- }
- return wrapper;
- })()
+ ? createSpacedXAxisFormatter(
+ xAxisFormatter,
+ ...getXAxisDomain(
+ [
+ rebasedDataA as Record<string, unknown>[],
+ rebasedDataB as Record<string, unknown>[],
+ ],
+ xAxisLabel,
+ ),
+ Math.max(width - 2 * TIMESERIES_CONSTANTS.gridOffsetLeft, 0),
+ )
: xAxisFormatter;
const yAxisTitleMarginPx = convertInteger(yAxisTitleMargin);
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 5052e50cd71..461832abd5d 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -122,8 +122,10 @@ import {
} from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import {
+ createSpacedXAxisFormatter,
getPercentFormatter,
getTooltipTimeFormatter,
+ getXAxisDomain,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
@@ -1205,46 +1207,24 @@ export default function transformProps(
// When showMaxLabel is true, ECharts may render a label at the axis
// boundary that formats identically to the last data-point tick (e.g.
- // "2005" appears twice with Year grain). Wrap the formatter to suppress
- // consecutive duplicate labels.
+ // "2005" appears twice with Year grain), and hideOverlap must stay off so
+ // that forced boundary label is never suppressed (#39899). Wrap the
+ // formatter to suppress consecutive duplicate labels and to thin out
+ // labels that would otherwise visually collide, since hideOverlap can no
+ // longer do that for us.
const showMaxLabel =
xAxisType === AxisType.Time &&
xAxisLabelRotation === 0 &&
!!resolvedTimeGrain;
const deduplicatedFormatter = showMaxLabel
- ? (() => {
- let lastLabel: string | undefined;
- let lastValue: number | undefined;
- const wrapper = (value: number | string) => {
- // ECharts formats the labels in repeated ascending passes. Reset the
- // dedup state when the sequence restarts so a forced boundary label
- // (e.g. the min date) isn't blanked by the previous pass's last label
- // when both format identically (e.g. a May-to-May range).
- if (
- typeof value === 'number' &&
- lastValue !== undefined &&
- value <= lastValue
- ) {
- lastLabel = undefined;
- }
- if (typeof value === 'number') {
- lastValue = value;
- }
- const label =
- typeof xAxisFormatter === 'function'
- ? (xAxisFormatter as Function)(value)
- : String(value);
- if (label === lastLabel) {
- return '';
- }
- lastLabel = label;
- return label;
- };
- if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
- (wrapper as any).id = (xAxisFormatter as any).id;
- }
- return wrapper;
- })()
+ ? createSpacedXAxisFormatter(
+ xAxisFormatter,
+ ...getXAxisDomain(
+ [rebasedData as Record<string, unknown>[]],
+ xAxisLabel,
+ ),
+ Math.max(width - 2 * TIMESERIES_CONSTANTS.gridOffsetLeft, 0),
+ )
: xAxisFormatter;
let xAxis: any = {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
index 3044bf10d70..28dce271fe8 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
@@ -52,6 +52,12 @@ export const TIMESERIES_CONSTANTS = {
microChartHeight: 60,
// One y-axis tick per this many pixels of chart height
yAxisPixelsPerTick: 80,
+ // Rough average glyph width (px) used to estimate whether adjacent x-axis
+ // time labels would visually collide, since the real rendered width isn't
+ // known until ECharts lays out the axis.
+ xAxisLabelCharWidthPx: 7,
+ // Minimum gap (px) to keep between adjacent x-axis time labels.
+ xAxisLabelMinGapPx: 8,
};
export enum OpacityEnum {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts
index 485bc2407ac..e02e52420e3 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts
@@ -32,6 +32,7 @@ import {
TimeGranularity,
ValueFormatter,
} from '@superset-ui/core';
+import { TIMESERIES_CONSTANTS } from '../constants';
export const getSmartDateDetailedFormatter = () =>
getTimeFormatter(SMART_DATE_DETAILED_ID);
@@ -213,3 +214,104 @@ export function getXAxisFormatter(
}
return String;
}
+
+type XAxisFormatterFn =
+ | TimeFormatter
+ | StringConstructor
+ | ((value: number | string) => string);
+
+/**
+ * Wraps an x-axis time formatter so that:
+ * - consecutive ticks that format to identical text are blanked (e.g. the
+ * boundary label forced by showMaxLabel duplicating the last real tick).
+ * - ticks that would render close enough to visually collide with the
+ * previously shown label are blanked, since disabling ECharts'
+ * `hideOverlap` (required to keep the forced boundary label visible, see
+ * #39899) also disables its native overlap suppression for every other
+ * label on the axis.
+ *
+ * The forced axis boundary labels (domainMin/domainMax) are never blanked by
+ * the spacing check so they stay visible regardless of density.
+ */
+export function createSpacedXAxisFormatter(
+ xAxisFormatter: XAxisFormatterFn | undefined,
+ domainMin: number | undefined,
+ domainMax: number | undefined,
+ plotWidthPx: number,
+): (value: number | string) => string {
+ const pixelsPerMs =
+ domainMin !== undefined && domainMax !== undefined && domainMax > domainMin
+ ? plotWidthPx / (domainMax - domainMin)
+ : undefined;
+ let lastLabel: string | undefined;
+ let lastValue: number | undefined;
+ let lastShownValue: number | undefined;
+ const wrapper = (value: number | string) => {
+ // ECharts formats the labels in repeated ascending passes. Reset the
+ // dedup/spacing state when the sequence restarts so a forced boundary
+ // label (e.g. the min date) isn't blanked by the previous pass's state
+ // when both format identically (e.g. a May-to-May range).
+ if (
+ typeof value === 'number' &&
+ lastValue !== undefined &&
+ value <= lastValue
+ ) {
+ lastLabel = undefined;
+ lastShownValue = undefined;
+ }
+ if (typeof value === 'number') {
+ lastValue = value;
+ }
+ const label =
+ typeof xAxisFormatter === 'function'
+ ? (xAxisFormatter as Function)(value)
+ : String(value);
+ if (label === lastLabel) {
+ return '';
+ }
+ const isBoundary =
+ typeof value === 'number' && (value === domainMin || value === domainMax);
+ if (
+ !isBoundary &&
+ typeof value === 'number' &&
+ pixelsPerMs !== undefined &&
+ lastShownValue !== undefined &&
+ (value - lastShownValue) * pixelsPerMs <
+ label.length * TIMESERIES_CONSTANTS.xAxisLabelCharWidthPx +
+ TIMESERIES_CONSTANTS.xAxisLabelMinGapPx
+ ) {
+ return '';
+ }
+ lastLabel = label;
+ if (typeof value === 'number') {
+ lastShownValue = value;
+ }
+ return label;
+ };
+ if (typeof xAxisFormatter === 'function' && 'id' in xAxisFormatter) {
+ (wrapper as { id?: unknown }).id = (xAxisFormatter as { id?: unknown }).id;
+ }
+ return wrapper;
+}
+
+/**
+ * Computes the [min, max] of a temporal x-axis column across one or more
+ * data record arrays, for use with createSpacedXAxisFormatter.
+ */
+export function getXAxisDomain(
+ dataRecordArrays: Record<string, unknown>[][],
+ xAxisCol: string,
+): [number | undefined, number | undefined] {
+ let domainMin: number | undefined;
+ let domainMax: number | undefined;
+ dataRecordArrays.forEach(records => {
+ records.forEach(record => {
+ const value = record[xAxisCol];
+ if (typeof value === 'number') {
+ if (domainMin === undefined || value < domainMin) domainMin = value;
+ if (domainMax === undefined || value > domainMax) domainMax = value;
+ }
+ });
+ });
+ return [domainMin, domainMax];
+}
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 d2a158a6e06..7ba47051474 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
@@ -1324,6 +1324,58 @@ test('#39899 - x-axis dates do not overlap and last label stays visible at 0° r
expect(axisLabel.hideOverlap).toBe(false);
});
+test('SC-119084 - closely spaced x-axis time labels do not visually overlap (mixed)', () => {
+ const startTime = Date.UTC(2026, 0, 1);
+ const data = Array.from({ length: 20 }, (_, i) => ({
+ __timestamp: startTime + i * 60 * 1000,
+ sum__num: i,
+ }));
+ const queryData = createTestQueryData(data, {
+ colnames: ['__timestamp', 'sum__num'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+ label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
+ });
+
+ const chartProps = createEchartsTimeseriesTestChartProps<
+ EchartsMixedTimeseriesFormData,
+ EchartsMixedTimeseriesProps
+ >({
+ ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
+ width: 300,
+ height: 400,
+ defaultQueriesData: [queryData, queryData],
+ formData: {
+ ...formData,
+ x_axis: '__timestamp',
+ xAxisTimeFormat: '%Y-%m-%d %H:%M:%S',
+ metrics: ['sum__num'],
+ metricsB: ['sum__num'],
+ groupby: [],
+ groupbyB: [],
+ xAxisLabelRotation: 0,
+ timeGrainSqla: TimeGranularity.MINUTE,
+ },
+ queriesData: [queryData, queryData],
+ });
+
+ const { echartOptions } = transformProps(chartProps);
+ const { axisLabel } = echartOptions.xAxis as Record<string, any>;
+ const labels = data.map(({ __timestamp }) =>
+ axisLabel.formatter(__timestamp),
+ );
+
+ // hideOverlap must stay off so ECharts' own collision detection can never
+ // suppress the forced boundary label (#39899 must not regress).
+ expect(axisLabel.hideOverlap).toBe(false);
+ // The formatter itself must thin out labels that are too close together to
+ // render legibly in the available width.
+ expect(labels.filter(label => label === '').length).toBeGreaterThan(0);
+ // The first and last labels are the forced axis boundaries and must always
+ // stay visible.
+ expect(labels[0]).not.toBe('');
+ expect(labels[labels.length - 1]).not.toBe('');
+});
+
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
// Regression test for https://github.com/apache/superset/issues/37921
// ("Residual" follow-up to #37055).
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
index 5baf08da31c..cd84221b354 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
@@ -352,6 +352,55 @@ test('#39899 - x-axis dates do not overlap and last label stays visible at 0° r
expect(axisLabel.hideOverlap).toBe(false);
});
+test('SC-119084 - closely spaced x-axis time labels do not visually overlap', () => {
+ const formData = {
+ colorScheme: 'bnbColors',
+ datasource: '3__table',
+ granularity_sqla: 'ds',
+ timeGrainSqla: TimeGranularity.MINUTE,
+ x_axis_time_format: '%Y-%m-%d %H:%M:%S',
+ metric: 'sum__num',
+ viz_type: 'my_viz',
+ };
+ const startTime = new Date('2026-01-01T00:00:00Z').getTime();
+ const data = Array.from({ length: 20 }, (_, i) => ({
+ sum__num: i,
+ __timestamp: startTime + i * 60 * 1000,
+ }));
+ const chartProps = new ChartProps({
+ formData,
+ width: 300,
+ height: 400,
+ queriesData: [
+ {
+ data,
+ colnames: ['sum__num', '__timestamp'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
+ },
+ ],
+ theme: supersetTheme,
+ });
+
+ const result = transformProps(
+ chartProps as unknown as EchartsTimeseriesChartProps,
+ );
+ const { axisLabel } = result.echartOptions.xAxis as Record<string, any>;
+ const labels = data.map(({ __timestamp }) =>
+ axisLabel.formatter(__timestamp),
+ );
+
+ // hideOverlap must stay off so ECharts' own collision detection can never
+ // suppress the forced boundary label (#39899 must not regress).
+ expect(axisLabel.hideOverlap).toBe(false);
+ // The formatter itself must thin out labels that are too close together to
+ // render legibly in the available width.
+ expect(labels.filter(label => label === '').length).toBeGreaterThan(0);
+ // The first and last labels are the forced axis boundaries and must always
+ // stay visible.
+ expect(labels[0]).not.toBe('');
+ expect(labels[labels.length - 1]).not.toBe('');
+});
+
test('last x-axis date is visible and not cut off when rotated -45°', () => {
const lastDataPointTimestamp = new Date('2026-12-01').getTime();
const result = transformProps(