FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

visxを用いて、Reactアプリケーション上での散布図の表現を広げる

こんにちは、株式会社FLINTERSでエンジニアをやっている丸山です。

この記事は2024年1月にFLINTERSが10周年を迎えることを記念して、133日連続でブログを書き続けるチャレンジの一環として書かれました。本記事は73日目の記事となります。

ちなみに63日目に投稿している丸山とは別の丸山になります。

今回の記事は、現在Reactを使って開発しているWebアプリケーションでデータの可視化を行う際に、visxという低レベルビジュアライゼーションコンポーネントライブラリを使用したので、それについて書きたいと思います。

はじめに

 データを取り扱う際、その可視化というのは重要です。
 これはデータを取り扱うWebアプリケーションでも同じであり、そのためWebアプリケーション開発で使われるJavaScript1や、JavaScriptを代替、拡張するようなAltJSであり静的型システムを特徴とするTypeScript2で使用するためのデータ可視化のためのライブラリもChart.js3やD34などを筆頭に多数存在しています。
 また、JavaScriptやTypeScriptを用いたWebアプリケーション開発では画面を表現するDOM(Document Object Model)5をUIフレームワークを通して操作することが多く、特にUIフレームワークの中ではFacebookが開発したReact6が有名であり、Reactを使ったデータ可視化用のライブラリもplotly7やnivo8などが存在します。
 しかし、今まで例示したライブラリの多くは開発者側の負担を下げるために高度に抽象化されており、表現力を犠牲にしてコードの記述量を減らすような使いやすさに最適化されていると言われています9。このため、可視化について複雑な要件があったり、データが複雑な時にその可視化が困難となります。
 一方で、そういった複雑な可視化の要件を満たすことができるようなD3というライブラリも存在します。しかしこれは、Reactを使用しているわけではないため、Reactを使用しているWebアプリケーションにD3を導入すると、DOMについて操作を行うものがReactとD3の2つが共存し、バグが発生してしまうおそれがあります。 また、Reactを用いてWebアプリケーションを開発することに慣れている開発者がD3を新たに学習する際、React以外でのDOMの操作について新しく学習する必要があるため、多くの学習コストが発生してしまいます。

 こうした課題に対してAirbnbがD3をReactでラッピングしたvisx10というライブラリを公開しています。 これにより、Reactベースで開発しているWebアプリケーションに対して、D3のような高機能な可視化ツールを導入コストを抑えて導入することができます。 図1では、今までの課題を整理して各ライブラリの対応関係をマッピングしています。

図1. JavaScript/TypeScript用のデータビジュアライゼーションライブラリについて、Reactの使用の有無、ライブラリが導入の容易もしくは高機能であるかの対応関係を表した図*1

 ここではD3のReactラッパーであるvisxを用いて、ユーザーがグラフについて拡大縮小を行え、マウスカーソルを用いてグラフ上のデータの詳細を見ることができるような散布図を作成しながらvisxの使い方を学んでいきます。
 全体の流れは次になります。 まずはじめに、データとグラフの構造を確認します。 その次にHTML要素の1つであるsvg要素のみで簡単な散布図を作成し、svgのあつかいを確認します。 その次にユーザーがグラフに対して操作を行える要素を省いたシンプルな散布図をvisxを用いて作成します。 その次に拡大縮小を行える機能のみを追加した散布図を作成します。 その次にマウスカーソルを用いて詳細なデータを表示できる機能のみを追加した散布図を作成します。 最後に拡大縮小を行う機能、マウスカーソルを用いて詳細なデータを表示できる機能を組み合わせた散布図を作成します。
 また、付録として、マウスカーソルを用いてデータの詳細を見る機能で使用する@visx/delaunayについて、類似のライブラリである@visx/voronoiとの違い、拡大縮小を行う機能とマウスカーソルを用いて詳細なデータを表示できる機能を組み合わせた散布図について、さらに細かな機能を追加した例について紹介します。

データと散布図

 visxで作る散布図について考えます。
 データはxとyの2次元の直交座標系で表現できる座標を持ち、更にデータ全体でユニークなIDを持ちます。 このデータについてTypeScriptでで表すと次のようになります。

type Data = {
    id: string;
    x: number;
    y: number;
}

 作成する散布図ですが、ユーザーが散布図に対して行える操作として2つの機能を提供します。
 まず1つ目は、グラフ描画エリア内でグラフの拡大縮小を行える様にします。これにより、散布図においてデータが集中している箇所を拡大して表示することによって詳細に観察することができ、縮小して表示することによって全体を俯瞰して観察することができます。
 2つ目は、散布図上のマウスカーソルから最も近いデータを取得し、そのデータの詳細を見ることができる吹き出しを表示します。これによって、データの詳細を記載した表を見ることなく、グラフ上からデータの詳細を調べられる様になります。

実装

 拡大縮小を行う機能とマウスカーソルを用いて詳細なデータを表示できる機能を組み合わせた散布図を作成するために必要な機能について、1つ1つ分割して散布図を作成するコードを実装しながらvisxの使い方について学んでいきます。
 実装の順番は、svg要素のみを用いた散布図、visxを用いた単純な散布図、グラフについて拡大縮小を行うために@visx/zoomを用いた散布図、マウスカーソルに最も近いデータの詳細を確認するために@visx/delaunayを用いた散布図、最終目標であるグラフについて拡大縮小行え、マウスカーソルに最も近いデータの詳細を確認するために@visx/zoom@visx/delaunayを用いた散布図という順番になります。

