はじめに
複雑なデータビジュアライゼーションで細部を拡大して見せるためのインタラクションとしてZoom & panがあります。JavaScriptによるデータビジュアライゼーションのためのZoom & panの実装としては、d3-zoomが有名です。Observableで様々な実装例を確認することができます。
https://observablehq.com/collection/@d3/d3-zoom
Webフロントエンド開発でReactが使われる場面は増えていますが、d3-zoomはD3.jsの一部であるため、d3-selectionをベースにしたDOM操作が前提とされています。本稿では、d3-zoomによるZoom & panの実装を、Reactアプリに組み込む方法を紹介します。
シンプルに実装する
細かいことを気にしなければ、ReactのuseRef
等を使った実DOM操作によって、Reactアプリにd3-zoomを組み込むことができます。
d3-zoomでイベントが発生した際に、イベントから拡大と移動の情報を取り出して、Reactの状態を更新し、Reactで拡大と移動の transform
を適用すれば良いでしょう。
実装例を以下に示します。
import { useEffect, useRef, useState } from "react";
import * as d3 from "d3";
function generateData(n, maxR) {
const data = [];
for (let i = 0; i < n; ++i) {
const r = Math.random() * maxR;
const t = Math.random() * Math.PI * 2;
data.push({
x: r * Math.cos(t),
y: r * Math.sin(t),
});
}
return data;
}
function Chart({ data }) {
const width = 300;
const height = 300;
const svgRef = useRef();
const [k, setK] = useState(1);
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const zoom = d3.zoom().on("zoom", (event) => {
const { x, y, k } = event.transform;
setK(k);
setX(x);
setY(y);
});
d3.select(svgRef.current).call(zoom);
}, []);
return (
<svg ref={svgRef} width={width} height={height}>
<g transform={`translate(${x},${y})scale(${k})`}>
<g transform={`translate(${width / 2},${height / 2})`}>
{data.map(({ x, y }, i) => {
return (
<g key={i}>
<circle r="3" cx={x} cy={y} />
</g>
);
})}
</g>
</g>
<text x="10" y="50">
fixed content
</text>
</svg>
);
}
export default function App() {
const [data, setData] = useState([]);
const n = 100;
const maxR = 100;
useEffect(() => {
setData(generateData(n, maxR));
}, []);
return (
<div>
<div>
<Chart data={data} />
</div>
<div>
<button
onClick={() => {
setData(generateData(n, maxR));
}}
>
update
</button>
</div>
</div>
);
}
コンポーネントの再描画を抑制する
Zoom & panを行う際には、マウスの移動に合わせて非常に高い頻度でイベントが発生します。SVGによるチャートの描画では多数の要素を描画するため、データ量によっては短時間で再描画を繰り返すとパフォーマンスの問題を生じる場合があります。
適切にコンポーネント分割を行うことで、Zoom & pan時のコンポーネントの再描画を減らすことを考えます。しかし、Zoom & panによって transform
が適用される要素はチャートのコンテンツよりも外側にあるため、通常は親コンポーネントの再描画によって子孫コンポーネントも再描画されてしまいます。
これを解決するために、Zoom & panによる transform
の適用を行う ZoomableSVG
と、チャートのコンテンツを表す ChartContent
の2つのコンポーネントを作成し、ZoomableSVG
に対して、children
を通じて ChartContent
を渡します。
完成形のサンプルは以下の通りになります。
import { useEffect, useRef, useState } from "react";
import * as d3 from "d3";
function generateData(n, maxR) {
const data = [];
for (let i = 0; i < n; ++i) {
const r = Math.random() * maxR;
const t = Math.random() * Math.PI * 2;
data.push({
x: r * Math.cos(t),
y: r * Math.sin(t),
});
}
return data;
}
function ZoomableSVG({ children, width, height }) {
console.log("ZoomableSVG");
const svgRef = useRef();
const [k, setK] = useState(1);
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const zoom = d3.zoom().on("zoom", (event) => {
const { x, y, k } = event.transform;
setK(k);
setX(x);
setY(y);
});
d3.select(svgRef.current).call(zoom);
}, []);
return (
<svg ref={svgRef} width={width} height={height}>
<g transform={`translate(${x},${y})scale(${k})`}>{children}</g>
<text x="10" y="50">
fixed content
</text>
</svg>
);
}
function ChartContent({ width, height, data }) {
console.log("ChartContent");
return (
<g transform={`translate(${width / 2},${height / 2})`}>
{data.map(({ x, y }, i) => {
return (
<g key={i}>
<circle r="3" cx={x} cy={y} />
</g>
);
})}
</g>
);
}
function Chart({ data }) {
console.log("Chart");
const width = 300;
const height = 300;
return (
<ZoomableSVG width={width} height={height}>
<ChartContent width={width} height={height} data={data} />
</ZoomableSVG>
);
}
export default function App() {
const [data, setData] = useState([]);
const n = 100;
const maxR = 100;
useEffect(() => {
setData(generateData(n, maxR));
}, []);
return (
<div>
<div>
<Chart data={data} />
</div>
<div>
<button
onClick={() => {
setData(generateData(n, maxR));
}}
>
update
</button>
</div>
</div>
);
}
CodePenでも動作確認ができるようにしました。
See the Pen by Yosuke Onoue (@likr) on CodePen.
コンソールを見ると、Zoom & panの操作を行った際に ZoomableSVG
のみが再描画されていて、ChartContent
の再描画は行われていないことが確認できます。