Closed12

Rechartsでグラフをごりごりカスタマイズするぞ!(RadarChart編)

10tera10tera

単純に使うだけならexampleをそのまま使うだけで使えちゃいます。

<div
      className={css({
        w: "700px",
        h: "640px",
      })}
    >
      <ResponsiveContainer width="100%" height="100%">
        <RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
          <PolarGrid />
          <PolarAngleAxis dataKey="subject" />
          <PolarRadiusAxis angle={30} domain={[0, 150]} />
          <Radar
            name="Mike"
            dataKey="A"
            stroke="#8884d8"
            fill="#8884d8"
            fillOpacity={0.6}
          />
          <Radar
            name="Lily"
            dataKey="B"
            stroke="#82ca9d"
            fill="#82ca9d"
            fillOpacity={0.6}
          />
          <Legend />
        </RadarChart>
      </ResponsiveContainer>
    </div>

10tera10tera

ただデフォルトのグラフでは物足りないので、カスタマイズすることになりました。
変えたい部分は

  • データの差分にのみ色をつけて、差を分かりやすくしたい
  • 項目のLabelが地味なので、ちょっとリッチにしたい
10tera10tera

データの差分にのみ色をつけたい

差分に色をつけるってどういうことかというと、こういうことです↓

ということで差に色をつける設定を探しましたが、ありませんでした(多分)。。。
なので、自分で作ることになります。

いくつか考えついた方法を書いていきます。

10tera10tera

方法1

今回はLily - Mikeの差分を取りたいので、MikeのRadar.fillをwhiteにする。
(引く方をwhiteに)

確かに差分にのみ色がついたが。。。。
色がwhiteなので、背景の目盛りまで消えてしまった!

ということでこの方法は使えませんでした。

10tera10tera

方法2

svg elementのpolygonを使って、頑張って差分を表現する。
ただし複雑な座標計算を要するので、大変です。。。
描画する自作polygonはRadarのshapeに渡すことで描画してくれます。またpropsにはpointsなどの座標データが最低限返ってきます。
https://recharts.org/en-US/api/Radar#shape

具体的には

のように項目と項目の間ごとに分割する。
分割された区間内のpolygonの形は3種類に分けることができる。

  • 描画なし
    • これは両方の項目の差分が負な時
  • 四角形
    • これは両方の項目の差分が正な時
    • これは比較的実装が楽(比較的)
    • 4点の座標をprops.pointsから頑張って算出しましょう
  • 三角形
    • これは片方の項目のみ差分が負な時
    • これは実装が大変です
    • 2線の交差点の座標を計算しないといけないので、四角形の時より実装が複雑になります
    • 根気よく計算しましょう

ということで、計算が大変なのでこの方法は採用しませんでした。次が本命です。

10tera10tera

方法3

cssのmaskを使います。コードは↓

const LilyRadar = ({
  points,
  // biome-ignore lint/suspicious/noExplicitAny: allow any type
}: { points: any }) => {
  let pointsStr = "";
  for (let i = 0; i < points.length; i++) {
    pointsStr += `${points[i].x},${points[i].y} `;
  }
  return (
    <>
      <polygon
        points={pointsStr}
        fill="rgba(246, 231, 239, 0.75)"
        mask="url(#mikeMask)"
      />
      <polygon points={pointsStr} fill="transparent" stroke="#A11261" />
    </>
  );
};

const MikeRadar = ({
  points,
  // biome-ignore lint/suspicious/noExplicitAny: allow any type
}: { points: any }) => {
  let pointsStr = "";
  for (let i = 0; i < points.length; i++) {
    pointsStr += `${points[i].x},${points[i].y} `;
  }
  return (
    <>
      <defs>
        <mask id="mikeMask">
          <rect x="0" y="0" width="1000" height="1000" fill="white" />
          <polygon points={pointsStr} fill="black" />
        </mask>
      </defs>
      <Polygon
        points={points}
        stroke="#F2B180"
        fill="transparent"
        className={css({
          pointerEvents: "none",
        })}
      />
    </>
  );
};

