D3を理解するために機能の一部を再実装してみる

記事を書いた日: 2024-01-14 (更新履歴)

要約: D3でグラフを描こうと思ったが、ドキュメントや使用例を読むだけではどうしても理解できなかった。そこで、理解するために機能の一部を自作してみることにする。

注意
完成まで時間がかかりそうだったので、この記事は書いている途中で公開している。 残りのTODOを今思いつく範囲で書いておく。

  • build-your-own-d3 リポジトリに上がっていないサンプルコードがある
  • d3-selection が難しいと言っているわりに Selection の説明を書く前に力尽きている
  • build-your-own-d3 にテストを追加する
  • その他細かい校正

この記事の要約

D3の難しさは、

  1. 使う上でD3が出力するSVGを意識する必要がある。一方で、通常のチャートライブラリでは出力した画像やHTMLの中身を意識することはない
  2. JavaScript、HTML、SVG など使うために必要になる事前知識が多い
  3. D3特有のAPI、特に d3-selection に慣れる必要がある

から来ている(と思う)ので、内部の挙動を理解しつつ使うためにはこれらの項目を事前に抑えておく必要がある。

これらについて理解するために、D3のAPIに沿って簡単なグラフを描画できるスクリプト mini-d3.jsデモページ)を作成した。

はじめに

この記事の背景

少し複雑な可視化をしようと思って D3(D3.js)の使い方を調べたが、難しくていまいち理解できなかった1。 そこでこの記事では、D3の挙動を理解することを目的として、D3と同じインターフェースで同じグラフを描画できるようなスクリプトを作ってみる2。 イメージとしては、D3のサンプルコードのうち、 <script src="https://d3js.org/d3.v7.min.js"></script> の部分を自作スクリプト <script src="./mini-d3.js"></script> に差し替えても動くようにする。 もちろん、D3の機能すべてを再実装するのは現実的ではないので「主なサンプルコードをある程度動かすことができる」くらいの目標にしておく。

1

念のため調べてみたが、よく言われているらしい: The Trouble with D3Is it just me, or is D3.js too hard?Could The Developer Experience For D3.js Be Improved

2

有名なツールやパッケージの中身を理解するためにはそれを再実装すればいい、と一部では言われていて、これを実践する記事は build-your-own-x という一大ジャンル?になっている。

この記事の扱っている範囲

この記事を書き始めてから気づいたが、D3の記事をゼロから書くといつまで経っても書き終わらないので、取り扱う項目を絞っておく必要がある。 この記事で扱うこと/扱わないことを以下のように決めておく。

この記事で扱うこと:

この記事で扱わないこと:

こう書いてみると、想定読者はいったい誰なんだろう?という疑問が浮かんでくる。 この文はこの記事をほぼ書き終えたときに書いているが、まだよくわかっていない。 まあいいや。

3

ここが怪しい場合は、D3 Tips and Tricks v7.x by Malcolm Maclean あたりを読んでからこの記事を読むのが良いと思う

実行環境

D3のバージョンについては、現在の最新版 (v7.8.5) をもとに記事を書く。 ただし、この記事では基本的な機能しか扱わないので、数年経ったくらいではそう影響を受けないと思われる。 まあ2018年のアップデートで基本的なAPIの挙動が大きく変わったという例はあるが、ここ数年はD3もあまり更新されていないようだし4多分大丈夫でしょう。

また、このページにあるコードの動作確認はChrome v120で行った。

4

ちなみに後で出てくる d3-selection は2023-09-30時点では最終更新が2年前だった。

この記事について

この記事自体はGitHubにあるmarkdownから生成されている。 また、最終的な実装は build-your-own-d3 リポジトリに入っている。

簡単な図形を描画する

グラフの描画処理を実装する前に、簡単な図形(文字列、長方形、折れ線など)を描画できるようにしておく。 これらの図形は、棒グラフや折れ線グラフなどの基本的なグラフを描画する際のパーツとして使われる。

文字列を描画する

まずはじめに、hello world という文字列を画面上に描画するだけの処理を実装してみる。

この処理をD3で実装しようとすると以下のようなコードになる。 これ以降、オリジナルのD3の https://d3js.org/d3.v7.min.js を使ってグラフを描画するコードを「本家D3」と呼ぶことにする。

あとのためにコメントで一行ずつ説明しておく。

