📊

victory-nativeでグラフを描画する

2022/08/08に公開

victory-native とは

victoryという React のグラフライブラリーあり、React Native 版がこの victory-native になります。
また、Expo での利用もサポートされています。
基本的に victory と victory-native はライブラリー名が違うだけで、共通のコンポーネントと共通の props で作られています。
ドキュメントサイトも共通化されており、victory をベースとして説明されています。

ただし、victory では使えていた一部のコンポーネントや props は victory-native では使えなかったりするので注意が必要です。
例えば VictoryPortal という指定された要素を常に 1 番手前の要素として出すコンポーネントがありますが、これは victory-native では使えません。
その名の通り Web の Portal を使っているため、Portal がまだ導入されていない React Native では使うことができません。

react-native-svg-chart について

同じ React Native のグラフライブラリーにreact-native-svg-chartがあります。
ライブラリーとしての人気はこちらの方がありますが、現在メンテナー不足でリポジトリー内に下記のように書かれています。

Looking for maintainers! I alone don't have the time to maintain this library anymore. Preferably looking for somebody who uses this library in their proffesional work (how I originally got the time to maintain).
(訳)メンテナ募集中私一人ではもうこのライブラリーをメンテナンスする時間がありません。できれば、プロフェッショナルな仕事でこのライブラリを使っている人を探しています(元々、私がメンテナンスする時間を確保する方法です)。

victory-native も同じ SVG 要素でグラフを描画しており、ドキュメントも react-native-svg-chart に勝るも劣らない充実具合です。
今後新しくグラフライブラリーを使うなら、この victory-native の方がもしかしたら良いかもしれません。

設定方法

victory-native は SVG を使ってグラフを描画しているので、victory-native とは別に react-native-svg が必要になります。
npm を使っている人は、yarn add の部分をnpm install -Dに置き換えてください。

yarn add react-native-svg victory-native
cd ios
pod install

LogBox に Require cycles を表示しないようにする

victory の中で循環参照をしているので、React Native で使用するとメトロサーバに大量のワーニングがでてきます。
動作には問題ないため、鬱陶しい時はエントリーポイント(index.js)に下記のように設定するといいでしょう。

import { LogBox } from "react-native";

LogBox.ignoreLogs(["Require cycle: node_modules/victory"]);

描画できるグラフの種類

結構な数のグラフ描画をサポートしています。
ギャラリーがあり、こちらで様々なサンプルが公開されているので、自分が作りたいグラフがないかはここを確かめるといいと思います。

  • 棒グラフ
  • 折れ線グラフ
  • 円グラフ
  • 積層型エリアグラフ
  • 周期グラフ(三角関数のあれ)
  • ボロノイ図
  • 複合図(棒グラフに折れ線グラフを重ねるとか)

棒グラフを作ってみる

シンプルな棒グラフを作ってみましょう。
グラフは VictoryChart コンポーネントの children に描画したいグラフコンポーネントを指定します。
VictoryChart はなくても描画できますが、軸を個別に設定する時や複合図を作る際には必要になります。

import * as React from "react";
import { VictoryBar, VictoryChart, VictoryTheme } from "victory-native";

const data: { x: number; y: number }[] = [
  { x: 1, y: 3 },
  { x: 2, y: 2 },
  { x: 3, y: 5 },
  { x: 4, y: 0 },
  { x: 5, y: 7 },
];

export const SampleBar = () => {
  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <VictoryChart theme={VictoryTheme.material}>
        <VictoryBar alignment="middle" data={data} />
      </VictoryChart>
    </View>
  );
};

このコンポーネントは下記のようなグラフとして描画されます。
data Props にグラフに描画するデータを渡すようにしますが、x 軸と y 軸は与えられたデータから自動的に victory-native が生成をします。
棒グラフのバーは x 軸の始点と終点(今回だと 0 と 4)の中心部分から描画されています。
victory-native によしなに任せると、表示に必要な範囲でのみ軸がひかれます。

シンプルな棒グラフ

