📐

Rechartsでグラフをごりごりカスタマイズするぞ!(RadarChart編 第二回)

2024/07/31に公開

はじめに

前回はこちらでRechartsをごりごりカスタマイズする方法について話しました。
https://zenn.dev/10tera/scraps/d4003c2c168973

前回はRadarChartを対象に

  • データの差分に色をつける方法
  • Labelをごりごりにカスタマイズする方法とそのテクニック

について話しました。
今回はLabelのカスタマイズ方法part2について話そうと思います。

Link

https://recharts.org/en-US
https://recharts.org/en-US/api/RadarChart

[復習]どうやって任意の要素をLabelに設定する?

RadarChartのLabelに任意の要素を描画したい場合は、PolarAngleAxisのtickに要素を渡すことで実現可能です。
https://recharts.org/en-US/api/PolarAngleAxis#tick

例えば。。。

<PolarAngleAxis
    dataKey="subject"
    tick={(label) => (
        <text x={label.x} y={label.y} textAnchor={label.textAnchor}>
            {label.payload.value}
        </text>
    )}
/>
全体
<ResponsiveContainer width="100%" height="100%">
          <RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
            <PolarGrid />
            <PolarAngleAxis
              dataKey="subject"
              tick={(label) => (
                <text x={label.x} y={label.y} textAnchor={label.textAnchor}>
                  {label.payload.value}
                </text>
              )}
            />
            <PolarRadiusAxis angle={30} domain={[0, 150]} />
            <Radar
              name="Mike"
              dataKey="A"
              stroke="#8884d8"
              fill="#8884d8"
              fillOpacity={0.6}
            />
            <Radar
              name="Lily"
              dataKey="B"
              stroke="#82ca9d"
              fill="#82ca9d"
              fillOpacity={0.6}
            />
            <Legend />
          </RadarChart>
        </ResponsiveContainer>

方法1(復習)

svg elementを駆使して頑張る、ただそれだけです。
前回はこの方法と位置制御を組み合わせて実装していました。
ここでみなさん思ったことがあるはずです。そう。。。

SVG要素を手書きしたくない!!!

ということで、一般的な<div/>や<p/>などを使う方法が。。。

方法2(foreignObjectを使う)

foreignObjectとはsvgの中に<div/>などを埋め込めるようにするための要素です。
https://developer.mozilla.org/ja/docs/Web/SVG/Element/foreignObject

<PolarAngleAxis
    dataKey="subject"
    tick={(label) => (
        <g transform={`translate(${label.x},${label.y})`}>
            <foreignObject
                xmlns="http://www.w3.org/1999/xhtml"
                width={100}
                height={100}
            >
                <p>{label.payload.value}</p>
            </foreignObject>
        </g>
    )}
/>

これで普通の要素も描画できるようになりました!
(ただwidth,heightを指定しないといけないのがなんかなぁと。。。)

方法3(createPortalを使う)

createPortalとは子要素をDOM上の別の場所にレンダリングできる機能です。
https://ja.react.dev/reference/react-dom/createPortal
これを使って、CustomLabelをグラフコンポーネントの外(<svg/>の外側)に描画させようという作戦です。

まずは<RadarChart/>をwrapする<div/>を作り、idを指定します。この時position:"relative"を忘れないようにしましょう。理由はCustomLabelをposition:"absolute"で親要素を基準に重ねて配置するからです。

<div
    id={id}
    style={{
        width: "700px",
        height: "700px",
        position: "relative",
    }}
>
    <ResponsiveContainer width="100%" height="100%">
        <RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
            {/* 省略 */}
        </RadarChart>
    </ResponsiveContainer>
</div>

次にCustomLabelを作成します。

type Props = {
  label: any;
  parentId: string;
  children: ReactNode;
};

const CustomLabel = ({ label, parentId, children }: Props) => {
  const parentElement = document.getElementById(parentId);

  const x = label.x as number;
  const y = label.y as number;

  if (!parentElement) return <></>;

  return createPortal(
    <div
      style={{
        position: "absolute",
        top: `${y}px`,
        left: `${x}px`,
        zIndex: "100",
      }}
    >
      {children}
    </div>,
    parentElement
  );
};

CustomLabelを使うときは描画したい要素を子要素に入れることで、svg elementでない要素も描画できるようになります。

<PolarAngleAxis
    dataKey="subject"
    tick={(label) => (
        <CustomLabel label={label} parentId={id}>
            <div
                style={{
                    padding: "16px 8px",
                    background: "white",
                    borderRadius: "8px",
                    border: "2px solid gray",
                }}
            >
                {label.payload.value}
            </div>
        </CustomLabel>
    )}
/>

次にCustomLabelの位置制御を実装します。現状だとCustomLabelの左上が基準になってしまっているので、調整する必要があります。
前回ではlabel.textAnchorとMath.sin/cosを使った制御をしました。今回は若干別の方法を使います。

最初に位置制御の基準をCustomLabel中央にする

const ref = useRef<HTMLDivElement>(null);

const [labelWidth, setLabelWidth] = useState(0);
const [labelHeight, setLabelHeight] = useState(0);

useEffect(() => {
    const resizeObserver = new ResizeObserver((entries) =>
      entries.map((entry) => {
        if (entry.target === ref.current) {
          setLabelWidth(entry.borderBoxSize[0].inlineSize);
          setLabelHeight(entry.borderBoxSize[0].blockSize);
        }
      })
    );

    if (ref.current) {
      resizeObserver.observe(ref.current);
    }

    return () => {
      resizeObserver.disconnect();
    };
  }, []);

