【D3】バブルチャートを実装する

# はじめに
この記事では、JavaScript 向けのデータ可視化ライブラリである D3 を使って、バブルチャートを実装する手順を記載します。

https://d3js.org/

# 開発環境
開発環境は以下の通りです。
- Windows 11
- Next.js 14.2.4
- React 18.3.1
- TypeScript 5.5.2
- D3 7.9.0
- @types/d3 7.4.3

# チャートに表示するデータ
これから作成するバブルチャートでは、X軸に `element`、Y軸に `score` を表示します。
バブルの大きさは `count` で表現します。

```data.ts
export const data = [
  { element: "A", score: 1, count: 2 },
  { element: "A", score: 2, count: 2 },
  { element: "A", score: 3, count: 4 },
  { element: "B", score: 1, count: 0 },
  { element: "B", score: 2, count: 5 },
  { element: "B", score: 3, count: 3 },
  { element: "C", score: 1, count: 7 },
  { element: "C", score: 2, count: 7 },
  { element: "C", score: 3, count: 1 },
];
```

# SVG のレンダリング
まずはチャートを表示するための SVG をレンダリングします。
今回は実装したチャートをこちらの SVG に `ref` を通して渡します。そのため、`ref` を SVG の `props` に追加します。
また、チャート全体の大きさを指定するため、`width` と `height` も追加します。

```page.tsx
"use client";

import { useRef } from "react";

export default function Page() {
  const svgRef = useRef<SVGSVGElement | null>(null);
  const width = 600;
  const height = 400;

  return <svg ref={svgRef} width={width} height={height} />;
}
```

この時点では、画面上には 600 × 400 の SVG タグが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/f2a4d9a4-5453-7953-7414-c5048cce4a19.png)

# チャートの実装
バブルチャートを作成する処理を実装していきます。
チャート作成処理は、`renderChart` という関数を作成し、その内部に実装していきます。`renderChart` を `useEffect` 内で初回レンダリング時に実行し、画面上にバブルチャートを描画します。

```page.tsx
"use client";

import { useEffect, useRef } from "react";

export default function Page() {
  const svgRef = useRef<SVGSVGElement | null>(null);
  const width = 600;
  const height = 400;

  const renderChart = () => {};

  useEffect(() => {
    renderChart();
  }, []);

  return <svg ref={svgRef} width={width} height={height} />;
}

```

## データの出力範囲の定義
それぞれ後述するAPIを利用して、X軸（`element`）方向・Y軸（`score`）方向・バブル（`count`）の大きさを定義します。

