Skip to content

Commit

Permalink
feat: [WD-13450] Upstream Doughnut Chart to RC
Browse files Browse the repository at this point in the history
Signed-off-by: Nkeiruka <nkeiruka.whenu@canonical.com>
  • Loading branch information
Kxiru committed Sep 9, 2024
1 parent b1d81e3 commit 49f0a88
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 0 deletions.
30 changes: 30 additions & 0 deletions src/components/DoughnutChart/DoughnutChart.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@import "vanilla-framework";

.doughnut-chart {
width: 6.5rem;

.doughnut-chart__tooltip {
display: block;
}

.doughnut-chart__tooltip > :only-child {
// Override the tooltip wrapper.
display: block !important;
}

.doughnut-chart__chart {
// Restrict hover areas to the strokes.
pointer-events: stroke;
}

.doughnut-chart__segment {
fill: transparent;

// Animate stroke size changes on hover.
transition: stroke-width 0.3s ease;
}
}

.doughnut-chart__legend {
list-style-type: none;
}
39 changes: 39 additions & 0 deletions src/components/DoughnutChart/DoughnutChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Meta, StoryObj } from "@storybook/react";

import DoughnutChart from "./DoughnutChart";

const meta: Meta<typeof DoughnutChart> = {
component: DoughnutChart,
tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<typeof DoughnutChart>;

/**
* 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.
*/
export const Default: Story = {
name: "Default",
args: {
chartID: "default",
segmentHoverWidth: 45,
segmentWidth: 40,
segments: [
{
color: "#0E8420",
tooltip: "Running",
value: 10,
},
{
color: "#CC7900",
tooltip: "Stopped",
value: 15,
},
{ color: "#C7162B", tooltip: "Frozen", value: 5 },
{ color: "#000", tooltip: "Error", value: 5 },
],
size: 150,
},
};
89 changes: 89 additions & 0 deletions src/components/DoughnutChart/DoughnutChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import DoughnutChart, { TestIds } from "./DoughnutChart";
import userEvent, { UserEvent } from "@testing-library/user-event";

describe("DoughnutChart", () => {
let userEventWithTimers: UserEvent;
const defaultProps = {
chartID: "test",
segmentHoverWidth: 10,
segmentWidth: 8,
size: 100,
segments: [
{
color: "#3498DB",
tooltip: "aaa",
value: 12,
},
{
color: "#E74C3C",
tooltip: "bbb",
value: 8,
},
{
color: "#F1C40F",
tooltip: "ccc",
value: 18,
},
{
color: "#2ECC71",
tooltip: "ddd",
value: 14,
},
],
};

beforeEach(() => {
jest.useFakeTimers();

userEventWithTimers = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
});
});

afterEach(() => {
jest.resetAllMocks();
});

it("renders", () => {
render(<DoughnutChart {...defaultProps} />);
expect(screen.getByTestId("chart")).toBeInTheDocument();
});

it("displays the correct number of segments", () => {
render(<DoughnutChart {...defaultProps} />);
const segments = screen.getAllByTestId(TestIds.Segment);
expect(segments).toHaveLength(defaultProps.segments.length);
});

it("shows tooltips on hover", async () => {
render(<DoughnutChart {...defaultProps} />);
await act(async () => {
await userEventWithTimers.hover(screen.getAllByTestId(TestIds.Label)[0]);
jest.runAllTimers();
});
expect(screen.getByTestId(TestIds.Tooltip)).toBeInTheDocument();
await userEvent.unhover(screen.getAllByTestId(TestIds.Segment)[0]);
await waitFor(() => {
expect(screen.getByTestId(TestIds.Tooltip)).not.toBeInTheDocument();

Check failure on line 69 in src/components/DoughnutChart/DoughnutChart.test.tsx

View workflow job for this annotation

GitHub Actions / Lint, build and test

Use `queryBy*` queries rather than `getBy*` for checking element is NOT present
});
});

it("applies custom styles to segments", () => {
render(<DoughnutChart {...defaultProps} />);
const segment = screen.getAllByTestId(TestIds.Segment)[0];
expect(segment).toHaveStyle(`stroke: ${defaultProps.segments[0].color}`);
expect(segment).toHaveStyle(`stroke-width: ${defaultProps.segmentWidth}`);
});

it("displays the label in the center if provided", () => {
render(<DoughnutChart {...defaultProps} label="Test Label" />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});

it("does not display the label if not provided", () => {
render(<DoughnutChart {...defaultProps} />);
expect(screen.queryByText("Test Label")).not.toBeInTheDocument();
});
});
210 changes: 210 additions & 0 deletions src/components/DoughnutChart/DoughnutChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React, { FC, useRef, useState } from "react";
import classNames from "classnames";
import Tooltip from "components/Tooltip";
import "./DoughnutChart.scss";

export type Segment = {
/**
* The colour of the segment.
*/
color: string;
/**
* The segment tooltip.
*/
tooltip?: string;
/**
* The segment length.
*/
value: number;
};

export type Props = {
/**
* The label in the centre of the doughnut.
*/
label?: string;
/**
* An optional class name applied to the wrapping element.
*/
className?: string;
/**
* The width of the segments when hovered.
*/
segmentHoverWidth: number;
/**
* The width of the segments.
*/
segmentWidth: number;
/**
* The doughnut segments.
*/
segments: Segment[];
/**
* The size of the doughnut.
*/
size: number;
/**
* ID associated to the specific instance of a Chart.
*/
chartID: string;
};