demo/insert-text/d3.html (本家D3)
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script src="https://d3js.org/d3.v7.min.js"></script>

  <script>
    d3.select("#chart")     // body 内にある id=chart の要素を探す
      .append("div")        // それに div 要素を追加する
      .text("hello world"); // さらにその div 要素に "hello world" という文字列を追加する
  </script>
</body>

最後の <script> 内にあるJSが実行されると、#chart というIDがついている div 要素が更新され、次の図のように chart 内に <div>hello world</div> が追加される。

hello-world (本家D3) の実行結果
hello-world (本家D3) の実行結果

このサンプルで使われている d3 を再実装してみる。

まずコメントに書かれている内容を読んでみると、そのままJavaScriptで実装できそうだということに気づく。 試しに書いてみる。

demo/insert-text/vanilla-js.html (Web APIを使うバージョン)
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script>
    const chart = document.querySelector("#chart");
    const div = document.createElement("div");
    chart.appendChild(div);
    const text = document.createTextNode("hello world");
    div.appendChild(text);
  </script>
</body>

上記のコードをHTMLファイルとして保存してブラウザで開くと、本家D3バージョンと同じ挙動になっていることを確認できる。

hello-world (Web APIを使うバージョン) の実行結果
hello-world (Web APIを使うバージョン) の実行結果

さて、この記事の目的は「D3と同じインターフェースで同じような挙動をするライブラリを実装する」ということだった。 そこで次にインターフェースを合わせてみる。

本家D3バージョンのグラフ描画処理のコードを再度眺めてみると、 d3.select("#chart").append("div").text("hello world"); という、D3に特徴的なメソッドチェインを基本としたインターフェースになっていることがわかる。 また d3.select の返り値は @types/d3-selection によれば Selection らしい。 以上の情報をもとに、ひとまず次のように Selection クラスを追加してみる5

demo/insert-text/myd3.html (自作バージョン)
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script>
    const d3 = {
      select: function (selector) {
        const el = document.querySelector(selector);
        return new Selection(el);
      },
    };

    class Selection {
      element;
      constructor(element) {
        this.element = element;
      }
      append(name) {
        const child = document.createElement(name);
        this.element.append(child);
        return new Selection(child);
      }
      text(content) {
        const txt = document.createTextNode(content);
        this.element.append(txt);
        return this;
      }
    }
  </script>

  <script>
    d3.select("#chart").append("div").text("hello world");
  </script>
</body>

これをHTMLとして保存しブラウザで開くと、本家D3バージョンと同じ挙動になっているのを確認できる。

これ以降では、この実装を徐々に拡張して描ける図形やグラフを増やしていく。

5

ちなみに d3.select実際の実装 も自作バージョンと同様に Selection を生成して返すだけの関数になっている。

長方形を描画する

本家D3で長方形を書くコードは以下の通りである。

demo/rectangle/d3.html
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script src="https://d3js.org/d3.v7.min.js"></script>

  <script>
    const svg = d3
      .select("#chart")
      .append("svg")
      .attr("width", 500)
      .attr("height", 500)
      .append("g");
    svg
      .append("rect")
      .attr("x", 200)
      .attr("y", 200)
      .attr("width", 50)
      .attr("height", 20)
      .attr("fill", "blue");
  </script>
</body>

これを実行すると、#chart に以下のようなSVG要素が追加される。

<div id="chart">
  <svg width="500" height="500">
    <g>
      <rect x="200" y="200" width="50" height="20" fill="blue"></rect>
    </g>
  </svg>
</div>

一応解説しておくと、500x500のSVG要素を作成してその中に g 要素を作成、さらにその中に (200, 200) の位置に 50x20 の青で塗りつぶされた長方形を描画している。

ここでは新しく attr というメソッドを使っているため、これを実装する必要がある。 Selection クラスに以下のような attr を追加してみる。

attr(key, value) {
  this.element.setAttribute(key, value);
  return this;
}

しかし、attr を追加しても、なぜか長方形は表示されない。

長方形は表示されない
長方形は表示されない

これはSVGを扱うときのハマりポイントなのだが、いったん理由は置いておくとして、

const child = document.createElement(name);

としていたところを

const child = document.createElementNS(
  "http://www.w3.org/2000/svg",
  name,
);

にすれば解決する。 実際、この変更を加えてみると、期待通り下図のように長方形が表示される。

