【React】レーダーチャート(radar chart)をライブラリ無しで実装する

やりたいこと

このようなレーダー(スパイダー)チャートをReactでSVGとして実装したい。

ReactのChartライブラリだとRechartsなどが候補として挙がるが、今回は外部のライブラリ無しでコンポーネントとして実装してみる。

実装方法

1. コンポーネント実装

import styles from "../styles/radarChart.module.scss";
// 対象のチャート形状(頂点の数)
const ALLOW_VERTEX_COUNTS = [3, 5, 6, 7];
const MIN_SCORE_VALUE = 0;
const MAX_SCORE_VALUE = 10;
const BASE_SIZE = 200;
type Score = {
value: number;
title: string;
};
export default function RadarChart({
scores,
baseColor,
ariaLabel,
}: {
scores: Score[];
baseColor: string;
ariaLabel: string;
}) {
let scoreValues: number[] = [];
try {
if (!ALLOW_VERTEX_COUNTS.includes(scores.length)) throw new Error();
scoreValues = scores.map(({ value }) => {
if (value < MIN_SCORE_VALUE || value > MAX_SCORE_VALUE) throw new Error();
return value;
});
} catch (e) {
return <></>;
}
return (
<div className={styles.block}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${BASE_SIZE} ${BASE_SIZE}`}
aria-label={ariaLabel}
>
<title>{ariaLabel}</title>
{createPaths(ariaLabel, baseColor, scoreValues)}
</svg>
<dl className={styles.scores}>
{scores.map((score) => (
<div className={styles.score}>
<dt>{score.title}</dt>
<dd className={styles.value}>{score.value.toFixed(1)}</dd>
</div>
))}
</dl>
</div>
);
}
/**
* チャートのパスを生成
*/
function createPaths(
ariaLabel: string,
baseColor: string,
scoreValues: number[]
) {
const vertexCount = scoreValues.length;
const sizes: number[] = [];
for (let i = vertexCount; i > 0; i--) {
sizes.push((BASE_SIZE * i) / vertexCount);
}
const allPentagonOffsets: Offset[][] = [];
for (const size of sizes) {
const pentagonOffsets: Offset[] = [];
for (let i = 0; i < vertexCount; i++) {
pentagonOffsets.push(getOffset(vertexCount, size, i));
}
allPentagonOffsets.push(pentagonOffsets);
}
// 外側に広がる線
const outwardLinePaths: JSX.Element[] = [];
allPentagonOffsets[0].forEach((offset, index) => {
outwardLinePaths.push(
<path
d={`M ${BASE_SIZE / 2} ${BASE_SIZE / 2} L ${offset.x} ${offset.y}`}
stroke="#dce5eb"
key={index}
/>
);
});
// 各拡大度のn角形
const pentagonLinePaths: JSX.Element[] = [];
allPentagonOffsets.forEach((allPentagonOffset, index) => {
let pentagonPath = "M ";
for (let i = 0; i <= allPentagonOffset.length; i++) {
if (i !== 0) pentagonPath += " L ";
const offset =
i === allPentagonOffset.length
? allPentagonOffset[0]
: allPentagonOffset[i];
pentagonPath += `${offset.x} ${offset.y}`;
}
pentagonLinePaths.push(
<path d={pentagonPath} fill="none" stroke="#dce5eb" key={index} />
);
});
// スコアを表したn角形
let pentagonD = "M ";
// スコアを表したn角形の頂点
const vertexPaths: JSX.Element[] = [];
for (let i = 0; i <= vertexCount; i++) {
if (i !== 0) pentagonD += " L ";
let offset = getOffset(
vertexCount,
(scoreValues[i] / MAX_SCORE_VALUE) * BASE_SIZE,
i
);
if (i !== vertexCount) {
vertexPaths.push(
<circle cx={offset.x} cy={offset.y} r="3" fill={baseColor} key={i} />
);
} else {
offset = getOffset(
vertexCount,
(scoreValues[0] / MAX_SCORE_VALUE) * BASE_SIZE,
0
);
}
pentagonD += `${offset.x} ${offset.y}`;
}
const pentagonPaths = (
<path d={pentagonD} fill={`${baseColor}30`} stroke={baseColor} />
);
return (
<>
{outwardLinePaths}
{pentagonLinePaths}
{pentagonPaths}
{vertexPaths}
</>
);
}
type Offset = {
x: string;
y: string;
};
function getOffset(vertexCount: number, size: number, i: number): Offset {
const x = (
BASE_SIZE / 2 +
(size / 2) * Math.sin(((2 * Math.PI) / vertexCount) * i)
).toFixed(1);
const y = (
BASE_SIZE / 2 -
(size / 2) * Math.cos(((2 * Math.PI) / vertexCount) * i)
).toFixed(1);
return { x: x, y: y };
}

radarChart.tsxを上記のように実装する。

2. スタイルをあてる

.block {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 230px;
height: 230px;
padding: 35px;
}
.radar-chart svg {
padding: 5px;
}
.scores {
position: absolute;
width: 100%;
height: 100%;
> .score:nth-child(1) {
top: -1%;
left: 50%;
transform: translateX(-50%);
}
> .score:nth-child(2) {
top: 24%;
right: 7%;
transform: translateX(50%);
}
> .score:nth-child(3) {
right: 7%;
bottom: 24%;
transform: translateX(50%);
}
> .score:nth-child(4) {
bottom: -1%;
left: 50%;
transform: translateX(-50%);
}
> .score:nth-child(5) {
bottom: 24%;
left: 7%;
transform: translateX(-50%);
}
> .score:nth-child(6) {
top: 24%;
left: 7%;
transform: translateX(-50%);
}
}
.score {
position: absolute;
font-size: 0.8rem;
text-align: center;
}
.value {
margin: 0;
}

チャート自体は描画できたがサイズや点数の配置が未設定なので、6角形のレーダーチャートの場合を想定してradarChart.module.scssを実装。

3. コンポーネントを呼び出す

import RadarChart from "./radarChart";
export default function SomeComponent() {
return (
<RadarChart
scores={[
{ title: "項目1", value: 5 },
{ title: "項目2", value: 6 },
{ title: "項目3", value: 7 },
{ title: "項目4", value: 8 },
{ title: "項目5", value: 9 },
{ title: "項目6", value: 10 },
]}
baseColor="#181b29"
ariaLabel="レーダーチャートのサンプル"
/>
);
}

あとは適当なpropsを指定し呼び出せばOK。各propsの詳細は以下の通り。

props詳細
scores項目の説明とスコア
baseColorチャート色付き部分の色
ariaLabelレーダーチャートの説明

これでレーダーチャートを使い回せるようになった 🎉