見出し画像

さわってみよう、D3.js(後編)

はじめに(おさらい)

先日、グラフを作成する技術のひとつである「D3.js」の紹介記事を書きました。

この記事で申し上げたかったのは以下の3点です。

  1. Baseball Savant等のサイトでも使われている技術(JavaScriptのライブラリ)

  2. 高度なグラフを作成することができるもの

  3. ただし、くそほど面倒

ただし、これでは具体的にどんなものかがさっぱりわからないので、Excelなら1分もかからないであろう簡単なグラフを例にコードを作成し、「まず作ってみる」ことを目的に前回の記事(以下「前編」とします)を書きました。

前編では作成物となるHTMLおよびJavaScriptファイル、およびその中身(コード)をご紹介し、その中身について無駄に細かい解説を入れていました。が、文字数の都合上、解説がHTMLのみという中途半端な状態で終わってしまったため、本記事(後編)では要となるJavaScriptの中身を解説し、その後のステップについて軽く触れたいと思います。

先に申し上げておきます。自分で引くほど長いです。申し訳ございません。


つくるもの(再掲)

前編で書いたもののほぼコピペですが、作成するファイル、およびその中身(コード)を再掲します。
なお、HTMLの解説については前編に記載しております。

アウトプットイメージ

サンプルとして、2023年度のセントラル・リーグにおける本塁打トップ3を棒グラフにしたものを作成しています。

作成するファイル

  • HTMLファイル

  • JavaScriptファイル(こちらにD3.jsを記述する)

後編ではJavaScriptの内容について解説します。

ファイルのコード

2つのファイルを同じフォルダに置き、HTMLファイルをブラウザへドラッグ&ドロップすると、上記画像のグラフが表示されるはずです(念のため動作確認はしています)。

HTML (index.html)

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <title>2023年 セ・リーグ本塁打3傑</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="script.js" defer></script>
    <style>
        .bar {
            fill: steelblue;
        }
        .bar:hover {
            fill: orange;
        }
        .axis-label {
            font-size: 12px;
        }
        .axis--x text {
            font-size: 14px;
        }
    </style>
</head>

<body>
    <h1>ホームラン数の棒グラフ</h1>
    <svg width="600" height="400"></svg>
</body>
</html>

JavaScript (script.js)

// データの準備
const data = [
    { "name": "岡本", "homerun": 41 },
    { "name": "村上", "homerun": 31 },
    { "name": "牧", "homerun": 29 }
];

// SVG要素の設定
const svg = d3.select("svg"),
    margin = { top: 20, right: 30, bottom: 50, left: 40 },
    width = +svg.attr("width") - margin.left - margin.right,
    height = +svg.attr("height") - margin.top - margin.bottom,
    g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// x軸とy軸のスケールを設定
const x = d3.scaleBand()
    .domain(data.map(d => d.name))
    .range([0, width])
    .padding(0.1);

const y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.homerun)])
    .nice()
    .range([height, 0]);

// x軸とy軸をSVGに追加
g.append("g")
    .attr("class", "axis axis--x")
    .attr("transform", `translate(0,${height})`)
    .call(d3.axisBottom(x))
    .append("text")
    .attr("class", "axis-label")
    .attr("x", width / 2)
    .attr("y", margin.bottom - 10)
    .attr("fill", "#000")
    .text("選手名");

g.append("g")
    .attr("class", "axis axis--y")
    .call(d3.axisLeft(y).ticks(10))
    .append("text")
    .attr("class", "axis-label")
    .attr("x", -margin.left)
    .attr("y", -10)
    .attr("fill", "#000")
    .attr("text-anchor", "start")
    .text("ホームラン数");

// 棒グラフを描画
g.selectAll(".bar")
    .data(data)
    .enter().append("rect")
    .attr("class", "bar")
    .attr("x", d => x(d.name))
    .attr("y", d => y(d.homerun))
    .attr("width", x.bandwidth())
    .attr("height", d => height - y(d.homerun));

解説:JavaScript

今回のサンプルで作成するHTML、JavaScriptのうち、後者について説明します。

確かにソースは長ったらしいのですが、今回の場合、大きく以下の4つの要素に分類できます。

  1. データの定義

  2. 描画エリアの定義

  3. 軸の定義

  4. グラフの描画

作成するグラフの種類・形式によりますが、概ね共通する部分だと思いますので、これらについて順を追って説明します。

要素1:データの定義

グラフを作成するための元データを定義します。ソースコードでは以下の部分がこれに該当します。

// データの準備
const data = [
    { "name": "岡本", "homerun": 41 },
    { "name": "村上", "homerun": 31 },
    { "name": "牧", "homerun": 29 }
];

