Reactにd3-zoomを良い感じに組み込む

# はじめに

複雑なデータビジュアライゼーションで細部を拡大して見せるためのインタラクションとしてZoom & panがあります。JavaScriptによるデータビジュアライゼーションのためのZoom & panの実装としては、[d3-zoom](https://github.com/d3/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` を適用すれば良いでしょう。

実装例を以下に示します。

```jsx
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` を渡します。

完成形のサンプルは以下の通りになります。


```jsx
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でも動作確認ができるようにしました。

<p class="codepen" data-height="500" data-default-tab="js,result" data-slug-hash="vYmBEPE" data-user="likr" style="height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/likr/pen/vYmBEPE">
  </a> by Yosuke Onoue (<a href="https://codepen.io/likr">@likr</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

コンソールを見ると、Zoom & panの操作を行った際に `ZoomableSVG` のみが再描画されていて、`ChartContent` の再描画は行われていないことが確認できます。