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: Add tick label rotation #394

Merged
merged 4 commits into from
Jun 4, 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,73 @@
import React, { useRef } from 'react'
import { VisXYContainer, VisAxis, VisLine, VisGroupedBar } from '@unovis/react'
import { XYDataRecord, generateXYDataRecords } from '@src/utils/data'
import { Scale } from '@unovis/ts'

export const title = 'Axis with Ticks Rotation'
export const subTitle = 'Generated Data'

export const component = (): JSX.Element => {
const accessors = [
(d: XYDataRecord) => d.y,
(d: XYDataRecord) => d.y1,
(d: XYDataRecord) => d.y2,
() => Math.random(),
() => Math.random(),
]
return (
<>
<VisXYContainer<XYDataRecord> data={generateXYDataRecords(15)}
xScale={Scale.scaleTime()}
margin={{ top: 5, left: 5, bottom: 40, right: 5 }}>
<VisLine x={d => d.x} y={accessors}/>
<VisAxis type='x'
numTicks={15}
tickFormat={(x: number) => `${Intl.DateTimeFormat().format(x)}`}
tickTextAngle={60}
tickTextAlign={'left'}
/>
<VisAxis type='y'
tickFormat={(y: number) => `${y * 10000}`}
tickTextAngle={40}
position={'right'}
/>
</VisXYContainer>

<VisXYContainer<XYDataRecord> data={generateXYDataRecords(15)}
xScale={Scale.scaleTime()}
margin={{ top: 35, left: 65, bottom: 5, right: 5 }}>
<VisLine x={d => d.x} y={accessors}/>
<VisAxis type='x'
numTicks={15}
tickFormat={(x: number) => `${x}`}
tickTextAngle={30}
tickTextFitMode='trim'
tickTextAlign={'right'}
position='top'
/>
<VisAxis type='y'
tickFormat={(y: number) => `${y}`}
tickTextAngle={40}
position={'right'}
/>
</VisXYContainer>

<VisXYContainer<XYDataRecord> ariaLabel='A simple example of a Grouped Bar chart' data={generateXYDataRecords(15)} margin={{ top: 5, left: 5 }} xDomain={[1, 10]}>
<VisGroupedBar x={d => d.x} y={accessors} />
<VisAxis
type='x'
tickFormat= { d => new Date(d).toDateString()}
tickTextAngle={-30}
tickTextWidth={20}
tickTextFitMode='wrap'
tickTextAlign={'right'}
/>
<VisAxis
type='y'
tickFormat={(y: number) => `${y * 100000000}`}
tickTextAngle={-60}
/>
</VisXYContainer>
</>
)
}
2 changes: 2 additions & 0 deletions packages/ts/src/components/axis/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface AxisConfigInterface<Datum> extends Partial<XYComponentConfigInt
tickTextAlign?: TextAlign | string;
/** Font color of the tick text as CSS string. Default: `null` */
tickTextColor?: string | null;
/** Text rotation angle for ticks. Default: `undefined` */
tickTextAngle?: number;
/** The spacing in pixels between the tick and it's label. Default: `8` */
tickPadding?: number;
}
Expand Down
34 changes: 16 additions & 18 deletions packages/ts/src/components/axis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
constructor (config?: AxisConfigInterface<Datum>) {
super()
if (config) this.setConfig(config)

this.axisGroup = this.g.append('g')
this.gridGroup = this.g.append('g')
.attr('class', s.grid)
Expand Down Expand Up @@ -217,26 +216,28 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
tickText.nodes().forEach(node => interrupt(node))

tickText.each((value: number | Date, i: number, elements: ArrayLike<SVGTextElement>) => {
const text = config.tickFormat?.(value, i, tickValues) ?? `${value}`
let text = config.tickFormat?.(value, i, tickValues) ?? `${value}`
const textElement = elements[i] as SVGTextElement
const textMaxWidth = config.tickTextWidth || (config.type === AxisType.X ? this._containerWidth / (ticks.size() + 1) : this._containerWidth / 5)
const styleDeclaration = getComputedStyle(textElement)
const fontSize = Number.parseFloat(styleDeclaration.fontSize)
const fontFamily = styleDeclaration.fontFamily
const textOptions: UnovisTextOptions = {
verticalAlign: config.type === AxisType.X ? VerticalAlign.Top : VerticalAlign.Middle,
width: textMaxWidth,
textRotationAngle: config.tickTextAngle,
separator: config.tickTextSeparator,
wordBreak: config.tickTextForceWordBreak,
}

if (config.tickTextFitMode === FitMode.Trim) {
const textElementSelection = select<SVGTextElement, string>(textElement).text(text)
trimSVGText(textElementSelection, textMaxWidth, config.tickTextTrimType as TrimMode, true, fontSize, 0.58)
} else {
const textBlock: UnovisText = { text, fontFamily, fontSize }
const textOptions: UnovisTextOptions = {
verticalAlign: config.type === AxisType.X ? VerticalAlign.Top : VerticalAlign.Middle,
width: textMaxWidth,
separator: config.tickTextSeparator,
wordBreak: config.tickTextForceWordBreak,
}
renderTextToSvgTextElement(textElement, textBlock, textOptions)
text = select<SVGTextElement, string>(textElement).text()
}

const textBlock: UnovisText = { text, fontFamily, fontSize }
renderTextToSvgTextElement(textElement, textBlock, textOptions)
})

selection
Expand Down Expand Up @@ -311,16 +312,13 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu

const marginX = type === AxisType.X ? 0 : (-1) ** (+(axisPosition === Position.Left)) * labelMargin
const marginY = type === AxisType.X ? (-1) ** (+(axisPosition === Position.Top)) * labelMargin : 0

const rotation = type === AxisType.Y ? -90 : 0

// Append new label
selection
.append('text')
.attr('class', s.label)
.text(label)
.attr('dy', `${this._getLabelDY()}em`)
.attr('transform', `translate(${offsetX + marginX},${offsetY + marginY}) rotate(${rotation})`)
.attr('transform', `translate(${offsetX + marginX},${offsetY + marginY})`)
.style('font-size', labelFontSize)
.style('fill', this.config.labelColor)
}
Expand All @@ -342,17 +340,17 @@ export class Axis<Datum> extends XYComponentCore<Datum, AxisConfigInterface<Datu
}

