【React Native】グラフデータの詳細を表示するツールチップを実装

#はじめに
React Nativeのグラフ表示で、データポイントをタッチしたときに詳細が表示されるようなツールチップを実装しました。基本的な実装は<a href="https://levelup.gitconnected.com/adding-tooltip-to-react-native-charts-67606c5d3182">こちら</a>を参考にしたのですが、データポイント以外の部分をタッチしたときにツールチップが非表示にならないという課題があったので、非表示になるように工夫しました。

完成品はこちらです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/d8df4b42-a1a9-1c61-666c-6e754ef3c23b.gif" width="400px"/>

#使用ライブラリ
- <a href="https://github.com/indiespirit/react-native-chart-kit">react-native-chart-kit</a>
- <a href="https://github.com/react-native-svg/react-native-svg">react-native-svg</a>

#実装手順
実装の流れを簡単にまとめました。

##プロジェクト作成
```expo init <project name>```でプロジェクトを作成します。今回はテンプレートに```blank(TypeScript)```を選択しました。
プロジェクトフォルダ直下に```src/components/LineChart.tsx```を作成します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/e9c1b2b7-7d7f-2094-19a4-04cfad9b6753.png" width="400px"/>

##ライブラリ導入
プロジェクト作成後、```yarn add moment react-native-chart-kit react-native-svg```で必要なライブラリを導入します。

##グラフ作成
ツールチップ実装の前にグラフを作成します。

```LineChart.tsx

import React from 'react';
import { Dimensions, StyleSheet } from 'react-native';
import { LineChart } from 'react-native-chart-kit';

export const LineChartComponent = () => {
  const lineChartWidth: number = Dimensions.get('window').width - 40;
  const lineChartHeight: number = 250;

  const labels: string[] = ['3/1', '3/2', '3/3', '3/4', '3/5', '3/6', '3/7'];
  const dataSets: number[] = [1, 4, 0, 4, 2, 2, 0];

  const data = {
    labels,
    datasets: [
      {
        data: dataSets,
        color: () => `rgba(230, 22, 115, 1)`,
        strokeWidth: 1,
      },
    ],
  };

  const chartConfig = {
    backgroundGradientFrom: '#fff',
    backgroundGradientTo: '#fff',
    decimalPlaces: 1,
    strokeWidth: 0.5,
    fillShadowGradient: '#fff',
    color: () => `rgba(89, 87, 87, 1)`,
    propsForDots: {
      r: '3',
    },
  };

  return (
    <LineChart
      data={data}
      width={lineChartWidth}
      height={lineChartHeight}
      formatYLabel={(value) =>
        Number.isInteger(Number(value)) ? String(parseInt(value, 10)) : ''
      }
      chartConfig={chartConfig}
      withVerticalLines={false}
      style={styles.lineChartStyle}
      withDots
      segments={3}
      yAxisSuffix=""
      fromZero
    />
  );
};

const styles = StyleSheet.create({
  lineChartStyle: {
    borderWidth: 0.5,
    borderRadius: 10,
    borderColor: 'grey',
    marginHorizontal: 10,
    paddingVertical: 10,
  },
}
```

``data={data}``の部分でデータを読み込みます。
dataの中身については、labelsが横軸、datasetsが縦軸に対応しています。データポイントと線の色(color)、線の太さ(strokeWidth)などをdatasetsの中で設定することができます。

```javascript
  const data = {
    labels,
    datasets: [
      {
        data: dataSets,
        color: () => `rgba(230, 22, 115, 1)`,
        strokeWidth: 1,
      },
    ],
  };
```
opacityの設定を行うことでcolorの透過率を変えることもできます。

```javascript
color: (opacity = 1) => `rgba(230, 22, 115, ${opacity})`,
```

``chartConfig={chartConfig}``の部分でグラフの設定を行います。グラフの背景色(background~)やデータポイントのサイズ(propsForDots)を設定することができます。

```javascript
  const chartConfig = {
    backgroundGradientFrom: '#fff',
    backgroundGradientTo: '#fff',
    decimalPlaces: 1,
    strokeWidth: 0.5,
    fillShadowGradient: '#fff',
    color: () => `rgba(89, 87, 87, 1)`,
    propsForDots: {
      r: '3',
    },
  };
```
その他にも縦横軸のカスタマイズやグラフの基準点を常に0にする設定などいろいろあります。

##ツールチップ表示
ツールチップの表示をできるようにします。ただ、この時点では非表示にすることはできません。

