7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに

複雑なデータビジュアライゼーションで細部を拡大して見せるためのインタラクションとして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 の再描画は行われていないことが確認できます。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?