From 07739b569b41b698f718c8c76f8a8b6696f9f899 Mon Sep 17 00:00:00 2001 From: Surya Teja Hanumandla Date: Fri, 26 Sep 2025 20:07:51 +0530 Subject: [PATCH] Component | Donut: Intro labels inside and outside of donut slices --- .../misc/donut/donut-full-height/index.tsx | 66 +++++++++++--- .../donut/half-donut-full-height/index.tsx | 88 ++++++++++++++----- .../donut/half-donut-with-labels/index.tsx | 5 ++ packages/ts/src/components/donut/config.ts | 20 ++++- packages/ts/src/components/donut/index.ts | 57 +++++++++++- packages/ts/src/components/donut/style.ts | 13 +++ packages/ts/src/components/donut/types.ts | 5 ++ 7 files changed, 217 insertions(+), 37 deletions(-) diff --git a/packages/dev/src/examples/misc/donut/donut-full-height/index.tsx b/packages/dev/src/examples/misc/donut/donut-full-height/index.tsx index 0b58d89bc..b9141fa03 100644 --- a/packages/dev/src/examples/misc/donut/donut-full-height/index.tsx +++ b/packages/dev/src/examples/misc/donut/donut-full-height/index.tsx @@ -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.Outside) + const [segmentLabelOffset, setSegmentLabelOffset] = useState(25) + const isInside = segmentLabelPosition === DonutSegmentLabelPosition.Inside + return ( - - d} - data={data} - padAngle={0.02} - duration={props.duration} - arcWidth={80} - /> - +
+
+ + + +
+ + d} + data={data} + padAngle={0.02} + duration={props.duration} + arcWidth={80} + showSegmentLabels={showSegmentLabels} + segmentLabelOffset={segmentLabelOffset} + segmentLabel={d => `${d}`} + segmentLabelPosition={segmentLabelPosition} + /> + +
) } - diff --git a/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx b/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx index b9b33325e..2792780b6 100644 --- a/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx +++ b/packages/dev/src/examples/misc/donut/half-donut-full-height/index.tsx @@ -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>(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.Outside) + const [segmentLabelOffset, setSegmentLabelOffset] = useState(10) + + const isInside = segmentLabelPosition === DonutSegmentLabelPosition.Inside + return ( - - d} - data={data} - padAngle={0.02} - duration={0} - arcWidth={80} - angleRange={currentAngleRange} - centralLabel="Central Label" - centralSubLabel="Sub-label" - /> - + <> +
+ + + +
+ + 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} + /> + + ) } diff --git a/packages/dev/src/examples/misc/donut/half-donut-with-labels/index.tsx b/packages/dev/src/examples/misc/donut/half-donut-with-labels/index.tsx index 4bc559d55..8f334eac8 100644 --- a/packages/dev/src/examples/misc/donut/half-donut-with-labels/index.tsx +++ b/packages/dev/src/examples/misc/donut/half-donut-with-labels/index.tsx @@ -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' @@ -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} /> ) diff --git a/packages/ts/src/components/donut/config.ts b/packages/ts/src/components/donut/config.ts index 56d55863d..5cfe11f07 100644 --- a/packages/ts/src/components/donut/config.ts +++ b/packages/ts/src/components/donut/config.ts @@ -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 extends ComponentConfigInterface { /** Accessor function for getting the unique data record id. Used for more persistent data updates. Default: `(d, i) => d.id ?? i` */ @@ -48,6 +50,18 @@ export interface DonutConfigInterface 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; + + /** 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 = { @@ -65,9 +79,13 @@ export const DonutDefaultConfig: DonutConfigInterface = { 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, } diff --git a/packages/ts/src/components/donut/index.ts b/packages/ts/src/components/donut/index.ts index 599a00940..361c130a7 100644 --- a/packages/ts/src/components/donut/index.ts +++ b/packages/ts/src/components/donut/index.ts @@ -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' @@ -93,7 +93,23 @@ export class Donut extends ComponentCore extends ComponentCore>(`.${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 diff --git a/packages/ts/src/components/donut/style.ts b/packages/ts/src/components/donut/style.ts index 332df1786..e98833e2d 100644 --- a/packages/ts/src/components/donut/style.ts +++ b/packages/ts/src/components/donut/style.ts @@ -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: */ @@ -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; +` diff --git a/packages/ts/src/components/donut/types.ts b/packages/ts/src/components/donut/types.ts index 2132c1673..e2e3002de 100644 --- a/packages/ts/src/components/donut/types.ts +++ b/packages/ts/src/components/donut/types.ts @@ -15,3 +15,8 @@ export interface DonutArcDatum extends PieArcDatum { } export type DonutArcAnimState = { startAngle: number; endAngle: number; innerRadius: number; outerRadius: number; padAngle?: number } + +export enum DonutSegmentLabelPosition { + Inside = 'inside', + Outside = 'outside', +}