Closed5

Recharts のカスタマイズやエラー対応など

kurosamekurosame

React のチャートライブラリである Recharts を利用していますが、API 提供のプロパティだとカスタマイズが難しい部分は自作するしかありません

いくつか自作したコンポーネントやカスタムフック、Recharts の API ドキュメント の把握では解決できなかった問題をスクラップとして上げます

環境は以下です

  • React v16.12.0
  • Recharts v2.1.13
  • Emotion v10.0.27

他にも思い出したら追記していこうと思います
Recharts を使ってて思ったのは、カスタマイズ性があまり高くないと感じました
けっこう完成されたチャートライブラリなのでそのまま使う分にはストレスなく使える印象です

kurosamekurosame

ツールチップ

Recharts の API にツールチップはすでにあるのですが、カスタマイズします

理由は以下の公式 Example を見ても分かりますが、ツールチップがチャートのコンテナーから必ず出ないように実装されているため、動きが安定しないからです
https://recharts.org/en-US/examples/CustomContentOfTooltip

今作っている画面は

  • チャートのコンテナーが小さい
    • ツールチップがチャートのコンテナーから必ず出ない実装ゆえ、マウスカーソルとツールチップが重なりやすい
  • チャートにクリックイベントがある
    • マウスカーソルとツールチップが重なるとクリックの妨げになる

上記の制限があるので、ツールチップコンポーネントを自作します
ちなみにツールチップの API が提供してくれているプロパティ(position、coordinate、wrapperStyle で頑張る)はどれもやりたいことができなかったです

作るもの

マウスの動きに追従するツールチップです
必ずマウスカーソルの右下にツールチップが出るようにします

マウスの座標を返すカスタムフック

まずは現在のマウスの座標を返すカスタムフックを作成します

import React, { useEffect, useState } from "react";

const useMousePosition = () => {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handler = (e: MouseEvent) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    document.documentElement.addEventListener("mousemove", handler);
    return () => {
      document.documentElement.removeEventListener("mousemove", handler);
    };
  }, []);

  return position;
};

mousemove イベントでマウスが動いている間は連続で handler を実行してしまいますが、とりあえずこれで座標は取れます

ツールチップコンポーネント

ツールチップ内のコンテンツはユースケースによって変わってくると思うので、このコンポーネントはツールチップの外観と表示制御のみを行います
ツールチップ内のコンテンツは children で渡すようにします

export const Tooltip: React.FC<Props> = ({ isOpen, children }) => {
  const position = useMousePosition();

  return (
    <div
      css={{
        ...(isOpen
          ? {
              position: "fixed",
              top: position.y - 10,
              left: position.x - 10,
              border: "2px solid #ddd",
              backgroundColor: "white",
              padding: 10,
              zIndex: 9999,
            }
          : { display: "none" }),
      }}
    >
      {children}
    </div>
  );
};

呼び出し方は以下のような感じです

<Tooltip isOpen={true}>
  <div css={{ color: "red" }}>AAAAAAAA</div>
  <div css={{ color: "blue" }}>BBBBBBBB</div>
  <div css={{ color: "green" }}>CCCCCCCC</div>
  <div css={{ color: "orange" }}>DDDDDDDD</div>
</Tooltip>

State 管理用カスタムフック

ツールチップに関する State 管理をこのカスタムフックにまとめています

import React, { useState } from "react";

type NameType = string;
type ValueType = string | number;

export const useTooltip: () => [
  boolean,
  { [key: NameType]: ValueType },
  (payload: { [key: NameType]: ValueType }) => void,
  () => void
] = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [data, setData] = useState<{ [key: NameType]: ValueType }>({});

  const open = (payload: { [key: NameType]: ValueType }) => {
    setData(payload);
    setIsOpen(true);
  };

  const close = () => {
    setIsOpen(false);
  };

  return [isOpen, data, open, close];
};

payload はチャートコンポーネントの onMouseEnter イベントなどから取れるチャート要素のオブジェクト(名前(キー)と値)が入っています

useTooltip は以下のように使います

