Skip to content
Draft
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
66 changes: 55 additions & 11 deletions packages/dev/src/examples/misc/donut/donut-full-height/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,66 @@
import React from 'react'
import React, { useState } from 'react'
import { VisSingleContainer, VisDonut } from '@unovis/react'
import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index'
import { DonutSegmentLabelPosition } from '@unovis/ts/components/donut/types'

export const title = 'Donut: Full Height'
export const subTitle = 'Testing the resize behavior'

export const component = (props: ExampleViewerDurationProps): React.ReactNode => {
const data = [3, 2, 5, 4, 0, 1]

const [showSegmentLabels, setShowSegmentLabels] = useState(true)
const [segmentLabelPosition, setSegmentLabelPosition] = useState<DonutSegmentLabelPosition>(DonutSegmentLabelPosition.Outside)
const [segmentLabelOffset, setSegmentLabelOffset] = useState(25)
const isInside = segmentLabelPosition === DonutSegmentLabelPosition.Inside

return (
<VisSingleContainer style={{ height: '100%' }}>
<VisDonut
value={d => d}
data={data}
padAngle={0.02}
duration={props.duration}
arcWidth={80}
/>
</VisSingleContainer>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 0', display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="checkbox"
checked={showSegmentLabels}
onChange={e => setShowSegmentLabels(e.target.checked)}
/>
Show segment labels
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
Position:
<select
value={segmentLabelPosition}
onChange={e => setSegmentLabelPosition(e.target.value as DonutSegmentLabelPosition)}
>
<option value={DonutSegmentLabelPosition.Outside}>Outside</option>
<option value={DonutSegmentLabelPosition.Inside}>Inside</option>
</select>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, opacity: isInside ? 0.5 : 1 }}>
Offset:
<input
type="range"
min={10}
max={50}
value={segmentLabelOffset}
onChange={e => setSegmentLabelOffset(Number(e.target.value))}
disabled={isInside}
/>
<span>{segmentLabelOffset}</span>
</label>
</div>
<VisSingleContainer style={{ flex: 1 }}>
<VisDonut
value={d => d}
data={data}
padAngle={0.02}
duration={props.duration}
arcWidth={80}
showSegmentLabels={showSegmentLabels}
segmentLabelOffset={segmentLabelOffset}
segmentLabel={d => `${d}`}
segmentLabelPosition={segmentLabelPosition}
/>
</VisSingleContainer>
</div>
)
}

Original file line number Diff line number Diff line change
@@ -1,37 +1,79 @@
import React, { useEffect, useState } from 'react'
import { VisSingleContainer, VisDonut } from '@unovis/react'
import React, { useEffect, useRef, useState } from 'react'
import { VisSingleContainer, VisDonut, VisDonutRef } from '@unovis/react'
import { DONUT_HALF_ANGLE_RANGES } from '@unovis/ts'
import { DonutSegmentLabelPosition } from '@unovis/ts/components/donut/types'

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

const ref = useRef<VisDonutRef<number>>(null)
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)
setCurrentAngleRange((prev: [number, number]) => {
const currentIndex = DONUT_HALF_ANGLE_RANGES.indexOf(prev)
return DONUT_HALF_ANGLE_RANGES[(currentIndex + 1) % DONUT_HALF_ANGLE_RANGES.length]
})
}, [])

const [showSegmentLabels, setShowSegmentLabels] = useState(true)
const [segmentLabelPosition, setSegmentLabelPosition] = useState<DonutSegmentLabelPosition>(DonutSegmentLabelPosition.Outside)
const [segmentLabelOffset, setSegmentLabelOffset] = useState(10)

