Rechartsでグラフをごりごりカスタマイズするぞ!(RadarChart編 第二回)
はじめに
前回はこちらでRechartsをごりごりカスタマイズする方法について話しました。
前回はRadarChartを対象に
- データの差分に色をつける方法
- Labelをごりごりにカスタマイズする方法とそのテクニック
について話しました。
今回はLabelのカスタマイズ方法part2について話そうと思います。
Link
[復習]どうやって任意の要素をLabelに設定する?
RadarChartのLabelに任意の要素を描画したい場合は、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/>などを埋め込めるようにするための要素です。
<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上の別の場所にレンダリングできる機能です。
これを使って、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に変換して埋め込む方法もやってみましたが、なんかうまくいかなかったので断念しました。
Discussion