export const BarChart: React.FC<Props> = () => {
  const [isOpenTooltip, tooltipData, openTooltip, closeTooltip] = useTooltip();

  return (
    <>
      <Tooltip isOpen={isOpenTooltip}>
        <div>{tooltipData["key"]}</div>
      </Tooltip>
      ...
      <Bar
        onMouseEnter={({ payload }) => {
          openTooltip(payload);
        }}
        onMouseLeave={() => {
          closeTooltip();
        }}
      />
    </>
  );
};
kurosamekurosame

チャートのレスポンシブ対応

BarChart や LineChart のコンポーネントに width と height を渡すことはできますが、50%のように%指定はサポートされていません
具体的な数値を number 型で渡す必要があります(たとえば、width={730}のように)

そこで困るのが、height は固定でも問題ないとしても、width については画面幅に合わせてレスポンシブにチャート幅を変更してほしいです

まあでもそこはちゃんと API が用意されていて、ResponsiveContainer を使えばレスポンシブ対応可能です。

<ResponsiveContainer width="100%" height={300}>
  <LineChart data={data}>...</LineChart>
</ResponsiveContainer>

ただ、ResponsiveContainer を使った時に、自分の環境だと以下の警告が出るようになりました
Chrome の DevTool などを使って、画面幅をリサイズするたびにこの警告が出ます

Warning: Cannot update a component from inside the function body of a different component.

この警告の詳細は公式サイトの以下に書いてあります
https://reactjs.org/blog/2020/02/26/react-v16.13.0.html#warnings-for-some-updates-during-render

上記から引用
レンダリング中に setState を呼び出すことはサポートされていますが、同じコンポーネントに対してのみです。別のコンポーネントでレンダリング中に setState を呼び出すと、警告が表示されるようになりました。

今回これが ResponsiveContainer 内部で起きているので改修するのが難しく、また警告もできれば消したいと思って、ResponsiveContainer を使わずチャートのレスポンシブ対応を自作することにしました

作るもの

LineChart などのコンポーネントの width に%が渡せないのであれば、レンダリング後のチャートの width を計算したものを渡せば良いと考え、以下を作りました

import React, { useEffect, useState } from "react";

export const useResizeObserver = (
  ref: React.MutableRefObject<HTMLElement | null>
): DOMRectReadOnly | null => {
  const [rect, setRect] = useState<DOMRectReadOnly | null>(null);

  useEffect(() => {
    const ro = new ResizeObserver((es) => {
      setRect(es[0].contentRect);
    });
    ref.current && ro.observe(ref.current);

    return () => ro.disconnect();
  }, []);

  return rect;
};

標準の WebAPI で用意されている ResizeObserver を使えば、HTMLElement のサイズの変化を監視して、リサイズ後の Element の rect を得ることができます
この rect にリサイズ後の width が含まれています

Element 自体は ref で渡せばオッケーです

ちなみに ResizeObserver に渡せるコールバック関数の戻り値は配列になっており、observe を複数回別の Element を渡して呼び出せば、複数 Element の監視が可能です

ro.observe(ref1.current);
ro.observe(ref2.current);
ro.observe(ref3.current);

useResizeObserver は以下のように使います

import React, { useRef } from "react";

export const Chart: React.FC<Props> = () => {
  const ref = useRef<HTMLDivElement | null>(null);
  const rect = useResizeObserver(ref);

  return (
    <div ref={ref}>
      <LineChart width={rect?.width ?? 500} height={300}>
        ...
      </LineChart>
    </div>
  );
};
kurosamekurosame

トラブルシューティング

PieChart のラベルが途切れる