長方形が表示された
長方形が表示された

なぜ createElement ではなく createElementNS を使う必要があるか? ここで作りたいのはSVGの名前空間に属する <svg> なのだが、document.createElement("svg") だと svg という名前のHTML要素を作ってしまう6からだ。 そのため、SVGの名前空間に属する <svg> を作りたい場合は、createElementNS の第一引数で明示的に名前空間を指定する必要がある。

実際の append の実装では append("svg") の場合に名前空間として http://www.w3.org/2000/svg を使い、そうでない場合も親要素の名前空間を引き継ぐ実装になっている。 ちなみに、D3ではSVG以外の名前空間もサポートしている

6

Document.createElementのドキュメントを注意深く読んでみると、たしかに「HTML 文書において、 document.createElement() メソッドは tagName で指定された HTML 要素を生成し」と明記されている。

一応ソースコード全体をまとめておく。

demo/rectangle/myd3.html
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script>
    const d3 = {
      select: function (selector) {
        const el = document.querySelector(selector);
        return new Selection(el);
      },
    };

    class Selection {
      element;
      constructor(element) {
        this.element = element;
      }
      append(name) {
        const child = document.createElementNS(
          "http://www.w3.org/2000/svg",
          name,
        );
        this.element.append(child);
        return new Selection(child);
      }
      text(content) {
        const txt = document.createTextNode(content);
        this.element.append(txt);
        return this;
      }
      attr(key, value) {
        this.element.setAttribute(key, value);
        return this;
      }
    }
  </script>

  <script>
    const svg = d3
      .select("#chart")
      .append("svg")
      .attr("width", 500)
      .attr("height", 500)
      .append("g");
    svg
      .append("rect")
      .attr("x", 200)
      .attr("y", 200)
      .attr("width", 50)
      .attr("height", 20)
      .attr("fill", "blue");
  </script>
</body>

折れ線を描画する

SVGのパスで描画したうずまき
SVGのパスで描画したうずまき
examples/svg-path/d3.html
<!doctype html>
<meta charset="utf-8" />
<style>
  .line {
    fill: none;
    stroke: steelblue;
    stroke-width: 1px;
  }
</style>
<body>
  <div id="chart"></div>

  <script src="https://d3js.org/d3.v7.min.js"></script>

  <script>
    const pathDefinition = "M10,10 V50 H50 V10 H20 V40 H40 V20 H30 V30";
    const svg = d3
      .select("#chart")
      .append("svg")
      .attr("width", 500)
      .attr("height", 500)
      .append("g");
    svg.append("path").attr("d", pathDefinition).attr("class", "line");
  </script>
</body>

実はパスを追加する処理については、これまでの実装のままで動く。 SVG のパス <path> については、参考になるリンクだけ貼っておく。

外部のJSONからデータを読み込む

examples/load-json/data.json
[
  { "x": 0, "y": 0, "width": 50, "height": 50 },
  { "x": 100, "y": 100, "width": 50, "height": 100 },
  { "x": 0, "y": 200, "width": 100, "height": 50 }
]

d3.json を使って上記のようなJSONファイルをロードし、それをもとに下図のように複数の長方形を描画してみる。

JSONの中身をもとに長方形を描画する
JSONの中身をもとに長方形を描画する

D3バージョンは以下の通り。

examples/load-json/d3.html
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script src="https://d3js.org/d3.v7.min.js"></script>
  <script>
    const svg = d3
      .select("#chart")
      .append("svg")
      .attr("width", 500)
      .attr("height", 500)
      .append("g");

    d3.json("./data.json").then((data) => {
      for (const d of data) {
        svg
          .append("rect")
          .attr("class", "bar")
          .attr("x", d.x)
          .attr("y", d.y)
          .attr("width", d.width)
          .attr("height", d.height);
      }
    });
  </script>
</body>

D3の例を見たことがあると、dataenter を使っていない上記のコードは違和感があるかもしれない7。 こういう書き方になっているのは、dataenter はややこしくてすぐに実装できないためである。

7

ただし、d3-selection のAPIに慣れるまでは、(邪道ではあるものの)こういう書き方を使い続けるのもひょっとするとアリなんじゃないかな〜とは思っている。 でも慣れたら d3-selection のAPIを使ったほうが便利ではある。

d3.json は単に Promise を返しているだけなので、以下のように実装しておけば良い。

examples/load-json/myd3.html の d3 定義の部分
const d3 = {
  select: function (selector) {
    const el = document.querySelector(selector);
    return new Selection(el);
  },
  async json(path) {
    return fetch(path)
      .then((response) => response.text())
      .then((data) => JSON.parse(data));
  },
};

ちなみに実際のd3-fetchのソースコードもほぼ同じような実装になっている。

パッケージとして整理する?

コード量が増えてきたので今のうちにパッケージの形に整えておきたい。 しかし、ステップバイステップで説明を進める都合上、パッケージの形にまとめると、build-your-own-d3 リポジトリ内に大量の package.json を作る必要があり面倒になる。 そのためこの記事では、多少無理をしつつ、以下のように単一のJSファイルにすべて実装を詰め込む形で話を進める。

examples/setup-package/myd3.html
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <script src="./myd3.js"></script>
  <script>
    const svg = d3
      .select("#chart")
      .append("svg")
      .attr("width", 500)
      .attr("height", 500)
      .append("g");

    d3.json("./data.json").then((data) => {
      for (const d of data) {
        svg
          .append("rect")
          .attr("class", "bar")
          .attr("x", d.x)
          .attr("y", d.y)
          .attr("width", d.width)
          .attr("height", d.height);
      }
    });
  </script>
</body>
examples/setup-package/myd3.js
const d3 = {
  select: function (selector) {
    const el = document.querySelector(selector);
    return new Selection(el);
  },
  async json(path) {
    return fetch(path)
      .then((response) => response.json())
  },
};

class Selection {
  element;
  constructor(element) {
    this.element = element;
  }
  append(name) {
    const child = document.createElementNS(
      "http://www.w3.org/2000/svg",
      name,
    );
    this.element.append(child);
    return new Selection(child);
  }
  text(content) {
    const txt = document.createTextNode(content);
    this.element.append(txt);
    return this;
  }
  attr(key, value) {
    this.element.setAttribute(key, value);
    return this;
  }
}

もしパッケージとして整備したい場合は、実際のD3の構成が参考になる。 実際のD3の構成を見てみると、おおまかな機能ごとにリポジトリが別れていて、 d3/d3リポジトリですべてを読み込む形になっている8

私も最初に TypeScript で自作D3を実装したときは、本家D3と同様にディレクトリに分けた上で、一番上の index.ts で export * from "./selection"; のように export する形にした。

8

ただしこれについては今後変わる可能性がある: Adopt a monorepo · Issue #3791 · d3/d3

棒グラフを描画する

このセクションでは、最終的に以下のような棒グラフ (デモページ) が描けるようになることを目標にして実装を進めていく。

自作バージョンのD3で描画した棒グラフ
自作バージョンのD3で描画した棒グラフ

グラフは「D3 Tips and Tricks v7.x」で使われていたもので、元データはこちらのJSONにアップロード済み。

これ以降はかなり込み入った実装になるため、本家D3の実装で必要なものだけを抜き出す形で実装を進める。 特にこのセクションでは、 d3-selection の実装を参考に実装を進めていく。

棒グラフの棒を描画する

スケーリングの処理や目盛りなどは一旦無視して、下図のように棒グラフの棒の部分だけをまず描いてみる。

自作D3で描画した棒グラフ(スケーリングなし、heightがsalesの値になっている)
自作D3で描画した棒グラフ(スケーリングなし、heightがsalesの値になっている)

まずは本家D3を使ってこれを描画するコードを作る。

examples/bar-chart/bars/myd3.html
<!doctype html>
<meta charset="utf-8" />
<body>
  <div id="chart"></div>

  <!-- <script src="./myd3.js"></script> -->
  <script src="https://d3js.org/d3.v7.min.js"></script>

  <script>
    // スケーリングを実装していないので、とりあえず sales の最大値を埋めておく
    const height = 59;
    const width = 960;
    const barwidth = 50;

    const svg = d3
      .select("#chart")
      .append("svg")
      .attr("width", width)
      .attr("height", height)
      .append("g");

    d3.json("./../sales.json").then((data) => {
      svg
        .selectAll(".bar")
        .data(data)
        .enter()
        .append("rect")
        .attr("class", "bar")
        .attr("x", function (d) {
          return xScale(d.salesperson);
        })
        .attr("width", barwidth)
        .attr("y", function (d) {
          return yScale(d.sales);
        })
        .attr("height", function (d) {
          return height - yScale(d.sales);
        });

      svg
        .append("g")
        .attr("transform", `translate(0,${height})`)
        .call(d3.axisBottom(xScale));

      svg.append("g").call(d3.axisLeft(yScale));
    });

    // 仮実装
    let called = -1;
    function xScale(name) {
      called += 1;
      return barwidth * called;
    }
    function yScale(y) {
      return 59 - y;
    }
  </script>
</body>

自作D3でもこのサンプルコードが動くようにするためには、d3.selectAll Selection.data Selection.enter を実装し、さらに Selection.attr がバインドしたデータを使えるようにする必要がある。 本家 d3-selection の実装をもとに以下のように実装を追加してみる。

examples/bar-chart/bars/myd3.js
const d3 = {
  select: function (selector) {
    const element = document.querySelector(selector);
    return new Selection([[element]], [document.documentElement]);
  },
  json: async function (path) {
    return fetch(path).then((response) => response.json());
  },
  scaleBand,
  scaleLinear,
  axisLeft,
  axisBottom,
};

class Selection {
  #groups;
  #parents;
  #enter;

  constructor(groups, parents) {
    this.#groups = groups;
    this.#parents = parents;
  }

  append(name) {
    return this.select(function () {
      const child = document.createElementNS(
        "http://www.w3.org/2000/svg",
        name,
      );
      child.__data__ = this.__data__;
      return this.appendChild(child);
    });
  }

  select(selectorOrFunction) {
    const selectFunction = this.#makeSelectFunction(selectorOrFunction);
    const subgroups = this.#groups.map((group) =>
      group.map((node, i) => {
        if (node === null) {
          return undefined;
        }
        const subnode = selectFunction.call(node, node.__data__, i, group);
        if ("__data__" in node) {
          subnode.__data__ = node.__data__;
        }
        return subnode;
      }),
    );

    return new Selection(subgroups, this.#parents);
  }

  #makeSelectFunction(selectorOrFunction) {
    if (typeof selectorOrFunction == "string") {
      return function () {
        return this.querySelector(selectorOrFunction);
      };
    } else {
      return selectorOrFunction;
    }
  }

  attr(key, valueOrFunction) {
    return this.#each(function (__data__) {
      const value = Selection.getValue(valueOrFunction, __data__);
      this.setAttribute(key, value);
      return this;
    });
  }

  static getValue(valueOrFunction, __data__) {
    if (typeof valueOrFunction == "function") {
      return valueOrFunction(__data__);
    } else if (["number", "string"].includes(typeof valueOrFunction)) {
      return String(valueOrFunction);
    } else {
      return valueOrFunction.apply(__data__);
    }
  }

  #each(callback) {
    const groups = this.#groups.map(function (group) {
      return group.map(function (node, i) {
        return callback.call(node, node.__data__, i);
      });
    });
    return new Selection(groups, this.#parents);
  }

  selectAll(selector) {
    const subgroups = [];
    const parents = [];
    this.#groups.forEach((group) => {
      group.forEach((node) => {
        subgroups.push(Array.from(node.querySelectorAll(selector)));
        parents.push(node);
      });
    });
    return new Selection(subgroups, parents);
  }

  data(__data__) {
    const groupsLength = this.#groups.length;
    const dataLength = __data__.length;
    const enter = new Array(groupsLength);
    for (let i = 0; i < groupsLength; i++) {
      enter[i] = new Array(dataLength);
      Selection.bindIndex(
        this.#parents[i],
        this.#groups[i],
        enter[i],
        __data__,
      );
    }
    this.#enter = enter;
    return this;
  }

  static bindIndex(parent, group, enter, data) {
    for (let i = 0; i < data.length; i++) {
      if (i < group.length) {
        group[i].__data__ = data[i];
      } else {
        enter[i] = new EnterNode(parent, data[i]);
      }
    }
  }

  enter() {
    return new Selection(this.#enter || this.#groups, this.#parents);
  }

  call(callback) {
    arguments[0] = this;
    callback.apply(null, arguments);
    return this;
  }
}

class EnterNode {
  constructor(parent, __data__) {
    this.parent = parent;
    this.__data__ = __data__;
  }
  appendChild(child) {
    return this.parent.appendChild(child);
  }
}

function scaleBand() {}

function scaleLinear() {}

function axisLeft() {
  return function () {};
}

function axisBottom() {
  return function () {};
}

スケーリングの処理を実装する

ここでは、D3のコードで先ほど省略していた d3.scaleBand などのスケーリングの処理を実装する。 D3を使ってグラフを描画するコードは以下のとおり。

<!doctype html>
<meta charset="utf-8" />
<style>
  .bar {
    fill: steelblue;
  }
</style>
<body>
  <!-- <script src="https://d3js.org/d3.v7.min.js"></script> -->
  <script src="./myd3.js"></script>

  <script>
    const svgWidth = 960;
    const svgHeight = 500;
    const margin = { top: 20, right: 20, bottom: 30, left: 40 };
    const width = svgWidth - margin.left - margin.right;
    const height = svgHeight - margin.top - margin.bottom;

    const xScale = d3.scaleBand().range([0, width]).padding(0.1);
    const yScale = d3.scaleLinear().range([height, 0]);

    const svg = d3
      .select("body")
      .append("svg")
      .attr("width", svgWidth)
      .attr("height", svgHeight)
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    d3.json("../sales.json").then(function (data) {
      xScale.domain(
        data.map(function (d) {
          return d.salesperson;
        }),
      );
      yScale.domain([
        0,
        Math.max(
          ...data.map(function (d) {
            return d.sales;
          }),
        ),
      ]);

      svg
        .selectAll(".bar")
        .data(data)
        .enter()
        .append("rect")
        .attr("class", "bar")
        .attr("x", function (d) {
          return xScale(d.salesperson);
        })
        .attr("width", xScale.bandwidth())
        .attr("y", function (d) {
          return yScale(d.sales);
        })
        .attr("height", function (d) {
          return height - yScale(d.sales);
        });

      svg
        .append("g")
        .attr("transform", "translate(0," + height + ")")
        .call(d3.axisBottom(xScale));

      svg.append("g").call(d3.axisLeft(yScale));
    });
  </script>
</body>

この描画処理を実行できるようにするために、自作D3のほうに追加で scaleBandscaleLinear を実装する必要がある。 長くなるのでここでは scaleLinear のみ載せておく。

mini-d3.js から抜粋した scaleLinear の実装
function scaleLinear() {
  const scale = function (x) {
    const [xl, xh] = scale._domain;
    const [yl, yh] = scale._range;
    return yl + ((yh - yl) * (x - xl)) / (xh - xl);
  };
  scale._range = [];
  scale._domain = [];
  scale.range = function (_range) {
    this._range = _range;
    return this;
  };
  scale.domain = function (_domain) {
    this._domain = _domain;
    return this;
  };
  scale.getTickPoints = function () {
    return range(this._domain[0], this._domain[1], 5);
  };
  return scale;
}

function range(l, h, stepsize) {
  const values = [];
  for (let current = l; current <= h; current += stepsize) {
    values.push(current);
  }
  return values;
}

この実装を自作D3に追加したあと、HTMLをブラウザで開くと以下のようなグラフが表示される。

自作D3で描画した棒グラフ(スケーリングあり)
自作D3で描画した棒グラフ(スケーリングあり)

軸と目盛りを描画する

棒グラフの棒の部分は描画できたので、残りの軸と目盛りを描画する関数 axisLeft axisBottom を実装する。

ざっくり言ってしまうと軸の線を引いて適切な位置にテキストを配置するだけだが、雑に実装してもわりと長くなる。 そのため、ここでは axisLeft のみを載せておく。

mini-d3.js から抜粋した axisLeft の実装
const tickLength = 6;
const tickLineWidth = 0.5;

function axisLeft(scale) {
  return function (axisRoot) {
    const mainLineLength = Math.abs(scale._range[1] - scale._range[0]);
    const valueLine =
      `M-${tickLength},${mainLineLength + tickLineWidth} ` +
      `H${tickLineWidth} V${tickLineWidth} H-${tickLength}`;

    axisRoot
      .attr("fill", "none")
      .attr("font-size", "10")
      .attr("font-family", "sans-serif")
      .attr("text-anchor", "end");
    axisRoot
      .append("path")
      .attr("class", "domain")
      .attr("stroke", "currentColor")
      .attr("d", valueLine);

    const ticks = axisRoot
      .selectAll(".tick")
      .data(scale.getTickPoints())
      .enter()
      .append("g")
      .attr("class", "tick")
      .attr("opacity", "1")
      .attr("transform", (d) => `translate(0, ${scale(d)})`);

    ticks.append("line").attr("stroke", "currentColor").attr("x2", -tickLength);
    ticks
      .append("text")
      .attr("fill", "currentColor")
      .attr("x", -9)
      .attr("dy", "0.32em")
      .text((d) => d);

    return axisRoot;
  };
}

最終的なコードとデモページはこちら:

マジックナンバーが何箇所か出現していることからもわかるとおり、かなり限定的な実装にはなっているものの、たった約300行で棒グラフが描画できた。

折れ線グラフを描画する

D3 Tips and Tricks v7.x で扱われている他のグラフも描画できるようにしたい。 詳細は省略するが、例えば d3.timeParsed3.scaleTime などを追加で実装すると、以下のような折れ線グラフが書けるようになる。

自作バージョンのD3で描画した折れ線グラフ
自作バージョンのD3で描画した折れ線グラフ

「軸と目盛りを描画する」の実装を見ると想像がつくと思うが、これ以降は込み入ってくるので必要なコード量がかなり増えてくる。 ここまで理解できればもう本家D3のソースコードを読んだほうが理解が早いと思うので、これ以降は読者の課題ということにしておきたい。

現状の実装で足りていない部分を一応列挙しておく。

最後に

D3とはなにか

そもそも「D3が難しい」といったとき、「D3は可視化ライブラリとして難しい」というふうに解釈されるが、 D3のことを「可視化ライブラリ」「チャートライブラリ」というとミスリーディングなのかなと思う。 いやもちろん、D3はグラフを描画するために使うライブラリではあるのだが、D3を扱う上ではユーザーはSVGを意識する必要があって、 その一方で通常の可視化ライブラリの場合はグラフの中身の構造を意識しなくてよい、という点でD3は他の有名な可視化ライブラリとは大きく異なる。

記事を書いているときに改めて調べて気づいたが、公式ドキュメントでも "D3 is a low-level toolbox", "D3 is not a charting library in the traditional sense." と書かれている9

9

え?でも普通にD3はグラフ描画ライブラリ/チャートライブラリって紹介されてない?と疑問に思ったので調べてみた。 D3の紹介文を見てみると、"D3 (or D3.js) is a free, open-source JavaScript library for visualizing data."だったり"The JavaScript library for bespoke data visualization"(bespokeに強調)だったりと明言を避けた書き方をしていて、chart library などといい切っていないことに気づく。 なるほどねぇ。

あらためて、D3はなぜ難しいのか?

D3をチャートライブラリではなくSVGを生成するライブラリと認識すれば、D3の難しさはある程度説明がつくような気もするが、その他にも難しさの原因はあるように感じる。 この記事を書いているときに思いついた理由を列挙してみる。

とはいえ、じゃあどうすればとっつきやすくなるか?と聞かれると返答に困る12ので、頑張ってD3に慣れるしかなさそうだ。

10

メソッド名を短くしたいというのも理解できなくはないが、 細かい部分を調整したり、複雑な可視化を行えたりする、というD3の特性上、命名に関しては短くすることより、どちらかといえば冗長気味にしたほうがいいケースが多いと思う。 例えば、日々のデータハンドリングの途中で行う可視化(読み返されることを考慮する必要がほぼない)と、ウェブサイトで大きく表示される一点ものの複雑な可視化(書かれたあとにメンテナンスのために読み返されることが多い)だと、前者であれば簡潔なほうがよさそうだが、後者であれば冗長気味になっても可読性が高いほうがいいだろう。

11

TypeScriptで型を付けようという提案が2018年に上がっているが却下されている。 却下されるのは意外だなと最初は思ったが、d3配下の一つ一つのリポジトリはわりと小さいということもあって、最悪型なしでもなんとか理解できる、というのはあるかもしれない。

12

関数名をもう少し長めにして、TypeScriptで型をつける、くらいでエンドユーザーの使用感はわりと改善しそうな気もするがどうだろうか? まあそれくらいの違いだったら、D3の資産を捨ててまで別ライブラリに移行するほどでもなさそう。