📊

[React] visxを使ったデータビジュアライゼーションの第一歩として棒グラフを描いてみる

2023/10/29に公開

React でデータビジュアライゼーションを行うにあたり、visx を使ってまずは単純な棒グラフを描いてみたけどなかなか大変だったので、実例としてメモしておく。

visx とは

visx は Airbnb が作ったビジュアライゼーションライブラリ。

https://airbnb.io/visx

ギャラリーにはいくつものサンプルがあり、色々な種類の図によりビジュアライゼーションを実現可能で、かなりイケてる見た目にできることが分かる。

https://airbnb.io/visx/gallery

visx は公式サイトに書いてある通り、単純なチャートライブラリではない。色々なビジュアライゼーションを行うための基本的な道具が揃えてある、という感じ。

したがってライブラリの方向性としてはかなりプリミティブ寄りで、「データを流し込めば簡単にいい感じに表示される」というものではない。

そもそもの作りからして、描画ライブラリである D3.js を React で使いやすくラップしたものであり、最終的に出力される SVG (Scalable Vector Graphics) の画像を上手く生成するために色々サポートしてくれる、という感覚。

ということでとても自由度が高く、好きなようにカスタマイズできるが、自分で書くコード量がかなり多くなってしまうので、使うシチュエーションを選ぶライブラリとなっている。

実際に棒グラフを描いてみる

サンプルとして、単純な棒グラフを描いてみる。

公式のギャラリーにも棒グラフのサンプルはあるものの、オシャレ方向に全力を出しすぎているので、下の画像のようにもうちょっと普通のものを作ってみる。

visx で作ってみた普通の棒グラフ
visx で作ってみた普通の棒グラフ

プロジェクトの準備

以下のコマンドで準備できる。これは npm と Vite を使っているので、必要に応じて適当に読み替えて欲しい。

npm create vite@latest visx-sample -- --template react-swc-ts
cd visx-sample
npm i
npm i @visx/visx

コード

src/App.tsx を以下のように書き換えて、npm run dev を実行することで、ブラウザでグラフが表示可能となる。

なお値を乱数で生成しているため、リロードするたびに値が変わる。

src/App.tsx
import { useMemo, useState } from "react";
import "./App.css";
import { scaleLinear, scaleBand } from "@visx/scale";
import { Bar } from "@visx/shape";
import { Group } from "@visx/group";
import { AxisBottom, AxisLeft } from "@visx/axis";
import { GridRows } from "@visx/grid";

// データの型
type Item = {
  month: number;
  value: number;
};

// 適当なデータを生成する
function getData(): Item[] {
  const data = [];
  // 1月~12月として、適当な値を乱数で生成する
  for (let i = 1; i <= 12; i++) {
    const value = Math.floor(Math.random() * 1000);
    data.push({ month: i, value });
  }
  return data;
}

// コンポーネントに渡すプロパティ
export type BarChartProps = {
  width: number;
  height: number;
  margin?: { top: number; right: number; bottom: number; left: number };
};

// 定数
const BOTTOM_AXIS_HEIGHT = 46; // 下(X軸)のラベル部分の高さ
const LEFT_AXIS_WIDTH = 26; // 左(Y軸)のラベル部分の幅
const DEFAULT_MARGIN = { top: 20, right: 20, bottom: 10, left: 40 };