svg要素のみを用いた散布図の作成

 visxでのグラフの描画はHTMLのsvg11要素内で描画が行われるため、まずはsvg要素の挙動の確認のために、svg要素のみで簡単な散布図を作成します。

 HTML内で描画を行うsvg要素にはwidthheightが必要です。これは画面上でのsvg要素の描画領域のサイズを扱っており、width={"400px"}width={"100%"}のように単位を指定せずにwidth={400}のように数値のみを入力するとpx単位でサイズが決定します。また、このsvg要素上で描画される要素は、必ずxyの座標を指定してすることになりますが、このときsvg要素上での座標は描画領域の左上を原点として、右に向かって進むにつれてxの値が増加し、下に向かって進むにつれてyの値が増加します。また、その時の値はpxを単位としており、x方向について、widthの値より大きな値に描画される場合はその部分はsvg要素の描画領域に入らず、画面上に描画されないことになります。また同様に、xの値が0より小さな値に描画される場合も描画領域に入らず描画されないことになってしまいます。また、y方向についてはheightが対応しています。 これついて端的に図にまとめたものが図2となります。

図2. svg要素上での座標系を表した図*2

 例えば次の様なデータを生成する関数を作成します。
 この関数は31個のデータを生成しますが、データの座標についてはx, yともに-100から500まで、20ずつ増加します。

function generateData(): Data[] {
  return new Array(31).fill(null).map((_, i) => ({
    id: `ID:${i}`,
    x: i * 20 - 100,
    y: i * 20 - 100,
  }));
}

 このデータを用いてsvg要素のみで散布図を作成する次のコードを実行すると次の図3が描画されます。

import { Fragment } from "react";

function SvgGraph() {
  const width = 400;
  const height = 400;
  const data = generateData();

  return (
    <svg width={width} height={height}>
      <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
      {data.map((d) => (
        <Fragment key={d.id}>
          <circle cx={d.x} cy={d.y} r={2} fill={"#000"} />
          <text x={d.x} y={d.y}>
            (x: {d.x}, y: {d.y})
          </text>
        </Fragment>
      ))}
    </svg>
  );
}

図3. widthheightが400であるsvg要素上で、(-100, -100)から(500, 500)まで<text><circle>をx座標y座標ともに20ずつ値を増加させながら描画した図

 図3からはsvg要素内での座標はx座標、y座標ともに左上から右下に向かって増加するようになっており、0より小さい座標に描画される箇所とsvgのwidthheightより大きい座標に表示される箇所はsvgの描画領域に描画されないということが確認できます。

 この課題についてはsvg要素のviewBox属性12を使うことによって解決することが可能です。viewBox属性は4つの数字をスペース区切りで記された値をとります。この4つの数字はx座標の最小の値y座標の最小の値x方向の幅y方向の幅の値が入ります。

 例えば、先ほどの例を利用して、svg要素のviewBox属性についての値を次のコードのように"-100 -100 500 500"とした場合は、xとyの座標が-100から500の幅の領域で描画されることとなるため、図4のように図3では描画されなかった0より小さい座標に描画される要素やwidthheightの値である400より大きな座標に描画されている要素も確認することができます。

<svg width={width} height={height} viewBox="-100 -100 500 500">
  <rect x={-100} y={-100} width={500} height={500} fill={"#E3E3E3"} />
  {data.map((d) => (
    <Fragment key={d.id}>
      <circle cx={d.x} cy={d.y} r={2} fill={"#000"} />
      <text x={d.x} y={d.y}>
        (x: {d.x}, y: {d.y})
      </text>
    </Fragment>
  ))}
</svg>

図4. widthheightが400であるsvg要素のviewBox属性の値を"-100 -100 500 500"とした上で、(-100, -100)から(500, 500)まで<text><circle>をx座標y座標ともに20ずつ値を増加させながら描画した図

 ただし、svg要素の座標系は左上を原点にして、右下に向かってxとyが増加するようになっているため、viewBox属性の値を次のコードのように-100 500 500 -500のような値にしてもy座標の方向が上下反転して描画されるということはなく、図5のように描画され、DevTools上のConsoleではError: <svg> attribute viewBox: A negative value is not valid. ("500 -100 -100 500")というエラーが表示されることも確認できます。

<svg width={width} height={height} viewBox="-100 500 500 -500">
  <rect x={-100} y={-100} width={500} height={500} fill={"#E3E3E3"} />
  {data.map((d) => (
    <Fragment key={d.id}>
      <circle cx={d.x} cy={d.y} r={2} fill={"#000"} />
      <text x={d.x} y={d.y}>
        (x: {d.x}, y: {d.y})
      </text>
    </Fragment>
  ))}
</svg>

図5. widthheightが400であるsvg要素のviewBox属性の値を"-100 500 500 -500"とした上で、(-100, -100)から(500, 500)まで<text><circle>をx座標y座標ともに20ずつ値を増加させながら描画した図

visxを用いて単純な散布図の作成

 前節ではsvg要素上でviewBox属性を指定せずに描画を行おうとすると、その座標はsvgのwidthheightに縛られ、その値より大きな座標には描画できず、また、0より小さい値の描画はできないということを確認しました。また、viewBox属性に値を指定しても、座標の方向を変更することはできず、必ず左上を原点として、右下に向かってxとyが増加することを確認しました。

 では、今度はvisxを用いて上記の課題を解決した散布図を作成します。 visxには@visx/scale13というd3-scale14のラッパーが存在しており、データの座標を別のスケールに変換を行う機能を提供しています。

