D3で棒グラフを描くには、100行以上のコードを書く必要がある。という実しやかな説がある。これが事実かどうか確かめてみたい。

まずは棒だけ表示する

D3には、データを渡すだけでグラフを表示してくれるような便利なメソッドはない。特有のDOM操作メソッドを用いて地道に作成するしかない(D3の基本的なDOM操作メソッドは別ページにまとめた)。

まずは[50, 100, 150]というデータで、棒フラグっぽいものを描画してみる。

html
<!DOCTYPE html><html><head><!-- D3.jsの読み込み --><script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script></head>
  <body>
    <script>
      // SVGコンテナの描画
      const svg = d3.select("body")
                    .append("svg")
                    .attr("width",200)
                    .attr("height",200);

      // 棒グラフの描画
      svg.selectAll("rect")
         .data([50, 100, 150])
         .join("rect")
         .attr("x", (d, i) => i * 50)
         .attr("y", d => 200 - d)
         .attr("width", 40)
         .attr("height", d => d)
         .attr("fill","gold");
    </script>
  </body>
</html>

このサンプルでは、selectAll("rect")というメソッドで<rect>用の空のセレクション配列を作成し、data()メソッドでデータと対応づけ、join()メソッドで各要素をドキュメントに追加。以降のattr()メソッドでそれぞれの位置や幅、高さを指定している。

この時点でなかなか面倒くさいが、棒グラフと名乗るからには、せめて軸くらいは添えたいところだ。

軸を描画する

先の黄色い棒のように、DOMを操作してSVGで軸を書くこともできる。ただ、D3には軸を描画する便利なメソッドが用意されている。

便利とは言っても、柔軟性が高いぶん相応に手間がかかる。データを視覚化できる量に変換するためのスケールという仕組みや、スケールに応じた軸を作るための軸ジェネレータ、軸の設定などを勉強しないといけない。

ここでは、シンプルな棒グラフを表示するのに必要な、最低限の情報をまとめる(スケールの概要軸の概要は、別ページにまとめている)。

軸の描画は、以下のようなステップを踏む必要がある。

  1. グラフで表現するデータに応じたスケールを作成する。
  2. 軸ジェネレータファクトリにスケール渡し、軸ジェネレータを作成する。
  3. 軸ジェネレータのメソッドを通して、生成する軸の設定を行う。
  4. 任意のSelectionオブジェクトのcall()メソッドに軸ジェネレータを渡す。
  5. attr()メソッドで軸の位置を調整する。

スケール

スケールは、需要に合わせてデータを変換するための関数兼オブジェクトである。例えば[0.5, 1.0, 1.5]のようなデータをそのままピクセルにしては、読むのが大変だ。適切にスケールアップしてグラフ化してやる必要がある。この役目を負うのがスケールである。

シンプルな棒グラフを描く場合、バンドスケールと線形スケールを使う。バンドスケールはデータ範囲をデータ数で割り、データに該当する幅を返す。線形スケールはデータを等倍した値を返す。

線形スケールは目盛り、バンドスケールはラベルの描画に役立つ。

js
// スケールの設定
const xScale = d3.scaleBand(["A", "B", "C"], [0, 60]);
console.log(xScale("A")); // 0
console.log(xScale("B")); // 20
console.log(xScale("C")); // 60
console.log(xScale("D")); // undefined

const yScale = d3.scaleLinear([0, 1.5], [60, 0]);
console.log(yScale(0)); // 60
console.log(yScale(0.5)); // 40.00000000000001
console.log(yScale(1)); // 20.000000000000004
console.log(yScale(2)); // -19.999999999999996

スケールは、その種類に対応するファクトリメソッドによって作る。上記の場合、scaleBand()scaleLinear()がそれである。

ファクトリメソッドの引数には、入力値の範囲(ドメイン)と、マッピングする出力値の範囲(レンジ)を渡す。

scaleBand()などの一部を除き、多くの場合、ドメインは数値(あるいは数値に強制変換できるデータ)の配列である。レンジは、そのスケールの内部で置換を担当する機構(インタポレータという)が対応している型なら、なんでもいい。

スケールを関数として使うと、引数の値をスケールの種類に応じて変換した値を返す。

また、スケールはその種類に応じた各種メソッドも保持している。それらを使ってスケールの動作を設定したり、現在の設定値を取得したりできる。

例えば線形スケールで丸め誤差をなくしたい場合は、以下のようにインタポレータを指定できる。

js
const yScale = d3
  .scaleLinear()
  .domain([0, 1.5]) // スケールを作った後で、そのdomain()メソッドと
  .range([60, 0]) // range()メソッドを使い、ドメインとレンジを設定することもできる
  .interpolate(d3.interpolateRound);
