Oteto Blogのロゴ

【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レーダーチャートの説明

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