diff --git a/src/components/DoughnutChart/DoughnutChart.scss b/src/components/DoughnutChart/DoughnutChart.scss new file mode 100644 index 00000000..e6f3d0ea --- /dev/null +++ b/src/components/DoughnutChart/DoughnutChart.scss @@ -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; +} diff --git a/src/components/DoughnutChart/DoughnutChart.stories.tsx b/src/components/DoughnutChart/DoughnutChart.stories.tsx new file mode 100644 index 00000000..e656ecb8 --- /dev/null +++ b/src/components/DoughnutChart/DoughnutChart.stories.tsx @@ -0,0 +1,39 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import DoughnutChart from "./DoughnutChart"; + +const meta: Meta = { + component: DoughnutChart, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +/** + * 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, + }, +}; diff --git a/src/components/DoughnutChart/DoughnutChart.test.tsx b/src/components/DoughnutChart/DoughnutChart.test.tsx new file mode 100644 index 00000000..7115969e --- /dev/null +++ b/src/components/DoughnutChart/DoughnutChart.test.tsx @@ -0,0 +1,95 @@ +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(); + jest.useRealTimers(); + }); + + it("renders", () => { + render(); + expect(screen.getByTestId("chart")).toBeInTheDocument(); + }); + + it("displays the correct number of segments", () => { + render(); + const segments = screen.getAllByTestId(TestIds.Segment); + expect(segments).toHaveLength(defaultProps.segments.length); + }); + + it("shows tooltips on hover", async () => { + render(); + await act(async () => { + await userEventWithTimers.hover( + screen.getAllByTestId(TestIds.Section)[0], + ); + jest.runAllTimers(); + }); + // These tooltips may be better placed in the parent element of the Tooltip component however this is not accessible. + // expect(screen.getByTestId(TestIds.Tooltip)).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-portal")).toBeInTheDocument(); + await userEvent.unhover(screen.getAllByTestId(TestIds.Segment)[0]); + await waitFor(() => { + expect(screen.queryByTestId("tooltip-portal")).not.toBeInTheDocument(); + }); + }); + + it("applies custom styles to segments", () => { + render(); + 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(); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + }); + + it("does not display the label if not provided", () => { + render(); + expect(screen.queryByText("Test Label")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/DoughnutChart/DoughnutChart.tsx b/src/components/DoughnutChart/DoughnutChart.tsx new file mode 100644 index 00000000..f7c90261 --- /dev/null +++ b/src/components/DoughnutChart/DoughnutChart.tsx @@ -0,0 +1,212 @@ +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", + Section = "Section", +} + +const DoughnutChart: FC = ({ + 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 ( + { + // 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 ( +
+ + + + + {/* Cover the canvas, this will be the visible area. */} + + {/* Cut out the center circle so that the hover state doesn't grow inwards. */} + + + + {/* Force the group to cover the full size of the canvas, otherwise it will only mask the children (in their non-hovered state) */} + + {segmentNodes} + + {label ? ( + + + {label} + + + ) : null} + + +
+ ); +}; + +export default DoughnutChart; diff --git a/src/components/DoughnutChart/index.ts b/src/components/DoughnutChart/index.ts new file mode 100644 index 00000000..5a137e05 --- /dev/null +++ b/src/components/DoughnutChart/index.ts @@ -0,0 +1,3 @@ +export { default } from "./DoughnutChart"; +export type { Props as DoughnutChartProps } from "./DoughnutChart"; +export type { Segment } from "./DoughnutChart"; diff --git a/src/index.ts b/src/index.ts index c5a4bdb7..abc35966 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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";