export enum TestIds {
Label = "label",
Segment = "segment",
Chart = "chart",
}

const DoughnutChart: FC<Props> = ({
className,
label,
segmentHoverWidth,
segmentWidth,
segments,
size,
chartID,
}): JSX.Element => {
const [tooltipMessage, setTooltipMessage] = useState<
Segment["tooltip"] | null
>(null);

const id = useRef(`doughnut-chart-${chartID}`);
const hoverIncrease = segmentHoverWidth - segmentWidth;
const adjustedHoverWidth = segmentHoverWidth + hoverIncrease;
// The canvas needs enough space so that the hover state does not get cut off.
const canvasSize = size + adjustedHoverWidth - segmentWidth;
const diameter = size - segmentWidth;
const radius = diameter / 2;
const circumference = Math.round(diameter * Math.PI);
// Calculate the total value of all segments.
const total = segments.reduce(
(totalValue, segment) => (totalValue += segment.value),
0,
);
let accumulatedLength = 0;
const segmentNodes = segments.map(({ color, tooltip, value }, i) => {
// The start position is the value of all previous segments.
const startPosition = accumulatedLength;
// The length of the segment (as a portion of the doughnut circumference)
const segmentLength = (value / total) * circumference;
// The space left until the end of the circle.
const remainingSpace = circumference - (segmentLength + startPosition);
// Add this segment length to the running tally.
accumulatedLength += segmentLength;

return (
<circle
className="doughnut-chart__segment"
cx={radius - segmentWidth / 2 - hoverIncrease}
cy={radius + segmentWidth / 2 + hoverIncrease}
data-testid={TestIds.Segment}
key={i}
onMouseOut={
tooltip
? () => {
// Hide the tooltip.
setTooltipMessage(null);
}
: undefined
}
onMouseOver={
tooltip
? () => {
setTooltipMessage(tooltip);
}
: undefined
}
r={radius}
style={{
stroke: color,
strokeWidth: segmentWidth,
// The dash array used is:
// 1 - We want there to be a space before the first visible dash so
// by setting this to 0 we can use the next dash for the space.
// 2 - This gap is the distance of all previous segments
// so that the segment starts in the correct spot.
// 3 - A dash that is the length of the segment.
// 4 - A gap from the end of the segment to the start of the circle
// so that the dash array doesn't repeat and be visible.
strokeDasharray: `0 ${startPosition.toFixed(
2,
)} ${segmentLength.toFixed(2)} ${remainingSpace.toFixed(2)}`,
}}
// Rotate the segment so that the segments start at the top of
// the chart.
transform={`rotate(-90 ${radius},${radius})`}
/>
);
});

return (
<div
className={classNames("doughnut-chart", className)}
style={{ maxWidth: `${canvasSize}px` }}
data-testid={TestIds.Chart}
>
<Tooltip
className="doughnut-chart__tooltip"
followMouse={true}
message={tooltipMessage}
position="right"
>
<style>
{/* Set the hover width of the segments. */}
{`#${id.current} .doughnut-chart__segment:hover {
stroke-width: ${adjustedHoverWidth} !important;
}`}
</style>
<svg
className="doughnut-chart__chart"
id={id.current}
viewBox={`0 0 ${canvasSize} ${canvasSize}`}
>
<mask id="myMask">
{/* Cover the canvas, this will be the visible area. */}
<rect
fill="white"
height={canvasSize}
width={canvasSize}
x="0"
y="0"
/>
{/* Cut out the center circle so that the hover state doesn't grow inwards. */}
<circle
cx={canvasSize / 2}
cy={canvasSize / 2}
fill="black"
r={radius - segmentWidth / 2}
/>
</mask>
<g mask="url(#myMask)">
{/* Force the group to cover the full size of the canvas, otherwise it will only mask the children (in their non-hovered state) */}
<rect
fill="transparent"
height={canvasSize}
width={canvasSize}
x="0"
y="0"
/>
<g>{segmentNodes}</g>
</g>
{label ? (
<text
x={radius + adjustedHoverWidth / 2}
y={radius + adjustedHoverWidth / 2}
>
<tspan
className="doughnut-chart__label"
data-testid={TestIds.Label}
>
{label}
</tspan>
</text>
) : null}
</svg>
</Tooltip>
</div>
);
};

export default DoughnutChart;
3 changes: 3 additions & 0 deletions src/components/DoughnutChart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default } from "./DoughnutChart";
export type { Props as DoughnutChartProps } from "./DoughnutChart";
export type { Segment } from "./DoughnutChart";
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { default as Col } from "./components/Col";
export { default as ConfirmationButton } from "./components/ConfirmationButton";
export { default as ConfirmationModal } from "./components/ConfirmationModal";
export { default as ContextualMenu } from "./components/ContextualMenu";
export { default as DoughnutChart } from "./components/DoughnutChart";
export { default as EmptyState } from "./components/EmptyState";
export { default as Field } from "./components/Field";
export { default as Form } from "./components/Form";
Expand Down Expand Up @@ -112,6 +113,7 @@ export type {
MenuLink,
Position,
} from "./components/ContextualMenu";
export type { DoughnutChartProps, Segment } from "./components/DoughnutChart";
export type { EmptyStateProps } from "./components/EmptyState";
export type { FieldProps } from "./components/Field";
export type { FormProps } from "./components/Form";
Expand Down

0 comments on commit 49f0a88

Please sign in to comment.