```LineChart.tsx

import React, { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import { LineChart } from 'react-native-chart-kit';
import { Rect, Text as TextSVG, Svg } from 'react-native-svg';

export const LineChartComponent = () => {
  const lineChartWidth: number = Dimensions.get('window').width - 40;
  const lineChartHeight: number = 250;

  const labels: string[] = ['3/1', '3/2', '3/3', '3/4', '3/5', '3/6', '3/7'];
  const dataSets: number[] = [1, 4, 0, 4, 2, 2, 0];

  const [tooltipPos, setTooltipPos] = useState({
    x: 0,
    y: 0,
    visible: false,
    value: 0,
    date: '',
  });

  const data = {
    labels,
    datasets: [
      {
        data: dataSets,
        color: () => `rgba(230, 22, 115, 1)`,
        strokeWidth: 1,
      },
    ],
  };

  const chartConfig = {
    backgroundGradientFrom: '#fff',
    backgroundGradientTo: '#fff',
    decimalPlaces: 1,
    strokeWidth: 0.5,
    fillShadowGradient: '#fff',
    color: () => `rgba(89, 87, 87, 1)`,
    propsForDots: {
      r: '3',
    },
  };

  return (
    <LineChart
      data={data}
      width={lineChartWidth}
      height={lineChartHeight}
      formatYLabel={(value) =>
        Number.isInteger(Number(value)) ? String(parseInt(value, 10)) : ''
      }
      chartConfig={chartConfig}
      withVerticalLines={false}
      style={styles.lineChartStyle}
      withDots
      segments={3}
      yAxisSuffix=""
      fromZero
      decorator={() => {
        return tooltipPos.visible ? (
          <View>
            <Svg>
              <Rect
                x={tooltipPos.x - 45}
                y={tooltipPos.y - 15}
                rx={5}
                ry={5}
                width="50"
                height="40"
                fill="red"
                opacity={0.5}
              />
              <TextSVG
                x={tooltipPos.x - 20}
                y={tooltipPos.y}
                fill="#fff"
                fontSize="10"
                fontWeight="bold"
                textAnchor="middle"
              >
                {tooltipPos.date}
              </TextSVG>
              <TextSVG
                x={tooltipPos.x - 26}
                y={tooltipPos.y + 17}
                fill="#fff"
                fontSize="14"
                fontWeight="bold"
                textAnchor="middle"
              >
                {tooltipPos.value}&nbsp;件
              </TextSVG>
            </Svg>
          </View>
        ) : null;
      }}
      onDataPointClick={(data) => {
        return setTooltipPos((prevState) => {
          return {
            ...prevState,
            x: data.x,
            y: data.y,
            value: data.value,
            date: labels[data.index],
            visible: true,
          };
        });
      }}
    />
  );
};

const styles = StyleSheet.create({
  lineChartStyle: {
    borderWidth: 0.5,
    borderRadius: 10,
    borderColor: 'grey',
    marginHorizontal: 10,
    paddingVertical: 10,
  },
});
```

ツールチップを表示するために肝となるのが、``onDataPointClick``と``decorator``の2つのプロパティです。

###onDataPointClick
このプロパティと使うと、タッチしたデータポイントの位置(x, y)や値(value)などの情報を取得することができます。
ここで取得した情報を表示内容や表示位置の設定に利用します。

```
Object {
  "dataset": Object {
    "color": [Function color],
    "data": Array [
      1,
      4,
      0,
      4,
      2,
      2,
      0,
    ],
    "strokeWidth": 1,
  },
  "getColor": [Function getColor],
  "index": 1,
  "value": 4,
  "x": 110.28571428571428,
  "y": 16,
}
```

``onDataPointClick``で取得した情報を``decorator``に渡すために、``tooltipPos``というstateを定義します。

```javascript
  const [tooltipPos, setTooltipPos] = useState({
    x: 0,
    y: 0,
    visible: false,
    value: 0,
    date: '',
  });
```

###decorator
こちらのプロパティでグラフ上に追加の図を表示することができます。
tooltipPosのx, yでタッチしたデータポイント付近に図が表示されるようにし、date, valueでデータ詳細を表示します。

```javascript
      decorator={() => {
        return (
          <View>
            <Svg>
              <Rect
                x={tooltipPos.x - 45}
                y={tooltipPos.y - 15}
                rx={5}
                ry={5}
                width="50"
                height="40"
                fill="red"
                opacity={0.5}
              />
              <TextSVG
                x={tooltipPos.x - 20}
                y={tooltipPos.y}
                fill="#fff"
                fontSize="10"
                fontWeight="bold"
                textAnchor="middle"
              >
                {tooltipPos.date}
              </TextSVG>
              <TextSVG
                x={tooltipPos.x - 26}
                y={tooltipPos.y + 17}
                fill="#fff"
                fontSize="14"
                fontWeight="bold"
                textAnchor="middle"
              >
                {tooltipPos.value}&nbsp;件
              </TextSVG>
            </Svg>
          </View>
        );
      }}
```

##ツールチップ非表示
データポイント以外をタッチしたときにツールチップが非表示になるようにします。