例えば @visx/scaleからscaleLinearを使う場合次のような使い方ができます。

const width = 400;
const data = generateData();
const xMin = Math.min(...data.map((d) => d.x));
const xMax = Math.max(...data.map((d) => d.x));

const xScale = scaleLinear({
  domain: [xMin, xMax],
  range: [0, width],
});

このxScaleは、xを入力とした時に次の変換をします。

\displaystyle{
f(x _ i) = (x _ i - x _ {min}) \frac{width}{x _ {max} - x _ {min}}
}

 つまり、dataについての配列の中でx座標の最小値がxMin、最大値がxMaxとした時に、横幅がwidthであるsvg要素の描画領域に全てのdataが描画される様にxの値を変換します。

 では、この@visx/scaleを利用して散布図を作成します。コードは次になり、その描画結果は図6になります。

import { scaleLinear } from "@visx/scale";
import { Fragment } from "react";

function Simple() {
  const width = 400;
  const height = 400;
  const data = generateData();
  const xMin = Math.min(...data.map((d) => d.x));
  const xMax = Math.max(...data.map((d) => d.x));
  const yMin = Math.min(...data.map((d) => d.y));
  const yMax = Math.max(...data.map((d) => d.y));

  const xScale = scaleLinear({
    domain: [xMin, xMax],
    range: [0, width],
  });

  const yScale = scaleLinear({
    domain: [yMin, yMax],
    range: [0, height],
  });

  return (
    <svg width={width} height={height}>
      <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
      {data.map((d) => (
        <Fragment key={d.id}>
          <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2} fill={"#000"} />
          <text x={xScale(d.x)} y={yScale(d.y)}>
            (x: {d.x}, y: {d.y})
          </text>
        </Fragment>
      ))}
    </svg>
  );
}

図6. @visx/scaleを使用してsvg要素上に(-100, -100)から(500, 500)まで<text><circle>をx座標y座標ともに20ずつ値を増加させながら描画した図

 svg上でtext要素を描画する時は指定する座標がtext要素の左下となるため、データ全体の中でxの最大値、yの最小値となる様な座標のデータのtext要素は見えないが、viewBoxの値を指定しなかったsvg要素の散布図で作成した場合に描画されなかったマイナスの値の座標のデータやwidthheightより大きい座標のデータが描画されるようになっているのが確認できる。

 また、yScaleについてrangeの順番を入れ替え次のようにすると、図7の結果になり、y軸の方向が入れ替わり、下から上に向かって値が増加するようなったのが確認できる。

const yScale = scaleLinear({
  domain: [yMin, yMax],
  range: [height, 0],
});

図7. @visx/scaleを使用してrangeの値を[height, 0]の順番に変更し、y軸について下から上に向かって値が増加するようになった図

 このほかにも@visx/scalescaleLinear以外にも対数スケールのscaleLogを用意していたりするため、さまざまなスケールに変換することができます。

 つまり、@visx/scaleを使うとsvg要素上に描画したいデータの範囲を柔軟に変更することができることが確認できました。

@visx/zoomを用いてグラフについて拡大縮小を行える散布図の作成

 ここからはユーザーがグラフに対して操作を行えるようにします。 まずはグラフに対して拡大縮小を行える機能を追加します。

 今までのグラフは扱うデータが重ならずに描画されるように生成されていたため、svg要素上に全てのデータを表示してもtext要素で描画されている文字を読むことができていました。しかしこれがもし、扱うデータの数が100個に増え、かつ座標がランダムに描画されるようになった場合はどうなるでしょうか?これは図8のように描画され、text要素で描画されている文字を読むことが困難になります。

図8. ランダムな座標に100個のデータを描画した図

 しかし、このようなグラフでも、描画要素について拡大縮小を行うことができるようになれば、データが密集している箇所を拡大することによってデータや文字の重なりがなくなり、見やすくなります。これを実現するのが@visx/zoom15になります。

 まずはデータの座標が0から400の範囲でランダム生成され、データの個数も任意の数生成する方法を記載します。

function generateData(volume: number): Data[] {
  return new Array(volume).fill(null).map((_, i) => ({
    id: `ID:${i}`,
    x: Math.random() * 400,
    y: Math.random() * 400,
  }));
}

 次に@visx/zoomを導入していきます。まずは単純にマウスを使ってドラッグすることによってグラフを左右に移動させることができ、スクロールによって拡大縮小をできるということだけをできるようにします。

import { scaleLinear } from "@visx/scale";
import { Zoom } from "@visx/zoom";
import { Fragment } from "react";

function ZoomGraph() {
  const width = 400;
  const height = 400;
  const data = generateData(100);

  // データの生成範囲は0から400までのランダムなので対応してdomainを設定する
  const xScale = scaleLinear({
    domain: [0, 400],
    range: [0, width],
  });
  const yScale = scaleLinear({
    domain: [0, 400],
    range: [height, 0],
  });

  return (
    <Zoom<SVGSVGElement> width={width} height={height}>
      {(zoom) => (
        <svg
          width={width}
          height={height}
          style={{
            cursor: zoom.isDragging ? "grabbing" : "grab",
            touchAction: "none",
          }}
          ref={zoom.containerRef}
        >
          <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
          <g transform={zoom.toString()}>
            {data.map((d) => (
              <Fragment key={d.id}>
                <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2} fill={"#000"} />
                <text x={xScale(d.x)} y={yScale(d.y)}>
                  (x: {Math.round(d.x)}, y: {Math.round(d.y)})
                </text>
              </Fragment>
            ))}
          </g>
        </svg>
      )}
    </Zoom>
  );
}

 @visx/zoomからZoomを使用すると、その子要素に対してzoomを提供します。 これを使用することによってsvgキャンバス上の要素について、拡大縮小や並行移動ができるようになります。
 具体的にはsvg要素のrefzoom.containerRefを使用することによって、svgの描画範囲でドラッグやスクロールを行って拡大縮小や並行移動を行えるようになります。  しかしこれだけではsvg要素内で描画されている要素に変化は起きません。移動させるにはさらにtransform={zoom.toString()}を使用した<g>要素の子要素に移動させたい要素を配置する必要があり、子要素に配置された場合は拡大縮小や並行移動して描画される対象となり、<g>要素の外にいる要素はその移動対象外となります。 先ほどのコードを実行し、これをマウスのスクロールを用いて拡大縮小、ドラッグを用いて並行移動を行う操作した動画が図9になります。

図9. @visx/zoomを使って散布図の拡大縮小、並行移動を行う

 しかしこれでは拡大を行なった場合に。text要素の文字サイズまで拡大が行われてデータを見るのには適切ではありません。
 この場合は、拡大縮小を行った際に画面上のサイズを変更したくない要素のサイズについて拡大率の逆数であるzoom.invert().scaleXもしくはzoom.invert().scaleYをかけることによって解決できます。 具体的には次の様なコードになり、その結果が図10になります。

<Zoom<SVGSVGElement> width={width} height={height}>
  {(zoom) => (
    <svg
      width={width}
      height={height}
      style={{
        cursor: zoom.isDragging ? "grabbing" : "grab",
        touchAction: "none",
      }}
      ref={zoom.containerRef}
    >
      <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
      <g transform={zoom.toString()}>
        {data.map((d) => (
          <Fragment key={d.id}>
            <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2 * zoom.invert().scaleX} fill={"#000"} />
            <text x={xScale(d.x)} y={yScale(d.y)} fontSize={16 * zoom.invert().scaleX}>
              (x: {Math.round(d.x)}, y: {Math.round(d.y)})
            </text>
          </Fragment>
        ))}
      </g>
    </svg>
  )}
</Zoom>

図10. @visx/zoomを使って散布図の拡大縮小、並行移動を行い、拡大率の逆数を使用することによってtext要素の画面上に描画されるサイズの変更を防ぐ

 また拡大縮小などの操作はzoom.scale()などを使用することによってsvg要素上でのマウスによるドラッグやスクロール以外でも行うことができます。具体的なコードは次になり、その結果は図11になります。

<Zoom<SVGSVGElement> width={width} height={height}>
  {(zoom) => (
    <>
      <svg
        width={width}
        height={height}
        style={{
          cursor: zoom.isDragging ? "grabbing" : "grab",
          touchAction: "none",
        }}
        ref={zoom.containerRef}
      >
        <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
        <g transform={zoom.toString()}>
          {data.map((d) => (
            <Fragment key={d.id}>
              <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2 * zoom.invert().scaleX} fill={"#000"} />
              <text x={xScale(d.x)} y={yScale(d.y)} fontSize={16 * zoom.invert().scaleX}>
                (x: {Math.round(d.x)}, y: {Math.round(d.y)})
              </text>
            </Fragment>
          ))}
        </g>
      </svg>
      <button onClick={() => zoom.scale({ scaleX: 1.2, scaleY: 1.2 })}>+</button>
      <button onClick={() => zoom.scale({ scaleX: 0.8, scaleY: 0.8 })}>-</button>
      <button onClick={() => zoom.reset()}>reset</button>
    </>
  )}
</Zoom>

図11. @visx/zoomを使って散布図の拡大縮小、並行移動を行い、拡大率の変更などを<button>をクリックして行う

@visx/delaunayを用いてマウスカーソルに最も近いデータの詳細を確認できる散布図の作成

 @visx/zoomを使用することによって、密集したデータについて見やすくなりました。 しかしそれでも全てのデータについてtext要素が表示されていてはデータの分布などが見にくいです。 もし、座標についてのtextを非表示すればデータのプロットだけが表示されデータの分布について見やすくはなりますが、グラフ上から個々のプロットの座標の値を直接見ることができなくなってしまいます。

 そこで、@visx/delaunay16からvoronoiという機能を使います。この機能はsvg要素上の画面について最も近いデータの座標ごとに画面の領域を分割してくれるVoronoi図17を作ることができます。 これを使用することによって、svg要素上に置かれたマウスカーソルに最も近いデータを取得することができ、そのデータのみtext要素でデータの座標を表示することができるようになります。

次はそれを実装したコードになり、図12がその実行結果になります。

import { voronoi, Polygon } from "@visx/delaunay";
import { localPoint } from "@visx/event";
import { scaleLinear } from "@visx/scale";
import { useState, useMemo, useRef, Fragment } from "react";

