diff --git a/packages/open-ui-kit/src/charts/index.ts b/packages/open-ui-kit/src/charts/index.ts index 08f272a..2e39886 100644 --- a/packages/open-ui-kit/src/charts/index.ts +++ b/packages/open-ui-kit/src/charts/index.ts @@ -11,5 +11,6 @@ export * from "./donut-chart/donut-chart"; export * from "./gauge-chart/gauge-chart"; export * from "./horizontal-bar-chart/horizontal-bar-chart"; export * from "./line-chart/line-chart"; +export * from "./spider-chart"; export * from "./common/types"; diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-conical-gradient.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-conical-gradient.tsx new file mode 100644 index 0000000..e0624f4 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-conical-gradient.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +const CustomElement = ({ + points, + color, +}: { + points: { x: number; y: number }[]; + color: string; +}) => { + const path = + points + .map((p) => [p.x, p.y]) + .map((c, i) => (i ? `${c[0]} ${c[1]}` : `M${c[0]} ${c[1]}`)) + .join(" ") + "Z"; + + return ( + + + + + + + + + ); +}; + +export default CustomElement; diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-lines.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-lines.tsx new file mode 100644 index 0000000..3a3041c --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-lines.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useTheme } from "@mui/material"; + +const RADIAN = Math.PI / 180; + +export default function CustomLines({ + polarAngles, + scale, + ...props +}: { + polarAngles: number[]; + innerRadius: number; + outerRadius: number; + cx: string; + cy: string; + width: number; + height: number; + scale: number; +}) { + const theme = useTheme(); + const polarToCartesian = ( + cx: number, + cy: number, + radius: number, + angle: number, + ) => ({ + x: cx + Math.cos(-RADIAN * angle) * radius, + y: cy + Math.sin(-RADIAN * angle) * radius, + }); + + function convertPercentageToNumeric( + cx: string, + cy: string, + width: number, + height: number, + ): [number, number] { + const x = (parseFloat(cx.replace("%", "")) / 100) * width; + const y = (parseFloat(cy.replace("%", "")) / 100) * height; + return [x, y]; + } + + const getPolarLine = (angle: number) => { + const [cx, cy] = convertPercentageToNumeric( + props.cx, + props.cy, + props.width, + props.height, + ); + const start = polarToCartesian(cx, cy, props.innerRadius, angle); + const end = polarToCartesian(cx, cy, props.outerRadius * scale, angle); + return ( + + ); + }; + return <>{polarAngles.map((angle) => getPolarLine(angle))}>; +} diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-polar-grid.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-polar-grid.tsx new file mode 100644 index 0000000..efa649f --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-polar-grid.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useTheme } from "@mui/material"; + +const RADIAN = Math.PI / 180; + +function CustomGrid({ + polarRadius, + polarAngles, + scale, + ...props +}: { + polarRadius: number[]; + polarAngles: number[]; + cx: string; + cy: string; + width: number; + height: number; + scale: number; +}) { + const theme = useTheme(); + + const polarToCartesian = ( + cx: number, + cy: number, + radius: number, + angle: number, + ) => ({ + x: cx + Math.cos(-RADIAN * angle) * radius * scale, + y: cy + Math.sin(-RADIAN * angle) * radius * scale, + }); + + function convertPercentageToNumeric( + cx: string, + cy: string, + width: number, + height: number, + ): [number, number] { + const x = (parseFloat(cx.replace("%", "")) / 100) * width; + const y = (parseFloat(cy.replace("%", "")) / 100) * height; + return [x, y]; + } + + const getPolygonPath = (radius: number) => { + let path = ""; + + const [cx, cy] = convertPercentageToNumeric( + props.cx, + props.cy, + props.width, + props.height, + ); + + polarAngles.forEach((angle: number, i: number) => { + const point = polarToCartesian(cx, cy, radius, angle); + if (i) { + path += `L ${point.x},${point.y}`; + } else { + path += `M ${point.x},${point.y}`; + } + }); + path += "Z"; + + return path; + }; + const getConcentricPolygon = (entry: number, index: number) => { + const total = polarRadius.length; + const t = total > 1 ? index / (total - 1) : 1; + // Inner ring more visible, outer ring lighter + const start = theme.palette.mode === "dark" ? 0.32 : 0.6; + const end = theme.palette.mode === "dark" ? 0.12 : 0.3; + const opacity = start + (end - start) * t; + + return ( + + ); + }; + return ( + <>{polarRadius.map((entry, index) => getConcentricPolygon(entry, index))}> + ); +} + +export default CustomGrid; diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-radar-labels.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-radar-labels.tsx new file mode 100644 index 0000000..e5f9a16 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-radar-labels.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useTheme } from "@mui/material"; +import { Offset } from "../types/spider-chart.types"; + +const RADIAN = Math.PI / 180; + +const CustomLabels = (props: { + cx: string; + cy: string; + width: number; + height: number; + labelOffsets: Offset[]; + outerRadius: number; + band: number; + tooltipTicks: { value: string; coordinate: number }[]; + data: { subject: string }[]; +}) => { + const theme = useTheme(); + + const polarToCartesian = ( + cx: number, + cy: number, + radius: number, + angle: number, + ) => ({ + x: cx + Math.cos(-RADIAN * angle) * radius, + y: cy + Math.sin(-RADIAN * angle) * radius, + }); + + function convertPercentageToNumeric( + cx: string, + cy: string, + width: number, + height: number, + ): [number, number] { + const x = (parseFloat(cx.replace("%", "")) / 100) * width; + const y = (parseFloat(cy.replace("%", "")) / 100) * height; + return [x, y]; + } + + const getTickLineCoord = (coordinate: number, index: number) => { + let [cx, cy] = convertPercentageToNumeric( + props.cx, + props.cy, + props.width, + props.height, + ); + if (props.labelOffsets && props.labelOffsets.length > index) { + cx += props.labelOffsets[index].cx; + cy += props.labelOffsets[index].cy; + } + const tickLineSize = 8; + const p2 = polarToCartesian( + cx, + cy, + props.outerRadius + props.band * tickLineSize, + coordinate, + ); + + return { x: p2.x, y: p2.y }; + }; + + if (!props.tooltipTicks) return <>>; + return ( + <> + {props.tooltipTicks.map((tick, index) => { + const { x, y } = getTickLineCoord(tick.coordinate, index); + return ( + + {props.data[index].subject} + + ); + })} + > + ); +}; + +export default CustomLabels; diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-radar-tick.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-radar-tick.tsx new file mode 100644 index 0000000..584eb84 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-radar-tick.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useTheme } from "@mui/material"; + +type TickProps = { + payload?: { value: string }; + x?: number; + y?: number; +}; + +export default function CustomRadarTick({ x = 0, y = 0, payload }: TickProps) { + const theme = useTheme(); + return ( + + + + {payload?.value} + + + + ); +} diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/custom-tooltip.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/custom-tooltip.tsx new file mode 100644 index 0000000..d01b059 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/custom-tooltip.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; + +import { Typography } from "@mui/material"; +import { ExtendedDataPoint } from "../types/spider-chart.types"; +import { StyledTooltip } from "../styles/spider-chart.styles"; + +export type CustomTooltipProps = { + active?: boolean; + payload?: { payload: ExtendedDataPoint }[]; + tooltipContent?: (dataPoint: ExtendedDataPoint) => React.ReactNode; +}; + +const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { + if (!active || !payload || !payload[0].payload.subject) { + return null; + } + const data = payload[0].payload; + + return ( + + + {data.variableA && ( + + {data.variableA} {data.subject} + + )} + + + ); +}; + +export default CustomTooltip; diff --git a/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx b/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx new file mode 100644 index 0000000..79a8a35 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/components/spider-chart.tsx @@ -0,0 +1,173 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRef } from "react"; +import { + Customized, + PolarRadiusAxis, + Radar, + RadarChart, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { useTheme } from "@mui/material"; +import CustomConicalGradient from "./custom-conical-gradient"; +import CustomLines from "./custom-lines"; +import CustomPolarGrid from "./custom-polar-grid"; +import CustomLabels from "./custom-radar-labels"; +import CustomRadarTick from "./custom-radar-tick"; +import CustomTooltip from "./custom-tooltip"; +import { StyledRadarChart } from "../styles/spider-chart.styles"; +import { + ExtendedDataPoint, + SpiderChartProps, +} from "../types/spider-chart.types"; + +const TICK_COUNT = 3; + +const getMaxValueFromVariables = (numbers: number[]) => Math.max(...numbers); + +const calculateDomain = (data: ExtendedDataPoint[]) => [ + 0, + getMaxValueFromVariables( + data.filter((x) => x.variableA).map((x) => x.variableA ?? 0), + ), +]; + +/** + * Spider charts, also known as radar charts or star plots, are used to display multivariate data in a two-dimensional chart. + * Each variable is represented by an axis radiating from a common center point, and the values of the variables are plotted as data points along the corresponding axis. + * The range of values in a spider chart depends on the specific variables being represented. + */ +export const SpiderChart = ({ + data, + radars, + band = 30, + tickBand = 5, + scale = 1, + outerRadius = 90, + labelOffsets, + showTooltip = true, + onTooltipClick, + tooltipContent, + customTooltip, +}: SpiderChartProps) => { + const tooltipValue = useRef(null); + const theme = useTheme(); + + const domain = calculateDomain(data); + + const handleClick = () => { + if (tooltipValue.current !== null && onTooltipClick) + onTooltipClick(tooltipValue.current); + }; + + const sortData = (a: ExtendedDataPoint, b: ExtendedDataPoint) => { + return a.subject.localeCompare(b.subject); + }; + + const dataPadded = data.sort(sortData).map((dp) => { + return Object.fromEntries( + Object.entries(dp).map(([key, value]) => { + if (key.length === 1) return [key, Number(value)]; + return [key, value]; + }), + ); + }); + + return ( + + + + i * (360 / data.length) + tickBand)} + /> + i * (360 / data.length) + band)} + /> + i * (360 / data.length) + band)} + /> + + {radars.map((radar, index) => ( + + ))} + {showTooltip && ( + + ) + } + cursor={{ stroke: "transparent", fill: "transparent" }} + /> + )} + + } + ticks={[0, Math.round(domain[1] / 2), domain[1]].map((v, i) => ({ + value: v, + coordinate: v, + index: i, + }))} + angle={90} + orientation="right" + domain={domain} + axisLine={false} + /> + + + + ); +}; diff --git a/packages/open-ui-kit/src/charts/spider-chart/index.ts b/packages/open-ui-kit/src/charts/spider-chart/index.ts new file mode 100644 index 0000000..2c9e95d --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export { SpiderChart } from "./components/spider-chart"; +export type { SpiderChartProps } from "./types/spider-chart.types"; diff --git a/packages/open-ui-kit/src/charts/spider-chart/stories/spider-chart.stories.tsx b/packages/open-ui-kit/src/charts/spider-chart/stories/spider-chart.stories.tsx new file mode 100644 index 0000000..a897341 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/stories/spider-chart.stories.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Meta, StoryObj } from "@storybook/react"; +import { SpiderChart } from "../components/spider-chart"; +import { ExtendedDataPoint } from "../types/spider-chart.types"; +import { Box } from "@mui/material"; + +const meta: Meta = { + title: "Charts/SpiderChart", + component: SpiderChart, + tags: ["autodocs"], + argTypes: { + data: { + description: "The data object that is being passed to the spider chart.", + }, + radars: { description: "The radar types to display in the chart." }, + outerRadius: { + description: "The outer radius of the chart.", + }, + band: { + description: "The outer radius of the chart", + }, + onTooltipClick: { + description: "Callback to call on tooltip click", + }, + tooltipContent: { + description: "Tooltip content can be overriden", + }, + showTooltip: { + description: "Toggle on/off the tooltip on hover", + }, + labelOffsets: { + description: "Manully adjust the labels position", + }, + tickBand: { + description: "Control the spaces of the lables from center", + }, + scale: { + description: "Control the size of the radars", + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const data: ExtendedDataPoint[] = [ + { + subject: "Math", + variableA: 150, + }, + { + subject: "Chinese", + variableA: 60, + }, + { + subject: "English", + variableA: 80, + }, + { + subject: "Geography", + variableA: 99, + }, + { + subject: "Physics", + variableA: 110, + }, + { + subject: "History", + variableA: 151, + }, +]; + +export const Example: Story = { + render: (args) => { + return ( + + + + ); + }, + args: { + data: data, + radars: [ + { + name: "Test", + dataKey: "variableA", + }, + ], + labelOffsets: [ + { cx: 30, cy: 10 }, + { cx: -10, cy: 10 }, + { cx: -10, cy: -20 }, + { cx: -15, cy: 0 }, + { cx: -15, cy: 0 }, + { cx: -20, cy: 0 }, + ], + }, +}; diff --git a/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts b/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts new file mode 100644 index 0000000..2eff280 --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/styles/spider-chart.styles.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { styled } from "@mui/material"; + +export const StyledTooltip = styled("div")(({ theme }) => ({ + width: "max-content", + borderRadius: "4px", + boxShadow: "0px 4px 30px rgba(9, 13, 50, 0.25)", + color: theme.palette.vars.baseTextDefault, + backgroundColor: theme.palette.background.paper, + padding: "2px 8px", +})); + +export const StyledRadarChart = styled("div")({ + width: "100%", + height: "100%", + ".recharts-active-dot": { + "& > *": { + strokeWidth: "0 !important", + }, + }, +}); diff --git a/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts b/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts new file mode 100644 index 0000000..e372b3a --- /dev/null +++ b/packages/open-ui-kit/src/charts/spider-chart/types/spider-chart.types.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Cisco Systems, Inc. and its affiliates + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ContentType } from "recharts/types/component/Tooltip"; + +export type DataPoint = { + subject: string; +}; + +export type ExtendedDataPoint = { + variableA?: number; +} & DataPoint; + +export type RadarType = { + name: string; + dataKey: string; + fill?: string; + background?: string; + shape?: React.ReactElement; +}; + +export type Offset = { + cx: number; + cy: number; +}; + +export type SpiderChartProps = { + data: ExtendedDataPoint[]; + radars: RadarType[]; + outerRadius?: number; + padData?: number; + band?: number; + onTooltipClick?: (subject: string) => void; + tooltipContent?: (dataPoint: ExtendedDataPoint) => React.ReactNode; + showTooltip?: boolean; + customTooltip?: ContentType; + labelOffsets?: Offset[]; + tickBand?: number; + scale?: number; +};