console.log(yScale(0)); // 60
console.log(yScale(0.5)); // 40
console.log(yScale(1)); // 20
console.log(yScale(2)); // -20

線形スケールには、上記コードと同じ挙動をするrangeRound()メソッドも用意されている。小数を扱う分には、range()メソッドではなくこちらを利用した方が良いかもしれない。というか良い。そっちの方が読みやすい。

スケールの使いどころは、大きく2つある。軸ジェネレータの内部設定と、グラフの表示位置の計算である。

まずは軸ジェネレータの設定方法から見ていく。

軸ジェネレータと軸

軸ジェネレータは、文字通り軸を作るためのオブジェクトである。軸の種類に応じて、4つのファクトリメソッドを使い分けて作る。d3.axisTop()d3.axisRight()d3.axisBottom()d3.axisLeft()がそれで、名称に対応する方向に目盛りが出力される。

これらのファクトリメソッドにスケールを渡すと、軸ジェネレータ(axisオブジェクト)が返される。そしてaxisオブジェクトのメソッドを通して、描画する軸の各部のサイズや余白を調整する。

軸の座標は、レンジに指定した値に基づいて決められる。座標の対象となるのは、目盛りが生えている長い棒、文字通り軸である。デフォルトの配置から移動したい場合は、transform属性を使って、表示位置を調整してやる必要がある。

以下は、必要最低限の設定で縦軸と横軸を表示するサンプルである。デフォルトの配置だと目盛りが隠れてしまうため、各部のサイズを計算して、最小限の移動で軸の全容が表示されるよう試みる。

html
<!DOCTYPE html><html><head><!-- D3.jsの読み込み --><script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script></head>
  <body>
    <script>
      const svgWidth = 200;
      const svgHeight = 200;
      const tickSize = 6; //目盛りのサイズ
      const tickPadding = 3;//目盛りとラベルの間の余白
      const fontSize = 10 * 2;//フォントサイズ。3桁の数字も描画できるように2倍している
                              //フォントサイズは縦幅であり、
                              //横幅とは対応していないため目視で適当に設定した
      const axisSize = tickSize + tickPadding + fontSize;
      const graphWidth = svgWidth - axisSize;
      const graphHeight = svgHeight - axisSize;

      // SVGコンテナを追加
      const svg = d3
        .select("body")
        .append("svg")
        .attr("width", svgWidth)
        .attr("height", svgHeight)
        .style("background-color","salmon");

      // データセットとラベル
      const dataset = [50, 100, 150];
      const minData = 0;
      const maxData = 200;
      const labels = ["A", "B", "C"];

      // スケールの設定
      const xScale = d3
        .scaleBand()
        .domain(labels)
        .range([0, graphWidth]);

      const yScale = d3
        .scaleLinear()
        .domain([minData, maxData])
        .range([graphHeight, 0]);

      // 軸の描画
      svg
        .append("g")
        .attr("transform", `translate(${axisSize - 0.5}, ${graphHeight + 0.5})`)
        .call(d3.axisBottom(xScale));
        // 軸は1つずつ<g>タグでまとめるのが一般的
      svg
        .append("g")
        .attr("transform", `translate(${axisSize - 0.5}, 0.5)`)
        .call(d3.axisLeft(yScale));
    </script>
  </body>
</html>

軸のサイズを頑張って整理したのに、Y軸のラベルが描画領域からはみ出ている。ショックだ。

ちなみに軸のサイズを左右する項目は以下の通りである。

項目名 デフォルト値 設定用の軸ジェネレータのメソッド
ラベルのフォント 10px なし。スタイリングで設定。
ラベルと目盛りの間 3px tickPadding(padding)
目盛り 6px 一括:tickSize(size)
外側:tickSizeOuter(size)
内側:tickSizeInner(size)
目盛りと軸の間 0 offset(offset)
軸の太さ 1px なし。スタイリングで設定。

先のサンプルでは、これらを踏まえて、グラフサイズから軸のサイズを差し引き、軸がギリギリ表示されるように試みた。そして失敗した。

余白が足りない。もっと余白を。

余白

軸を描画する際は、軸が表示される側だけでなく、上下左右に余白を入れた方が良い。なぜなら端の目盛りやラベルが切れてしまうからだ。

D3には、グラフの余白を設定するための"Margin Convention"という慣習がある。これは、以下のように上下左右の余白をmarginという1つのオブジェクトで管理し、必要な箇所で差し引くというものである。