x 軸と y 軸を設定してバーの左右に余白をつめる

軸の設定を victory-native に任せると、あまりがなくつまった印象を受けます。
VictoryAxis を使うことで、自分で任意の軸の範囲を設定することができます。
デフォルトでは x 軸の描画になっていますが、dependentAxisの props を渡すと y 軸の描画に変更されます。
x 軸または y 軸のメモリはtickValuesで任意の範囲を指定することができます。
メモリの値を間引いたり、特定の文字を出したりしたい時はtickFormatで指定します。

グラフの描画領域に余白を入れる場合、2 通りの方法があります。
VictoryChart に domainPadding もしくは domain を設定すると余白を入れることができます。
domainPadding の設定が楽ですが、グラフに表示するデータが多かったりするとデータが 1 つ欠損したりするので注意が必要です。
今回は domain で設定をしています。

サンプルコード
import * as React from "react";
import {
  VictoryBar,
  VictoryChart,
  VictoryTheme,
  VictoryAxis,
} from "victory-native";

const data: { x: number; y: number }[] = [
  { x: 1, y: 3 },
  { x: 2, y: 2 },
  { x: 3, y: 5 },
  { x: 4, y: 0 },
  { x: 5, y: 7 },
];
const yAxis = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

export const SampleBar = () => {
  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <VictoryChart
        domain={{ x: [0, 6], y: [0, 10] }}
        theme={VictoryTheme.material}
      >
        {/* y軸  */}
        <VictoryAxis dependentAxis tickValues={yAxis} tickCount={2} />
        {/* x軸  */}
        <VictoryAxis tickFormat={(t) => (t === 6 ? "" : t)} />
        {/* 棒グラフ  */}
        <VictoryBar alignment="middle" data={data} />
      </VictoryChart>
    </View>
  );
};

上記コードでは domain に{ x: [0, 6], y: [0, 10] }を設定しております。
これにより、グラフの描画領域が x 軸は 0〜6 に、y 軸は 0〜10 に設定がされます。
棒グラフに渡しているデータは x が 1〜5 に、y 軸は 0〜7 の間になっています。
domain の数値とデータの数値はそれぞれ対応するようにグラフ内にプロットされていくため、これにより
x 軸は左右それぞれに 1 データ分の空きができ、見た目上はそこが余白になります。
これは y 軸も同様です。

これで、よく見る一般的な棒グラフができあがります。

左右に余白があり、y軸は10まで表示できる棒グラフ

日付データを渡して x 軸にプロットする

vivtory-native は x 軸も y 軸も数値データ(Number 型)を期待しており、その数値データを元に描画していきます。
では日付データ(Date 型)の場合はどうでしょうか。
日付データを渡すことも可能ですが、そのままだと意図した見た目にはなりません。

先ほどのグラフの x 値を 2022/4/1〜2022/4/5 の日付データに変えると下のような表示になります(VictoryChart の domain 指定は外しています)。
日付データは内部で UNIX タイムスタンプに変換されるため、x 軸が膨大な桁数でプロットされ、メモリがとても読めるような表示でなくなります。

x軸の数値が視認できない棒グラフ

D3 を使って日付データを変換する

このままでは日付データを使ったグラフを描画することができないので、日付データを変換します。
ゴリゴリの SVG 操作をやったことがある人にとってはおなじみの D3 を使います。
D3 とはなんぞやという説明を始めると、victory-native よりも長くなるので、詳しくは説明しません。
使うのは D3 のデータ変換系を担うd3-scale というライブラリーです。
victory-native をインストールしていると自動的に D3 の周辺ライブラリーもインストールされています。

d3-scale を使うことで、日付データを特定の範囲内の値にマッピングすることができるようになります。
すなわち、2022/4/1〜2022/4/5 の UNIX タイムスタンプを 1〜5 の数値に変換できるようになります。
ここでは d3-scale で変換した上で、x 軸のメモリに4/1形式に日付を出力します。

