Chartjsのパイチャートにリーダー線を描画する

# はじめに
今年の夏から担当するプロジェクトが変わり、仕事では初のフロントサイドの開発を任されていました。
その中でも苦労したチャートの外側にラベルとリーダー線を描画する方法を説明します。
こちらはchartjsの標準機能に存在しないので自力で実装する必要があります。

## 完成形のイメージ
パイチャートもしくはドーナッツチャートの外側にラベルを描画し、ラベルに対してチャートの円周上からリーダー線を伸ばす形です。（ちなみに現場ではリーダー線のことを通称髭と呼んでいました）
細かいデータが多くなった場合（画像で言うとGreen～Orange間）にラベル同士が重なってしまうので、その場合はY軸をずらすといったことをしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/414958/5a293a4f-d017-ff49-81b0-49145e90a248.png)

# 環境
nodejs v18.16.0  
react v18.2.0  
typescript v5.2.2  
chartjs v4.4.3  
react-chartjs-2 v5.2.0

# 実装
## 標準的なパイチャートの描画
今回はラベル＋リーダー線を描画するプラグインを作成するので、`plugins`には後ほど作成する`drawLabelPlugin`を指定します。それ以外は至って普通のパイチャートなので、既に作成済みの場合は飛ばしていただいて構いません。

```typescript:PieChart.tsx
import React, { FC } from 'react';
import { Pie } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
import drawLabelPlugin from '../plugins/drawLabelPlugin';

ChartJS.register(ArcElement, Tooltip, Legend);

const PieChart: FC = () => {
  const data = {
    labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
    datasets: [
      {
        data: [12000, 8000, 5000, 1000, 500, 200],
        backgroundColor: [
          "rgba(255, 99, 132, 0.2)",
          "rgba(54, 162, 235, 0.2)",
          "rgba(255, 206, 86, 0.2)",
          "rgba(75, 192, 192, 0.2)",
          "rgba(153, 102, 255, 0.2)",
          "rgba(255, 159, 64, 0.2)",
        ],
        borderColor: [
          "rgba(255, 99, 132, 1)",
          "rgba(54, 162, 235, 1)",
          "rgba(255, 206, 86, 1)",
          "rgba(75, 192, 192, 1)",
          "rgba(153, 102, 255, 1)",
          "rgba(255, 159, 64, 1)",
        ],
        borderWidth: 1,
      },
    ],
  };

  const options = {
    responsive: false,
    radius: '45%',
    plugins: {
    },
  };

  return (
    <React.Fragment>
      <Pie
          data={data}
          options={options}
          plugins={[drawLabelPlugin]}
          width={1000}
          height={1000}
        />
    </React.Fragment>
  )
};

export default PieChart;
```
</details>

## ラベルを描画する
ラベル＋リーダー線を描画するプラグインを実装します。
重要な考え方としては、ラベルはデータの若いものから順番に描画されるので、ラベルを描画する度にその位置を記憶し、新たなラベルを描画する際は記憶したラベルに被らないようにY座標をずらして描画するということです。

<details><summary>全体コードはこちら</summary>