const isInside = segmentLabelPosition === DonutSegmentLabelPosition.Inside

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>
<>
<div style={{ padding: '8px 0', display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="checkbox"
checked={showSegmentLabels}
onChange={e => setShowSegmentLabels(e.target.checked)}
/>
Show segment labels
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
Position:
<select
value={segmentLabelPosition}
onChange={e => setSegmentLabelPosition(e.target.value as DonutSegmentLabelPosition)}
>
<option value={DonutSegmentLabelPosition.Outside}>Outside</option>
<option value={DonutSegmentLabelPosition.Inside}>Inside</option>
</select>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, opacity: isInside ? 0.5 : 1 }}>
Offset:
<input
type="range"
min={10}
max={50}
value={segmentLabelOffset}
onChange={e => setSegmentLabelOffset(Number(e.target.value))}
disabled={isInside}
/>
<span>{segmentLabelOffset}</span>
</label>
</div>
<VisSingleContainer style={{ height: '100%' }}>
<VisDonut
ref={ref}
value={d => d}
data={data}
padAngle={0.02}
duration={0}
arcWidth={80}
angleRange={currentAngleRange}
centralLabel="Central Label"
centralSubLabel="Sub-label"
segmentLabel={d => (d as number).toString()}
segmentLabelOffset={!isInside ? segmentLabelOffset : undefined}
showSegmentLabels={showSegmentLabels}
segmentLabelPosition={segmentLabelPosition}
/>
</VisSingleContainer>
</>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import { VisSingleContainer, VisDonut } from '@unovis/react'
import { DONUT_HALF_ANGLE_RANGE_TOP } from '@unovis/ts'
import { DonutSegmentLabelPosition } from '@unovis/ts/components/donut/types'

export const title = 'Half Donut: Labels'
export const subTitle = 'Testing the label offsets'
Expand All @@ -19,6 +20,10 @@ export const component = (): React.ReactNode => {
centralLabel='Central Label'
centralSubLabel='Central Sub Label'
centralLabelOffsetY={-24}
showSegmentLabels={true}
segmentLabelOffset={25}
segmentLabel={d => `${d}`}
segmentLabelPosition={DonutSegmentLabelPosition.Outside}
/>
</VisSingleContainer>
)
Expand Down
20 changes: 19 additions & 1 deletion packages/ts/src/components/donut/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import { ComponentConfigInterface, ComponentDefaultConfig } from 'core/component/config'

// Types
import { ColorAccessor, NumericAccessor } from 'types/accessor'
import { ColorAccessor, NumericAccessor, StringAccessor } from 'types/accessor'
import { DonutSegmentLabelPosition } from './types'


