Rechartsの軸ラベルを見切れないようにする方法

この記事は　[intimatemerger Advent Calendar 2023](https://qiita.com/advent-calendar/2023/intimatemerger)　の ２０日目の記事です。

## はじめに

はじめまして。
[株式会社インティメート・マージャー](https://corp.intimatemerger.com/)開発本部でエンジニアをしています、 @april418 です。

弊社では多くのデータを扱っています。
そのデータを活かすために昨今では生成AIの活用なども行っていますが、人の目で見て判断できるようにすることも大切です。
そのため、弊社の IM-DMP という製品では、グラフなどを用いてデータを可視化する機能を提供しています。

IM-DMP では React ベースのグラフ表示ライブラリ [Recharts](https://recharts.org/) を使用しています。
レスポンシブ対応や幅広い種類のグラフに対応しているなど、非常に素晴らしいライブラリです。

しかし、日本語のラベルだと自動改行がうまくいかず、ラベルが見切れてしまう問題が発生しました。
これを解決していきたいというのが今回の内容になります。

それでは本題に行ってみましょう！

## TL;DR

- ラベルの文字数からざっくりラベルの要素サイズを算出
  - 全角と半角を考慮に入れるのがミソ
  - フォントによって実際のサイズとはズレがあるのでそのあたりは調整が必要かも
- 計算したサイズを `XAxis` `YAxis` に設定

## 問題の事象

長い文字列がラベルとして入ってきた際に以下のようになってしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166554/5c2e05fa-edbc-0163-1405-b72aa186ef9d.png)

縦棒グラフだともっとひどいことに…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166554/fd348c6e-3f4f-87e1-3c66-dc0f2bc38800.png)

## 解決法

上記の対策を施して出来たサンプルが[こちら](https://codepen.io/april418/pen/qBgMexM)になります。
（CodePen にて実装しています）

こんな感じでフォントサイズを変更してもラベルが見切れないようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166554/cbfef60b-887a-adfe-becc-60c58adf4988.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166554/04cd1dcf-07cb-397b-7a10-c5e237b2d5af.png)

横棒グラフでも大丈夫です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166554/0750f1f3-7c8e-6f9c-de14-58893613b020.png)

## 実装のポイント

### ラベルのサイズを計算する

Recharts の軸ラベルは `XAxis` `YAxis` というコンテナと、 その中の `Tick` というラベルで構成されています。

本来ラベルのサイズを計算するには、レンダリングされた後にラベルのサイズを取得するのが最も正確です。
ですが、この方法は一度レンダリングした後にコンテナサイズを再計算して再レンダリングを行うため、パフォーマンス的な懸念があります。

そのため、今回はラベルの文字列の長さから簡易的なラベルサイズを割り出し、そのサイズをコンテナに設定することにしました。

初めは `文字列の長さ * フォントサイズ`　のような形で計算していたのですが、当然全角文字と半角文字でかなりの誤差が生じてしまうので、正規表現で全角半角を判定して半角文字なら半分のサイズで計算するようにしました。

```ts
/**
 * 半角文字かどうかを判定します
 * @param {string} char - 判定する文字
 * @return {boolean} 半角文字の場合はtrue、それ以外の場合はfalse
 */
const isHankaku = (char: string) => {
  return /[\x01-\x7E]|[\uFF65-\uFF9F]/.test(char);
};

/**
 * 全角文字かどうかを判定します
 * @param {string} char - 判定する文字
 * @return {boolean} 全角文字の場合はtrue、それ以外の場合はfalse
 */
const isZenkaku = (char: string) => {
  return !isHankaku(char);
};

/**
 * 文字列中の半角文字の数をカウントします
 * @param {string} str - カウントする文字列
 * @return {number} 半角文字の数
 */
const countHankaku = (str: string) => {
  return [...str].filter(isHankaku).length;
};

/**
 * 文字列中の全角文字の数をカウントします
 * @param {string} str - カウントする文字列
 * @return {number} 全角文字の数
 */
const countZenkaku = (str: string) => {
  return [...str].filter(isZenkaku).length;
};

/**
 * 文字列の長さを計算します
 * @param {string} str - 長さを計算する文字列
 * @param {number} charLen - 全角文字の長さ（デフォルトは12）
 * @return {number} 計算された文字列の長さ
 */
const calclateStringLength = (str: string, charLen = 12) => {
  return (countHankaku(str) / 2 + countZenkaku(str)) * charLen;
};
```