```typescript:drawLeaderLinePlugin.ts
import { Chart } from 'chart.js';

interface Point {
  x: number,
  y: number,
}

enum LabelPoints {
  TOP_LEFT = 'topLeft',
  TOP_RIGHT = 'topRight',
  BOTTOM_LEFT = 'bottomLeft',
  BOTTOM_RIGHT = 'bottomRight',
  LINE = 'line'
}

// フォント設定
const font = 'Arial';
const textSize = 14;
const fontSize = `${textSize}px ${font}`;
const textColor = '#616161';

// 線の長さ
const lineLength = 40;
// ラベルが衝突した際にずらす幅
const yShiftSize = 33;

const drawLeaderLinePlugin = {
  id: 'drawLeaderLinePlugin',
  afterDraw(chart: Chart<'doughnut'>) {
    const ctx = chart.ctx;
    const labelPoints: {[key: string]: Point}[] = [];

    chart.data.datasets.forEach((dataset, i) => {
      chart.getDatasetMeta(i).data.forEach((datapoint, index) => {
        if (chart.data.labels) {
          // ラベルに表示するテキスト
          const labelText = chart.data.labels[index] as string;
          const labelData = chart.data.datasets[0].data[index].toString();

          // チャートの中心
          const centerX = datapoint.x;
          const centerY = datapoint.y;

          // 中央角を求める
          const { startAngle, endAngle, outerRadius } = datapoint.getProps(['startAngle', 'endAngle', 'outerRadius']);
          const middleAngle = ((startAngle as number) + (endAngle as number)) / 2;

          // 円周上の点を求める
          const pointOnCircleX = centerX + (outerRadius as number) * Math.cos(middleAngle);
          const pointOnCircleY = centerY + (outerRadius as number) * Math.sin(middleAngle);

          // 線を生やす方向を決める
          const { x, y } = datapoint.tooltipPosition(true);
          const xLine = x >= centerX ? pointOnCircleX + lineLength : pointOnCircleX - lineLength;
          const yLine = y >= centerY ? pointOnCircleY + lineLength : pointOnCircleY - lineLength;
          const extraLine = x >= centerX ? lineLength - 15 : - lineLength + 15;

          // ラベルを描画する位置を決める
          ctx.font = fontSize;
          const textAlign = x >= centerX ? 'left' : 'right';
          const plusFivePx = x >= centerX ? 5 : -5;   // 髭先とテキストとの間隔を空ける
          const labelPosition = textAlign === 'left' ? 'right' : 'left';
          ctx.textBaseline = 'middle';
          ctx.fillStyle = textColor;

          // ラベルに表示するテキストの長さを測る
          const labelTextWidth = ctx.measureText(labelText).width;
          const labelDataWidth = ctx.measureText(labelData).width;
          const labelWidth = labelTextWidth < labelDataWidth ? labelDataWidth : labelTextWidth;

          // 配置予定のラベルの位置
          const currentLabelPoints: {[key: string]: Point} = {};
          currentLabelPoints[LabelPoints.TOP_LEFT] = {
            x: xLine + extraLine + plusFivePx - (textAlign === 'left' ? 0 : labelWidth),
            y: yLine - Number(textSize)/2
          }
          currentLabelPoints[LabelPoints.TOP_RIGHT] = {
            x: xLine + extraLine + plusFivePx + (textAlign === 'right' ? 0 : labelWidth),
            y: yLine - Number(textSize)/2
          }
          currentLabelPoints[LabelPoints.BOTTOM_LEFT] = {
            x: xLine + extraLine + plusFivePx - (textAlign === 'left' ? 0 : labelWidth),
            y: yLine + Number(textSize) * 1.5
          }
          currentLabelPoints[LabelPoints.BOTTOM_RIGHT] = {
            x: xLine + extraLine + plusFivePx + (textAlign === 'right' ? 0 : labelWidth),
            y: yLine + Number(textSize) * 1.5
          }
          currentLabelPoints[LabelPoints.LINE] = {
            x: xLine,
            y: yLine
          }

          // 衝突判定
          let isShift = false;
          let shiftedLabelPoints: {[key: string]: Point} = currentLabelPoints;
          for (let i=0; i<labelPoints.length; i++) {
            if (shoudShiftLabel(currentLabelPoints, labelPoints[i])) {
              isShift = true;
            }
          }

          if (isShift) {
            // 衝突していた場合、位置をずらす
            // ずらす方向を決める
            const shiftYLine = y >= centerY ? yShiftSize : -yShiftSize;
            shiftedLabelPoints[LabelPoints.TOP_LEFT] = {
              x: currentLabelPoints[LabelPoints.TOP_LEFT].x,
              y: labelPoints.slice(-1)[0][LabelPoints.TOP_LEFT].y + shiftYLine
            }
            shiftedLabelPoints[LabelPoints.TOP_RIGHT] = {
              x: currentLabelPoints[LabelPoints.TOP_RIGHT].x,
              y: labelPoints.slice(-1)[0][LabelPoints.TOP_RIGHT].y + shiftYLine
            }
            shiftedLabelPoints[LabelPoints.BOTTOM_LEFT] = {
              x: currentLabelPoints[LabelPoints.BOTTOM_LEFT].x,
              y: labelPoints.slice(-1)[0][LabelPoints.BOTTOM_LEFT].y + shiftYLine
            }
            shiftedLabelPoints[LabelPoints.BOTTOM_RIGHT] = {
              x: currentLabelPoints[LabelPoints.BOTTOM_RIGHT].x,
              y: labelPoints.slice(-1)[0][LabelPoints.BOTTOM_RIGHT].y + shiftYLine
            }
            shiftedLabelPoints[LabelPoints.LINE] = {
              x: currentLabelPoints[LabelPoints.LINE].x,
              y: labelPoints.slice(-1)[0][LabelPoints.LINE].y + shiftYLine,
            }
          }

          // ラベル描画
          // ラベルフォント
          ctx.textAlign = 'left';
          ctx.font = 'bold ' + fontSize;
          // ラベルの描画
          ctx.fillText(
            labelText,
            labelPosition === 'left' ?
            shiftedLabelPoints[LabelPoints.LINE].x + plusFivePx + extraLine - labelWidth - 4 :
              shiftedLabelPoints[LabelPoints.LINE].x + plusFivePx + extraLine,
            shiftedLabelPoints[LabelPoints.LINE].y
          );

          // データのフォント
          ctx.font = fontSize;
          // データの描画
          ctx.fillText(
            labelData,
            labelPosition === 'left' ?
              x + extraLine - labelWidth - 4 :
              x + extraLine,
            y + Number(textSize)+2
          );

          // 接続線描画
          ctx.beginPath();
          ctx.moveTo(pointOnCircleX, pointOnCircleY);
          ctx.lineTo(shiftedLabelPoints[LabelPoints.LINE].x, shiftedLabelPoints[LabelPoints.LINE].y);
          ctx.lineTo(shiftedLabelPoints[LabelPoints.LINE].x + extraLine, shiftedLabelPoints[LabelPoints.LINE].y);
          ctx.strokeStyle = dataset.backgroundColor ? (dataset.backgroundColor as string[])[index] : '';
          ctx.stroke();

          // ラベルの位置を保存
          labelPoints.push(shiftedLabelPoints);
        }
      });
    });
  }
}

/**
 * ラベルをずらす判定
 * @param label1 
 * @param label2 
 * @returns 
 */
function shoudShiftLabel (label1: {[key: string]: Point}, label2: {[key: string]: Point}): boolean {
  let result = false;
  Object.entries(LabelPoints).forEach(([key, rectPoint]) => {
    if (key !== LabelPoints.LINE) {
      if (labelCollisionDetection(label1, label2, rectPoint)){
        result = true;
      }
      if (labelCollisionDetection(label2, label1, rectPoint)) {
        result = true;
      }
    }
  })
  return result;
}

/**
 * ラベル衝突判定
 * @param label1 
 * @param label2 
 * @param rectPoint 
 * @returns 
 */
function labelCollisionDetection (label1: {[key: string]: Point}, label2: {[key: string]: Point}, rectPoint: LabelPoints): boolean {
  if (
    label2[LabelPoints.TOP_LEFT].x < label1[rectPoint].x &&
    label2[LabelPoints.BOTTOM_RIGHT].x > label1[rectPoint].x &&
    label2[LabelPoints.TOP_LEFT].y < label1[rectPoint].y &&
    label2[LabelPoints.BOTTOM_RIGHT].y > label1[rectPoint].y
  ) {
    return true;
  }
  return false;
}


export default drawLeaderLinePlugin;
```
</details>

