Repository navigation
fix(echarts): prevent x-axis time labels from overlapping #43669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bb4bdb2
0b8e3ba
66ae59d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import { | |
| getTimeFormatter, | ||
| isSavedMetric, | ||
| NumberFormats, | ||
| NumberFormatter, | ||
| QueryFormMetric, | ||
| SMART_DATE_DETAILED_ID, | ||
| SMART_DATE_ID, | ||
|
|
@@ -32,6 +33,7 @@ import { | |
| TimeGranularity, | ||
| ValueFormatter, | ||
| } from '@superset-ui/core'; | ||
| import { TIMESERIES_CONSTANTS } from '../constants'; | ||
|
|
||
| export const getSmartDateDetailedFormatter = () => | ||
| getTimeFormatter(SMART_DATE_DETAILED_ID); | ||
|
|
@@ -213,3 +215,151 @@ export function getXAxisFormatter( | |
| } | ||
| return String; | ||
| } | ||
|
|
||
| type XAxisFormatterFn = | ||
| | TimeFormatter | ||
| | NumberFormatter | ||
| | 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). | ||
| * | ||
| * Use this instead of createSpacedXAxisFormatter when the axis geometry | ||
| * doesn't match the spacing model's horizontal-plot assumptions, e.g. a | ||
| * horizontal orientation chart, where the time axis runs vertically along | ||
| * the side of the chart rather than along the bottom. | ||
| */ | ||
| export function createDedupXAxisFormatter( | ||
| xAxisFormatter: XAxisFormatterFn | undefined, | ||
| ): (value: number | string) => string { | ||
| 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 { id?: unknown }).id = (xAxisFormatter as { id?: unknown }).id; | ||
| } | ||
| return wrapper; | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| }); | ||
|
Comment on lines
+357
to
+362
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Major
|
||
| }); | ||
| return [domainMin, domainMax]; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The collision threshold assumes every rendered character is exactly seven pixels wide, but the formatter is also used for localized and custom time formats. Wide glyphs or long localized month names can exceed this estimate, causing the wrapper to retain labels whose actual rendered footprints overlap; narrow glyphs can also cause valid labels to be unnecessarily removed. Use measured text width or a conservative width calculation based on the actual formatter output and font. [possible bug]
Severity Level: Major⚠️
Prompt for AI Agent 🤖
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged — per rebenitez1802's review, the fixed 7px/char estimate is a real but acceptable heuristic (worst case still beats no suppression). A measureText-based estimate would be a nice follow-up, not blocking this fix.