// 棒グラフのコンポーネント
function BarChart({ width, height, margin = DEFAULT_MARGIN }: BarChartProps) {
  const [data] = useState(getData());

  // グラフ本体を描画する座標の範囲
  const xMin = margin.left + LEFT_AXIS_WIDTH; // 左
  const xMax = width - margin.right; // 右
  const yMin = margin.top; // 上
  const yMax = height - margin.top - margin.bottom - BOTTOM_AXIS_HEIGHT; // 下

  // X軸とY軸のスケール
  // 値と座標を関連付けて計算してくれる
  // 描画パフォーマンス向上のためにメモ化する
  const monthScale = useMemo(
    () =>
      scaleBand<number>({
        domain: data.map((d) => d.month), // scaleBand では値の一覧
        range: [xMin, xMax], // 描画する画面の範囲(左と右)
        padding: 0.4, // 棒同士の間隔
      }),
    [data, xMin, xMax]
  );
  const valueScale = useMemo(
    () =>
      scaleLinear<number>({
        domain: [0, Math.max(...data.map((v) => v.value))], // scaleLinear では最小値と最大値
        range: [yMax, yMin], // 描画する画面の範囲(下と上)
        nice: true, // 描画範囲の上下を、ちょうど区切りが良い値になるよう自動設定してくれる
      }),
    [data, yMin, yMax]
  );

  return (
    <svg width={width} height={height}>
      // 背景を描画
      <rect x={0} y={0} width={width} height={height} fill={"black"} rx={6} />
      // Y軸の値のみのグリッド
      <GridRows
        top={yMin}
        left={xMin}
        width={xMax - xMin}
        height={yMax - yMin}
        scale={valueScale}
        stroke="#555"
      />
      // グラフの実際の描画部分
      <Group top={margin.top}>
        {data.map((d) => {
          // 棒のサイズや位置は visx が計算してくれる
          // 棒のY軸位置(barY)は、棒の高さを元に、上からの位置を計算する(SVGの座標が上からなので)
          const barWidth = monthScale.bandwidth();
          const barHeight = yMax - valueScale(d.value);
          const barX = monthScale(d.month);
          const barY = yMax - barHeight;
          return (
            <>
              // グラフの棒
              <Bar
                key={`bar-${d.month}`}
                x={barX}
                y={barY}
                width={barWidth}
                height={barHeight}
                fill="slategray"
              />
              // 棒の値を、SVG の text 要素として追加
              <text
                key={`value-${d.month}`}
                x={barX}
                y={barY}
                dx={barWidth / 2}
                dy="-0.25em"
                style={{
                  fill: "darkgray",
                  fontSize: "8pt",
                  textAnchor: "middle",
                }}
              >
                {d.value}
              </text>
            </>
          );
        })}
      </Group>
      // 下にあるX軸の値とラベル
      <AxisBottom
        scale={monthScale}
        top={yMax + margin.top} // 上のマージンを考慮して下にずらす
        label=""
        labelProps={{
          fill: "darkgray",
          fontSize: 12,
          textAnchor: "middle",
        }}
        stroke="darkgray"
        tickStroke="darkgray"
        tickLabelProps={{
          fill: "darkgray",
          fontSize: 12,
          textAnchor: "middle",
        }}
      />
      // 左にあるY軸の値とラベル
      <AxisLeft
        scale={valueScale}
        left={margin.left + LEFT_AXIS_WIDTH}
        top={margin.top} // 上のマージンを考慮して下にずらす
        label="値が縦軸"
        labelProps={{
          fill: "darkgray",
          fontSize: 12,
          textAnchor: "middle",
        }}
        stroke="darkgray"
        tickStroke="darkgray"
        tickLabelProps={{
          fill: "darkgray",
          fontSize: 12,
          textAnchor: "end",
        }}
      />
    </svg>
  );
}

function App() {
  return (
    <>
      <BarChart width={400} height={300} />
    </>
  );
}

export default App;

長い。でもやっていることは位置を計算して描画する処理がほとんどなので、そんなに難しくはない。

解説

今回のメインである BarChart コンポーネントを見ての通り、最終的には SVG 要素を生成することになっており、svg タグ自体や中身の要素を自分で書いたりする部分はかなりプリミティブっぽさを感じる。特に座標を計算したりプロパティを全部設定したりする必要がありなかなか大変。

でも図を自分で全部書くのと違うところは、例えば scaleLinear() に関する部分がある。これは値の範囲(最小値と最大値)と描画座標の範囲を元にして、「この値はこの座標」といった計算を行ってくれるもの。でもそれだけではなく、描画範囲の最小値と最大値をちょうど良い値に設定してくれたり(nice: true を指定)、軸やグリッドの要素(Grid 等)に渡すと適切に描画してくれたりと、自分でやるとなるとかなり面倒な処理をサポートしてくれるので、データビジュアライゼーションを行うときにはかなり助かる。

このように上手いことサポートしてくれる部分と、実際に描画する部分が分かれていることで、凝った描画を効率的に実装できる作りになっている。

(実際のところ、このあたりの処理は D3.js がやっていることではあるので、厳密に言えば visx の恩恵ではないかもしれないけども)

コツと注意点

その他のコツや注意点はコードにコメントとして記載しているが、書ききれなかったものを以下に記す。

  • text の文字や Axis のラベルは SVG の要素なので、色は fill プロパティで設定する。
    • この SVG であることを活用すれば stroke を指定して縁取りする、みたいなことも可能。
  • 上記コードではマージンを考慮してグラフを描画しているが、マージンの計算は外に出した方が楽なので、実際に書くときはそちらがおすすめ。
    • 別コンポーネント(DOM要素)の子要素としてグラフ部分を配置し、親の方でマージンを元にした幅をグラフ部分に渡して描画するのが良い。

(あと、コンポーネントを import するときに(自分の環境では)補完が効かず、ファイルを調べてそれぞれ追加する必要があるので結構面倒だけど、これは何か解決方法がありそうな気はしつつ、調べきれていない)

まとめ

単純にグラフを描画するような普通の用途だけなら他のチャートライブラリを使う方が圧倒的に楽なので、通常はそちらがおすすめ。

ただ、自分で編み出した表現でデータビジュアライゼーションをやりたかったり、凝ったグラフを作りたかったり、などの明確な目的がある場合は、 visx を使えば面白いものが作れると思うので、ぜひチャレンジしてみては。

Discussion