Skip to content

Commit fe6f254

Browse files
authored
Merge pull request #40 from outshift-open/spider-chart
feat: spider chart
2 parents 096b8a0 + f6a17c3 commit fe6f254

File tree

12 files changed

+708
-0
lines changed

12 files changed

+708
-0
lines changed

packages/open-ui-kit/src/charts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export * from "./donut-chart/donut-chart";
1111
export * from "./gauge-chart/gauge-chart";
1212
export * from "./horizontal-bar-chart/horizontal-bar-chart";
1313
export * from "./line-chart/line-chart";
14+
export * from "./spider-chart";
1415

1516
export * from "./common/types";
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2025 Cisco Systems, Inc. and its affiliates
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
const CustomElement = ({
8+
points,
9+
color,
10+
}: {
11+
points: { x: number; y: number }[];
12+
color: string;
13+
}) => {
14+
const path =
15+
points
16+
.map((p) => [p.x, p.y])
17+
.map((c, i) => (i ? `${c[0]} ${c[1]}` : `M${c[0]} ${c[1]}`))
18+
.join(" ") + "Z";
19+
20+
return (
21+
<svg>
22+
<clipPath id="clip">
23+
<path d={path} />
24+
</clipPath>
25+
<foreignObject width="100%" height="100%" clipPath="url(#clip)">
26+
<div
27+
style={{
28+
width: "100%",
29+
height: "100%",
30+
background: color,
31+
}}
32+
/>
33+
</foreignObject>
34+
</svg>
35+
);
36+
};
37+
38+
export default CustomElement;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2025 Cisco Systems, Inc. and its affiliates
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useTheme } from "@mui/material";
8+
9+
const RADIAN = Math.PI / 180;
10+
11+
export default function CustomLines({
12+
polarAngles,
13+
scale,
14+
...props
15+
}: {
16+
polarAngles: number[];
17+
innerRadius: number;
18+
outerRadius: number;
19+
cx: string;
20+
cy: string;
21+
width: number;
22+
height: number;
23+
scale: number;
24+
}) {
25+
const theme = useTheme();
26+
const polarToCartesian = (
27+
cx: number,
28+
cy: number,
29+
radius: number,
30+
angle: number,
31+
) => ({
32+
x: cx + Math.cos(-RADIAN * angle) * radius,
33+
y: cy + Math.sin(-RADIAN * angle) * radius,
34+
});
35+
36+
function convertPercentageToNumeric(
37+
cx: string,
38+
cy: string,
39+
width: number,
40+
height: number,
41+
): [number, number] {
42+
const x = (parseFloat(cx.replace("%", "")) / 100) * width;
43+
const y = (parseFloat(cy.replace("%", "")) / 100) * height;
44+
return [x, y];
45+
}
46+
47+
const getPolarLine = (angle: number) => {
48+
const [cx, cy] = convertPercentageToNumeric(
49+
props.cx,
50+
props.cy,
51+
props.width,
52+
props.height,
53+
);
54+
const start = polarToCartesian(cx, cy, props.innerRadius, angle);
55+
const end = polarToCartesian(cx, cy, props.outerRadius * scale, angle);
56+
return (
57+
<line
58+
key={`angle-${angle}`}
59+
stroke={theme.palette.vars.baseBorderDefault}
60+
opacity={theme.palette.mode === "dark" ? 0.5 : 1}
61+
x1={start.x}
62+
y1={start.y}
63+
x2={end.x}
64+
y2={end.y}
65+
></line>
66+
);
67+
};
68+
return <>{polarAngles.map((angle) => getPolarLine(angle))}</>;
69+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2025 Cisco Systems, Inc. and its affiliates
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useTheme } from "@mui/material";
8+
9+
const RADIAN = Math.PI / 180;
10+
11+
function CustomGrid({
12+
polarRadius,
13+
polarAngles,
14+
scale,
15+
...props
16+
}: {
17+
polarRadius: number[];
18+
polarAngles: number[];
19+
cx: string;
20+
cy: string;
21+
width: number;
22+
height: number;
23+
scale: number;
24+
}) {
25+
const theme = useTheme();
26+
27+
const polarToCartesian = (
28+
cx: number,
29+
cy: number,
30+
radius: number,
31+
angle: number,
32+
) => ({
33+
x: cx + Math.cos(-RADIAN * angle) * radius * scale,
34+
y: cy + Math.sin(-RADIAN * angle) * radius * scale,
35+
});
36+
37+
function convertPercentageToNumeric(
38+
cx: string,
39+
cy: string,
40+
width: number,
41+
height: number,
42+
): [number, number] {
43+
const x = (parseFloat(cx.replace("%", "")) / 100) * width;
44+
const y = (parseFloat(cy.replace("%", "")) / 100) * height;
45+
return [x, y];
46+
}
47+
48+
const getPolygonPath = (radius: number) => {
49+
let path = "";
50+
51+
const [cx, cy] = convertPercentageToNumeric(
52+
props.cx,
53+
props.cy,
54+
props.width,
55+
props.height,
56+
);
57+
58+
polarAngles.forEach((angle: number, i: number) => {
59+
const point = polarToCartesian(cx, cy, radius, angle);
60+
if (i) {
61+
path += `L ${point.x},${point.y}`;
62+
} else {
63+
path += `M ${point.x},${point.y}`;
64+
}
65+
});
66+
path += "Z";
67+
68+
return path;
69+
};
70+
const getConcentricPolygon = (entry: number, index: number) => {
71+
const total = polarRadius.length;
72+
const t = total > 1 ? index / (total - 1) : 1;
73+
// Inner ring more visible, outer ring lighter
74+
const start = theme.palette.mode === "dark" ? 0.32 : 0.6;
75+
const end = theme.palette.mode === "dark" ? 0.12 : 0.3;
76+
const opacity = start + (end - start) * t;
77+
78+
return (
79+
<path
80+
fill={theme.palette.vars.baseBorderDefault}
81+
opacity={opacity}
82+
className="recharts-polar-grid-concentric-polygon"
83+
key={`path-${index}`}
84+
d={getPolygonPath(entry)}
85+
/>
86+
);
87+
};
88+
return (
89+
<>{polarRadius.map((entry, index) => getConcentricPolygon(entry, index))}</>
90+
);
91+
}
92+
93+
export default CustomGrid;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright 2025 Cisco Systems, Inc. and its affiliates
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useTheme } from "@mui/material";
8+
import { Offset } from "../types/spider-chart.types";
9+
10+
const RADIAN = Math.PI / 180;
11+
12+
const CustomLabels = (props: {
13+
cx: string;
14+
cy: string;
15+
width: number;
16+
height: number;
17+
labelOffsets: Offset[];
18+
outerRadius: number;
19+
band: number;
20+
tooltipTicks: { value: string; coordinate: number }[];
21+
data: { subject: string }[];
22+
}) => {
23+
const theme = useTheme();
24+
25+
const polarToCartesian = (
26+
cx: number,
27+
cy: number,
28+
radius: number,
29+
angle: number,
30+
) => ({
31+
x: cx + Math.cos(-RADIAN * angle) * radius,
32+
y: cy + Math.sin(-RADIAN * angle) * radius,
33+
});
34+
35+
function convertPercentageToNumeric(
36+
cx: string,
37+
cy: string,
38+
width: number,
39+
height: number,
40+
): [number, number] {
41+
const x = (parseFloat(cx.replace("%", "")) / 100) * width;
42+
const y = (parseFloat(cy.replace("%", "")) / 100) * height;
43+
return [x, y];
44+
}
45+
46+
const getTickLineCoord = (coordinate: number, index: number) => {
47+
let [cx, cy] = convertPercentageToNumeric(
48+
props.cx,
49+
props.cy,
50+
props.width,
51+
props.height,
52+
);
53+
if (props.labelOffsets && props.labelOffsets.length > index) {
54+
cx += props.labelOffsets[index].cx;
55+
cy += props.labelOffsets[index].cy;
56+
}
57+
const tickLineSize = 8;
58+
const p2 = polarToCartesian(
59+
cx,
60+
cy,
61+
props.outerRadius + props.band * tickLineSize,
62+
coordinate,
63+
);
64+
65+
return { x: p2.x, y: p2.y };
66+
};
67+
68+
if (!props.tooltipTicks) return <></>;
69+
return (
70+
<>
71+
{props.tooltipTicks.map((tick, index) => {
72+
const { x, y } = getTickLineCoord(tick.coordinate, index);
73+
return (
74+
<text
75+
key={`tick=${index}`}
76+
x={x}
77+
y={y}
78+
fill={theme.palette.vars.baseTextDefault}
79+
style={{ ...theme.typography.caption }}
80+
>
81+
<tspan>{props.data[index].subject}</tspan>
82+
</text>
83+
);
84+
})}
85+
</>
86+
);
87+
};
88+
89+
export default CustomLabels;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2025 Cisco Systems, Inc. and its affiliates
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useTheme } from "@mui/material";
8+
9+
type TickProps = {
10+
payload?: { value: string };
11+
x?: number;
12+
y?: number;
13+
};
14+
15+
export default function CustomRadarTick({ x = 0, y = 0, payload }: TickProps) {
16+
const theme = useTheme();
17+
return (
18+
<g>
19+
<text dx={5} y={y - 5} fill={theme.palette.vars.baseTextMedium}>
20+
<tspan x={x} dy="0em" fontSize={"0.625rem"} fontWeight={"600"}>
21+
{payload?.value}
22+
</tspan>
23+
</text>
24+
</g>
25+
);
26+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2025 Cisco Systems, Inc. and its affiliates
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import React from "react";
8+
9+
import { Typography } from "@mui/material";
10+
import { ExtendedDataPoint } from "../types/spider-chart.types";
11+
import { StyledTooltip } from "../styles/spider-chart.styles";
12+
13+
export type CustomTooltipProps = {
14+
active?: boolean;
15+
payload?: { payload: ExtendedDataPoint }[];
16+
tooltipContent?: (dataPoint: ExtendedDataPoint) => React.ReactNode;
17+
};
18+
19+
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
20+
if (!active || !payload || !payload[0].payload.subject) {
21+
return null;
22+
}
23+
const data = payload[0].payload;
24+
25+
return (
26+
<StyledTooltip>
27+
<Typography variant="caption">
28+
{data.variableA && (
29+
<div>
30+
{data.variableA} {data.subject}
31+
</div>
32+
)}
33+
</Typography>
34+
</StyledTooltip>
35+
);
36+
};
37+
38+
export default CustomTooltip;

0 commit comments

Comments
 (0)