🦁

rechartsのCustomActiveShapePieChartをTypeScriptで攻略する

2024/06/18に公開

記事作成時の recharts のバージョン: 2.12.7

はじめに

前回の記事recharts の円グラフ入門(TypeScript)では、recharts の円グラフの基本的な使い方を紹介しました。

今回のテーマとなるCustomActiveShapePieChartは、rechats の公式サイトの samples の中でも発展的な内容になっており、また、TypeScript への変換も少しコツが必要です。

ということで、早速本題です。

CustomActiveShapePieChart

ドーナツ型の円グラフです。とてもおしゃれですね。
ドーナツの穴の部分にデータのラベルが、ドーナツの外側の部分にデータの値が表示されるようになっています。
下の公式サイトに飛んでみると分かりますが、円グラフをホバーすると表示されるラベルも変化するとてもインタラクティブなグラフです。

公式のサンプルコード:https://recharts.org/en-US/examples/CustomActiveShapePieChart

TypeScript に変換したコードを先に載せておきます。

import { useCallback, useState } from "react";
import { PieChart, Pie, Sector, ResponsiveContainer } from "recharts";

const data = [
  { name: "Group A", value: 400 },
  { name: "Group B", value: 300 },
  { name: "Group C", value: 300 },
  { name: "Group D", value: 200 },
];

type Data = (typeof data)[number];

type ActiveShapeProps = {
  cx: number;
  cy: number;
  midAngle: number;
  innerRadius: number;
  outerRadius: number;
  startAngle: number;
  endAngle: number;
  fill: string;
  payload: Data;
  percent: number;
  value: number;
};

// ユーザー定期型ガードを追加
const isPropertyAccessible = (
  value: unknown
): value is { [key: string]: unknown } => {
  return value != null;
};

const isActiveShapeProps = (props: unknown): props is ActiveShapeProps => {
  if (!isPropertyAccessible(props)) return false;
  if (!isPropertyAccessible(props.payload)) return false;
  return (
    typeof props.cx === "number" &&
    typeof props.cy === "number" &&
    typeof props.midAngle === "number" &&
    typeof props.innerRadius === "number" &&
    typeof props.outerRadius === "number" &&
    typeof props.startAngle === "number" &&
    typeof props.endAngle === "number" &&
    typeof props.fill === "string" &&
    typeof props.payload.name === "string" &&
    typeof props.payload.value === "number" &&
    typeof props.percent === "number" &&
    typeof props.value === "number"
  );
};
const renderActiveShape = (props: unknown) => {
  if (!isActiveShapeProps(props)) return <></>;
  const RADIAN = Math.PI / 180;
  const {
    cx,
    cy,
    midAngle,
    innerRadius,
    outerRadius,
    startAngle,
    endAngle,
    fill,
    payload,
    percent,
    value,
  } = props;
  const sin = Math.sin(-RADIAN * midAngle);
  const cos = Math.cos(-RADIAN * midAngle);
  const sx = cx + (outerRadius + 10) * cos;
  const sy = cy + (outerRadius + 10) * sin;
  const mx = cx + (outerRadius + 30) * cos;
  const my = cy + (outerRadius + 30) * sin;
  const ex = mx + (cos >= 0 ? 1 : -1) * 22;
  const ey = my;
  const textAnchor = cos >= 0 ? "start" : "end";
  return (
    <g>
      <text
        x={cx}
        y={cy}
        dy={8}
        textAnchor="middle"
        fill={fill}
        fontSize="1.5em"
      >
        {payload.name}
      </text>
      <Sector
        cx={cx}
        cy={cy}
        innerRadius={innerRadius}
        outerRadius={outerRadius}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
      <Sector
        cx={cx}
        cy={cy}
        startAngle={startAngle}
        endAngle={endAngle}
        innerRadius={outerRadius + 6}
        outerRadius={outerRadius + 10}
        fill={fill}
      />
      <path
        d={`M${sx},${sy}L${mx},${my}L${ex},${ey}`}
        stroke={fill}
        fill="none"
      />
      <circle cx={ex} cy={ey} r={2} fill={fill} stroke="none" />
      <text
        x={ex + (cos >= 0 ? 1 : -1) * 12}
        y={ey}
        textAnchor={textAnchor}
        fill="#333"
        fontSize="1.5em"
      >{`PV ${value}`}</text>
      <text
        x={ex + (cos >= 0 ? 1 : -1) * 12}
        y={ey}
        dy={18}
        textAnchor={textAnchor}
        fill="#999"
        fontSize="1.0em"
      >
        {`(Rate ${(percent * 100).toFixed(2)}%)`}
      </text>
    </g>
  );
};

export const CustomActiveShapePieChart = () => {
  const [activeIndex, setActiveIndex] = useState(0);
  const onPieEnter = useCallback(
    (_: string, index: number) => {
      setActiveIndex(index);
    },
    [setActiveIndex]
  );

  return (
    <ResponsiveContainer width="80%" height={400}>
      <PieChart>
        <Pie
          activeIndex={activeIndex}
          activeShape={renderActiveShape}
          data={data}
          innerRadius="60%"
          fill="#8884d8"
          dataKey="value"
          onMouseEnter={onPieEnter}
        />
      </PieChart>
    </ResponsiveContainer>
  );
};

