diff --git a/packages/dev/src/examples/xy-components/axis/axis-tick-label-overlap/index.tsx b/packages/dev/src/examples/xy-components/axis/axis-tick-label-overlap/index.tsx new file mode 100644 index 000000000..401f94fd3 --- /dev/null +++ b/packages/dev/src/examples/xy-components/axis/axis-tick-label-overlap/index.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { VisXYContainer, VisAxis } from '@unovis/react' +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { Scale } from '@unovis/ts' + +export const title = 'Axis Tick Label Overlap' +export const subTitle = 'Resolving overlapping labels' +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + return (<> + + + + + + + + + + + + + ) +} diff --git a/packages/ts/src/components/axis/config.ts b/packages/ts/src/components/axis/config.ts index 9e1001447..fc88dbb07 100644 --- a/packages/ts/src/components/axis/config.ts +++ b/packages/ts/src/components/axis/config.ts @@ -27,8 +27,12 @@ export interface AxisConfigInterface extends Partial string); /** Explicitly set tick values. Default: `undefined` */ @@ -53,6 +57,11 @@ export interface AxisConfigInterface extends Partial = { domainLine: true, numTicks: undefined, minMaxTicksOnly: false, + minMaxTicksOnlyWhenWidthIsLess: 250, tickTextWidth: undefined, tickTextSeparator: undefined, tickTextForceWordBreak: false, @@ -76,10 +86,12 @@ export const AxisDefaultConfig: AxisConfigInterface = { tickTextFontSize: null, tickTextAlign: undefined, tickTextColor: null, + tickTextAngle: undefined, labelMargin: 8, labelColor: null, tickFormat: undefined, tickValues: undefined, fullSize: true, tickPadding: 8, + tickTextHideOverlapping: undefined, } diff --git a/packages/ts/src/components/axis/index.ts b/packages/ts/src/components/axis/index.ts index c217f06a8..abd572f15 100644 --- a/packages/ts/src/components/axis/index.ts +++ b/packages/ts/src/components/axis/index.ts @@ -15,6 +15,7 @@ import { FitMode, TextAlign, TrimMode, UnovisText, UnovisTextOptions, VerticalAl import { smartTransition } from 'utils/d3' import { renderTextToSvgTextElement, trimSVGText } from 'utils/text' import { isEqual } from 'utils/data' +import { rectIntersect } from 'utils/misc' // Local Types import { AxisType } from './types' @@ -29,16 +30,16 @@ export class Axis extends XYComponentCore = AxisDefaultConfig public config: AxisConfigInterface = this._defaultConfig - axisGroup: Selection - gridGroup: Selection + private axisGroup: Selection + private gridGroup: Selection private _axisRawBBox: DOMRect private _axisSizeBBox: SVGRect private _requiredMargin: Spacing private _defaultNumTicks = 3 - private _minMaxTicksOnlyEnforceWidth = 250 + private _collideTickLabelsAnimFrameId: ReturnType - events = {} + protected events = {} constructor (config?: AxisConfigInterface) { super() @@ -49,7 +50,7 @@ export class Axis extends XYComponentCore extends XYComponentCore): SVGRect { + private _getAxisSize (selection: Selection): SVGRect { const bBox = selection.node().getBBox() return bBox } - _getRequiredMargin (axisSize = this._axisSizeBBox): Spacing { + private _getRequiredMargin (axisSize = this._axisSizeBBox): Spacing { const { config: { type, position } } = this switch (type) { @@ -130,7 +131,7 @@ export class Axis extends XYComponentCore extends XYComponentCore { + private _buildAxis (): D3Axis { const { config: { type, position, tickPadding } } = this const ticks = this._getNumTicks() @@ -168,7 +171,7 @@ export class Axis extends XYComponentCore { + private _buildGrid (): D3Axis { const { config: { type, position } } = this const ticks = this._getNumTicks() @@ -186,7 +189,7 @@ export class Axis extends XYComponentCore extends XYComponentCore('g.tick > text') .filter(tickValue => tickValues.some((t: number | Date) => isEqual(tickValue, t))) // We use isEqual to compare Dates .classed(s.tickLabel, true) + .classed(s.tickLabelHideable, Boolean(config.tickTextHideOverlapping)) .style('fill', config.tickTextColor) as Selection | Selection @@ -251,7 +255,69 @@ export class Axis extends XYComponentCore('g.tick > text') + + if (!config.tickTextHideOverlapping) { + tickTextSelection.style('opacity', null) + return + } + + cancelAnimationFrame(this._collideTickLabelsAnimFrameId) + // Colliding labels in the next frame to prevent forced reflow + this._collideTickLabelsAnimFrameId = requestAnimationFrame(() => { + this._collideTickLabels(tickTextSelection) + }) + } + + private _collideTickLabels (selection: Selection): void { + type SVGOverlappingTextElement = SVGTextElement & { + _visible: boolean; + } + + // Reset visibility of all labels + selection.each((d, i, elements) => { + const node = elements[i] as SVGOverlappingTextElement + node._visible = true + }) + + // We do three iterations because not all overlapping labels can be resolved in the first iteration + const numIterations = 3 + for (let i = 0; i < numIterations; i += 1) { + // Run collision detection and set labels visibility + selection.each((d, i, elements) => { + const label1 = elements[i] as SVGOverlappingTextElement + const isLabel1Visible = label1._visible + if (!isLabel1Visible) return + + // Calculate bounding rect of point's label + const label1BoundingRect = label1.getBoundingClientRect() + + for (let j = i + 1; j < elements.length; j += 1) { + if (i === j) continue + const label2 = elements[j] as SVGOverlappingTextElement + const isLabel2Visible = label2._visible + if (isLabel2Visible) { + const label2BoundingRect = label2.getBoundingClientRect() + const intersect = rectIntersect(label1BoundingRect, label2BoundingRect, -5) + if (intersect) { + label2._visible = false + break + } + } + } + }) + } + + // Hide the overlapping labels + selection.each((d, i, elements) => { + const label = elements[i] as SVGOverlappingTextElement + select(label).style('opacity', label._visible ? 1 : 0) + }) + } + + private _getNumTicks (): number { const { config: { type, numTicks } } = this if (numTicks) return numTicks @@ -271,23 +337,23 @@ export class Axis extends XYComponentCore (v >= scaleDomain[0]) && (v <= scaleDomain[1])) + if (config.tickValues) { + return config.tickValues.filter(v => (v >= scaleDomain[0]) && (v <= scaleDomain[1])) } - if (minMaxTicksOnly || (type === AxisType.X && this._width < this._minMaxTicksOnlyEnforceWidth)) { + if (config.minMaxTicksOnly || (config.type === AxisType.X && this._width < config.minMaxTicksOnlyWhenWidthIsLess)) { return scaleDomain as number[] } return null } - _getFullDomainPath (tickSize = 0): string { + private _getFullDomainPath (tickSize = 0): string { const { config: { type } } = this switch (type) { case AxisType.X: return `M0.5, ${tickSize} V0.5 H${this._width + 0.5} V${tickSize}` @@ -295,7 +361,7 @@ export class Axis extends XYComponentCore extends XYComponentCore extends XYComponentCore text') @@ -355,7 +421,7 @@ export class Axis extends XYComponentCore extends XYComponentCore extends ContainerCore { // At first we need to set the domain to the scales const components = clean([...this.components, xAxis, yAxis]) + this._setScales(...components) this._updateScalesDomain(...components) // Calculate margin required by the axes diff --git a/packages/website/docs/auxiliary/Axis.mdx b/packages/website/docs/auxiliary/Axis.mdx index 1694df401..47f4c9fba 100644 --- a/packages/website/docs/auxiliary/Axis.mdx +++ b/packages/website/docs/auxiliary/Axis.mdx @@ -210,13 +210,21 @@ The specified count is only a hint, the axis can have more or fewer ticks depend property="numTicks"/> ### Display Only Minimum and Maximum Ticks -Set the `minMaxTicksOnly` property to `true` if you only want to see the two end ticks on the axis: +Set the `minMaxTicksOnly` property to `true` if you only want to see the two end ticks on the axis. + +:::note +To display the minimum and maximum ticks only when the chart width is limited (this behavior is enabed my default), +you can use the `minMaxTicksOnlyWhenWidthIsLess` property (defaults to 250px). This helps avoid clutter in smaller visualizations while still +providing essential information. +::: + + ### Set Ticks Explicitly You can customize the ticks displayed by providing the _Axis_ component with a number array. The following example only shows even values for x after getting the `tickValue` array from a filter function. @@ -227,6 +235,21 @@ function tickValues() { ``` d.x}}/> +### Hide Overlapping Ticks `1.5.0` +To prevent overlapping tick labels on the axis, you can use the `tickTextHideOverlapping` +property. When enabled, it hides any tick labels that would otherwise overlap with +one another based on a simple bounding box collision detection algorithm. This +ensures cleaner, more legible axes, particularly in cases where the available space +is limited or when displaying many ticks. + +:::note +The algorithm used for detecting overlaps may not be accurate when a `tickTextAngle` is specified, +so results can vary depending on tick rotation. +::: + +d.x}} tickTextHideOverlapping={true}/> + + ## Displaying Multiple Axes More commonly, you will want to display both an x and y axis for your graph. You can display multiple axes in an _XY Container_ like so: