From 3cd554ff5b6628ef6553b83a2e35afa3fa0a9cb7 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 18 Oct 2024 09:56:27 -0700 Subject: [PATCH 1/6] Component | Axis: Adding data modifiers (public/private) --- packages/ts/src/components/axis/index.ts | 38 ++++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/ts/src/components/axis/index.ts b/packages/ts/src/components/axis/index.ts index c217f06a8..4ae95e14d 100644 --- a/packages/ts/src/components/axis/index.ts +++ b/packages/ts/src/components/axis/index.ts @@ -29,8 +29,8 @@ 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 @@ -38,7 +38,7 @@ export class Axis extends XYComponentCore) { super() @@ -49,7 +49,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 +130,7 @@ export class Axis extends XYComponentCore extends XYComponentCore { + private _buildAxis (): D3Axis { const { config: { type, position, tickPadding } } = this const ticks = this._getNumTicks() @@ -168,7 +168,7 @@ export class Axis extends XYComponentCore { + private _buildGrid (): D3Axis { const { config: { type, position } } = this const ticks = this._getNumTicks() @@ -186,7 +186,7 @@ export class Axis extends XYComponentCore extends XYComponentCore extends XYComponentCore extends XYComponentCore extends XYComponentCore extends XYComponentCore extends XYComponentCore text') @@ -355,7 +355,7 @@ export class Axis extends XYComponentCore extends XYComponentCore Date: Fri, 18 Oct 2024 11:35:56 -0700 Subject: [PATCH 2/6] Component | Axis: Implement tick label collision detection - Adding `tickTextHideOverlapping` config option to Axis component and implementing collision detection algorithm to hide overlapping tick labels - Configurable `minMaxTicksOnlyWhenWidthIsLess` property (was hard coded before) --- packages/ts/src/components/axis/config.ts | 12 +++- packages/ts/src/components/axis/index.ts | 73 +++++++++++++++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/ts/src/components/axis/config.ts b/packages/ts/src/components/axis/config.ts index 9e1001447..38aa229bd 100644 --- a/packages/ts/src/components/axis/config.ts +++ b/packages/ts/src/components/axis/config.ts @@ -27,8 +27,10 @@ export interface AxisConfigInterface extends Partial string); /** Explicitly set tick values. Default: `undefined` */ @@ -53,6 +55,11 @@ export interface AxisConfigInterface extends Partial = { domainLine: true, numTicks: undefined, minMaxTicksOnly: false, + minMaxTicksOnlyWhenWidthIsLess: 250, tickTextWidth: undefined, tickTextSeparator: undefined, tickTextForceWordBreak: false, @@ -76,10 +84,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 4ae95e14d..2b90b11be 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' @@ -36,7 +37,7 @@ export class Axis extends XYComponentCore protected events = {} @@ -148,6 +149,8 @@ export class Axis extends XYComponentCore { @@ -251,6 +254,64 @@ 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 + }) + + // 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 @@ -272,15 +333,15 @@ 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[] } From 96932e81dd725a49228c8f14210c17976efb6a30 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 18 Oct 2024 11:37:07 -0700 Subject: [PATCH 3/6] Dev | Demo | Axis: Tick Labels Overlapping --- .../axis/axis-tick-label-overlap/index.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/dev/src/examples/xy-components/axis/axis-tick-label-overlap/index.tsx 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 (<> + + + + + + + + + + + + + ) +} From b401dea88b8466d6c461f68ab147fc101b6ae67e Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 18 Oct 2024 14:10:29 -0700 Subject: [PATCH 4/6] Container | XYContainer | Auto Margin: Set scales before updating domain Fixes the problem with axis tick labels going off screen because the auto margins were not calculated properly. It was happening when a different scale (e.g. Scale.scaleTime()) is provided to the container (and it generates ticks differently from the default scale), because this new scale was not passed to the Axis component during pre-render --- packages/ts/src/containers/xy-container/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ts/src/containers/xy-container/index.ts b/packages/ts/src/containers/xy-container/index.ts index 21dee0524..f3bce3f4b 100644 --- a/packages/ts/src/containers/xy-container/index.ts +++ b/packages/ts/src/containers/xy-container/index.ts @@ -389,6 +389,7 @@ export class XYContainer 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 From de26885f99b3bae986500e7fa45df7dcedcd1ddf Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Fri, 18 Oct 2024 14:45:44 -0700 Subject: [PATCH 5/6] Website | Docs | Axis: Info about `tickTextHideOverlapping` --- packages/ts/src/components/axis/config.ts | 4 +++- packages/website/docs/auxiliary/Axis.mdx | 25 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/ts/src/components/axis/config.ts b/packages/ts/src/components/axis/config.ts index 38aa229bd..fc88dbb07 100644 --- a/packages/ts/src/components/axis/config.ts +++ b/packages/ts/src/components/axis/config.ts @@ -29,7 +29,9 @@ export interface AxisConfigInterface extends Partial string); 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: From 9a852f21f1f9ad9d44cfe4d21e6c8f165760dfb0 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Wed, 30 Oct 2024 15:55:47 -0700 Subject: [PATCH 6/6] Component | Axis: Tick label collision iterations and transitions --- packages/ts/src/components/axis/index.ts | 45 +++++++++++++----------- packages/ts/src/components/axis/style.ts | 8 ++++- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/ts/src/components/axis/index.ts b/packages/ts/src/components/axis/index.ts index 2b90b11be..abd572f15 100644 --- a/packages/ts/src/components/axis/index.ts +++ b/packages/ts/src/components/axis/index.ts @@ -212,6 +212,7 @@ export class Axis 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 @@ -281,29 +282,33 @@ export class Axis extends XYComponentCore { - 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 + 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) => { diff --git a/packages/ts/src/components/axis/style.ts b/packages/ts/src/components/axis/style.ts index bfd94b101..80c36d9a1 100644 --- a/packages/ts/src/components/axis/style.ts +++ b/packages/ts/src/components/axis/style.ts @@ -18,6 +18,7 @@ export const globalStyles = injectGlobal` --vis-axis-tick-label-text-decoration: none; --vis-axis-label-font-size: 14px; --vis-axis-tick-line-width: 1px; + --vis-axis-tick-label-hide-transition: opacity 400ms ease-in-out; --vis-axis-grid-line-width: 1px; /* --vis-axis-domain-line-width: // Undefined by default to allow fallback to var(--vis-axis-grid-line-width) */ @@ -99,7 +100,6 @@ export const tick = css` text-decoration: var(--vis-axis-tick-label-text-decoration); stroke: none; } - ` export const label = css` @@ -113,3 +113,9 @@ export const label = css` export const tickLabel = css` label: tick-label; ` + +export const tickLabelHideable = css` + label: tick-label-hideable; + opacity: 0; + transition: var(--vis-axis-tick-label-hide-transition); +`