let x = label.x as number;
let y = label.y as number;
x = x - labelWidth / 2;
y = y - labelHeight / 2;

中央から均等に離す

懐かしの三角関数を使います。

const angle = label.payload.coordinate;
const rad = (angle * Math.PI) / 180;
x = x + gap * Math.cos(rad) - labelWidth / 2;
y = y - gap * Math.sin(rad) - labelHeight / 2;

gapはpropsで外側から自由に変えれるようになっています。

ここでもう少しだけgapを大きくしてみました。

(なんか上下のLabelが他よりも離れている気がする。。。)
条件によってはバランスが若干崩れてしまう可能性があるため、↓を実装することにしました。

CustomLabelの幅/高さの比率によって、変位を制御する

const wRate = labelWidth / (labelHeight + labelWidth);
const hRate = labelHeight / (labelHeight + labelWidth);
x = x + gap * wRate * Math.cos(rad) - labelWidth / 2;
y = y - gap * hRate * Math.sin(rad) - labelHeight / 2;

果たしてこの方法が正しいのかは自分でもよくわかっていませんが、、、

なんだかマシになった気がしますね。

コード全体
import {
  Legend,
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
  ResponsiveContainer,
} from "recharts";
import { ConvertToSvg } from "./ConvertToSvg";
import { ReactNode, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";

type Props = {
  label: any;
  parentId: string;
  children: ReactNode;
  gap: number;
};

const CustomLabel = ({ label, parentId, children, gap }: Props) => {
  const ref = useRef<HTMLDivElement>(null);

  const [labelWidth, setLabelWidth] = useState(0);
  const [labelHeight, setLabelHeight] = useState(0);
  const parentElement = document.getElementById(parentId);

  useEffect(() => {
    const resizeObserver = new ResizeObserver((entries) =>
      entries.map((entry) => {
        if (entry.target === ref.current) {
          setLabelWidth(entry.borderBoxSize[0].inlineSize);
          setLabelHeight(entry.borderBoxSize[0].blockSize);
        }
      })
    );

    if (ref.current) {
      resizeObserver.observe(ref.current);
    }

    return () => {
      resizeObserver.disconnect();
    };
  }, []);

  let x = label.x as number;
  let y = label.y as number;
  const angle = label.payload.coordinate;
  const rad = (angle * Math.PI) / 180;
  const wRate = labelWidth / (labelHeight + labelWidth);
  const hRate = labelHeight / (labelHeight + labelWidth);
  x = x + gap * wRate * Math.cos(rad) - labelWidth / 2;
  y = y - gap * hRate * Math.sin(rad) - labelHeight / 2;

  if (!parentElement) return <></>;

  return createPortal(
    <div
      ref={ref}
      style={{
        position: "absolute",
        top: `${y}px`,
        left: `${x}px`,
        zIndex: "100",
      }}
    >
      {children}
    </div>,
    parentElement
  );
};

export const CustomRadarChart = () => {
  const id = useId();

  const data = [
    {
      subject: "Math",
      A: 120,
      B: 110,
      fullMark: 150,
    },
    {
      subject: "Chinese",
      A: 98,
      B: 130,
      fullMark: 150,
    },
    {
      subject: "English",
      A: 86,
      B: 130,
      fullMark: 150,
    },
    {
      subject: "Geography",
      A: 99,
      B: 100,
      fullMark: 150,
    },
    {
      subject: "Physics",
      A: 85,
      B: 90,
      fullMark: 150,
    },
    {
      subject: "History",
      A: 65,
      B: 85,
      fullMark: 150,
    },
  ];

  return (
    <div
      style={{
        paddingTop: "100px",
        paddingLeft: "50px",
      }}
    >
      <div
        id={id}
        style={{
          width: "700px",
          height: "700px",
          position: "relative",
        }}
      >
        <ResponsiveContainer width="100%" height="100%">
          <RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
            <PolarGrid />
            <PolarAngleAxis
              dataKey="subject"
              tick={(label) => (
                <CustomLabel label={label} parentId={id} gap={170}>
                  <div
                    style={{
                      width: "100px",
                      padding: "16px 8px",
                      background: "white",
                      borderRadius: "8px",
                      border: "2px solid gray",
                    }}
                  >
                    {label.payload.value}
                  </div>
                </CustomLabel>
              )}
            />

            <PolarRadiusAxis angle={30} domain={[0, 150]} />
            <Radar
              name="Mike"
              dataKey="A"
              stroke="#8884d8"
              fill="#8884d8"
              fillOpacity={0.6}
            />
            <Radar
              name="Lily"
              dataKey="B"
              stroke="#82ca9d"
              fill="#82ca9d"
              fillOpacity={0.6}
            />
          </RadarChart>
        </ResponsiveContainer>
      </div>
      <ConvertToSvg>
        <div
          style={{
            padding: "0px 20px",
            background: "white",
            borderRadius: "12px",
            border: "2px solid blue",
            display: "flex",
          }}
        >
          <p
            style={{
              fontSize: "24px",
              lineHeight: "130%",
            }}
          >
            Test
          </p>
        </div>
      </ConvertToSvg>
    </div>
  );
};

最後に

svg要素以外の要素も描画できるようになり、よりごりごりにカスタマイズできるようになったのではないでしょうか。
使い回すことができるCustomLabel Componentを実装することができて、とても満足な気持ちです。

皆さんも、Rechartsでごりごりカスタマイズしてみませんか?

余談

vercel/satoriを使ってsvgに変換して埋め込む方法もやってみましたが、なんかうまくいかなかったので断念しました。
https://github.com/vercel/satori

Discussion