Skip to content

Commit 6eab4a8

Browse files
author
Roman Snapko
authored
Add donut chart (#2147)
<!-- Ensure the title clearly reflects what was changed. Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes. --> Part of OPS-3910
1 parent 500c185 commit 6eab4a8

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)