function VoronoiGraph() {
  const width = 400;
  const height = 400;
  const volume = 100;
  const data = useMemo(() => generateData(volume), [volume]);

  const svgRef = useRef<SVGSVGElement>(null);
  const [hoveredData, setHoveredData] = useState<Data | null>(null);

  const xScale = scaleLinear({
    domain: [0, 400],
    range: [0, width],
  });
  const yScale = scaleLinear({
    domain: [0, 400],
    range: [height, 0],
  });

  const voronoiDiagram = voronoi<Data>({
    data: data,
    x: (d) => xScale(d.x),
    y: (d) => yScale(d.y),
    width: width,
    height: height,
  });

  return (
    <svg width={width} height={height} ref={svgRef}>
      <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
      <g
        onMouseMove={(event) => {
          if (!svgRef.current) return;
          const point = localPoint(svgRef.current, event);
          if (!point) return;
          const closest = voronoiDiagram.delaunay.find(point.x, point.y);
          setHoveredData(data[closest]);
        }}
        onMouseLeave={() => {
          setHoveredData(null);
        }}
      >
        {data.map((d, i) => (
          <Fragment key={d.id}>
            <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2} fill={"#000"} />
            <Polygon
              polygon={voronoiDiagram.cellPolygon(i)}
              fill={"#F00"}
              stroke={"#222"}
              fillOpacity={hoveredData?.id === d.id ? 0.5 : 0}
            />
          </Fragment>
        ))}
        {hoveredData && (
          <>
            <rect
              x={xScale(hoveredData.x) + 4}
              y={yScale(hoveredData.y) - 20}
              width={124}
              height={24}
              fill={"#FFF"}
              stroke={"#000"}
            />
            <text x={xScale(hoveredData.x) + 6} y={yScale(hoveredData.y) - 4} fontSize={16}>
              (x: {Math.round(hoveredData.x)}, y: {Math.round(hoveredData.y)})
            </text>
          </>
        )}
      </g>
    </svg>
  );
}

図12. @visx/delaunayを用いて、svg要素上のマウスカーソルから最も近いデータの座標をtext要素で表示する

 voronoi図は@visx/delauneyvoronoiで作成します。これにはデータとその座標、それにvoronoi図を計算して欲しい領域を指定します。 マウスカーソルの位置から最も近いデータを表示するにはマウスカーソルの位置を取得する必要がありますが、これは<g>要素のonMouseMoveonMouseLeaveで扱います。これによって<g>要素の子要素の上に置かれているマウスの情報を取得します。取得した情報を使って最も近いデータをuseStateを使ってhoveredDataにセットします。すると、このhoveredDataを通して、データの座標を表すtext要素を描画できるようになります。

 また、通常の散布図では領域を分割する線や領域を描画する必要はないと思います。もし非表示にしたければ<Polygon />を削除すればよいですが、<g>要素上でonMouseMoveを使ってマウスの位置を取得できるのが<g>要素の子要素の上のみなのため、<Polygon>を削除すると<circle>の上にマウスを乗せた時のみにしか吹き出しが表示されなくなってしまいます。
 この場合は、<g>要素の子要素にvoronoi図で扱いたい領域と同じの大きさの<rect x={0} y={0} width={width} height={height} fillOpacity={0} />のような要素を持たせることによってこの問題が解決します。あるいは、<g>要素にonMouseMoveを持たせなくても先ほど例示したような<rect>onMouseMoveを持たせても解決する。この場合は描画されている要素の最前面にonMouseMoveを持たせた<rect>を配置する必要があるが、<g>要素に持たせた場合は描画されている子要素全てでマウスカーソルに反応するのに対して、<rect>などに持たせた場合は、マウスカーソルに反応する領域がその<rect>に制限されるため、<g>要素の子要素の上全てでマウスカーソルが反応して欲しくない場合はそのように制限をかけることもできる。

次のコードはonMouseMoveを持った<g>要素の子要素にvoronoi図の領域を満たすような<rect>を配置した時のコードである。また、図13はその実行結果である。

<svg width={width} height={height} ref={svgRef}>
  <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
  <g
    onMouseMove={(event) => {
      if (!svgRef.current) return;
      const point = localPoint(svgRef.current, event);
      if (!point) return;
      const closest = voronoiDiagram.delaunay.find(point.x, point.y);
      setHoveredData(data[closest]);
    }}
    onMouseLeave={() => {
      setHoveredData(null);
    }}
  >
    {data.map((d) => (
      <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2} fill={"#000"} key={d.id} />
    ))}
    <rect x={0} y={0} width={width} height={height} fillOpacity={0} />
    {hoveredData && (
      <>
        <rect
          x={xScale(hoveredData.x) + 4}
          y={yScale(hoveredData.y) - 20}
          width={124}
          height={24}
          fill={"#FFF"}
          stroke={"#000"}
        />
        <text x={xScale(hoveredData.x) + 6} y={yScale(hoveredData.y) - 4} fontSize={16}>
          (x: {Math.round(hoveredData.x)}, y: {Math.round(hoveredData.y)})
        </text>
      </>
    )}
  </g>
</svg>

図13. @visx/delaunayを用いて、svg要素上のマウスカーソルから最も近いデータの座標をtext要素で表示するが、voronoi図のエリアは描画しない

@visx/zoom@visx/delaunayを用いた散布図の作成

 今までの実装で、@visx/zoom用いてsvg要素上で描画されている要素を拡大縮小を行えるようになり、@visx/delaunayを用いて、svg要素上のマウスカーソルの座標に最も近いデータの座標をtext要素で表示できるようになりました。これを組み合わせると次のようになり、それを実行したものが図14になります。

import { voronoi, Polygon } from "@visx/delaunay";
import { localPoint } from "@visx/event";
import { scaleLinear } from "@visx/scale";
import { Zoom } from "@visx/zoom";
import { useMemo, useState, useRef, Fragment } from "react";