export interface DonutConfigInterface<Datum> extends ComponentConfigInterface {
/** Accessor function for getting the unique data record id. Used for more persistent data updates. Default: `(d, i) => d.id ?? i` */
Expand Down Expand Up @@ -48,6 +50,18 @@ export interface DonutConfigInterface<Datum> extends ComponentConfigInterface {

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

/** Show labels for individual segments. Default: `true` */
showSegmentLabels?: boolean;

/** Segment label accessor function. Default `undefined` */
segmentLabel?: StringAccessor<Datum>;

/** segement label position, 'inside' or 'outside'. Default: `inside` */
segmentLabelPosition?: DonutSegmentLabelPosition;

/** Radial offset in pixels for outside labels (from outer radius). Default : `12` */
segmentLabelOffset?: number;
}

export const DonutDefaultConfig: DonutConfigInterface<unknown> = {
Expand All @@ -65,9 +79,13 @@ export const DonutDefaultConfig: DonutConfigInterface<unknown> = {
centralSubLabel: undefined,
centralSubLabelWrap: true,
showEmptySegments: false,
showSegmentLabels: true,
segmentLabel: undefined,
emptySegmentAngle: 0.5 * Math.PI / 180,
showBackground: true,
backgroundAngleRange: undefined,
centralLabelOffsetX: undefined,
centralLabelOffsetY: undefined,
segmentLabelPosition: DonutSegmentLabelPosition.Inside,
segmentLabelOffset: 10,
}
57 changes: 55 additions & 2 deletions packages/ts/src/components/donut/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { wrapSVGText } from 'utils/text'
import { Spacing } from 'types/spacing'

// Local Types
import { DonutArcDatum, DonutArcAnimState, DonutDatum } from './types'
import { DonutArcDatum, DonutArcAnimState, DonutDatum, DonutSegmentLabelPosition } from './types'

// Config
import { DonutDefaultConfig, DonutConfigInterface } from './config'
Expand Down Expand Up @@ -93,7 +93,23 @@ export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Da
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
// Base radius before outside label adjustment
const baseRadius = config.radius ||
Math.min(
width - bleed.left - bleed.right,
height - bleed.top - bleed.bottom
) / 2

// If labels are outside, shrink donut so labels fit inside original component box
let outerRadius = baseRadius
if (config.showSegmentLabels && config.segmentLabelPosition === DonutSegmentLabelPosition.Outside) {
const labelOffset = config.segmentLabelOffset ?? 12
const outsideBuffer =
// Optional extra buffer config; fallback to 8
(config as any).segmentLabelOutsideBuffer ?? 8
outerRadius = Math.max(1, baseRadius - labelOffset - outsideBuffer)
}

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)
Expand Down Expand Up @@ -150,6 +166,43 @@ export class Donut<Datum> extends ComponentCore<Datum[], DonutConfigInterface<Da
.attr('class', s.segmentExit)
.call(removeArc, duration)

if (config.showSegmentLabels) {
const labels = this.arcGroup
.selectAll<SVGTextElement, DonutArcDatum<Datum>>(`.${s.segmentLabel}`)
.data(arcData, d => config.id(d.data, d.index))

const labelsEnter = labels.enter().append('text')
.attr('class', s.segmentLabel)
.attr('dy', '0.35em')
.attr('text-anchor', 'middle')

const labelsGroup = labels.merge(labelsEnter)
labelsGroup
.attr('transform', d => {
const midAngle = (d.startAngle + d.endAngle) / 2
if (config.segmentLabelPosition === DonutSegmentLabelPosition.Outside) {
const offset = config.segmentLabelOffset ?? 12
const r = outerRadius + offset
const x = Math.sin(midAngle) * r
const y = -Math.cos(midAngle) * r
return `translate(${x},${y})`
}
const [cx, cy] = this.arcGen.centroid(d)
return `translate(${cx},${cy})`
})
.text(d => typeof config.segmentLabel === 'function'
? config.segmentLabel(d.data, d.index)
: `${d.data || ''}`)
.style('display', d => {
// Hide if arc too small for inside labels; outside labels always shown
if (config.segmentLabelPosition === DonutSegmentLabelPosition.Outside) return null
return (d.endAngle - d.startAngle > 0.2 ? null : 'none')
})
labels.exit().remove()
} else {
this.arcGroup.selectAll(`.${s.segmentLabel}`).remove()
}

// Label
const labelTextAnchor = isHalfDonutRight ? 'start' : isHalfDonutLeft ? 'end' : 'middle'
this.centralLabel
Expand Down
13 changes: 13 additions & 0 deletions packages/ts/src/components/donut/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const root = css`
export const variables = injectGlobal`
:root {
--vis-donut-central-label-font-size: 16px;
--vis-donut-label-font-size: 12px;
--vis-donut-central-label-text-color: #5b5f6d;
// Undefined by default to allow proper fallback to var(--vis-font-family)
/* --vis-donut-central-label-font-family: */
Expand Down Expand Up @@ -69,3 +70,15 @@ export const centralSubLabel = css`
font-weight: var(--vis-donut-central-sub-label-font-weight);
fill: var(--vis-donut-central-sub-label-text-color);
`

export const segmentLabel = css`
label: segment-label;
text-anchor: middle;
dominant-baseline: middle;
user-select: none;
font-size: var(--vis-donut-label-font-size);
`

export const annotationLane = css`
label: annotation-lane;
`
5 changes: 5 additions & 0 deletions packages/ts/src/components/donut/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ export interface DonutArcDatum<Datum> extends PieArcDatum<Datum> {
}

export type DonutArcAnimState = { startAngle: number; endAngle: number; innerRadius: number; outerRadius: number; padAngle?: number }

export enum DonutSegmentLabelPosition {
Inside = 'inside',
Outside = 'outside',
}
Loading