```LineChart.tsx

import React, { useEffect, useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import { LineChart } from 'react-native-chart-kit';
import { Rect, Text as TextSVG, Svg } from 'react-native-svg';

export const LineChartComponent = () => {
  const lineChartWidth: number = Dimensions.get('window').width - 40;
  const lineChartHeight: number = 250;

  const labels: string[] = ['3/1', '3/2', '3/3', '3/4', '3/5', '3/6', '3/7'];
  const dataSets: number[] = [1, 4, 0, 4, 2, 2, 0];

  const START_POINT = 0;
  const TOUCH_AREA = 10;

  const [tooltipPos, setTooltipPos] = useState({
    x: 0,
    y: 0,
    visible: false,
    value: 0,
    date: '',
  });
  const [touchLocation, setTouchLocation] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const isOutOfTouchArea =
      tooltipPos.x - TOUCH_AREA >= touchLocation.x ||
      touchLocation.x >= tooltipPos.x + TOUCH_AREA ||
      tooltipPos.y - TOUCH_AREA >= touchLocation.y ||
      touchLocation.y >= tooltipPos.y + TOUCH_AREA;

    if (tooltipPos.x === START_POINT) return;

    if (isOutOfTouchArea) {
      return setTooltipPos((prevState) => {
        return {
          ...prevState,
          visible: false,
        };
      });
    }
  }, [touchLocation]);

  const data = {
    labels,
    datasets: [
      {
        data: dataSets,
        color: () => `rgba(230, 22, 115, 1)`,
        strokeWidth: 1,
      },
    ],
  };

  const chartConfig = {
    backgroundGradientFrom: '#fff',
    backgroundGradientTo: '#fff',
    decimalPlaces: 1,
    strokeWidth: 0.5,
    fillShadowGradient: '#fff',
    color: () => `rgba(89, 87, 87, 1)`,
    propsForDots: {
      r: '3',
    },
  };

  return (
    <View
      onTouchEnd={(e) => {
        setTouchLocation({
          x: e.nativeEvent.locationX,
          y: e.nativeEvent.locationY,
        });
      }}
    >
      <LineChart
        data={data}
        width={lineChartWidth}
        height={lineChartHeight}
        formatYLabel={(value) =>
          Number.isInteger(Number(value)) ? String(parseInt(value, 10)) : ''
        }
        chartConfig={chartConfig}
        withVerticalLines={false}
        style={styles.lineChartStyle}
        withDots
        segments={3}
        yAxisSuffix=""
        fromZero
        decorator={() => {
          return tooltipPos.visible ? (
            <View>
              <Svg>
                <Rect
                  x={tooltipPos.x - 45}
                  y={tooltipPos.y - 15}
                  rx={5}
                  ry={5}
                  width="50"
                  height="40"
                  fill="red"
                  opacity={0.5}
                />
                <TextSVG
                  x={tooltipPos.x - 20}
                  y={tooltipPos.y}
                  fill="#fff"
                  fontSize="10"
                  fontWeight="bold"
                  textAnchor="middle"
                >
                  {tooltipPos.date}
                </TextSVG>
                <TextSVG
                  x={tooltipPos.x - 26}
                  y={tooltipPos.y + 17}
                  fill="#fff"
                  fontSize="14"
                  fontWeight="bold"
                  textAnchor="middle"
                >
                  {tooltipPos.value}&nbsp;件
                </TextSVG>
              </Svg>
            </View>
          ) : null;
        }}
        onDataPointClick={(data) => {
          return setTooltipPos((prevState) => {
            return {
              ...prevState,
              x: data.x,
              y: data.y,
              value: data.value,
              date: labels[data.index],
              visible: true,
            };
          });
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  lineChartStyle: {
    borderWidth: 0.5,
    borderRadius: 10,
    borderColor: 'grey',
    marginHorizontal: 10,
    paddingVertical: 10,
  },
});

```
グラフのタッチ位置を取得するために、touchLocationという新しいstateを定義し、LineChartコンポーネントをViewコンポーネントでラッピングします。

```javascript
const [touchLocation, setTouchLocation] = useState({ x: 0, y: 0 });

~中略~
    <View
      onTouchEnd={(e) => {
        setTouchLocation({
          x: e.nativeEvent.locationX,
          y: e.nativeEvent.locationY,
        });
      }}
    >
      <LineChart
        data={data}
~後略~
```

useEffectで、touchLocationが変化したとき(タッチしたとき)に、タッチした位置がデータポイントから10以上離れていればツールチップを非表示にするという処理を追加します。

```javascript
  useEffect(() => {
    const isOutOfTouchArea =
      tooltipPos.x - TOUCH_AREA >= touchLocation.x ||
      touchLocation.x >= tooltipPos.x + TOUCH_AREA ||
      tooltipPos.y - TOUCH_AREA >= touchLocation.y ||
      touchLocation.y >= tooltipPos.y + TOUCH_AREA;

    if (tooltipPos.x === START_POINT) return;

    if (isOutOfTouchArea) {
      return setTooltipPos((prevState) => {
        return {
          ...prevState,
          visible: false,
        };
      });
    }
  }, [touchLocation]);
```
一方、データポイントをタッチしたときにツールチップを表示する処理を追加します。

```javascript
        onDataPointClick={(data) => {
          return setTooltipPos((prevState) => {
            return {
              ...prevState,
              x: data.x,
              y: data.y,
              value: data.value,
              date: labels[data.index],
              visible: true,
            };
          });
        }}
```

これで実装完了です。

#おわりに
ツールチップの導入で、react-native-chart-kitはカスタマイズの自由度が高いことがわかりました。他にUX向上につながる機能があるかどうか、いろいろ触りながら確かめてみたいと思います。