今回はサンプルということもあり、データを直接定義しています。いわゆる「オブジェクト形式」として、"name"と"homerun"の項目(フィールド)を持つレコードを3つ書いています。
なお、"const"はJavaScriptにおける変数(定数)を定義するためのものです。元データを書き換えることは基本的にないので、後で値を変更可能な"let"ではなく"const"を用いて定義するのが一般的です。

実際にグラフを作成する場合は、扱うデータが膨大になるため、直接JavaScriptで定義することはほぼありません。CSVあるいはJSONファイルといったソースファイルを読み込み、それをデータとして使用することが一般的です。(今回は扱いませんが)D3ではこうしたファイルの読み込みにも対応しており、簡単なコードを書くだけで読み込みができます。

要素2:描画エリアの定義

グラフをどこに描くのか、どれくらいの大きさにするのかを定義します。ソースコードでは以下の部分がこれに該当します。

// SVG要素の設定
const svg = d3.select("svg"),
    margin = { top: 20, right: 30, bottom: 50, left: 40 },
    width = +svg.attr("width") - margin.left - margin.right,
    height = +svg.attr("height") - margin.top - margin.bottom,
    g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

ここで定義しているのは以下の3つです。

  1. どのHTML要素にグラフを配置するのか

  2. グラフをどれくらいのサイズにするのか

  3. グラフをどれくらい移動させるか

これらについて順を追って説明します。

1. どのHTML要素にグラフを配置するのか

該当する箇所は以下の部分です。

const svg = d3.select("svg"),

ここで着目するのはselect("svg")の部分です。
今回の場合は、HTMLの"svg"タグの部分にグラフを描くよう指定しています。HTMLにsvg要素は1つしかないので、その部分にグラフが描画されるようになります。

なお、指定先はタグだけでなく、IDを用いることも可能です。1つのHTMLに複数のグラフを配置したい場合、例えばdivのような要素にそれぞれのIDを指定することで、IDに応じたグラフを配置することになるかと思います。

2. グラフをどれくらいのサイズにするのか

該当する箇所は以下の部分です。

    margin = { top: 20, right: 30, bottom: 50, left: 40 },
    width = +svg.attr("width") - margin.left - margin.right,
    height = +svg.attr("height") - margin.top - margin.bottom,

今回のサンプルの場合、まず余白を"margin"として定義し、その後に横・縦のサイズを設定しています。

marginは上下左右それぞれの値を設定できます。今回は上:20px、右:30px、下:50px、左:40pxで指定しています。下と左がやや多めの値になっていますが、これは軸を配置する際に一定の余白が必要となるためです。

その下で、marginの値を基に、横のサイズ・縦のサイズをそれぞれ"width", "height"として定義しています。
今回のサンプルでは、横のサイズを設定する際、"+svg.attr("width")"という値から、左右のmarginの値を引いています。この"+svg.attr("width")"は、HTMLのsvgタグで指定したwidthのことを指します。具体的にはHTMLの以下が該当します。

<svg width="600" height="400"></svg>

HTML側でsvgのwidthを600pxに指定しています。よって、グラフの描画領域の横幅(width)は、ここから左右のマージンを引いた、600px - 40px - 30px = 530pxになります。
同様に縦幅(height)は、400px - 20px - 50px = 330pxになります。

3. グラフをどれくらい移動させるか

該当する箇所は以下の部分です。

g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

ここではグラフの描画先(今回はHTMLのsvg要素)から、グラフをどれだけ移動させるかを定義します。
先ほど余白の値を定義しましたが、それだけで自動的に余白を設定してくれるような親切仕様はありません。左と上の余白の分だけグラフを動かしてあげる必要があります。これにより、先ほど定義した上下左右の余白を担保することが可能となります。

なお、ここで"g"なる変数(定数)を定義しています。これはグラフ全体をまとめるためのもので、以降これを対象に軸や棒を描画していくことになります。

要素3:軸の定義

今回のサンプルは棒グラフなので、横軸(X軸)・縦軸(Y軸)を定義します。グラフの種類によっては不要となることもあります。ソースコードでは以下の部分がこれに該当します。

// x軸とy軸のスケールを設定
const x = d3.scaleBand()
    .domain(data.map(d => d.name))
    .range([0, width])
    .padding(0.1);

const y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.homerun)])
    .nice()
    .range([height, 0]);

D3.jsではいちいち軸も描いてあげないといけません。以前「手書きでグラフを書く感覚に近い」と述べましたが、Excelがいかに親切設計だったのかがわかってきます。

今回はそれぞれの軸を"x", "y"として、主に以下の3点を定義しています。

  1. データのどの項目を使うのか

  2. データの値の範囲をどれくらいにするのか

  3. 軸の横幅・縦幅をどれくらいのサイズにするのか

