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: