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 | Donut: Half Donut #509

Merged
merged 4 commits into from
Jan 29, 2025
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
10 changes: 8 additions & 2 deletions packages/angular/src/components/donut/donut.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export class VisDonutComponent<Datum> implements DonutConfigInterface<Datum>, Af

/** Background angle range. When undefined, the value will be taken from `angleRange`. Default: `undefined` */
@Input() backgroundAngleRange?: [number, number]

/** Central label and sub-label horizontal offset in pixels. Default: `undefined` */
@Input() centralLabelOffsetX?: number

/** Central label and sub-label vertical offset in pixels. Default: `undefined` */
@Input() centralLabelOffsetY?: number
@Input() data: Datum[]

component: Donut<Datum> | undefined
Expand All @@ -131,8 +137,8 @@ export class VisDonutComponent<Datum> implements DonutConfigInterface<Datum>, Af
}

private getConfig (): DonutConfigInterface<Datum> {
const { duration, events, attributes, id, value, angleRange, padAngle, sortFunction, cornerRadius, color, radius, arcWidth, centralLabel, centralSubLabel, centralSubLabelWrap, showEmptySegments, emptySegmentAngle, showBackground, backgroundAngleRange } = this
const config = { duration, events, attributes, id, value, angleRange, padAngle, sortFunction, cornerRadius, color, radius, arcWidth, centralLabel, centralSubLabel, centralSubLabelWrap, showEmptySegments, emptySegmentAngle, showBackground, backgroundAngleRange }
const { duration, events, attributes, id, value, angleRange, padAngle, sortFunction, cornerRadius, color, radius, arcWidth, centralLabel, centralSubLabel, centralSubLabelWrap, showEmptySegments, emptySegmentAngle, showBackground, backgroundAngleRange, centralLabelOffsetX, centralLabelOffsetY } = this
const config = { duration, events, attributes, id, value, angleRange, padAngle, sortFunction, cornerRadius, color, radius, arcWidth, centralLabel, centralSubLabel, centralSubLabelWrap, showEmptySegments, emptySegmentAngle, showBackground, backgroundAngleRange, centralLabelOffsetX, centralLabelOffsetY }
const keys = Object.keys(config) as (keyof DonutConfigInterface<Datum>)[]
keys.forEach(key => { if (config[key] === undefined) delete config[key] })

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { useEffect, useState } from 'react'
import { VisSingleContainer, VisDonut } from '@unovis/react'
import { DONUT_HALF_ANGLE_RANGES } from '@unovis/ts'

export const title = 'Half Donut: Full Height'
export const subTitle = 'Testing the resize behavior'
export const component = (): JSX.Element => {
const data = [3, 2, 5, 4, 0, 1]

const [currentAngleRange, setCurrentAngleRange] = useState(DONUT_HALF_ANGLE_RANGES[0])

// Cycle through the half-donut angle ranges for testing
useEffect(() => {
const interval = setInterval(() => {
setCurrentAngleRange((prev: [number, number]) => {
const currentIndex = DONUT_HALF_ANGLE_RANGES.indexOf(prev)
return DONUT_HALF_ANGLE_RANGES[(currentIndex + 1) % DONUT_HALF_ANGLE_RANGES.length]
})
}, 2000)
return () => clearInterval(interval)
}, [])

return (
<VisSingleContainer style={{ height: '100%' }} >
<VisDonut
value={d => d}
data={data}
padAngle={0.02}
duration={0}
arcWidth={80}
angleRange={currentAngleRange}
centralLabel="Central Label"
centralSubLabel="Sub-label"
/>
</VisSingleContainer>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react'
import { VisSingleContainer, VisDonut } from '@unovis/react'
import { DONUT_HALF_ANGLE_RANGE_TOP } from '@unovis/ts'

export const title = 'Half Donut: Labels'
export const subTitle = 'Testing the label offsets'
export const component = (): JSX.Element => {
const data = [3, 2, 5, 4, 0, 1]

return (
<VisSingleContainer style={{ height: '100%' }} >
<VisDonut
value={d => d}
data={data}
padAngle={0.02}
duration={0}
arcWidth={80}
angleRange={DONUT_HALF_ANGLE_RANGE_TOP}
centralLabel='Central Label'
centralSubLabel='Central Sub Label'
centralLabelOffsetY={-24}
/>
</VisSingleContainer>
)
}
3 changes: 3 additions & 0 deletions packages/ts/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export { XYLabels } from './components/xy-labels'
export { NestedDonut } from './components/nested-donut'
export { Annotations } from 'components/annotations'

// Constants
export * from './components/donut/constants'

// Config Interfaces
export type { LineConfigInterface } from './components/line/config'
export type { StackedBarConfigInterface } from './components/stacked-bar/config'
Expand Down
8 changes: 8 additions & 0 deletions packages/ts/src/components/donut/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface DonutConfigInterface<Datum> extends ComponentConfigInterface {
showBackground?: boolean;
/** Background angle range. When undefined, the value will be taken from `angleRange`. Default: `undefined` */
backgroundAngleRange?: [number, number];

/** Central label and sub-label horizontal offset in pixels. Default: `undefined` */
centralLabelOffsetX?: number;

/** Central label and sub-label vertical offset in pixels. Default: `undefined` */
centralLabelOffsetY?: number;
}

export const DonutDefaultConfig: DonutConfigInterface<unknown> = {
Expand All @@ -62,4 +68,6 @@ export const DonutDefaultConfig: DonutConfigInterface<unknown> = {
emptySegmentAngle: 0.5 * Math.PI / 180,
showBackground: true,
backgroundAngleRange: undefined,
centralLabelOffsetX: undefined,
centralLabelOffsetY: undefined,
}
11 changes: 11 additions & 0 deletions packages/ts/src/components/donut/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const DONUT_HALF_ANGLE_RANGES = Array.from({ length: 4 }, (_, i): [number, number] => {
const offset = -Math.PI / 2 + i * Math.PI / 2
return [offset, offset + Math.PI]
})

export const [
DONUT_HALF_ANGLE_RANGE_TOP,
DONUT_HALF_ANGLE_RANGE_RIGHT,
DONUT_HALF_ANGLE_RANGE_BOTTOM,
DONUT_HALF_ANGLE_RANGE_LEFT,
] = DONUT_HALF_ANGLE_RANGES
60 changes: 54 additions & 6 deletions packages/ts/src/components/donut/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { DonutDefaultConfig, DonutConfigInterface } from './config'
// Modules
import { createArc, updateArc, removeArc } from './modules/arc'

// Constants
import { DONUT_HALF_ANGLE_RANGES } from './constants'

// Styles
import * as s from './style'

Expand Down Expand Up @@ -68,9 +71,37 @@ export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Da
.filter(d => config.showEmptySegments || getNumber(d.datum, config.value, d.index))

const duration = isNumber(customDuration) ? customDuration : config.duration
const outerRadius = config.radius || Math.min(this._width - bleed.left - bleed.right, this._height - bleed.top - bleed.bottom) / 2

// Handle half-donut cases, which adjust the scaling and positioning.
// One of these is true if we are dealing with a half-donut.
const [
isHalfDonutTop,
isHalfDonutRight,
isHalfDonutBottom,
isHalfDonutLeft,
] = DONUT_HALF_ANGLE_RANGES.map(angleRange =>
config.angleRange && (
config.angleRange[0] === angleRange[0] &&
config.angleRange[1] === angleRange[1]
)
)
const isVerticalHalfDonut = isHalfDonutTop || isHalfDonutBottom
const isHorizontalHalfDonut = isHalfDonutRight || isHalfDonutLeft

// Compute the bounding box of the donut,
// considering it may be a half-donut
const width = this._width * (isHorizontalHalfDonut ? 2 : 1)
const height = this._height * (isVerticalHalfDonut ? 2 : 1)

const outerRadius = config.radius || Math.min(width - bleed.left - bleed.right, height - bleed.top - bleed.bottom) / 2
const innerRadius = config.arcWidth === 0 ? 0 : clamp(outerRadius - config.arcWidth, 0, outerRadius - 1)

const translateY = this._height / 2 + (isHalfDonutTop ? outerRadius / 2 : isHalfDonutBottom ? -outerRadius / 2 : 0)
const translateX = this._width / 2 + (isHalfDonutLeft ? outerRadius / 2 : isHalfDonutRight ? -outerRadius / 2 : 0)
const translate = `translate(${translateX},${translateY})`

this.arcGroup.attr('transform', translate)

this.arcGen
.startAngle(d => d.startAngle)
.endAngle(d => d.endAngle)
Expand All @@ -86,8 +117,6 @@ export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Da
.value(d => getNumber(d.datum, config.value, d.index) || 0)
.sort((a, b) => config.sortFunction?.(a.datum, b.datum))

this.arcGroup.attr('transform', `translate(${this._width / 2},${this._height / 2})`)

const arcData: DonutArcDatum<Datum>[] = pieGen(data).map(d => {
const arc = {
...d,
Expand Down Expand Up @@ -122,22 +151,40 @@ export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Da
.call(removeArc, duration)

// Label
const labelTextAnchor = isHalfDonutRight ? 'start' : isHalfDonutLeft ? 'end' : 'middle'
this.centralLabel
.attr('transform', `translate(${this._width / 2},${this._height / 2})`)
.attr('dy', config.centralSubLabel ? '-0.55em' : null)
.style('text-anchor', labelTextAnchor)
.text(config.centralLabel ?? null)

this.centralSubLabel
.attr('transform', `translate(${this._width / 2},${this._height / 2})`)
.attr('dy', config.centralLabel ? '0.55em' : null)
.style('text-anchor', labelTextAnchor)
.text(config.centralSubLabel ?? null)

if (config.centralSubLabelWrap) wrapSVGText(this.centralSubLabel, innerRadius * 1.9)

// Label placement
const labelTranslateX = (config.centralLabelOffsetX || 0) + translateX
let labelTranslateY = (config.centralLabelOffsetY || 0) + translateY

// Special case label placement for half donut
if (isVerticalHalfDonut && config.centralLabelOffsetX === undefined && config.centralLabelOffsetY === undefined) {
const halfDonutLabelOffsetY = isHalfDonutTop
? -this.centralSubLabel.node().getBoundingClientRect().height
: isHalfDonutBottom
? this.centralLabel.node().getBoundingClientRect().height
: 0
labelTranslateY = halfDonutLabelOffsetY + translateY
}
const labelTranslate = `translate(${labelTranslateX},${labelTranslateY})`
this.centralLabel.attr('transform', labelTranslate)
this.centralSubLabel.attr('transform', labelTranslate)

// Background
this.arcBackground.attr('class', s.background)
.attr('visibility', config.showBackground ? null : 'hidden')
.attr('transform', `translate(${this._width / 2},${this._height / 2})`)
.attr('transform', translate)

smartTransition(this.arcBackground, duration)
.attr('d', this.arcGen({
Expand All @@ -148,3 +195,4 @@ export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Da
}))
}
}

39 changes: 39 additions & 0 deletions packages/website/docs/misc/Donut.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ description: Learn how to configure a Donut chart
import CodeBlock from '@theme/CodeBlock'
import { PropsTable } from '@site/src/components/PropsTable'
import { DocWrapper, InputWrapper } from '../wrappers'
import {
DONUT_HALF_ANGLE_RANGE_TOP,
DONUT_HALF_ANGLE_RANGE_RIGHT,
DONUT_HALF_ANGLE_RANGE_BOTTOM,
DONUT_HALF_ANGLE_RANGE_LEFT
} from '@unovis/ts'

export const donutProps = () => ({
name: "Donut",
Expand All @@ -27,12 +33,45 @@ the `centralSubLabelWrap` property to `false`), while the main label is supposed
implemented.
<DocWrapper {...donutProps()} centralLabel="Label" centralSubLabel="Long sub-label wraps onto the next line"/>

### Label Position
You can adjust the position of both the central label and sub-label using offset properties:

#### Horizontal Offset
Use `centralLabelOffsetX` to move the labels left or right (negative values move left, positive values move right):
<DocWrapper {...donutProps()}
centralLabel="Offset Label"
centralSubLabel="Moved horizontally"
centralLabelOffsetX={20}
/>

#### Vertical Offset
Use `centralLabelOffsetY` to move the labels up or down (negative values move up, positive values move down):
<DocWrapper {...donutProps()}
centralLabel="Offset Label"
centralSubLabel="Moved vertically"
centralLabelOffsetY={-15}
/>


You can combine both offsets to position the labels exactly where you need them.

## Angle Range
By default, a _Donut_ will populate values in the angle range `[0, 2π]`. You can adjust your _Donut_'s `angleRange` property to a `[a,b]` of type `[number, number]`
where a[0] = the starting position and a[1] = the ending position (in radians). A common example might be when you want an incomplete/semi circle:
<DocWrapper {...donutProps()} angleRange={[1, Math.PI]}/>

### Half Donut Charts
For convenience, Unovis provides preset angle ranges to create half donut charts in different orientations. You can import these constants from `@unovis/ts`:

```ts
import {
DONUT_HALF_ANGLE_RANGE_TOP,
DONUT_HALF_ANGLE_RANGE_RIGHT,
DONUT_HALF_ANGLE_RANGE_BOTTOM,
DONUT_HALF_ANGLE_RANGE_LEFT
} from '@unovis/ts'
```

## Sorting
By default, each _segment_ is placed in order of appearance within your `data` array, from
To change this, provide a sorting function to the `sortFunction` property. The following example displays the segments in descending order:
Expand Down
Loading