Skip to content

Commit e772b8b

Browse files
author
Roman Snapko
committed
Add DonutChart component with radial labels and empty state handling
1 parent da06fb8 commit e772b8b

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { DonutChart } from '@/ui/chart';
4+
import { ThemeAwareDecorator } from '../../../.storybook/decorators';
5+
6+
/**
7+
* A donut chart component built on top of Recharts primitives.
8+
* Supports configurable inner/outer radius, tooltip, and legend.
9+
*/
10+
const meta: Meta<typeof DonutChart> = {
11+
title: 'ui/Charts/DonutChart',
12+
component: DonutChart,
13+
tags: ['autodocs'],
14+
parameters: {
15+
layout: 'centered',
16+
},
17+
args: {
18+
className: 'h-[250px] min-w-[300px]',
19+
dataKey: 'value',
20+
nameKey: 'name',
21+
innerRadius: 60,
22+
outerRadius: 90,
23+
showTooltip: true,
24+
showLegend: true,
25+
},
26+
decorators: [ThemeAwareDecorator],
27+
} satisfies Meta<typeof DonutChart>;
28+
29+
export default meta;
30+
31+
type Story = StoryObj<typeof meta>;
32+
33+
const categoryData = [
34+
{ name: 'compute', value: 400 },
35+
{ name: 'storage', value: 300 },
36+
{ name: 'network', value: 200 },
37+
{ name: 'database', value: 100 },
38+
];
39+
40+
const categoryConfig = {
41+
compute: {
42+
label: 'Compute',
43+
theme: { light: '#3b82f6', dark: '#60a5fa' },
44+
},
45+
storage: {
46+
label: 'Storage',
47+
theme: { light: '#10b981', dark: '#34d399' },
48+
},
49+
network: {
50+
label: 'Network',
51+
theme: { light: '#f59e0b', dark: '#fbbf24' },
52+
},
53+
database: {
54+
label: 'Database',
55+
theme: { light: '#ef4444', dark: '#f87171' },
56+
},
57+
};
58+
59+
/**
60+
* A simple donut chart with a single data series.
61+
*/
62+
export const Default: Story = {
63+
args: {
64+
data: categoryData,
65+
config: categoryConfig,
66+
},
67+
};
68+
69+
/**
70+
* Donut chart with a larger inner radius, creating a thinner ring.
71+
*/
72+
export const ThinRing: Story = {
73+
args: {
74+
data: categoryData,
75+
config: categoryConfig,
76+
innerRadius: 75,
77+
outerRadius: 90,
78+
},
79+
};
80+
81+
/**
82+
* Donut chart with a smaller inner radius, creating a thicker ring.
83+
*/
84+
export const ThickRing: Story = {
85+
args: {
86+
data: categoryData,
87+
config: categoryConfig,
88+
innerRadius: 40,
89+
outerRadius: 90,
90+
},
91+
};
92+
93+
/**
94+
* Donut chart with all category values set to zero, showing an empty state.
95+
*/
96+
export const EmptyState: Story = {
97+
args: {
98+
data: [
99+
{ name: 'compute', value: 0 },
100+
{ name: 'storage', value: 0 },
101+
{ name: 'network', value: 0 },
102+
{ name: 'database', value: 0 },
103+
],
104+
config: categoryConfig,
105+
},
106+
};
107+
108+
/**
109+
* Donut chart with mixed values — legend labels are hidden for zero-value slices.
110+
*/
111+
export const PartialEmpty: Story = {
112+
args: {
113+
data: [
114+
{ name: 'compute', value: 400 },
115+
{ name: 'storage', value: 0 },
116+
{ name: 'network', value: 200 },
117+
{ name: 'database', value: 0 },
118+
],
119+
config: categoryConfig,
120+
className: 'h-[300px] min-w-[400px]',
121+
},
122+
};
123+
124+
/**
125+
* Donut chart combining all features: radial legend, custom radii.
126+
*/
127+
export const AllFeatures: Story = {
128+
args: {
129+
data: categoryData,
130+
config: categoryConfig,
131+
showLegend: true,
132+
innerRadius: 60,
133+
outerRadius: 90,
134+
className: 'h-[300px] min-w-[400px]',
135+
},
136+
};
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as React from 'react';
2+
import { Pie, PieChart as RechartsPieChart } from 'recharts';
3+
import {
4+
ChartConfig,
5+
ChartContainer,
6+
ChartTooltip,
7+
ChartTooltipContent,
8+
} from './chart';
9+
10+
const RADIAN = Math.PI / 180;
11+
12+
type RadialLabelProps = {
13+
cx: number;
14+
cy: number;
15+
midAngle: number;
16+
outerRadius: number;
17+
name: string;
18+
fill: string;
19+
config: ChartConfig;
20+
value: number;
21+
};
22+
23+
function RadialLabel({
24+
cx,
25+
cy,
26+
midAngle,
27+
outerRadius,
28+
name,
29+
fill,
30+
config,
31+
value,
32+
}: RadialLabelProps): React.JSX.Element | null {
33+
if (!value) {
34+
return null;
35+
}
36+
const GAP = 24;
37+
const DOT_RADIUS = 4;
38+
const x = cx + (outerRadius + GAP) * Math.cos(-midAngle * RADIAN);
39+
const y = cy + (outerRadius + GAP) * Math.sin(-midAngle * RADIAN);
40+
41+
const label = config[name]?.label ?? name;
42+
const isRight = x >= cx;
43+
44+
return (
45+
<g>
46+
<circle cx={x} cy={y} r={DOT_RADIUS} fill={fill} />
47+
<text
48+
x={x + (isRight ? DOT_RADIUS + 4 : -(DOT_RADIUS + 4))}
49+
y={y}
50+
textAnchor={isRight ? 'start' : 'end'}
51+
dominantBaseline="central"
52+
className="fill-foreground text-xs"
53+
style={{ fontSize: 12 }}
54+
>
55+
{label as string}
56+
</text>
57+
</g>
58+
);
59+
}
60+
61+
export type DonutChartProps = {
62+
data: { name: string; value: number }[];
63+
config: ChartConfig;
64+
dataKey?: string;
65+
nameKey?: string;
66+
innerRadius?: number;
67+
outerRadius?: number;
68+
showTooltip?: boolean;
69+
showLegend?: boolean;
70+
className?: string;
71+
};
72+
73+
export function DonutChart({
74+
data,
75+
config,
76+
dataKey = 'value',
77+
nameKey = 'name',
78+
innerRadius = 60,
79+
outerRadius = 90,
80+
showTooltip = true,
81+
showLegend = true,
82+
className,
83+
}: DonutChartProps): React.JSX.Element {
84+
const isEmpty = data.every((entry) => !entry.value);
85+
const mappedData = isEmpty
86+
? [{ name: '__empty__', value: 1, fill: 'hsl(var(--muted))' }]
87+
: data.map((entry) => ({
88+
...entry,
89+
fill: `var(--color-${entry.name})`,
90+
}));
91+
92+
return (
93+
<ChartContainer config={config} className={className}>
94+
<RechartsPieChart accessibilityLayer>
95+
{showTooltip && !isEmpty && (
96+
<ChartTooltip
97+
cursor={false}
98+
content={<ChartTooltipContent hideLabel />}
99+
/>
100+
)}
101+
<Pie
102+
data={mappedData}
103+
dataKey={dataKey}
104+
nameKey={nameKey}
105+
innerRadius={innerRadius}
106+
outerRadius={outerRadius}
107+
label={
108+
showLegend && !isEmpty
109+
? (props) => (
110+
<RadialLabel
111+
key={props.name}
112+
cx={props.cx}
113+
cy={props.cy}
114+
midAngle={props.midAngle}
115+
outerRadius={props.outerRadius}
116+
name={props.name}
117+
fill={props.fill}
118+
config={config}
119+
value={props.value}
120+
/>
121+
)
122+
: false
123+
}
124+
labelLine={false}
125+
/>
126+
</RechartsPieChart>
127+
</ChartContainer>
128+
);
129+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './bar-chart';
22
export * from './chart';
3+
export * from './donut-chart';

0 commit comments

Comments
 (0)