### 各データポイントの円周上の点を求める
ラベルとリーダー線を描画する際の基準点を取得する必要があるので、各データポイント毎に下記の処理を行います。
1. チャートの中心を求める
`datapoint.x`などで取得できます。他にも`chart.chartArea.left/2`といった取得方法がありますが、必ずしもcanvasの中央にチャートが描画されているとは限らないので、前者がおすすめです。
1. 中央角を求める
中央角を求めるにはまず開始角、終了角、チャートの半径が必要です。これらの値は`datapoint.getProps`に対応した文字列を渡すと取得できます。
それらを下記の式で計算すると中央角を求められます。
（開始角 + 終了角）÷ 2 = 中央角
1. 円周上の点を求める
先ほど求めたチャートの半径と中央角を用いて三角関数で円周上の点を求めます。
x座標を求めるにはcosθ、y座標はsinθですね。

```typescript
// チャートの中心
const centerX = datapoint.x;
const centerY = datapoint.y;

// 各データの開始角、終了角、チャートの半径を取得
const { startAngle, endAngle, outerRadius } = datapoint.getProps(['startAngle', 'endAngle', 'outerRadius']);
const middleAngle = ((startAngle as number) + (endAngle as number)) / 2;

// 円周上の点を求める
const pointOnCircleX = centerX + (outerRadius as number) * Math.cos(middleAngle);
const pointOnCircleY = centerY + (outerRadius as number) * Math.sin(middleAngle);
```