1. データのどの項目を使うのか

今回は横軸に"name", 縦軸に"homerun"を取っています。項目(フィールド)が2つしかないのでそれ以外やりようがないのですが、実際は元データのフィールドが極めて多くなる場合もあるかと思います。具体的には以下の箇所で指定しています。

// x軸
const x = d3.scaleBand()
    .domain(data.map(d => d.name))

// y軸
const y = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.homerun)])

軸の形式を"scaleBand"あるいは"scaleLinear"で、項目を"domain"で指定しています。

X軸では質的データ(名前)を、Y軸では量的データ(ホームラン数)を扱っています。そのため、軸の種類を質的データ用の"scaleBand"、量的データ用の"scaleLinear"として指定します。
scaleBandの場合は均等に配置してくれますが、scaleLinearの場合は値の範囲を設定する必要があります。それは次項で述べます。

なお、ここからD3の特徴的な書き方が2つ出てきます。
変数"data"は省略して"d"とすることができます。"d.name"と指定した場合、dataのフィールド"name"を指定することになります。そこは妙に優しいポイントかもしれません。
また、D3上の各要素は.(ドット)で属性をつなげる必要があります。さまざまな属性を組み合わせて一つの要素とし、定義or配置をしていくことになります。

2. データの値の範囲をどれくらいにするのか

今回のサンプルでは、値の範囲を設定するのは量的データを扱うY軸のみです。該当する箇所は以下の部分です。

// y軸
    .domain([0, d3.max(data, d => d.homerun)])
    .nice()

まず、domainで最小値・最大値を設定します。最小値は0、最大値はhomerunの最も大きい値(今回では41)となります。よって、値の範囲は0から41までとなります。

ただし、今回はこれに加えて"nice()"を加えています。これは掛け声ではなく、「いい塩梅に値の区切りをD3にお任せする」属性です。サイズや値の範囲によってD3が勝手に決めてくれるため、区切りの値がどうなるかはやってみてなんぼなところがあります。
なお、今回のサンプルでは5ずつ区切るようになったので、最大値が45になっています。微妙な調整を加えることができる反面、いちいちそれを書かなければならないのが良いところでもあり悪いところでもあります。

3. 軸の横幅・縦幅をどれくらいのサイズにするのか

該当する箇所は以下の部分です。

// x軸
    .range([0, width])
    .padding(0.1);

// y軸
    .range([height, 0]);

まず、"range"属性で始点・終点の位置を設定します。先ほど横幅(width)、縦幅(height)の値を設定したので、0からそれぞれの値までが範囲となります。
ここで注意するのは縦幅の部分です。横幅の場合は「始点、終点」の順で設定しますが、縦幅は「終点、始点」の順に設定する必要があります。

なお、X軸では"padding"を設定しています。これは各グラフの要素の間隔を指定するものです。0だとそれぞれの棒がみっちりくっついた状態となってしまうため、0.1という値を指定することで、一定の間隔を設けています。paddingは0(0%)〜1(100%)の間で指定します。

要素4:グラフの描画

ここまでやってようやく、グラフの各要素を描画します。今回描画するのはX軸・Y軸・棒の3つです。これを例によってそれぞれ書いていくわけです。ああめんどくせえ。

X軸の描画

該当する箇所は以下の部分です。

// x軸の描画
g.append("g")
    .attr("class", "axis axis--x")
    .attr("transform", `translate(0,${height})`)
    .call(d3.axisBottom(x))
    .append("text")
    .attr("class", "axis-label")
    .attr("x", width / 2)
    .attr("y", margin.bottom - 10)
    .attr("fill", "#000")
    .text("選手名");

先ほど定義したグラフ群を示す"g"に、X軸の要素を追加します。追加するのは軸そのものと、軸のラベルの2つです。

属性"class"はスタイルシート(CSS)で見た目の調整を行うために設定しています。今回のサンプルではHTML側で文字のサイズを調整するために設定しています。
(詳細はHTMLのソースをご覧ください)

軸そのものを追加するには、axisBottomを使用します。ただし棒の下に軸を置きたいので、軸の位置をtransformで移動させる必要があります。今回は高さをheightの分だけ下に移動させています。その後で軸の呼び出しを行い、棒の下に置くようにしています。

次に、軸のラベル「選手名」を追加します。追加はappend("text")で行います。
これも配置の位置を決める必要があります。今回は左右中央、軸の下に置きたかったので、横位置(x)をwidthの半分、縦位置(y)を軸から下、つまり余白部分に置くようにしています。ここで位置を指定する際の基点は「軸の位置」となります。
下の余白を多めに取っていたのはこのためです。

加えて、文字色を"fill"で黒色("#000")に設定し、書く文字("選手名")を定義してラベルを配置しています。これらの値も可変にすることは可能ですが、今回は固定にしています。