<ResponsiveContainer width="100%" height="100%">
        <RadarChart cx="50%" cy="50%" outerRadius="80%" data={data}>
          <PolarGrid />
          <PolarAngleAxis dataKey="subject" />
          <PolarRadiusAxis angle={30} domain={[0, 150]} />
          <Radar
            name="Lily"
            dataKey="B"
            shape={({ points }) => <LilyRadar points={points} />}
          />
          <Radar
            name="Mike"
            dataKey="A"
            shape={({ points }) => <MikeRadar points={points} />}
          />
          <Legend />
        </RadarChart>
      </ResponsiveContainer>

仕組みはMikeのRadarをカスタム描画するタイミングでpointsを元にmaskを作ります。
maskではwhiteなところを残しつつ、その中のblackなところを切り取ります(説明が下手すぎる)。↓

<defs>
        <mask id="mikeMask">
          <rect x="0" y="0" width="1000" height="1000" fill="white" />
          <polygon points={pointsStr} fill="black" />
        </mask>
      </defs>

そしてLilyのRadarをカスタム描画するタイミングで、maskにidを指定します。

mask="url(#mikeMask)"

ということで差分に色をつけることができました

10tera10tera

項目のLabelをリッチにしたい

次に項目のLabelをリッチにしようと思います。
項目のLabelとは「History」や「English」とある部分です。
色を変えるだけなら簡単に変えれますが、自分でカスタマイズしようとするとPolarAngleAxisのtickにコンポーネントを渡すことで実現できます。
https://recharts.org/en-US/api/PolarAngleAxis#tick

ただ渡すコンポーネントはRadar同様にsvg elementなので、divとかは渡すことができません(これが何気に大変。。。)。

10tera10tera
tick={(label) => {
              return (
                <CustomLabel
                  label={label}
                  data={data}
                />
              );
            }}

このlabel内で主に使うデータは

  • 基準座標のx,y
  • textAnchor
    • start/middle/end
  • payload
    • payload.valueにはLabelのテキストが格納されている
10tera10tera

CustomLabelの中で<rect/>などを配置してそのx,yにlabel.xなどをそのまま渡そうとすると、グラフにCustomLabelが重なってしまうので、色々と座標計算する必要があります。

  • textAnchorを元にx軸方向を調整する
    • textAnchorを組み込むことで、Labelを中央配置することができます
    • ResizeObserverなどを使って可変要素のwidthをgetすることで、CustomLabel全体のwidthを保持しておく
    • そのwidth(コード内ではgroupWidth)とtextAnchorを元に↓のような調整を行う
    •   let transformX = 0;
          switch (label.textAnchor) {
            case "start":
              transformX = 0;
              break;
            case "middle":
              transformX = groupWidth / 2;
              break;
            case "end":
              transformX = groupWidth;
              break;
            default:
              break;
          }
      const x = label.x - transformX;
      
  • Labelの大きさが大きくなりすぎると、textAnchorを組み込んでもグラフに重なってしまうので、中心から同じ距離離してあげる必要があります
    •   //中心からどれくらいの割合で離れさせるか
          const gap = 10;
          const rad = (label.payload.coordinate * Math.PI) / 180;
          const x = label.x + gap * Math.cos(rad);
          const y = label.y - gap * Math.sin(rad);
      
    • gapは離す重みを表しています。この数値を調整することでどれくらい離すかを決めることができます。
10tera10tera

愚痴

  • shapeとかから提供されるpropsの型がanyなのが使いにくかったです。
    • やはり型は固ければ良い
  • props.payloadの中にもっとデータを入れて欲しいなぁと思った
    • 自分でデータ整形して、渡さないと自由にCustomLabelを作りにくい
このスクラップは4ヶ月前にクローズされました