Skip to content

Commit 49f0a88

Browse files
committed
feat: [WD-13450] Upstream Doughnut Chart to RC
Signed-off-by: Nkeiruka <[email protected]>
1 parent b1d81e3 commit 49f0a88

File tree

6 files changed

+373
-0
lines changed

6 files changed

+373
-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+
segmentWidth: 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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { act, render, screen, waitFor } from "@testing-library/react";
2+
import React from "react";
3+
import DoughnutChart, { TestIds } from "./DoughnutChart";
4+
import userEvent, { UserEvent } from "@testing-library/user-event";
5+
6+
describe("DoughnutChart", () => {
7+
let userEventWithTimers: UserEvent;
8+
const defaultProps = {
9+
chartID: "test",
10+
segmentHoverWidth: 10,
11+
segmentWidth: 8,
12+
size: 100,
13+
segments: [
14+
{
15+
color: "#3498DB",
16+
tooltip: "aaa",
17+
value: 12,
18+
},
19+
{
20+
color: "#E74C3C",
21+
tooltip: "bbb",
22+
value: 8,
23+
},
24+
{
25+
color: "#F1C40F",
26+
tooltip: "ccc",
27+
value: 18,
28+
},
29+
{
30+
color: "#2ECC71",
31+
tooltip: "ddd",
32+
value: 14,
33+
},
34+
],
35+
};
36+
37+
beforeEach(() => {
38+
jest.useFakeTimers();
39+
40+
userEventWithTimers = userEvent.setup({
41+
advanceTimers: jest.advanceTimersByTime,
42+
});
43+
});
44+
45+
afterEach(() => {
46+
jest.resetAllMocks();
47+
});
48+
49+
it("renders", () => {
50+
render(<DoughnutChart {...defaultProps} />);
51+
expect(screen.getByTestId("chart")).toBeInTheDocument();
52+
});
53+
54+
it("displays the correct number of segments", () => {
55+
render(<DoughnutChart {...defaultProps} />);
56+
const segments = screen.getAllByTestId(TestIds.Segment);
57+
expect(segments).toHaveLength(defaultProps.segments.length);
58+
});
59+
60+
it("shows tooltips on hover", async () => {
61+
render(<DoughnutChart {...defaultProps} />);
62+
await act(async () => {
63+
await userEventWithTimers.hover(screen.getAllByTestId(TestIds.Label)[0]);
64+
jest.runAllTimers();
65+
});
66+
expect(screen.getByTestId(TestIds.Tooltip)).toBeInTheDocument();
67+
await userEvent.unhover(screen.getAllByTestId(TestIds.Segment)[0]);
68+
await waitFor(() => {
69+
expect(screen.getByTestId(TestIds.Tooltip)).not.toBeInTheDocument();
70+
});
71+
});
72+
73+
it("applies custom styles to segments", () => {
74+
render(<DoughnutChart {...defaultProps} />);
75+
const segment = screen.getAllByTestId(TestIds.Segment)[0];
76+
expect(segment).toHaveStyle(`stroke: ${defaultProps.segments[0].color}`);
77+
expect(segment).toHaveStyle(`stroke-width: ${defaultProps.segmentWidth}`);
78+
});
79+
80+
it("displays the label in the center if provided", () => {
81+
render(<DoughnutChart {...defaultProps} label="Test Label" />);
82+
expect(screen.getByText("Test Label")).toBeInTheDocument();
83+
});
84+
85+
it("does not display the label if not provided", () => {
86+
render(<DoughnutChart {...defaultProps} />);
87+
expect(screen.queryByText("Test Label")).not.toBeInTheDocument();
88+
});
89+
});
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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 wrapping element.
28+
*/
29+
className?: string;
30+
/**
31+
* The width of the segments when hovered.
32+
*/
33+
segmentHoverWidth: number;
34+
/**
35+
* The width of the segments.
36+
*/
37+
segmentWidth: number;
38+
/**
39+
* The doughnut segments.
40+
*/
41+
segments: Segment[];
42+
/**
43+
* The size of the doughnut.
44+
*/
45+
size: number;
46+
/**
47+
* ID associated to the specific instance of a Chart.
48+
*/
49+
chartID: string;
50+
};
51+
52+
export enum TestIds {
53+
Label = "label",
54+
Segment = "segment",
55+
Chart = "chart",
56+
}
57+
58+
const DoughnutChart: FC<Props> = ({
59+
className,
60+
label,
61+
segmentHoverWidth,
62+
segmentWidth,
63+
segments,
64+
size,
65+
chartID,
66+
}): JSX.Element => {
67+
const [tooltipMessage, setTooltipMessage] = useState<
68+
Segment["tooltip"] | null
69+
>(null);
70+
71+
const id = useRef(`doughnut-chart-${chartID}`);
72+
const hoverIncrease = segmentHoverWidth - segmentWidth;
73+
const adjustedHoverWidth = segmentHoverWidth + hoverIncrease;
74+
// The canvas needs enough space so that the hover state does not get cut off.
75+
const canvasSize = size + adjustedHoverWidth - segmentWidth;
76+
const diameter = size - segmentWidth;
77+
const radius = diameter / 2;
78+
const circumference = Math.round(diameter * Math.PI);
79+
// Calculate the total value of all segments.
80+
const total = segments.reduce(
81+
(totalValue, segment) => (totalValue += segment.value),
82+
0,
83+
);
84+
let accumulatedLength = 0;
85+
const segmentNodes = segments.map(({ color, tooltip, value }, i) => {
86+
// The start position is the value of all previous segments.
87+
const startPosition = accumulatedLength;
88+
// The length of the segment (as a portion of the doughnut circumference)
89+
const segmentLength = (value / total) * circumference;
90+
// The space left until the end of the circle.
91+
const remainingSpace = circumference - (segmentLength + startPosition);
92+
// Add this segment length to the running tally.
93+
accumulatedLength += segmentLength;
94+
95+
return (
96+
<circle
97+
className="doughnut-chart__segment"
98+
cx={radius - segmentWidth / 2 - hoverIncrease}
99+
cy={radius + segmentWidth / 2 + hoverIncrease}
100+
data-testid={TestIds.Segment}
101+
key={i}
102+
onMouseOut={
103+
tooltip
104+
? () => {
105+
// Hide the tooltip.
106+
setTooltipMessage(null);
107+
}
108+
: undefined
109+
}
110+
onMouseOver={
111+
tooltip
112+
? () => {
113+
setTooltipMessage(tooltip);
114+
}
115+
: undefined
116+
}
117+
r={radius}
118+
style={{
119+
stroke: color,
120+
strokeWidth: segmentWidth,
121+
// The dash array used is:
122+
// 1 - We want there to be a space before the first visible dash so
123+
// by setting this to 0 we can use the next dash for the space.
124+
// 2 - This gap is the distance of all previous segments
125+
// so that the segment starts in the correct spot.
126+
// 3 - A dash that is the length of the segment.
127+
// 4 - A gap from the end of the segment to the start of the circle
128+
// so that the dash array doesn't repeat and be visible.
129+
strokeDasharray: `0 ${startPosition.toFixed(
130+
2,
131+
)} ${segmentLength.toFixed(2)} ${remainingSpace.toFixed(2)}`,
132+
}}
133+
// Rotate the segment so that the segments start at the top of
134+
// the chart.
135+
transform={`rotate(-90 ${radius},${radius})`}
136+
/>
137+
);
138+
});
139+
140+
return (
141+
<div
142+
className={classNames("doughnut-chart", className)}
143+
style={{ maxWidth: `${canvasSize}px` }}
144+
data-testid={TestIds.Chart}
145+
>
146+
<Tooltip
147+
className="doughnut-chart__tooltip"
148+
followMouse={true}
149+
message={tooltipMessage}
150+
position="right"
151+
>
152+
<style>
153+
{/* Set the hover width of the segments. */}
154+
{`#${id.current} .doughnut-chart__segment:hover {
155+
stroke-width: ${adjustedHoverWidth} !important;
156+
}`}
157+
</style>
158+
<svg
159+
className="doughnut-chart__chart"
160+
id={id.current}
161+
viewBox={`0 0 ${canvasSize} ${canvasSize}`}
162+
>
163+
<mask id="myMask">
164+
{/* Cover the canvas, this will be the visible area. */}
165+
<rect
166+
fill="white"
167+
height={canvasSize}
168+
width={canvasSize}
169+
x="0"
170+
y="0"
171+
/>
172+
{/* Cut out the center circle so that the hover state doesn't grow inwards. */}
173+
<circle
174+
cx={canvasSize / 2}
175+
cy={canvasSize / 2}
176+
fill="black"
177+
r={radius - segmentWidth / 2}
178+
/>
179+
</mask>
180+
<g mask="url(#myMask)">
181+
{/* Force the group to cover the full size of the canvas, otherwise it will only mask the children (in their non-hovered state) */}
182+
<rect
183+
fill="transparent"
184+
height={canvasSize}
185+
width={canvasSize}
186+
x="0"
187+
y="0"
188+
/>
189+
<g>{segmentNodes}</g>
190+
</g>
191+
{label ? (
192+
<text
193+
x={radius + adjustedHoverWidth / 2}
194+
y={radius + adjustedHoverWidth / 2}
195+
>
196+
<tspan
197+
className="doughnut-chart__label"
198+
data-testid={TestIds.Label}
199+
>
200+
{label}
201+
</tspan>
202+
</text>
203+
) : null}
204+
</svg>
205+
</Tooltip>
206+
</div>
207+
);
208+
};
209+
210+
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";

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { default as Col } from "./components/Col";
2222
export { default as ConfirmationButton } from "./components/ConfirmationButton";
2323
export { default as ConfirmationModal } from "./components/ConfirmationModal";
2424
export { default as ContextualMenu } from "./components/ContextualMenu";
25+
export { default as DoughnutChart } from "./components/DoughnutChart";
2526
export { default as EmptyState } from "./components/EmptyState";
2627
export { default as Field } from "./components/Field";
2728
export { default as Form } from "./components/Form";
@@ -112,6 +113,7 @@ export type {
112113
MenuLink,
113114
Position,
114115
} from "./components/ContextualMenu";
116+
export type { DoughnutChartProps, Segment } from "./components/DoughnutChart";
115117
export type { EmptyStateProps } from "./components/EmptyState";
116118
export type { FieldProps } from "./components/Field";
117119
export type { FormProps } from "./components/Form";

0 commit comments

Comments
 (0)