### X軸方向
- [scaleBand](https://d3js.org/d3-scale/band#scaleBand): `domain`、`range` をもとにX軸方向の表示範囲を定義
- [band.domain](https://d3js.org/d3-scale/band#band_domain): X軸に表示する `element` の値（入力値）
- [band.range](https://d3js.org/d3-scale/band#band_range): 入力値を画面上に表示する範囲（出力値）

```page.tsx
const xScale = d3
 .scaleBand()
 .domain(data.map((d) => d.element))
 .range([0, width]);
```

### Y軸方向
- [scaleLinear](https://d3js.org/d3-scale/linear#scaleLinear)
- [scaleLinear.domain](https://d3js.org/d3-scale/linear#linear_domain)
- [scaleLinear.range](https://d3js.org/d3-scale/linear#linear_range)

```page.tsx
const yScale = d3
  .scaleLinear()
  .domain([0, d3.max(data, (d) => d.score) || 0])
  .range([height, 0]);
```

### バブルの大きさ

- [scaleSqrt](https://d3js.org/d3-scale/pow#scaleSqrt)
- [scaleLinear.domain](https://d3js.org/d3-scale/linear#linear_domain)
- [scaleLinear.range](https://d3js.org/d3-scale/linear#linear_range)

```page.tsx
const sizeScale = d3
  .scaleSqrt()
  .domain([0, d3.max(data, (d) => d.count) || 0])
  .range([0, 30]);
```

## D3 による SVG の操作を有効化
以下のAPIを利用して先ほどレンダリングした SVG を D3 で操作できるようにします。

- [select](https://d3js.org/d3-selection/selecting#select)

```page.tsx
const svg = d3.select(svgRef.current);
```

## SVG を初期化する
この後、実際に SVG を操作して、チャートを実装していきますが、その前に一度 SVG を初期化します。

- [selection.remove](https://d3js.org/d3-selection/modifying#selection_remove)

```page.tsx
svg.selectAll("*").remove();
```

:::note
今回のユースケースでは、表示するデータは常に固定で初回レンダリング時のみチャートの描画処理（`renderChart`）が実行されます。そのため、一見初期化処理は不要に思えます。ただ、今回の開発環境では、React の [Strict Mode](https://react.dev/reference/react/StrictMode) が有効になっているため、ローカル開発環境だと描画処理が2回実行されるため、初期化処理を追加しています。

#### 初期化処理あり
データの配列の要素数 (9) 分の `circle` タグが表示される

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/10f16d50-ad8e-2731-b678-1cdb7906a4e5.png)

#### 初期化処理なし
データの配列の要素数 (9) × レンダリング回数 (2) 分の `circle` タグが表示される

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/37803bef-adf8-7b82-9b5d-21dadbf14465.png)

:::

ここまではある意味実際にチャートを作成するための準備処理です。ここから実際にチャートを作成する処理を実装していきます。

## データを SVG に渡す
以下のAPIを利用してチャートに表示するデータを SVG に渡します。

- [selectAll](https://d3js.org/d3-selection/selecting#selectAll)
- [selection.data](https://d3js.org/d3-selection/joining#selection_data)

```page.tsx
const bubbles = svg.selectAll(".bubble").data(data);
```

## チャートの描画
以下のAPIを利用して、バブルの形・色・大きさ・表示位置などを指定していきます。

- [selection.enter](https://d3js.org/d3-selection/joining#selection_enter)
- [selection.append](https://d3js.org/d3-selection/modifying#selection_append)
- [selection.attr](https://d3js.org/d3-selection/modifying#selection_attr)

### `circle` タグ
バブルを表示するための `circle` タグを追加します。

```page.tsx
bubbles.enter().append("circle");
```

データの配列の要素数分の `circle` タグがレンダリングされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/a222ff4e-825d-63e3-144a-a5cd632ba865.png)

### クラス

```page.tsx
bubbles.enter().append("circle").attr("class", "bubble");
```

`circle` タグに bubble クラスが追加されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/5dc17ce2-41b7-7e15-bd9f-49c50bae62b7.png)

### 色

```page.tsx
bubbles
  .enter()
  .append("circle")
  .attr("class", "bubble")
  .attr("fill", "teal");
```

`circle` タグに `fill="teal"` が追加されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/d3ca4c3c-208d-8b6c-a231-2f49c3fa34b0.png)

### X軸方向の表示位置

```page.tsx
bubbles
  .enter()
  .append("circle")
  .attr("class", "bubble")
  .attr("fill", "teal")
  .attr("cx", (d) => xScale(d.element)! + xScale.bandwidth() / 2);
```

`circle` タグごとにX軸方向の表示位置が変わります。

![localhost_3000_charts_d3_buble-charts_render-function-Google-Chrome-2024-11-23-10-38-57.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/265fce03-48c9-9ebe-a8a8-710804d80bf6.gif)


### Y軸方向の表示位置

```page.tsx
bubbles
  .enter()
  .append("circle")
  .attr("class", "bubble")
  .attr("fill", "teal")
  .attr("cx", (d) => xScale(d.element)! + xScale.bandwidth() / 2)
  .attr("cy", (d) => yScale(d.score));
```

`circle` タグごとにX軸・Y軸方向の表示位置が変わります。

![localhost_3000_charts_d3_buble-charts_render-function-Google-Chrome-2024-11-23-10-42-00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/a212a448-611c-72f2-63da-fb5c7ee67cba.gif)


### 大きさ

```page.tsx
bubbles
  .enter()
  .append("circle")
  .attr("class", "bubble")
  .attr("fill", "teal")
  .attr("cx", (d) => xScale(d.element)! + xScale.bandwidth() / 2)
  .attr("cy", (d) => yScale(d.score))
  .attr("r", (d) => sizeScale(d.count));
```

`circle` タグごとにX軸・Y軸方向の表示位置、バブルの大きさが変わります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/b64aba17-f856-d172-8e26-9ca958988a57.png)

これで完成です！

# 完成形
完成したコードは以下の通りです。


```page.tsx
"use client";

import { useEffect, useRef } from "react";
import * as d3 from "d3";
import { data } from "../data";

export default function Page() {
  const svgRef = useRef<SVGSVGElement | null>(null);
  const width = 600;
  const height = 400;

  const renderChart = () => {
    // Define scales
    const xScale = d3
      .scaleBand()
      .domain(data.map((d) => d.element))
      .range([0, width]);

    const yScale = d3
      .scaleLinear()
      .domain([0, d3.max(data, (d) => d.score) || 0])
      .range([height, 0]);

    const sizeScale = d3
      .scaleSqrt()
      .domain([0, d3.max(data, (d) => d.count) || 0])
      .range([0, 30]);

    // Enable D3 API manipulate svg
    const svg = d3.select(svgRef.current);
    // Clear everything before appending new elements
    svg.selectAll("*").remove();
    // Bind data to bubbles
    const bubbles = svg.selectAll(".bubble").data(data);

    // Enter
    bubbles
      .enter()
      .append("circle")
      .attr("class", "bubble")
      .attr("fill", "teal")
      .attr("cx", (d) => xScale(d.element)! + xScale.bandwidth() / 2)
      .attr("cy", (d) => yScale(d.score))
      .attr("r", (d) => sizeScale(d.count));
  };

  useEffect(() => {
    renderChart();
  }, []);

  return <svg ref={svgRef} width={width} height={height} />;
}
```

# 参考
- [Band scales](https://d3js.org/d3-scale/band)
- [Joining data](https://d3js.org/d3-selection/joining)
- [Power scales](https://d3js.org/d3-scale/pow)
- [Selecting elements](https://d3js.org/d3-selection/selecting)
- [<StrictMode>](https://react.dev/reference/react/StrictMode)