Y軸の描画

該当する箇所は以下の部分です。

// y軸の描画
g.append("g")
    .attr("class", "axis axis--y")
    .call(d3.axisLeft(y).ticks(10))
    .append("text")
    .attr("class", "axis-label")
    .attr("x", -margin.left)
    .attr("y", -10)
    .attr("fill", "#000")
    .attr("text-anchor", "start")
    .text("ホームラン数");

Y軸もX軸と同様、軸そのものとラベルを追加しています。

軸については概ねX軸の場合と同様ですが、Y軸ではticksの値を10にしています。これは目盛りの数を指し、今回は10個の目盛りを表示するよう指定しています。前述したnice()と組み合わせることで、ちょうどいい間隔の目盛りが設定されるようになります。(no niceでも目盛りは10個作られますが、上限値が41となるため、40までは5本間隔なものの、最後の目盛りが41となって気持ち悪くなります)

ラベルについてもY軸と同様の考え方で位置、色、文字を指定して表示させます。ここでもラベルを余白の位置に置くようにしています。
なお、"text-anchor"なる属性を付加していますが、これは文字を左揃えにするためのものです。開始位置をグラフの範囲内に収めることで、文字がはみ出ないようにするための措置です。

棒の描画

いよいよ今回のグラフの主役、棒の描画です。該当する箇所は以下の部分です。

// 棒グラフを描画
g.selectAll(".bar")
    .data(data)
    .enter().append("rect")
    .attr("class", "bar")
    .attr("x", d => x(d.name))
    .attr("y", d => y(d.homerun))
    .attr("width", x.bandwidth())
    .attr("height", d => height - y(d.homerun));

ここまででもう自分がお腹いっぱいなのですが、これがないと棒抜きの棒グラフという麺のないラーメン状態になります。

肝心の棒は".enter().append("rect")"の部分で描画します。rect、すなわち矩形を追加するという意味です。

ここでもやはり横・縦の位置をx,yで指定しますが、これは元データのフィールドを軸に沿って指定するだけです。これを軸の引数(括弧内の値)に指定するだけで、D3が軸の形に沿ってデータをうまく配置してくれます。

また矩形の場合、その横幅(width)・縦幅(height)も指定する必要があります。これも事前の苦労の甲斐あって、設定した軸に沿った値を書いていくことでうまくD3が描画してくれます。

横幅(width)の場合、"x.bandwidth()"と指定することで、均等かつ(paddingで設定した)一定の間隔を持った幅となります。

ただし、縦幅については注意が必要です。"height - y(d.homerun)"と書くと、一見ホームランの多い人の棒の高さが小さくなりはしないかと思うかもしれません。しかし、ここで指定しているy(d.homerun)の値は「ホームランの値に基づいて計算されたY座標の位置」を指します。ホームラン数が多い選手のY座標は高くなる(つまり値が小さくなる)ため、「全体の高さからY座標の位置を引く」計算をすることで、ホームラン数に応じた矩形の縦幅を設定することができます。

なお、棒の色は今回設定したclassに基づき、HTML側でスタイルシートを書くことで指定しています。

まとめになってないまとめ

ここまで読んでくださる人がいる気がしないのですが、もし読んでくださった方にはまず厚く御礼申し上げます。

ほぼ逐語訳的な感じで解説を書き加えたため吐くくらいの分量になりましたが、言いたいことは以下の3点です。

  • ものすごく細かいところまで指定できるので、ほぼどんな形のグラフでも作れる

  • ただし、まともに取り組もうとするとくっそめんどくさい

  • 参考情報があんまりないので、生成AIを使った方が早い

最初の2点についてはここまででもう…と思いますので、最後の生成AIの部分だけ記載します。

ChatGPTをはじめとする生成AIは、このようなサンプルを秒で作成してくれます。「こんなグラフを作って」「ここを直して」といった指示でコードを作成してくれるため、D3.jsに関する知識がなくとも、AIとのやりとりを通じて作成してみた方が圧倒的に早いのではないかと個人的には思います。
「ここまで書いといてそれ?」と思われるかもしれませんが、これを参考情報なしに書けというのは相当にしんどいものがあります。自分もグラフの作成には生成AIに大きく依存しているところがあります。

作成にはかなりの手間がかかりますが、その分できた時の満足感は大きいものがあります。もし、もしもご興味を持たれた方はぜひ一度お試しいただき、データビジュアライゼーションの世界に触れていただければと思います。

最後に、改めてD3のすてきなサンプル集をご紹介することで、締めと代えさせていただきたいと思います。いや、だから本当にすごいんですってば。

この記事が気に入ったらサポートをしてみませんか?