### 線を生やす方向を決める
`datapoint.tooltipPosition(true)`からデータポイントの中央座標を取得します。ツールチップはデフォルトではデータポイントの中央に描画されるので、円を4分割した際に右上、右下、左下、左上のどこにデータポイントの中心があるのかを調べることができます。
チャートの上側にデータポイントがある場合は上方向へ、チャートの右にデータポイントがある場合は右側といったように、チャートの円に被らないように線を伸ばします。

:::note warn
chartjsではX座標とY座標はcanvasエリアの左下が0となり、チャートの中心が0ではない点に気を付けましょう。
:::

```typescript
// 線を生やす方向を決める
const { x, y } = datapoint.tooltipPosition(true);
const xLine = x >= centerX ? pointOnCircleX + lineLength : pointOnCircleX - lineLength;
const yLine = y >= centerY ? pointOnCircleY + lineLength : pointOnCircleY - lineLength;
const extraLine = x >= centerX ? lineLength - 15 : - lineLength + 15;
```

### ラベルを描画する位置を決める
前項の考え方と同じで、データポイントが中心の右側か左側かでテキストをどちらに詰めて描画するのかを決めます。
```typescript
// ラベルを描画する位置を決める
ctx.font = fontSize;
const textAlign = x >= centerX ? 'left' : 'right';
const plusFivePx = x >= centerX ? 5 : -5;   // 髭先とテキストとの間隔を空ける
const labelPosition = textAlign === 'left' ? 'right' : 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = textColor;
```

### ラベルの衝突判定を行う
ラベル同士が被る場合があるので、一定の幅だけラベルを描画するY軸をずらすことで解消します。
今回表示するラベルは2行あるので、ずらす幅は2行分です。
1. ラベルに表示するテキストの長さを測る
2行あるうちの長い方をラベルの長さとします。
1. ラベルの描画予定座標を決める
ラベルの四隅の座標をそれぞれ計算します。`plusFivePx`はリーダー線とラベルを5px離すために指定しています。
1. 衝突判定
ラベルの描画予定座標と、保存された既に描画済みのラベルの座標を比較し、ラベルの辺がお互いに被っていないかを判定します。
1. 衝突していた場合はずらす
衝突していた場合は予め決めた数値をラベル描画予定Y座標に、データポイントが中央より上の場合は加算、下の場合は減算します。