html
<!DOCTYPE html><html><head><!-- D3.jsの読み込み --><script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script></head>
  <body>
    <script>
      const svgWidth = 200;
      const svgHeight = 200;
      const tickSize = 6; //目盛りのサイズ
      const tickPadding = 3; //目盛りとラベルの間の余白
      const fontSize = 10 * 2; //ラベルのフォントサイズ。
      const axisSize = tickSize + tickPadding + fontSize;
      const graphWidth = svgWidth - axisSize;
      const graphHeight = svgHeight - axisSize;
      const margin = {
        top: 20,
        right: 16,
        bottom: 16 + axisSize,
        left: 16 + axisSize,
      };

      // SVGコンテナを追加
      const svg = d3
        .select("body")
        .append("svg")
        .attr("width", svgWidth)
        .attr("height", svgHeight)
        .style("background-color", "salmon");

      // データセットとラベル
      const dataset = [50, 100, 150];
      const minData = 0;
      const maxData = 200;
      const labels = ["A", "B", "C"];

      // スケールの設定
      const xScale = d3
        .scaleBand()
        .domain(labels)
        .range([margin.left, svgWidth - margin.right]);

      const yScale = d3
        .scaleLinear()
        .domain([minData, maxData])
        .range([svgHeight - margin.bottom, margin.top]);
      //👆左上が原点となるため、座標が0に向かうように設定する

      // 軸の描画
      svg
        .append("g")
        .attr("transform", `translate(0, ${svgHeight - margin.bottom})`)
        .call(d3.axisBottom(xScale));
      svg
        .append("g")
        .attr("transform", `translate(${margin.left}, 0)`)
        .call(d3.axisLeft(yScale));

    </script>
  </body>
</html>

translate()関数の指定がだいぶすっきりした。

今回は勉強ために軸のサイズをちまちま整理したが、目視しながらmarginの値を適当に調整するだけで十分そうである。

棒グラフ(完全体)を描画する

ここまでの知識をまとめて、目盛り付きの棒グラフを描画してみる。

html
<!DOCTYPE html><html><head><!-- D3.jsの読み込み --><script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script></head>
  <body>
    <script>
      const svgWidth = 200;
      const svgHeight = 200;
      const tickSize = 6; //目盛りのサイズ
      const tickPadding = 3; //目盛りとラベルの間の余白
      const fontSize = 10 * 2; //ラベルのフォントサイズ。
      const axisSize = tickSize + tickPadding + fontSize;
      const graphWidth = svgWidth - axisSize;
      const graphHeight = svgHeight - axisSize;
      const margin = {
        top: 20,
        right: 16,
        bottom: 16 + axisSize,
        left: 16 + axisSize,
      };

      // SVGコンテナを追加
      const svg = d3
        .select("body")
        .append("svg")
        .attr("width", svgWidth)
        .attr("height", svgHeight)
        .style("background-color", "salmon");

      // データセットとラベル
      const dataset = [50, 100, 150];
      const minData = 0;
      const maxData = 200;
      const labels = ["A", "B", "C"];

      // スケールの設定
      const xScale = d3
        .scaleBand()
        .domain(labels)
        .range([margin.left, svgWidth - margin.right]);

      const yScale = d3
        .scaleLinear()
        .domain([minData, maxData])
        .range([svgHeight - margin.bottom, margin.top]);

      // 軸の描画
      svg
        .append("g")
        .attr("transform", `translate(0, ${svgHeight - margin.bottom})`)
        .call(d3.axisBottom(xScale));
      svg
        .append("g")
        .attr("transform", `translate(${margin.left}, 0)`)
        .call(d3.axisLeft(yScale).ticks(5));  // ticks()は目盛り数を指定するメソッド

      // 棒グラフの描画
      svg.selectAll("rect")
        .data(dataset)
        .join("rect")
        .attr("x", (d, i) => xScale(labels[i]) + (xScale.bandwidth() / 4))
        .attr("y", d => yScale(d))
        .attr("width", xScale.bandwidth() / 2)
        .attr("height", d => (svgHeight - margin.bottom - 0.5) - yScale(d))
        .attr("fill", "gold");
    </script>
  </body>
</html>

<rect>タグを描画する際の、heightyの設定が少しわかりづらいかもしれない。軸が下にあるため、軸からの高さを基準に考えてしまいそうになる。座標を基準に計算しなければならない点に注意したい。

SVGでは、Y座標は下に行くほど大きくなる。そのため、データの値が大きい棒グラフ(長い棒グラフ)ほど、Y座標の値は小さくなる。

描画する棒の高さは、全体の高さからこの座標を引いた値となる。このサンプルで言えば、全体の高さは描画領域から下の余白を差し引いた値(svgHeight - margin.bottom)である。

ただ、このままだと軸とグラフのスタートが被ってしまうため、- 0.5を引いた。他にうまいやり方があるのなら知りたい。

このシンプルな棒グラフを書くのに60行くらい必要だった。こだわれば、100行はよゆーで越えそうである。

参考資料