基本的な事項の解説

<PieChart><Pie /></PieChart>のように、
PieChartコンポーネントの children としてPieコンポーネントを渡すことで円グラフを描画しています。

円グラフにマウスをホバーさせたときのラベルのアニメーションは、mouse enter イベントをトリガーに実行されるコールバック関数をonMouseEnterプロパティに設定することで設定しています。(React らしくuseStateuseCallbackを使って実装されてますね)

この円グラフのコア部分になるラベルの表示部分については、labelプロパティに DOM 要素を返す関数renderActiveShapeを設定することで実装されています。
この関数が返している SVG 要素の細かい内容については(ただちゃんと読んでないだけですが)今回は割愛したいと思います。

公式からカスタマイズした点

recharts のサンプルコードはすべて JavaScript で書かれていますが、基本的にはちょっとした修正で TypeScript に変換できます。

ただし、今回の円グラフは実は簡単にはいかない代物になっています。

詰まりポイントとしては、renderActiveShape関数の引数propsの型定義が難しい点にあります。

github のソースコードを見てみると、PieコンポーネントのactivShapeプロパティに渡せる値の型はActiveShape型であり、
ActiveShape型は具体的には以下であることがわかります。

export type ActiveShape<
  PropsType = Record<string, any>,
  ElementType = SVGElement
> =
  | ReactElement<SVGProps<ElementType>>
  | ((props: PropsType) => ReactElement<SVGProps<ElementType>>)
  | ((props: unknown) => JSX.Element)
  | SVGProps<ElementType>
  | boolean;

また、僕の使っている VScode にrenderActiveShape関数の返り値の型は何か聞いてみると、下記のようにJSX.Element型であることが分かります。

ここで、いま得た2つの情報を見比べてみると、
ActiveShape型の中で関数かつ返り値がJSX.Element型であるのは(props: unknown) => JSX.Element型のみであることがわかります。

ということで、renderActiveShape関数の引数propsの型はunknown型とするのが良さそうです。
(この辺りは深く理解できていない部分もあるので、もし間違っている点あったらぜひ教えてください)


ということで、ここからはrenderActiveShape関数の引数の型をunknown型にして、型の絞り込みを行って TypeScript のコンパイルエラーを解消していく部分の解説をします。

型の絞り込み

型の絞り込みによってunknown型を以下のActiveShapeProps型に絞り込みます。

const data = [
  { name: "Group A", value: 400 },
  { name: "Group B", value: 300 },
  { name: "Group C", value: 300 },
  { name: "Group D", value: 200 },
];

type Data = (typeof data)[number];

type ActiveShapeProps = {
  cx: number;
  cy: number;
  midAngle: number;
  innerRadius: number;
  outerRadius: number;
  startAngle: number;
  endAngle: number;
  fill: string;
  payload: Data;
  percent: number;
  value: number;
};

型の絞り込みでは、まずunknown型からプロパティアクセスできる型への絞り込みを行います。

具体的には、以下のようなユーザー定義型ガードの関数isPropertyAccessibleを作ります。

const isPropertyAccessible = (
  value: unknown
): value is { [key: string]: unknown } => {
  return value != null;
};

「プロパティアクセスできる型」は{ [key: string]: unknown }と表せることがポイントです。

(この実装は 「プロを目指す人のための TypeScript 入門」のコラム 30 の内容を参考にしました。詳細が知りたい型はそちらを参照ください。)

続いて、プロパティアクセスできる型からActiveShapeProps型に絞り込むためのユーザー定義型ガードを愚直に作ります。

const isActiveShapeProps = (props: unknown): props is ActiveShapeProps => {
  if (!isPropertyAccessible(props)) return false;
  if (!isPropertyAccessible(props.payload)) return false;
  return (
    typeof props.cx === "number" &&
    typeof props.cy === "number" &&
    typeof props.midAngle === "number" &&
    typeof props.innerRadius === "number" &&
    typeof props.outerRadius === "number" &&
    typeof props.startAngle === "number" &&
    typeof props.endAngle === "number" &&
    typeof props.fill === "string" &&
    typeof props.payload.name === "string" &&
    typeof props.payload.value === "number" &&
    typeof props.percent === "number" &&
    typeof props.value === "number"
  );
};

上記のユーザー定義型ガードisActiveShapePropsunknow型の絞り込みに使えば、無事コンパイルエラーを解消することができます!めでたしめでたし!

まとめ

recharts の発展的な円グラフCustomActiveShapePieChartの概要の説明と、TypeScript 化のポイントについて説明しました。

正直anyasを使えばここまでせずとも突破できるとは思いますが、TypeScript に負けた??気がするのであまりやりたくないですよね。

この情報が誰かの役に立つことがあれば嬉しいです。

Discussion