```typescript
// ラベルに表示するテキストの長さを測る
const labelTextWidth = ctx.measureText(labelText).width;
const labelDataWidth = ctx.measureText(labelData).width;
const labelWidth = labelTextWidth < labelDataWidth ? labelDataWidth : labelTextWidth;

// 配置予定のラベルの位置
const currentLabelPoints: {[key: string]: Point} = {};
currentLabelPoints[LabelPoints.TOP_LEFT] = {
  x: xLine + extraLine + plusFivePx - (textAlign === 'left' ? 0 : labelWidth),
  y: yLine - Number(textSize)/2
}
currentLabelPoints[LabelPoints.TOP_RIGHT] = {
  x: xLine + extraLine + plusFivePx + (textAlign === 'right' ? 0 : labelWidth),
  y: yLine - Number(textSize)/2
}
currentLabelPoints[LabelPoints.BOTTOM_LEFT] = {
  x: xLine + extraLine + plusFivePx - (textAlign === 'left' ? 0 : labelWidth),
  y: yLine + Number(textSize) * 1.5
}
currentLabelPoints[LabelPoints.BOTTOM_RIGHT] = {
  x: xLine + extraLine + plusFivePx + (textAlign === 'right' ? 0 : labelWidth),
  y: yLine + Number(textSize) * 1.5
}
currentLabelPoints[LabelPoints.LINE] = {
  x: xLine,
  y: yLine
}

// 衝突判定
let isShift = false;
let shiftedLabelPoints: {[key: string]: Point} = currentLabelPoints;
for (let i=0; i<labelPoints.length; i++) {
  if (shoudShiftLabel(currentLabelPoints, labelPoints[i])) {
    isShift = true;
  }
}

if (isShift) {
  // 衝突していた場合、位置をずらす
  // ずらす方向を決める
  const shiftYLine = y >= centerY ? yShiftSize : -yShiftSize;
  shiftedLabelPoints[LabelPoints.TOP_LEFT] = {
    x: currentLabelPoints[LabelPoints.TOP_LEFT].x,
    y: labelPoints.slice(-1)[0][LabelPoints.TOP_LEFT].y + shiftYLine
  }
  shiftedLabelPoints[LabelPoints.TOP_RIGHT] = {
    x: currentLabelPoints[LabelPoints.TOP_RIGHT].x,
    y: labelPoints.slice(-1)[0][LabelPoints.TOP_RIGHT].y + shiftYLine
  }
  shiftedLabelPoints[LabelPoints.BOTTOM_LEFT] = {
    x: currentLabelPoints[LabelPoints.BOTTOM_LEFT].x,
    y: labelPoints.slice(-1)[0][LabelPoints.BOTTOM_LEFT].y + shiftYLine
  }
  shiftedLabelPoints[LabelPoints.BOTTOM_RIGHT] = {
    x: currentLabelPoints[LabelPoints.BOTTOM_RIGHT].x,
    y: labelPoints.slice(-1)[0][LabelPoints.BOTTOM_RIGHT].y + shiftYLine
  }
  shiftedLabelPoints[LabelPoints.LINE] = {
    x: currentLabelPoints[LabelPoints.LINE].x,
    y: labelPoints.slice(-1)[0][LabelPoints.LINE].y + shiftYLine,
  }
}
```

### ラベル、リーダー線の描画
あとは上項で決めた情報を基に描画するだけです。
今回はデータ名を太字にしています。
最後に最終的にラベルを描画した座標を保存することで、次のラベル描画時に衝突判定を行えます。

```typescript
// ラベル描画
// ラベルフォント
ctx.textAlign = 'left';
ctx.font = 'bold ' + fontSize;
// ラベルの描画
ctx.fillText(
  labelText,
  labelPosition === 'left' ?
  shiftedLabelPoints[LabelPoints.LINE].x + plusFivePx + extraLine - labelWidth - 4 :
    shiftedLabelPoints[LabelPoints.LINE].x + plusFivePx + extraLine,
  shiftedLabelPoints[LabelPoints.LINE].y
);

// データのフォント
ctx.font = fontSize;
// データの描画
ctx.fillText(
  labelData,
  labelPosition === 'left' ?
  shiftedLabelPoints[LabelPoints.LINE].x + plusFivePx + extraLine - labelWidth - 4 :
    shiftedLabelPoints[LabelPoints.LINE].x + plusFivePx + extraLine,
  shiftedLabelPoints[LabelPoints.LINE].y + Number(textSize)+2
);

// 接続線描画
ctx.beginPath();
ctx.moveTo(pointOnCircleX, pointOnCircleY);
ctx.lineTo(shiftedLabelPoints[LabelPoints.LINE].x, shiftedLabelPoints[LabelPoints.LINE].y);
ctx.lineTo(shiftedLabelPoints[LabelPoints.LINE].x + extraLine, shiftedLabelPoints[LabelPoints.LINE].y);
ctx.strokeStyle = dataset.backgroundColor ? (dataset.backgroundColor as string[])[index] : '';
ctx.stroke();

// ラベルの位置を保存
labelPoints.push(shiftedLabelPoints);
```

# 最後に
あまりスマートな書き方ではない箇所が散見されるかと思いますが、自分なりの書き方で実装してみました。
実は今回と似たようなことを[chartjs-plugin-outerLabels](https://github.com/robcrocombe/chartjs-plugin-outerLabels)を使えば実装できそうだったのですが、線が出てこないところが要件と合わなかったんですよね。
他にもchartjs周りで苦労した点がいくつかあるので、気が乗ったら書きます。

# 参考文献
- [Chart.js公式ドキュメント](https://www.chartjs.org/docs/latest/)