function ZoomVoronoi() {
  const width = 400;
  const height = 400;
  const volume = 100;
  const data = useMemo(() => generateData(volume), [volume]);

  const svgRef = useRef<SVGSVGElement>(null);
  const [hoveredData, setHoveredData] = useState<Data | null>(null);

  const xScale = scaleLinear({
    domain: [0, 400],
    range: [0, width],
  });
  const yScale = scaleLinear({
    domain: [0, 400],
    range: [height, 0],
  });

  const voronoiDiagram = voronoi<Data>({
    data: data,
    x: (d) => xScale(d.x),
    y: (d) => yScale(d.y),
    width: width,
    height: height,
  });

  return (
    <Zoom<SVGSVGElement> width={width} height={height}>
      {(zoom) => (
        <svg
          width={width}
          height={height}
          style={{
            cursor: zoom.isDragging ? "grabbing" : "grab",
            touchAction: "none",
          }}
          ref={zoom.containerRef}
        >
          <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
          <g
            transform={zoom.toString()}
            onMouseMove={(event) => {
              if (!svgRef.current) return;
              const point = localPoint(svgRef.current, event);
              if (!point) return;
              const closest = voronoiDiagram.delaunay.find(
                point.x * zoom.invert().scaleX + zoom.invert().translateX,
                point.y * zoom.invert().scaleY + zoom.invert().translateY,
              );
              setHoveredData(data[closest]);
            }}
            onMouseLeave={() => {
              setHoveredData(null);
            }}
            ref={svgRef}
          >
            {data.map((d, i) => (
              <Fragment key={d.id}>
                <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2 * zoom.invert().scaleY} fill={"#000"} />
                <Polygon
                  polygon={voronoiDiagram.cellPolygon(i)}
                  fill={"#F00"}
                  stroke={"#222"}
                  fillOpacity={hoveredData?.id === d.id ? 0.5 : 0}
                />
              </Fragment>
            ))}
            <rect x={0} y={0} width={width} height={height} fillOpacity={0} />
            {hoveredData && (
              <>
                <rect
                  x={xScale(hoveredData.x) + 4 * zoom.invert().scaleX}
                  y={yScale(hoveredData.y) - 20 * zoom.invert().scaleY}
                  width={124 * zoom.invert().scaleX}
                  height={24 * zoom.invert().scaleY}
                  fill={"#FFF"}
                  stroke={"#000"}
                />
                <text
                  x={xScale(hoveredData.x) + 6 * zoom.invert().scaleX}
                  y={yScale(hoveredData.y) - 4 * zoom.invert().scaleY}
                  fontSize={16 * zoom.invert().scaleY}
                >
                  (x: {Math.round(hoveredData.x)}, y: {Math.round(hoveredData.y)})
                </text>
              </>
            )}
          </g>
        </svg>
      )}
    </Zoom>
  );
}

図14. @visx/zoom@visx/delaunayを用いて、svg要素の子要素の拡大縮小が行え、マウスカーソルに最も近いデータの座標を表示する

感想

 visxを用いることによって、Reactアプリケーション上でユーザがグラフについて操作が行えるような高機能さを持ち、かつ実装の自由度が高い散布図を実装することが可能であることが確認できました。また、今回作成したこれらの図はReactアプリケーションのsvg要素上に実装されているため、これが実装の限界ということではなく、他のvisxのライブラリやReactの機能を活用することによってより発展させたグラフを作成することも可能であることもわかります。  一方でその自由度の高さからデータの可視化手法や座標変換の操作など、データの取り扱いについての知識を一定レベル開発者側に求められているとも思います。  また、visxはD3をReactアプリケーション用にラッピングされたライブラリであるため、D3と機能が基本変わらないためか、visxのドキュメントの内容がそこまで充実していないように思える時もあります。このため、必要に応じて適宜D3のドキュメントやリポジトリを見るようにした方が実装に必要な理解が進む場合があると思います。  このため、直接D3を扱うよりかは学習コストは低いと思いますが、visxを使う場合もそれなりのコストを払う覚悟を持っておいた方が良いと思います。それでもグラフ作成にあたってのこの自由度の高さは魅力的であり、グラフをメインの機能とするWebアプリケーションであるならば、visxを用いてユーザが扱いやすいように機能やデザインをカスタムしていくのが良いように思えます。

付録

@visx/voronoi@visx/delaunay について

 visxを用いてVoronoi図を作成する方法は2つあります。 1つは今まで取り扱っていた@visx/delaunayを使う方法です。もう1つは@visx/voronoi18を使う方法です。 しかし、全く同じものであるならばライブラリが2つ作成される必要がないと思えますが、なぜ2つあるのでしょうか?

 理由は、visxはD3をReact用にラッピングしたライブラリであることです。 visxはD3をラッピングしたものであるため、visxで同じ機能が2つあるということはD3でも2つあるということになります。そのため、D3のリポジトリを確認すると確かにd3-voronoi19のリポジトリとd3-delaunay20のリポジトリがあることが確認できます。そのためD3のライブラリをラッピングしていくとvisxでvonoroi図を作成するライブラリが2つできるということはわかると思います。

 ではこの@visx/voronoi@visx/delaunayに何の違いがあるのでしょうか? これもD3のリポジトリを見るとすぐに解決することができ、d3-voronoiのREADME.md21に次が書いてあります。