_alignTickLabels (): void {
const { config: { type, tickTextAlign, position } } = this

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

const textAnchor = this._getTickTextAnchor(tickTextAlign as TextAlign)
const translateX = type === AxisType.X
? 0
: this._getYTickTextTranslate(tickTextAlign as TextAlign, position as Position)

tickText
.attr('transform', `translate(${translateX},0) rotate(${tickTextAngle})`)
.attr('text-anchor', textAnchor)
.attr('transform', `translate(${translateX},0)`)
}

_getTickTextAnchor (textAlign: TextAlign): string {
Expand Down
4 changes: 4 additions & 0 deletions packages/ts/src/types/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type UnovisText = {
export type UnovisWrappedText = UnovisText & {
// An array of text lines, where each element represents a single line of text.
_lines: string[];
// Maximum width of any line of text in this text block
_maxWidth: number;
// Estimated height of this text block
_estimatedHeight: number;
}
Expand All @@ -62,6 +64,8 @@ export type UnovisTextOptions = {
verticalAlign?: VerticalAlign | string;
// The horizontal text alignment ('left', 'center', or 'right').
textAlign?: TextAlign | string;
// Text rotation
textRotationAngle?: number;
// Whether to use a fast estimation method or a more accurate one for text calculations.
fastMode?: boolean;
// Force word break if they don't fit into the width
Expand Down
45 changes: 29 additions & 16 deletions packages/ts/src/utils/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,23 +382,25 @@ export function getWrappedText (

h += effectiveMarginPx
const dh = text.fontSize * text.lineHeight
let maxWidth = 0
// Iterate over lines and handle text overflow based on the height limit if provided
for (let k = 0; k < lines.length; k += 1) {
let line = lines[k]
h += dh

const lineWithEllipsis = `${line} …`
const textLengthPx = fastMode
? estimateStringPixelLength(lineWithEllipsis, text.fontSize, text.fontWidthToHeightRatio)
: getPreciseStringLengthPx(lineWithEllipsis, text.fontFamily, text.fontSize)

maxWidth = Math.max(textLengthPx, maxWidth)
if (height && (h + dh) > height && (k !== lines.length - 1)) {
// Remove hyphen character from the end of the line if it's there
const lastCharacter = line.charAt(line.length - 1)
if (lastCharacter === UNOVIS_TEXT_HYPHEN_CHARACTER_DEFAULT) {
line = line.substr(0, lines[k].length - 1)
}

const lineWithEllipsis = `${line} …`
const textLengthPx = fastMode
? estimateStringPixelLength(lineWithEllipsis, text.fontSize, text.fontWidthToHeightRatio)
: getPreciseStringLengthPx(lineWithEllipsis, text.fontFamily, text.fontSize)

if (textLengthPx < width) {
lines[k] = lineWithEllipsis
} else {
Expand All @@ -411,7 +413,7 @@ export function getWrappedText (
}

// Create wrapped text block with its calculated properties
blocks.push({ ...text, _lines: lines, _estimatedHeight: h - (prevBlock?._estimatedHeight || 0) })
blocks.push({ ...text, _lines: lines, _estimatedHeight: h - (prevBlock?._estimatedHeight || 0), _maxWidth: Math.max(maxWidth, prevBlock?._maxWidth ?? 0) })
})

return blocks
Expand Down Expand Up @@ -481,30 +483,41 @@ export const allowedSvgTextTags = ['text', 'tspan', 'textPath', 'altGlyph', 'alt
export function renderTextToSvgTextElement (
textElement: SVGTextElement,
text: UnovisText | UnovisText[],
options: UnovisTextOptions
options: UnovisTextOptions,
trimmed?: boolean
): void {
const wrappedText = getWrappedText(text, options.width, undefined, options.fastMode, options.separator, options.wordBreak)
const textElementX = options.x ?? +textElement.getAttribute('x')
const textElementY = options.y ?? +textElement.getAttribute('y')
const x = textElementX ?? 0
let y = textElementY ?? 0
if (options.textAlign) textElement.setAttribute('text-anchor', getTextAnchorFromTextAlign(options.textAlign))
if (options.textAlign) {
textElement.setAttribute('text-anchor', getTextAnchorFromTextAlign(options.textAlign))
}

if (options.verticalAlign && options.verticalAlign !== VerticalAlign.Top) {
const height = estimateWrappedTextHeight(wrappedText)
const dy = options.verticalAlign === VerticalAlign.Middle ? -height / 2
: options.verticalAlign === VerticalAlign.Bottom ? -height : 0

y += dy
}
if (options.textRotationAngle) {
textElement.setAttribute('transform', `rotate(${(options.textRotationAngle === 0 || options.textRotationAngle) ? options.textRotationAngle : 0} ${x} ${y})`)
} else {
textElement.removeAttribute('transform')
}

const parser = new DOMParser()
textElement.textContent = ''
wrappedText.forEach(block => {
const svgCode = renderTextToTspanStrings([block], x, y).join('')
const svgCodeSanitized = striptags(svgCode, allowedSvgTextTags)
const parsedSvgCode = parser.parseFromString(svgCodeSanitized, 'image/svg+xml').firstChild
textElement.appendChild(parsedSvgCode)
})
if (!trimmed) {
const parser = new DOMParser()
textElement.textContent = ''
wrappedText.forEach(block => {
const svgCode = renderTextToTspanStrings([block], x, y).join('')
const svgCodeSanitized = striptags(svgCode, allowedSvgTextTags)
const parsedSvgCode = parser.parseFromString(svgCodeSanitized, 'image/svg+xml').firstChild
textElement.appendChild(parsedSvgCode)
})
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/website/docs/auxiliary/Axis.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ Change the tick's label alignment with respect to the tick marker using `tickTex
options={['right', 'center', 'left']}
property="tickTextAlign"/>

### Label Rotation
Change the tick's label angle using `tickTextAngle` property with a number value. Use this variable along with `tickTextAlign` to make sure the tick label displays as desired.
<XYWrapperWithInput
{...defaultProps()}
tickTextAlign="left"
inputType="select"
data={generateTimeSeries(10)}
hiddenProps={{gridLine: false, x: d => d.timestamp, tickFormat: d=> new Date(d).toDateString()}}
defaultValue={15}
options={[15, 30, 45]}
property="tickTextAngle"/>

### Label Width
To limit the width of the tick labels (in pixels), you can use the `tickTextWidth` property.
Expand Down
Loading