サンプルコード
import * as React from "react";
import * as D3 from "d3-scale";
import {
  VictoryBar,
  VictoryChart,
  VictoryTheme,
  VictoryAxis,
} from "victory-native";

const scaleTime = D3.scaleTime()
  .domain([new Date("2022-4-1"), new Date("2022-4-5")])
  .range([1, 5]);
const chartData: { x: Date; y: number }[] = [
  { x: new Date("2022-4-1"), y: 3 },
  { x: new Date("2022-4-2"), y: 2 },
  { x: new Date("2022-4-3"), y: 5 },
  { x: new Date("2022-4-4"), y: 0 },
  { x: new Date("2022-4-5"), y: 7 },
];
const data: { x: number; y: number }[] = chartData.map((key) => ({
  x: Math.floor(scaleTime(key.x)),
  y: key.y,
}));
const format = (date: Date) => `${date.getMonth() + 1}/${date.getDate()}`;
const yAxis = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

export const SampleBar = () => {
  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <VictoryChart
        domain={{ x: [0, 6], y: [0, 10] }}
        theme={VictoryTheme.material}
      >
        <VictoryAxis dependentAxis tickValues={yAxis} tickCount={2} />
        <VictoryAxis
          tickFormat={(_, i) => (i === 5 ? "" : format(chartData[i].x))}
        />
        <VictoryBar alignment="middle" data={data} />
      </VictoryChart>
    </View>
  );
};

日付データの変換はscaleTimeという API を使用して、domain に変換したい日付を、range にマッピングする値を指定します。
それぞれ配列を受け取るようになっており、最小と最大を渡して範囲指定を行います。
すると scaleTime はマッピングする関数を返すので、その関数に日付データを渡すと範囲内の値に変換します。
なお、範囲内の値は必ずしも整数になるとは限らず小数点を含むことがあります。
そのため、Math.floor による切り捨てを行っておくのが無難でしょう。

const minDate = new Date("2022-04-01");
const maxDate = new Date("2022-04-5");
const minNumber = 1;
const maxNumber = 5;
const parser = scaleTime()
  .domain([minDate, maxDate])
  .range([minNumber, maxNumber]);
parser(new Date("2022-04-02")); // 2

4/1〜4/5のデータを表示している棒グラフ

グラフ内をタップできるようにする

最後にグラフの領域内をタップできるようにイベントを仕込んでみます。
イベント自体の書き方もそうですが、victory-native のイベント設定はだいぶクセが強めです。

VictoryBar に events props があり、ここにイベントハンドラーを渡すことができますが、タップ可能なのは棒グラフのバーのみとなります。
y の値が 0 でバーがでていない場合、そこはタップすることができないですし、
y の値が 1 の時は極端にタップ領域が狭くなったりと使い勝手がちょっと微妙です。
なので、バーがでている y 軸全体をタップすることができるように設定をしていきます。
VictoryBar の props では設定することができず、VictoryVoronoi というボロノイ図を描画するグラフを併用します。
これは公式サイト推奨のやり方で、VictoryVoronoi の説明にも下記のように記載されています。

VictoryVoronoi renders a dataset as a series polygons optimized for the nearest data point. VictoryVoronoi can be composed with VictoryChart to create voronoi overlays for charts, which are useful for attaching events to pieces of data that are otherwise difficult to interact with, usually due to their size.
(略) VictoryVoronoi はデータセットを、最も近いデータポイントに最適化された一連のポリゴンとしてレンダリングします。VictoryVoronoi は VictoryChart と組み合わせて、チャートのためのボロノイ・オーバーレイを作成することができます。これは、通常そのサイズのために対話することが難しいデータの断片にイベントをアタッチするのに便利です。

ボロノイ図とはこんなやつです。
最短ルートを探す時などに使うらしいです(よくわかっていない)。

ボロノイ図

正直ボロノイ図の説明とこの図だけでは、全然意味がわからないと思います。
筆者も最初まじで意味がわからなかったです。
説明するよりも、先に見てしまった方が理解が早いです。