Deprecation notice: Consider using the newer d3-delaunay instead of d3-voronoi. Based on Delaunator, d3-delaunay is 5-10× faster than d3-voronoi to construct the Delaunay triangulation or the Voronoi diagram, is more robust numerically, has Canvas rendering built-in, allows traversal of the Delaunay graph, and a variety of other improvements.

 つまり、d3-voronoiではなく、d3-delaunayを使いましょうということを言っています。また、d3-delaunayはd3-voronoiよりも5-10倍早いとも言っています。 そのほかにもd3-voronoiのリポジトリ自体は2019年にアーカイブされているため、尚のことd3-voronoiを使う理由がないように思えます。

 では、@visx/voronoi@visx/delaunayはちゃんとd3-voronoiとd3-delaunayを使用しているのか?ということが気になりますが、確認したところ使用していました。

なので、もしvisxを使ってVoronoi図を描きたい場合は@visx/delaunayを使うのが良いと思います。

実践

 今回の記事ではReactを使ったWebアプリケーションでvisxを使ってユーザーが操作できる散布図を作成しました。
 しかし、今までの例はなるべく少ないコード量でvisxの使い方を理解するためのものであったため、実際にアプリケーションで用いるとなると、もう少し工夫や実用的な例が欲しくなるところです。

 そのため、今回はzoomとvoronoiを使う例について改修を行い、データが正規分布に基づいて生成されるようにし、機能についても、吹き出しがsvgキャンバスから離れてもすぐ消えずにほんの少し待つ、吹き出しのサイズを表示される文字に基づいて変更される、拡大縮小の最大最小サイズに制限をかける、拡大縮小率をスライダーで操作するなどの機能を加えた例を掲載しておきます。また、その実行例は図15となります。

import { AxisBottom, AxisLeft } from "@visx/axis";
import { voronoi, Polygon } from "@visx/delaunay";
import { localPoint } from "@visx/event";
import { scaleLinear } from "@visx/scale";
import { Zoom } from "@visx/zoom";
import { ScaleLinear as Scale } from "d3-scale";
import { useMemo, useState, useRef, Fragment, useEffect } from "react";

interface Data {
  id: string;
  x: number;
  y: number;
}

// 正規分布に従って乱数を発生させる
function NormalRandom(): number {
  // logの中身を0にしないため値を少し加える
  const r1 = Math.random() + 0.0000001;
  const r2 = Math.random();
  return Math.sqrt(-2 * Math.log(r1)) * Math.cos(2 * Math.PI * r2);
}

function generateData(volume: number): Data[] {
  return new Array(volume).fill(null).map((_, i) => ({
    id: `ID:${i}`,
    x: NormalRandom() * 400,
    y: NormalRandom() * 400,
  }));
}

interface BaloonProps {
  data: Data;
  zoomInvRate: number;
  xScale: Scale<number, number>;
  yScale: Scale<number, number>;
}

function Baloon({ data, zoomInvRate, xScale, yScale }: BaloonProps) {
  const fontSize = 16;
  const text = `(x: ${Math.round(data.x)}, y: ${Math.round(data.y)})`;
  const padding = 3;

  const [ballonWidth, setBallonWidth] = useState<number>((text.length * fontSize + 2 * padding) * zoomInvRate);
  // fontSize*文字数だと全角半角などの文字サイズの差を対応できないtextの幅を取得して吹き出しの横幅を決める
  useEffect(() => {
    const canvas = document.getElementById(`baloon:${data.id}`);
    const canvasWidth = canvas?.getBoundingClientRect().width ?? 0;
    const width = canvasWidth !== 0 ? canvasWidth : text.length * fontSize;
    setBallonWidth((width + 2 * padding) * zoomInvRate);
  }, [data.id, zoomInvRate, text.length]);

  return (
    <>
      <rect
        x={xScale(data.x) + 4 * zoomInvRate}
        y={yScale(data.y) - 20 * zoomInvRate}
        width={ballonWidth}
        height={24 * zoomInvRate}
        fill={"#FFF"}
        stroke={"#000"}
        strokeWidth={zoomInvRate}
      />
      <text
        id={`baloon:${data.id}`}
        x={xScale(data.x) + 6 * zoomInvRate}
        y={yScale(data.y) - 4 * zoomInvRate}
        fontSize={fontSize * zoomInvRate}
      >
        {text}
      </text>
    </>
  );
}

