Skip to content
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

Component | Axis: Hiding overlapping tick labels #466

Merged
merged 6 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (<>
<VisXYContainer xDomain={[0, 1000]} height={75}>
<VisAxis type='x' numTicks={25} duration={props.duration} tickTextHideOverlapping={true}/>
</VisXYContainer>
<VisXYContainer xDomain={[0, 10000000]} height={75}>
<VisAxis type='x' numTicks={25} duration={props.duration} tickTextHideOverlapping={true}/>
</VisXYContainer>
<VisXYContainer xDomain={[0, 10000000]} height={75}>
<VisAxis type='x' numTicks={25} duration={props.duration} tickTextHideOverlapping={true} tickTextAngle={15}/>
</VisXYContainer>
<VisXYContainer xDomain={[0, Date.now()]} height={125} xScale={Scale.scaleTime()}>
<VisAxis type='x' numTicks={7} duration={props.duration} tickTextHideOverlapping={true}/>
</VisXYContainer>
</>)
}
14 changes: 13 additions & 1 deletion packages/ts/src/components/axis/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ export interface AxisConfigInterface<Datum> extends Partial<XYComponentConfigInt
tickLine?: boolean;
/** Sets whether to draw the domain line or not. Default: `true` */
domainLine?: boolean;
/** Draw the min and max axis ticks only. Default: `false` */
/** Draw only the min and max axis ticks. Default: `false` */
minMaxTicksOnly?: boolean;
/** Draw only the min and max axis ticks, when the chart
* width is less than the specified value.
* Default: `250` */
minMaxTicksOnlyWhenWidthIsLess?: number;
/** Tick label formatter function. Default: `undefined` */
tickFormat?: ((tick: number | Date, i: number, ticks: number[] | Date[]) => string);
/** Explicitly set tick values. Default: `undefined` */
Expand All @@ -53,6 +57,11 @@ export interface AxisConfigInterface<Datum> extends Partial<XYComponentConfigInt
tickTextColor?: string | null;
/** Text rotation angle for ticks. Default: `undefined` */
tickTextAngle?: number;
/** Hide tick labels that overlap with each other.
* To define overlapping, a simple bounding box collision detection algorithm is used.
* Which means the result won't be accurate when `tickTextAngle` is specified.
* Default: `undefined` */
tickTextHideOverlapping?: boolean;
/** The spacing in pixels between the tick and it's label. Default: `8` */
tickPadding?: number;
}
Expand All @@ -68,6 +77,7 @@ export const AxisDefaultConfig: AxisConfigInterface<unknown> = {
domainLine: true,
numTicks: undefined,
minMaxTicksOnly: false,
minMaxTicksOnlyWhenWidthIsLess: 250,
tickTextWidth: undefined,
tickTextSeparator: undefined,
tickTextForceWordBreak: false,
Expand All @@ -76,10 +86,12 @@ export const AxisDefaultConfig: AxisConfigInterface<unknown> = {
tickTextFontSize: null,
tickTextAlign: undefined,
tickTextColor: null,
tickTextAngle: undefined,
labelMargin: 8,
labelColor: null,
tickFormat: undefined,
tickValues: undefined,
fullSize: true,
tickPadding: 8,
tickTextHideOverlapping: undefined,
}
116 changes: 91 additions & 25 deletions packages/ts/src/components/axis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -29,16 +30,16 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
static selectors = s
protected _defaultConfig: AxisConfigInterface<Datum> = AxisDefaultConfig
public config: AxisConfigInterface<Datum> = this._defaultConfig
axisGroup: Selection<SVGGElement, unknown, SVGGElement, unknown>
gridGroup: Selection<SVGGElement, unknown, SVGGElement, unknown>
private axisGroup: Selection<SVGGElement, unknown, SVGGElement, unknown>
private gridGroup: Selection<SVGGElement, unknown, SVGGElement, unknown>

private _axisRawBBox: DOMRect
private _axisSizeBBox: SVGRect
private _requiredMargin: Spacing
private _defaultNumTicks = 3
private _minMaxTicksOnlyEnforceWidth = 250
private _collideTickLabelsAnimFrameId: ReturnType<typeof requestAnimationFrame>

events = {}
protected events = {}

constructor (config?: AxisConfigInterface<Datum>) {
super()
Expand All @@ -49,7 +50,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}

/** Renders axis to an invisible grouped to calculate automatic chart margins */
preRender (): void {
public preRender (): void {
const { config } = this
const axisRenderHelperGroup = this.g.append('g').attr('opacity', 0)

Expand All @@ -69,17 +70,17 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
axisRenderHelperGroup.remove()
}

getPosition (): Position {
public getPosition (): Position {
const { config: { type, position } } = this
return (position ?? ((type === AxisType.X) ? Position.Bottom : Position.Left)) as Position
}

_getAxisSize (selection: Selection<SVGGElement, unknown, SVGGElement, undefined>): SVGRect {
private _getAxisSize (selection: Selection<SVGGElement, unknown, SVGGElement, undefined>): 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) {
Expand Down Expand Up @@ -130,7 +131,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}
}

_render (duration = this.config.duration, selection = this.axisGroup): void {
public _render (duration = this.config.duration, selection = this.axisGroup): void {
const { config } = this

this._renderAxis(selection, duration)
Expand All @@ -148,9 +149,11 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}

if (config.tickTextAlign) this._alignTickLabels()

this._resolveTickLabelOverlap(selection)
}

_buildAxis (): D3Axis<any> {
private _buildAxis (): D3Axis<any> {
const { config: { type, position, tickPadding } } = this

const ticks = this._getNumTicks()
Expand All @@ -168,7 +171,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}
}

_buildGrid (): D3Axis<any> {
private _buildGrid (): D3Axis<any> {
const { config: { type, position } } = this

const ticks = this._getNumTicks()
Expand All @@ -186,7 +189,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}
}

_renderAxis (selection = this.axisGroup, duration = this.config.duration): void {
private _renderAxis (selection = this.axisGroup, duration = this.config.duration): void {
const { config } = this

const axisGen = this._buildAxis()
Expand All @@ -209,6 +212,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
const tickText = selection.selectAll<SVGTextElement, number | Date>('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<SVGTextElement, number, SVGGElement, unknown> | Selection<SVGTextElement, Date, SVGGElement, unknown>


Expand Down Expand Up @@ -251,7 +255,69 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}
}

_getNumTicks (): number {
private _resolveTickLabelOverlap (selection = this.axisGroup): void {
const { config } = this
const tickTextSelection = selection.selectAll<SVGTextElement, number | Date>('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<SVGTextElement, number | Date, SVGGElement, unknown>): 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
Expand All @@ -271,31 +337,31 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
return this._defaultNumTicks
}

_getConfiguredTickValues (): number[] | null {
const { config: { tickValues, type, minMaxTicksOnly } } = this
const scale = type === AxisType.X ? this.xScale : this.yScale
private _getConfiguredTickValues (): number[] | null {
const { config } = this
const scale = config.type === AxisType.X ? this.xScale : this.yScale
const scaleDomain = scale?.domain() as [number, number]

if (tickValues) {
return tickValues.filter(v => (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}`
case AxisType.Y: return `M${-tickSize}, ${this._height + 0.5} H0.5 V0.5 H${-tickSize}`
}
}

_renderAxisLabel (selection = this.axisGroup): void {
private _renderAxisLabel (selection = this.axisGroup): void {
const { type, label, labelMargin, labelFontSize } = this.config

// Remove the old label first to calculate the axis size properly
Expand Down Expand Up @@ -325,7 +391,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
.style('fill', this.config.labelColor)
}

_getLabelDY (): number {
private _getLabelDY (): number {
const { type, position } = this.config
switch (type) {
case AxisType.X:
Expand All @@ -341,7 +407,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}
}

_alignTickLabels (): void {
private _alignTickLabels (): void {
const { config: { type, tickTextAlign, tickTextAngle, position } } = this
const tickText = this.g.selectAll('g.tick > text')

Expand All @@ -355,7 +421,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
.attr('text-anchor', textAnchor)
}

_getTickTextAnchor (textAlign: TextAlign): string {
private _getTickTextAnchor (textAlign: TextAlign): string {
switch (textAlign) {
case TextAlign.Left: return 'start'
case TextAlign.Right: return 'end'
Expand All @@ -364,7 +430,7 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}
}

_getYTickTextTranslate (textAlign: TextAlign, axisPosition: Position = Position.Left): number {
private _getYTickTextTranslate (textAlign: TextAlign, axisPosition: Position = Position.Left): number {
const defaultTickTextSpacingPx = 9 // Default in D3
const width = this._axisRawBBox.width - defaultTickTextSpacingPx

Expand Down
8 changes: 7 additions & 1 deletion packages/ts/src/components/axis/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */

Expand Down Expand Up @@ -99,7 +100,6 @@ export const tick = css`
text-decoration: var(--vis-axis-tick-label-text-decoration);
stroke: none;
}

`

export const label = css`
Expand All @@ -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);
`
1 change: 1 addition & 0 deletions packages/ts/src/containers/xy-container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ export class XYContainer<Datum> 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
Expand Down
Loading
Loading