Skip to content

Commit 09dd5d5

Browse files
committed
feat: [WD-13450] Upstream Doughnut Chart to RC
Signed-off-by: Nkeiruka <[email protected]>
1 parent 85c995d commit 09dd5d5

File tree

6 files changed

+382
-0
lines changed

6 files changed

+382
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
@import "vanilla-framework";
2+
3+
.doughnut-chart {
4+
width: 6.5rem;
5+
6+
.doughnut-chart__tooltip {
7+
display: block;
8+
}
9+
10+
.doughnut-chart__tooltip > :only-child {
11+
// Override the tooltip wrapper.
12+
display: block !important;
13+
}
14+
15+
.doughnut-chart__chart {
16+
// Restrict hover areas to the strokes.
17+
pointer-events: stroke;
18+
}
19+
20+
.doughnut-chart__segment {
21+
fill: transparent;
22+
23+
// Animate stroke size changes on hover.
24+
transition: stroke-width 0.3s ease;
25+
}
26+
}
27+
28+
.doughnut-chart__legend {
29+
list-style-type: none;
30+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Meta, StoryObj } from "@storybook/react";
2+
3+
import DoughnutChart from "./DoughnutChart";
4+
5+
const meta: Meta<typeof DoughnutChart> = {
6+
component: DoughnutChart,
7+
tags: ["autodocs"],
8+
};
9+
10+
export default meta;
11+
12+
type Story = StoryObj<typeof DoughnutChart>;
13+
14+
/**
15+
* The Doughnut Chart component visually represents data segments in a circular format, with tooltips that appear on hover, and segments that can be customized via props.
16+
*/
17+
export const Default: Story = {
18+
name: "Default",
19+
args: {
20+
chartID: "default",
21+
segmentHoverWidth: 45,
22+
segmentThickness: 40,
23+
segments: [
24+
{
25+
color: "#0E8420",
26+
tooltip: "Running",
27+
value: 10,
28+
},
29+
{
30+
color: "#CC7900",
31+
tooltip: "Stopped",
32+
value: 15,
33+
},
34+
{ color: "#C7162B", tooltip: "Frozen", value: 5 },
35+
{ color: "#000", tooltip: "Error", value: 5 },
36+
],
37+
size: 150,
38+
},
39+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import React from "react";
3+
import DoughnutChart, { TestIds } from "./DoughnutChart";
4+
5+
describe("DoughnutChart", () => {
6+
const defaultProps = {
7+
chartID: "test",
8+
segmentHoverWidth: 10,
9+
segmentThickness: 8,
10+
size: 100,
11+
segments: [
12+
{
13+
color: "#3498DB",
14+
tooltip: "aaa",
15+
value: 12,
16+
},
17+
{
18+
color: "#E74C3C",
19+
tooltip: "bbb",
20+
value: 8,
21+
},
22+
{
23+
color: "#F1C40F",
24+
tooltip: "ccc",
25+
value: 18,
26+
},
27+
{
28+
color: "#2ECC71",
29+
tooltip: "ddd",
30+
value: 14,
31+
},
32+
],
33+
};
34+
35+
it("renders", () => {
36+
render(<DoughnutChart {...defaultProps} />);
37+
expect(screen.getByTestId("chart")).toBeInTheDocument();
38+
});
39+
40+
it("displays the correct number of segments", () => {
41+
render(<DoughnutChart {...defaultProps} />);
42+
const segments = screen.getAllByTestId(TestIds.Segment);
43+
expect(segments).toHaveLength(defaultProps.segments.length);
44+
});
45+
46+
it("shows tooltips on hover", async () => {
47+
const { container } = render(<DoughnutChart {...defaultProps} />);
48+
const segments = screen.getAllByTestId(TestIds.Segment);
49+
50+
fireEvent.mouseOver(segments[0]);
51+
fireEvent.click(container.firstChild.firstChild);
52+
53+
await waitFor(() => {
54+
expect(screen.getByText("aaa")).toBeInTheDocument();
55+
});
56+
});
57+
58+
it("applies custom styles to segments", () => {
59+
render(<DoughnutChart {...defaultProps} />);
60+
const segment = screen.getAllByTestId(TestIds.Segment)[0];
61+
expect(segment).toHaveStyle(`stroke: ${defaultProps.segments[0].color}`);
62+
expect(segment).toHaveStyle(
63+
`stroke-width: ${defaultProps.segmentThickness}`,
64+
);
65+
});
66+
67+
it("displays the label in the center if provided", () => {
68+
render(<DoughnutChart {...defaultProps} label="Test Label" />);
69+
expect(screen.getByTestId(TestIds.Label)).toHaveTextContent("Test Label");
70+
});
71+
72+
it("does not display the label if not provided", () => {
73+
render(<DoughnutChart {...defaultProps} />);
74+
expect(screen.queryByText("Test Label")).not.toBeInTheDocument();
75+
});
76+
});
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import React, { FC, useRef, useState } from "react";
2+
import classNames from "classnames";
3+
import Tooltip from "components/Tooltip";
4+
import "./DoughnutChart.scss";
5+
6+
export type Segment = {
7+
/**
8+
* The colour of the segment.
9+
*/
10+
color: string;
11+
/**
12+
* The segment tooltip.
13+
*/
14+
tooltip?: string;
15+
/**
16+
* The segment length.
17+
*/
18+
value: number;
19+
};
20+
21+
export type Props = {
22+
/**
23+
* The label in the centre of the doughnut.
24+
*/
25+
label?: string;
26+
/**
27+
* An optional class name applied to the label.
28+
*/
29+
labelClassname?: string;
30+
/**
31+
* An optional class name applied to the wrapping element.
32+
*/
33+
className?: string;
34+
/**
35+
* The width of the segments when hovered.
36+
*/
37+
segmentHoverWidth: number;
38+
/**
39+
* The width of the segments.
40+
*/
41+
segmentThickness: number;
42+
/**
43+
* The doughnut segments.
44+
*/
45+
segments: Segment[];
46+
/**
47+
* The size of the doughnut.
48+
*/
49+
size: number;
50+
/**
51+
* ID associated to the specific instance of a Chart.
52+
*/
53+
chartID: string;
54+
};
55+
56+
export enum TestIds {
57+
Label = "label",
58+
Segment = "segment",
59+
Chart = "chart",
60+
Section = "Section",
61+
}
62+
63+
const DoughnutChart: FC<Props> = ({
64+
className,
65+
label,
66+
labelClassname,
67+
segmentHoverWidth,
68+
segmentThickness,
69+
segments,
70+
size,
71+
chartID,
72+
}): JSX.Element => {
73+
const [tooltipMessage, setTooltipMessage] = useState<
74+
Segment["tooltip"] | null
75+
>(null);
76+
77+
const id = useRef(`doughnut-chart-${chartID}`);
78+
const hoverIncrease = segmentHoverWidth - segmentThickness;
79+
const adjustedHoverWidth = segmentHoverWidth + hoverIncrease;
80+
// The canvas needs enough space so that the hover state does not get cut off.
81+
const canvasSize = size + adjustedHoverWidth - segmentThickness;
82+
const diameter = size - segmentThickness;
83+
const radius = diameter / 2;
84+
const circumference = Math.round(diameter * Math.PI);
85+
// Calculate the total value of all segments.
86+
const total = segments.reduce(
87+
(totalValue, segment) => (totalValue += segment.value),
88+
0,
89+
);
90+
let accumulatedLength = 0;
91+
const segmentNodes = segments.map(({ color, tooltip, value }, i) => {
92+
// The start position is the value of all previous segments.
93+
const startPosition = accumulatedLength;
94+
// The length of the segment (as a portion of the doughnut circumference)
95+
const segmentLength = (value / total) * circumference;
96+
// The space left until the end of the circle.
97+
const remainingSpace = circumference - (segmentLength + startPosition);
98+
// Add this segment length to the running tally.
99+
accumulatedLength += segmentLength;
100+
101+
return (
102+
<circle
103+
className="doughnut-chart__segment"
104+
cx={radius - segmentThickness / 2 - hoverIncrease}
105+
cy={radius + segmentThickness / 2 + hoverIncrease}
106+
data-testid={TestIds.Segment}
107+
key={i}
108+
tabIndex={0}
109+
aria-label={tooltip ? `${tooltip}: ${value}` : `${value}`}
110+
onMouseOut={
111+
tooltip
112+
? () => {
113+
// Hide the tooltip.
114+
setTooltipMessage(null);
115+
}
116+
: undefined
117+
}
118+
onMouseOver={
119+
tooltip
120+
? () => {
121+
setTooltipMessage(tooltip);
122+
}
123+
: undefined
124+
}
125+
r={radius}
126+
style={{
127+
stroke: color,
128+
strokeWidth: segmentThickness,
129+
// The dash array used is:
130+
// 1 - We want there to be a space before the first visible dash so
131+
// by setting this to 0 we can use the next dash for the space.
132+
// 2 - This gap is the distance of all previous segments
133+
// so that the segment starts in the correct spot.
134+
// 3 - A dash that is the length of the segment.
135+
// 4 - A gap from the end of the segment to the start of the circle
136+
// so that the dash array doesn't repeat and be visible.
137+
strokeDasharray: `0 ${startPosition.toFixed(
138+
2,
139+
)} ${segmentLength.toFixed(2)} ${remainingSpace.toFixed(2)}`,
140+
}}
141+
// Rotate the segment so that the segments start at the top of
142+
// the chart.
143+
transform={`rotate(-90 ${radius},${radius})`}
144+
/>
145+
);
146+
});
147+
148+
return (
149+
<div
150+
className={classNames("doughnut-chart", className)}
151+
style={{ maxWidth: `${canvasSize}px` }}
152+
data-testid={TestIds.Chart}
153+
>
154+
<Tooltip
155+
className="doughnut-chart__tooltip"
156+
followMouse={true}
157+
message={tooltipMessage}
158+
position="right"
159+
>
160+
<style>
161+
{/* Set the hover width of the segments. */}
162+
{`#${id.current} .doughnut-chart__segment:hover {
163+
stroke-width: ${adjustedHoverWidth} !important;
164+
}`}
165+
</style>
166+
<svg
167+
className="doughnut-chart__chart"
168+
id={id.current}
169+
viewBox={`0 0 ${canvasSize} ${canvasSize}`}
170+
data-testid={TestIds.Section}
171+
aria-labelledby={`${id.current}-chart-title ${id.current}-chart-desc`}
172+
>
173+
{label && <title id={`${id.current}-chart-title`}>{label}</title>}
174+
<desc id={`${id.current}-chart-desc`}>
175+
{segments
176+
.map((segment) => {
177+
let description = "";
178+
if (segment.tooltip) description += `${segment.tooltip}: `;
179+
description += segment.value;
180+
return description;
181+
})
182+
.join(",")}
183+
</desc>
184+
185+
<mask id="canvasMask">
186+
{/* Cover the canvas, this will be the visible area. */}
187+
<rect
188+
fill="white"
189+
height={canvasSize}
190+
width={canvasSize}
191+
x="0"
192+
y="0"
193+
/>
194+
{/* Cut out the center circle so that the hover state doesn't grow inwards. */}
195+
<circle
196+
cx={canvasSize / 2}
197+
cy={canvasSize / 2}
198+
fill="black"
199+
r={radius - segmentThickness / 2}
200+
/>
201+
</mask>
202+
<g mask="url(#canvasMask)">
203+
{/* Force the group to cover the full size of the canvas, otherwise it will only mask the children (in their non-hovered state) */}
204+
<rect
205+
fill="transparent"
206+
height={canvasSize}
207+
width={canvasSize}
208+
x="0"
209+
y="0"
210+
/>
211+
<g>{segmentNodes}</g>
212+
</g>
213+
{label ? (
214+
<text
215+
x={radius + adjustedHoverWidth / 2}
216+
y={radius + adjustedHoverWidth / 2}
217+
>
218+
<tspan
219+
className={classNames("doughnut-chart__label", labelClassname)}
220+
data-testid={TestIds.Label}
221+
>
222+
{label}
223+
</tspan>
224+
</text>
225+
) : null}
226+
</svg>
227+
</Tooltip>
228+
</div>
229+
);
230+
};
231+
232+
export default DoughnutChart;

src/components/DoughnutChart/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default } from "./DoughnutChart";
2+
export type { Props as DoughnutChartProps } from "./DoughnutChart";
3+
export type { Segment } from "./DoughnutChart";

0 commit comments

Comments
 (0)