export default function Graph() {
  const width = 400;
  const height = 400;
  const volume = 100;
  const data = useMemo(() => generateData(volume), [volume]);

  const xMin = Math.min(...data.map((d) => d.x));
  const xMax = Math.max(...data.map((d) => d.x));
  const yMin = Math.min(...data.map((d) => d.y));
  const yMax = Math.max(...data.map((d) => d.y));
  const padding = 20;

  const svgRef = useRef<SVGSVGElement>(null);
  const [tooltipTimeoutId, setTooltipTimeoutId] = useState<number | null>(null);
  const [hoveredData, setHoveredData] = useState<Data | null>(null);

  const xScale = scaleLinear({
    domain: [xMin - padding, xMax + padding],
    range: [0, width],
  });
  const yScale = scaleLinear<number>({
    domain: [yMin - padding, yMax + padding],
    range: [height, 0],
  });

  const voronoiDiagram = voronoi<Data>({
    data: data,
    x: (d) => xScale(d.x),
    y: (d) => yScale(d.y),
    // Scaleの原点からx方向y方向に働くVoronoi図の有効範囲を通常の1.5倍にしてマウスが反応する範囲を広げる
    width: width * 1.5,
    height: height * 1.5,
  });
  // Voronoi図の有効範囲をマイナス方向にも余裕を持つ
  voronoiDiagram.xmin = -width * 0.5;
  voronoiDiagram.ymin = -height * 0.5;

  return (
    <Zoom<SVGSVGElement> width={width} height={height} scaleXMin={0.5} scaleXMax={7} scaleYMin={0.5} scaleYMax={7}>
      {(zoom) => (
        <>
          <svg
            width={width}
            height={height}
            style={{
              cursor: zoom.isDragging ? "grabbing" : "grab",
              touchAction: "none",
            }}
            ref={zoom.containerRef}
          >
            <rect x={0} y={0} width={width} height={height} fill={"#E3E3E3"} />
            <g
              transform={zoom.toString()}
              onMouseMove={(event) => {
                if (!svgRef.current) return;
                if (tooltipTimeoutId) {
                  clearTimeout(tooltipTimeoutId);
                  setTooltipTimeoutId(null);
                }

                const point = localPoint(svgRef.current, event);
                if (!point) return;
                const closest = voronoiDiagram.delaunay.find(
                  point.x * zoom.invert().scaleX + zoom.invert().translateX,
                  point.y * zoom.invert().scaleY + zoom.invert().translateY,
                );
                setHoveredData(data[closest]);
              }}
              onMouseLeave={() => {
                setTooltipTimeoutId(
                  window.setTimeout(() => {
                    setHoveredData(null);
                  }, 400),
                );
              }}
              ref={svgRef}
            >
              <AxisBottom top={yScale(0)} scale={xScale} />
              <AxisLeft left={xScale(0)} scale={yScale} />
              {data.map((d, i) => (
                <Fragment key={d.id}>
                  <circle cx={xScale(d.x)} cy={yScale(d.y)} r={2 * zoom.invert().scaleY} fill={"#000"} />
                  <Polygon
                    polygon={voronoiDiagram.cellPolygon(i)}
                    fill={"#F00"}
                    stroke={"#222"}
                    strokeOpacity={0.1}
                    fillOpacity={hoveredData?.id === d.id ? 0.4 : 0}
                  />
                </Fragment>
              ))}
              <rect x={0} y={0} width={width} height={height} fillOpacity={0} />
              {hoveredData && (
                <Baloon data={hoveredData} zoomInvRate={zoom.invert().scaleX} xScale={xScale} yScale={yScale} />
              )}
            </g>
          </svg>
          <button onClick={() => zoom.scale({ scaleX: 1.2, scaleY: 1.2 })}>+</button>
          <button onClick={() => zoom.scale({ scaleX: 0.8, scaleY: 0.8 })}>-</button>
          <button onClick={() => zoom.reset()}>reset</button>
          <input
            type={"range"}
            // 対数スケールで扱う
            value={-Math.log(zoom.invert().scaleY)}
            max={Math.log(7)}
            min={Math.log(0.5)}
            step={Math.log(7) / 15}
            onChange={(event) => {
              zoom.scale({
                scaleX: zoom.invert().scaleX * Math.E ** event.target.value,
                scaleY: zoom.invert().scaleY * Math.E ** event.target.value,
              });
            }}
          />
        </>
      )}
    </Zoom>
  );
}

図15. visxを用いて、ユーザがインタラクティブに操作を行うことができる散布図

参考


  1. JavaScript | MDN, https://developer.mozilla.org/en-US/docs/Web/JavaScript
  2. TypeScript: JavaScript With Syntax For Types., https://www.typescriptlang.org/
  3. Chart.js | Open source HTML5 Charts for your website, https://www.chartjs.org/
  4. D3 by Observable | The JavaScript library for bespoke data visualization, https://d3js.org/
  5. Document Object Model (DOM) - Web APIs | MDN, https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
  6. React, https://react.dev/
  7. React plotly.js in JavaScript, https://plotly.com/javascript/react/
  8. Home | nivo, https://nivo.rocks/
  9. Introducing visx from Airbnb. A collection of expressive, low-level… | by Chris C Williams | The Airbnb Tech Blog | Medium, https://medium.com/airbnb-engineering/introducing-visx-from-airbnb-fd6155ac4658
  10. visx | visualization components, https://airbnb.io/visx
  11. <svg> - SVG: Scalable Vector Graphics | MDN, https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg
  12. viewBox - SVG: Scalable Vector Graphics | MDN, https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox
  13. visx | @visx/scale documentation, https://airbnb.io/visx/docs/scale
  14. d3/d3-scale: Encodings that map abstract data to visual representation., https://github.com/d3/d3-scale
  15. visx | @visx/zoom documentation, https://airbnb.io/visx/docs/zoom
  16. visx | @visx/delaunay documentation, https://airbnb.io/visx/docs/delaunay
  17. Voronoi diagram - Wikipedia, https://en.wikipedia.org/wiki/Voronoi_diagram
  18. visx | @visx/voronoi documentation, https://airbnb.io/visx/docs/voronoi
  19. d3/d3-voronoi: Compute the Voronoi diagram of a set of two-dimensional points., https://github.com/d3/d3-voronoi
  20. d3/d3-delaunay: Compute the Voronoi diagram of a set of two-dimensional points., https://github.com/d3/d3-delaunay
  21. d3-voronoi/README.md at master · d3/d3-voronoi, https://github.com/d3/d3-voronoi/blob/master/README.md

*1:visx | visualization components, https://airbnb.io/visx

*2:配置 - SVG: スケーラブルベクターグラフィック | MDN, https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Positions