ラベルが長いと文字が途切れ、以下のようになる可能性があります
(実際のラベル名は11111あいうえお22222かきくけこ33333さしすせそ

残念ながら、これを解消できそうなプロパティは API に用意されてなさそうなので、該当箇所の CSS を上書きします

import { css } from "@emotion/react";

const pieStyle = css`
  svg {
    overflow: inherit;
  }
`;

<PieChart css={pieStyle}>...</PieChart>;

上記がデフォルトだとoverflow: hiddenになっているため、文字が途切れていました
ここを inherit に設定することで文字が途切れるのを解消できます
initial や unset などでもいけます
(なんでデフォルト hidden なんだろ)

また、ResponsiveContainer で PieChart を囲ってあげても解消できますが、用途が異なる使い方をしているので、現状はスタイル上書きで良いのかなと思います

チャートの要素にマウスオーバーした時にアクティブの要素としてレンダリングされない

以下のようにどのチャート要素が現在アクティブなのかが判別できなくなりました

本来は以下のようにアクティブな要素の Dot が大きくなったり、縦棒(cursor)が表示されます

原因はこれらのアクティブ要素のプロパティが Recharts の Tooltip コンポーネントに含まれているため、Recharts の Tooltip を利用しないと使えないからです

今回ツールチップは自作しているので、Recharts の Tooltip コンポーネントは利用しませんが、アクティブ要素のレンダリングは行ってほしいので、以下のような対応を取りました

export const Chart: React.FC<Props> = () => (
  <>
    {/* 以下は自作したツールチップ */}
    <Tooltip isOpen={true} />
    <LineChart>
      ...
      {/* 以下はRechartsのツールチップ */}
      <RTooltip wrapperStyle={{ display: "none" }} />
      <Line activeDot={{ r: 6 }} />
    </LineChart>
  </>
);

Recharts の Tooltip コンポーネントを実装していますが、{ display: "none" }でツールチップが出ないようにしています
こうすることで Recharts のツールチップは出さず(自作したツールチップのみ出る)に、activeDot や cursor などのプロパティを有効化できます

かなり微妙な方法ですが、これ以外なさそうな気がします
(アクティブ要素とツールチップの機能は分離しといてほしかった。。)

Jest(Storyshots)関連のエラー

Storybook を使っているので、作ったコンポーネントに対して Storyshots を流しています
Storyshots のテストコードは以下です
スナップショットの分割処理を行っていますが、それ以外とくに変わったことはしていません

import React from "react";
import { create } from "react-test-renderer";
import { createSerializer } from "@emotion/jest";
import initStoryshots, {
  Stories2SnapsConverter,
} from "@storybook/addon-storyshots";
import { act } from "@testing-library/react-hooks";

const converter = new Stories2SnapsConverter();

initStoryshots({
  asyncJest: true,
  snapshotSerializers: [createSerializer()],
  test: async ({ story, context, done }) => {
    let renderer;
    act(() => {
      renderer = create(React.createElement(story.render));
    });

    const snapshotFileName = converter.getSnapshotFileName(context);
    expect(renderer).toMatchSpecificSnapshot(snapshotFileName);

    if (done) done();
  },
});

node_modules 内のパッケージが ESM だと構文エラー

以下のようなエラーです
Jest を使っているとよく見るエラーかなと思います
以下は省略していますが、けっこう長いエラーです

Jest encountered an unexpected token
...

これは node_modules 内のパッケージが ESModules で提供されているため起きるエラーです
Jest というか ts-jest が古いと ESM をサポートしていないため、Jest 上で import/export が構文エラーになります

Vitest だとこのエラーが起きることはないのですが、Storyshots が Jest しかサポートしていないので、しょうがないですね

これの対応としては、babel-jest を使います

module.exports = {
  transform: {
    "^.+\\.tsx?$": "ts-jest",
    "^.+\\.jsx?$": "babel-jest",
  },
  transformIgnorePatterns: ["/node_modules/(?!@babel|d3-*|internmap)"],
};

また、babel.config ファイルも必要です

transformIgnorePatterns はトランスパイルさせないディレクトリ/ファイルを指定できます
デフォルト値には"/node_modules/"が含まれています

今回は Jest 上で ESM 提供パッケージを動かすため、これらを CJS にトランスパイルさせる必要があります
よって、正規表現の否定的先読みを利用して、ESMパッケージ以外のnode_modulesを transformIgnorePatterns に指定します
(上記だと Recharts で使われている d3 系の ESM パッケージなどを指定しています)

紛らわしいですが、一言でいうと node_modules 内の ESM パッケージだけトランスパイルするよって設定です

babel-jest 使いたくない

脱 Babel を目指してる場合だと、babel-jest とか入れたくないと思います
試してないですが、一応最新の ts-jest であれば、ESM をサポートしているみたいです(実験的サポート?)
https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/

Storyshots が Vitest に対応してくれるのが 1 番なんですけどね

ResizeObserver が未定義でエラー

ReferenceError: ResizeObserver is not defined

以下のように ResizeObserver をモック化します

window.ResizeObserver = jest.fn().mockImplementation(() => ({
  observe: jest.fn(),
  disconnect: jest.fn(),
}));

JSDOM で動かしているので、window.ResizeObserverとなります

Recharts の Tooltip がエラー

TypeError: Cannot read property 'focus' of null

以下のように Tooltip をモック化します

jest.mock("recharts", () => ({
  ...jest.requireActual("recharts"),
  Tooltip: (p: any) => <div {...p} />,
}));
kurosamekurosame

クリックしたチャートを強調表示したい

たとえば、以下のようなチャートがあった場合

BBBBB という名前の青の Line をクリックすると以下のように表示する機能です

一応公式サイトにチャートコンポーネントが onXXX イベントを使っている Example があったので、これを少し参考にしました
https://recharts.org/en-US/examples/CustomActiveShapePieChart

import React, { useState } from "react";

export const BarLineChart: React.FC<Props> = () => {
  const [clickedKey, setClickedKey] = useState<string | undefined>(undefined);

  const lineYAxis = [
    { key: "BBBBB", color: "#4784BF" },
    { key: "CCCCC", color: "#E8AC51" },
    { key: "DDDDD", color: "#5D5099" },
  ];

  const getChartColor = (y: { key: string; color: string }) =>
    clickedKey === y.key || !clickedKey ? y.color : "#d3d3d3";

  return (
    <ComposedChart
      onClick={
        clickedKey
          ? () => {
              setClickedKey(undefined);
            }
          : undefined
      }
    >
      <XAxis dataKey="date" />
      <YAxis yAxisId="bar" />
      <YAxis yAxisId="line" orientation="right" />
      <Legend />
      <Bar
        yAxisId="bar"
        dataKey="AAAAA"
        stackId={0}
        fill={getChartColor({ key: "AAAAA", color: "#DE6641" })}
      />
      {lineYAxis.map((y) => (
        <Line
          key={y.key}
          yAxisId="line"
          dataKey={y.key}
          stroke={getChartColor(y)}
          style={{ cursor: "pointer" }}
          onClick={() => {
            setClickedKey(y.key);
          }}
        />
      ))}
    </ComposedChart>
  );
};
  • clickedKey
    • クリックした Line のキーを保持します
  • getChartColor
    • クリックされた Line の場合のみ色付けして、それ以外はグレー(#d3d3d3)を返します
    • どの Line もクリックされていない場合は、すべての Line を色付けします
  • ComposedChart の onClick
    • Line 以外をクリックした時に元へ戻す処理を行っています

今回 Recharts は仕事で使っているのですが、「チャートを含むダッシュボード画面」は最近需要があって、よくあるのがいったん Tableau でダッシュボード画面を作って運用してみて、その後自社用にダッシュボード画面を新規作成するというパターンが多いように思えます
なので、Tableau で表現できてた機能は、大体必須開発要件に上がってきます
今回のクリックしたチャートを強調表示もまさにそれです

強調表示してないチャートが上に来る場合の対応

以下の例のように複数のラインチャートがある場合、グレーの(強調表示していない)チャートが上に来て強調表示したいチャートと被る可能性があります

これも API 提供のプロパティで対処する方法が見つからなかったので、以下の対応を取りました

<Line stroke={isHighlight ? '#4784BF' : '#d3d3d34d'} />

カラーコードを HEX で指定し、強調表示しないチャートは透過させます
4dは不透明度 30%になります

凡例(Legend)をクリックで強調表示

凡例をクリックした際に強調表示できるように実装します

import React, { useState } from "react";

export const BarLineChart: React.FC<Props> = () => {
  const [onMouseLegendType, setOnMouseLegendType] = useState<
    "bar" | "line" | undefined
  >(undefined);

  return (
    <ComposedChart>
      ...
      <Legend
        wrapperStyle={{
          cursor: onMouseLegendType === "line" ? "pointer" : "auto",
        }}
        onClick={({ payload }) => {
          payload.yAxisId === "line" && setClickedKey(payload.dataKey);
        }}
        onMouseEnter={({ payload }) => {
          setOnMouseLegendType(payload.yAxisId);
        }}
      />
    </ComposedChart>
  );
};

仕様は以下です

  • clickedKey / setClickedKey は前述と同様
  • Bar チャートは強調表示しない(凡例がクリックできない)
  • Line チャートのみ強調表示する
このスクラップは2022/10/14にクローズされました