ボロノイ図をプロットした棒グラフ

これで何をどうするかが大体わかってきたんじゃないかと思います。
ボロノイ図は見た目で言うとステンドグラスのような図ですが、値のセットの仕方でこのように柱状に並べることができます。
並列表示の棒グラフにも見えますね。

左右の余白を確保しているのでボロノイ図でも同様に空けていますが、こちらは合体することもできます。
柱状に並べる方法ですが、x 軸は棒グラフの値と同じものを、y 軸は全ての値を y 軸の最高値に設定すれば柱状に並びます。
今回作成している棒グラフは y 軸の最高値を 10 としているので、全て 10 にすれば大丈夫です。

後はこの柱状に並んだボロノイ図にイベントを設定すれば、y 軸全体をタップ領域にすることができます。

サンプルコード
import * as React from "react";
import * as D3 from "d3-scale";
import {
  VictoryBar,
  VictoryChart,
  VictoryTheme,
  VictoryAxis,
  VictoryVoronoi,
} from "victory-native";

const scaleTime = D3.scaleTime()
  .domain([new Date("2022-4-1"), new Date("2022-4-5")])
  .range([1, 5]);
const chartData: { x: Date; y: number }[] = [
  { x: new Date("2022-4-1"), y: 3 },
  { x: new Date("2022-4-2"), y: 2 },
  { x: new Date("2022-4-3"), y: 5 },
  { x: new Date("2022-4-4"), y: 0 },
  { x: new Date("2022-4-5"), y: 7 },
];
const data: { x: number; y: number }[] = chartData.map((key) => ({
  x: Math.floor(scaleTime(key.x)),
  y: key.y,
}));
const format = (date: Date) => `${date.getMonth() + 1}/${date.getDate()}`;
const yAxis = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

export const SampleBar = () => {
  const [index, setIndex] = React.useState(-1);

  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <VictoryChart
        domain={{ x: [0, 6], y: [0, 10] }}
        theme={VictoryTheme.material}
      >
        <VictoryAxis dependentAxis tickValues={yAxis} tickCount={2} />
        <VictoryAxis
          tickFormat={(t, i) => (i === 5 ? "" : format(chartData[i].x))}
        />
        <VictoryBar
          alignment="middle"
          data={data}
          style={{
            data: {
              fill: (props) => (props.index === index ? "#2196f3" : "#455A64"),
            },
          }}
        />
        <VictoryVoronoi
          data={data.map((key) => ({ x: key.x, y: 10 }))}
          events={[
            {
              target: "data",
              eventHandlers: {
                onPressIn: () => {
                  return [
                    {
                      eventKey: "all", // タップする時にスタイルをリセットする
                      mutation: () => undefined,
                    },
                  ];
                },
                onPress: () => {
                  return [
                    {
                      target: "data",
                      mutation: (props) => setIndex(props.index as number),
                    },
                  ];
                },
              },
            },
          ]}
        />
      </VictoryChart>
    </View>
  );
};

victory-native でのイベント設定は配列で設定するようになっており、target を指定してそれに対して eventHandlers キーで onPress などの React Native の各種イベントハンドラを渡すことができるようになっています。
target にはdatatickLabelsなどを指定することができる他、グラフに props で name を与えることができ、その name を指定することもできます。

棒グラフのバーをタップした所が青くハイライトされるアニメーション

これで一通りの説明は終わりです。
グラフライブラリーはだいたいどれもクセが強い部分がありますが、victory-native はイベントハンドラー周りがクセが強い印象があります。
筆者が試した限りでは、メモリ部分にあたる tickLabels のタップイベントの反応はかなり悪いです。
ラベルのかなり中心をタップしないと反応しなかったりと、ここらへんはどうにかならないかなぁと思っています。

ただ細かい調整はできるし、スタイル変更もかなり柔軟にできます。
ドキュメントも結構充実しているので、全体的に見ると凄く良いライブラリーだなと思います。

GitHubで編集を提案
CureApp テックブログ

Discussion