### グラフのレイアウトに応じて計算したラベルのサイズを設定する

カテゴリが縦に並ぶレイアウト ( `layout="vertical"` ) の場合はコンテナ要素である `YAxis` の `width` に先ほど計算した文字列の長さを設定するだけです。

しかし、カテゴリが横に並ぶレイアウト ( `layout="horizontal"` ) の場合はラベルが長すぎて他のラベルと被ってしまいます。
そのため、 `XAxis`　の `tick` にカスタムコンポーネントを設定してラベルを縦書きに変更し、コンテナ要素の `XAxis` の `height` に計算した文字列の長さを設定しています。

（ 見た目を整えるのに [Material UI](https://mui.com/) を併せて使用しています ）

```tsx
/**
 * 棒グラフの軸ラベルのプロパティ
 */
type CustomBarChartTickProps = {
  x?: number;
  y?: number;
  stroke?: string;
  fontSize?: number;
  writingMode?: "horizontal-tb" | "vertical-rl" | "vertical-lr";
  payload?: any;
};

/**
 * 棒グラフの軸ラベル
 * 縦書きにしたいので writingMode を設定できるようにしています
 */
const CustomBarChartTick: React.FC<CustomBarChartTickProps> = (props) => {
  const { x, y, stroke, fontSize, writingMode, payload } = props;
  const { value } = payload;

  return (
    <text
      x={x}
      y={y}
      fill={stroke}
      fontSize={fontSize}
      writingMode={writingMode}
    >
      <tspan>{value}</tspan>
    </text>
  );
};

/**
 * 棒グラフのデータ型
 */
type CustomBarChartData = {
  name: string;
  uv: number;
  pv: number;
  amt: number;
};

/**
 * 棒グラフのプロパティ
 */
type CustomBarChartProps = {
  layout: LayoutType;
  height: number;
  data: CustomBarChartData[];
  axisFontSize?: number;
};

/**
 * フォントサイズに応じてラベルのコンテナサイズを調整できる棒グラフ
 */
const CustomBarChart: React.FC<CustomBarChartProps> = (props) => {
  const theme = useTheme();
  const { layout, height, data, axisFontSize } = props;
  const axisSize = Math.max(
    ...data.map((d) => calclateStringLength(d.name, axisFontSize))
  );

  return (
    <ResponsiveContainer width="100%" height={height}>
      <BarChart
        layout={layout}
        data={data}
        margin={{
          top: 5,
          right: 30,
          left: 20,
          bottom: 5
        }}
      >
        <CartesianGrid stroke={theme.palette.grey[300]} strokeDasharray="3 3" />
        {layout === "horizontal" ? (
          <>
            <XAxis
              type="category"
              dataKey="name"
              interval={0}
              height={axisSize}
              tick={
                <CustomBarChartTick
                  stroke={theme.palette.text.primary}
                  fontSize={axisFontSize}
                  writingMode="vertical-rl"
                />
              }
            />
            <YAxis
              type="number"
              tick={{
                fontSize: axisFontSize,
                fill: theme.palette.text.primary
              }}
            />
          </>
        ) : (
          <>
            <XAxis
              type="number"
              tick={{
                fontSize: axisFontSize,
                fill: theme.palette.text.primary
              }}
            />
            <YAxis
              type="category"
              dataKey="name"
              interval={0}
              width={axisSize}
              tick={{
                fontSize: axisFontSize,
                fill: theme.palette.text.primary
              }}
            />
          </>
        )}
        <Tooltip />
        <Legend />
        <Bar dataKey="pv" fill={indigo[700]} />
        <Bar dataKey="uv" fill={amber[700]} />
      </BarChart>
    </ResponsiveContainer>
  );
};

```

## おわりに

読んでいただきありがとうございました。
あまりキレイな実装方法ではないですが、皆さんの参考になりましたら幸いです。

次回は @Jumons さんの「GCPの権限昇格の話」です！　お楽しみに！

https://qiita.com/advent-calendar/2023/intimatemerger