Skip to content

Commit

Permalink
Component | Donut: Half Donut
Browse files Browse the repository at this point in the history
  • Loading branch information
curran committed Dec 13, 2024
1 parent 591c064 commit d612fcc
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 6 deletions.
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'
centralLabelsOffsetY={-24}
/>
</VisSingleContainer>
)
}
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 labels horizontal offset in pixels. Default: `undefined` */
centralLabelsOffsetX?: number;

/** Central labels vertical offset in pixels. Default: `undefined` */
centralLabelsOffsetY?: 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,
centralLabelsOffsetX: undefined,
centralLabelsOffsetY: undefined,
}
70 changes: 64 additions & 6 deletions packages/ts/src/components/donut/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ import { createArc, updateArc, removeArc } from './modules/arc'
// Styles
import * as s from './style'

// Constants that support half donuts
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

export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Datum>> {
static selectors = s
protected _defaultConfig = DonutDefaultConfig as DonutConfigInterface<Datum>
Expand Down Expand Up @@ -68,9 +81,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 +127,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 +161,41 @@ 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 { centralLabelsOffsetX, centralLabelsOffsetY } = config
const labelTranslateX = (centralLabelsOffsetX || 0) + translateX
let labelTranslateY = (centralLabelsOffsetY || 0) + translateY

// Special case label placement for half donut
if (isVerticalHalfDonut && centralLabelsOffsetX === undefined && centralLabelsOffsetY === 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 Down

0 comments on commit d612fcc

Please sign in to comment.