【D3】散布図 (Scatter Chart) を実装する

# はじめに
この記事では、JavaScript 向けのデータ可視化ライブラリである D3 を使って、散布図 (Scatter Chart) を実装する手順を記載します。

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軸に `count`、Y軸に `score` を表示します。

```data.ts
export const data = [
  {
    score: 1,
    count: 2,
  },
  {
    score: 2,
    count: 2,
  },
  {
    score: 3,
    count: 4,
  },
  {
    score: 4,
    count: 5,
  },
  {
    score: 5,
    count: 5,
  },
];
```

```page.tsx
  const width = 600;
  const height = 400;
  const margin = { top: 20, right: 20, bottom: 40, left: 40 };
```

## データの出力範囲の指定
以下の関数を利用して、X方向・Y方向のデータの出力範囲を指定します。

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

X方向（水平方向）では、`count` の0から最大値までの値を `margin` を除いた `width` に表示します。

```page.tsx
  const xScale = d3
    .scaleLinear()
    .domain([0, d3.max(data, (d) => d.count) || 0]) // `count` values mapped to x-axis
    .range([margin.left, width - margin.right]);
```

Y方向（垂直方向）では、`score` の0から最大値までの値を `margin` を除いた `height` に表示します。

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

## 散布図のレンダリング
データの出力範囲が指定できたので、画面上に散布図をレンダリングします。

まず、`<svg>` タグを利用して散布図全体のサイズを指定します。

https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg

```page.tsx
  return (
    <svg width={width} height={height}></svg>
  );
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/e943cc0c-fb0a-61ca-1e9a-41e27b0dfcf8.png)

次は `<svg>` タグ内に散布図のドットを表示します。各ドットは、`<circle>` タグを使って表示します。

https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle

`<circle>` タグの属性（`props`）には以下の値を渡します。

- [cx](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/cx): X軸座標の位置
- [cy](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/cy): Y軸座標の位置
- [r](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/r): 円の半径
- [fill](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill): 円の色

```page.tsx
  return (
    <svg width={width} height={height}>
      {data.map((d, i) => (
        <circle
          key={i}
          cx={xScale(d.count)}
          cy={yScale(d.score)}
          r={5}
          fill="steelblue"
        />
      ))}
    </svg>
  );
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/0958bb52-4005-6df2-726c-fc58b82d4133.png)


## X軸 / Y軸の作成
次は X軸 / Y軸を作成して、それぞれどのような値を表示するかわかるようにします。
SVG の `<g>` (group) タグ内に軸を作成します。`<g>` タグを使うことで、SVGキャンバス上に表示する軸の位置指定を簡単にできます。

https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g

```page.tsx
  // Create refs for axes
  const xAxisRef = useRef<SVGGElement>(null);
  const yAxisRef = useRef<SVGGElement>(null);

  // Set up x-axis and y-axis with useEffect to handle D3 rendering
  useEffect(() => {
    if (xAxisRef.current) {
      const xAxis = d3.axisBottom(xScale);
      d3.select(xAxisRef.current).call(xAxis);
    }

    if (yAxisRef.current) {
      const yAxis = d3.axisLeft(yScale);
      d3.select(yAxisRef.current).call(yAxis);
    }
  }, [xScale, yScale]);
```

# 完成
最終的なコードを画面は以下の通りです。

```page.tsx
"use client";

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

const width = 600;
const height = 400;
const margin = { top: 20, right: 20, bottom: 40, left: 40 };

export default function Page() {
  const xScale = d3
    .scaleLinear()
    .domain([0, d3.max(data, (d) => d.count) || 0]) // `count` values mapped to x-axis
    .range([margin.left, width - margin.right]);

  const yScale = d3
    .scaleLinear()
    .domain([0, d3.max(data, (d) => d.score) || 0]) // `score` values mapped to y-axis
    .range([height - margin.bottom, margin.top]);

  // Create refs for axes
  const xAxisRef = useRef<SVGGElement>(null);
  const yAxisRef = useRef<SVGGElement>(null);

  // Set up x-axis and y-axis with useEffect to handle D3 rendering
  useEffect(() => {
    if (xAxisRef.current) {
      const xAxis = d3.axisBottom(xScale);
      d3.select(xAxisRef.current).call(xAxis);
    }

    if (yAxisRef.current) {
      const yAxis = d3.axisLeft(yScale);
      d3.select(yAxisRef.current).call(yAxis);
    }
  }, [xScale, yScale]);

  return (
    <svg width={width} height={height}>
      {data.map((d, i) => (
        <circle
          key={i}
          cx={xScale(d.count)}
          cy={yScale(d.score)}
          r={5}
          fill="steelblue"
        />
      ))}

      {/* X-axis */}
      <g ref={xAxisRef} transform={`translate(0, ${height - margin.bottom})`} />

      {/* Y-axis */}
      <g ref={yAxisRef} transform={`translate(${margin.left}, 0)`} />
    </svg>
  );
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318188/851c9c9d-2af1-7d7c-6547-bc03a0884bad.png)


# 参考
- [cx](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/cx)
- [cy](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/cy)
- [fill](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill)
- [Linear scales](https://d3js.org/d3-scale/linear)
- [r](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/r)
- [\<circle>](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle)
- [\<g>](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g)
- [\<sgv>](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg)