はじめに
Webページにデータの集計結果を掲載する必要があって、気になっていたD3.jsを利用してみました。
D3.jsの機能の特徴は、JavaScriptで効率的にデータ構造を作成・操作するメソッド群を供えていて、(グラフではなく)SVGオブジェクトを操作するためのメソッド群を駆使し、自力でグラフを描くためのライブラリといった印象です。その一方で、拡張性に富んでいて自由度が高く、何をしたらよいか途方に迷う系のライブラリでもあるなとも感じています。
D3.jsでは v3→v4 のバージョンアップの際に内部構造の大きな変更があったらしく、その事について掲載しているWebページが散見されます。v4のコードも大体はv7で動作するようですが、細かい点では修正が必要でした。
また、一般的なD3.jsの使い方は、利用例をみて、自分のデータを元にグラフを描く事だと思います。
ただ、このアプローチでは、少し修正したい、少し構造の違うデータを描画したい、などのニーズが生じた際に、困難に直面する機会が増えると思います。
困った事や気がついた事について、メモを残しておくことにします。
参考資料
- 公式サイト
- www.d3-graph-gallery.com - v4を利用したOrdered Barplot Example
- 公式ガイド - Grouped Bar Chart Example
- 公式ガイド - d3.scaleLinear
- D3 6.0 migration guide
- SVG 1.1 2nd 仕様書 - Data Structure
- SVG 1.1 2nd 仕様書 - Text
セットアップ
以下のようにd3.v7.min.jsを利用しています。
<script src="https://d3js.org/d3.v7.min.js"></script>
次からは説明を加えていくことによって、自分自身が理解を深めることを目的としたメモを残しておきます。
Y軸の描画を例とする、見通しの良いコードの記述方法
www.d3-graph-gallery.com - v4を利用したOrdered Barplot Example では次のようなコードが紹介されています。
// https://www.d3-graph-gallery.com/graph/barplot_ordered.html
// Add Y axis
var y = d3.scaleLinear()
.domain([0, 13000])
.range([ height, 0]);
svg.append("g")
.call(d3.axisLeft(y));
これはv7でも問題なく機能していますが、公式サイトのv7に対応したGrouped Bar Chart Exampleでは次のようなコードが利用されています。上記のコードとは違うグラフで関数化されていたり、値域などの細かい点での違いもありますが、そのまま転記します。
// https://observablehq.com/@d3/grouped-bar-chart
// 関数を呼び出し chart にSVGのオブジェクトを格納
chart = GroupedBarChart(stateages, {
x: d => d.state,
y: d => d.population / 1e6,
z: d => d.age,
xDomain: d3.groupSort(stateages, D => d3.sum(D, d => -d.population), d => d.state).slice(0, 6), // top 6
yLabel: "↑ Population (millions)",
zDomain: ages,
...
})
// SVGオブジェクトを返却する関数の定義
function GroupedBarChart(data, {
...
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [ymin, ymax]
...
yType = d3.scaleLinear, // type of y-scale
...
yFormat, // a format specifier string for the y-axis
} = {}) {
...
const yScale = yType(yDomain, yRange);
...
const yAxis = d3.axisLeft(yScale).ticks(height / 60, yFormat);
...
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
...
前者のvar y
と後者のconst yAxis
は同じaxisオブジェクトを表しています。
この2つのコードには次のような違いがあります。
- 後者は関数化されており、引数によるパラメータ化と処理の分離が徹底されている
- Y軸の値(Domain)の幅を、前者のコードは
.domain([0,13000])
で指定しているが、後者では、yDomain引数を媒介し、yType・yScaleオブジェクトを経由して、d3.axisLeft(yScale)
で反映している - 前者の
.range([ height, 0]);
も同様に、後者ではyScaleオブジェクトに含まれ、d3.axisLeft(yScale)
によって反映されている - 前者の
.call(d3.axisLeft(y));
で反映している処理は、後者ではyAxisオブジェクトを経由し、.call(yAxis)
で反映している
前述のコードの方が量的にシンプルなので望ましく感じるかもしれませんが、D3.jsを様々に応用したいのであれば、後者の関数化されたコードの内容を理解するように努めた方が良いと思います。
特にvar
を利用したり、関数化していない場合には、1ページ内に複数のグラフを描こうとした時に、意図しない変数の上書きが発生し、応用が難しくなります。
var
が使われていればあまり参考にせずに、const
を中心に、必要な場合にはlet
を使って変数を宣言し、内部構造を含めて書き直すのがお勧めです。
関数の戻り値で渡されたSVGオブジェクトをDOMに反映する
公式サイトのExamplesの各ページでは説明されていないようですが、次のような方法で関数の戻り値のSVGオブジェクトをDOMに加えることができます。
まず描画したい場所にid属性を持つタグを配置します。
<div id="svg0001"></div>
関数の戻り値(svg要素)を、ここの子要素に加えます。selection.append(type)のマニュアルを確認すると、appendの引数にchartオブジェクトを直接与えてしまうと() => document.createElement(chart)
と変換されてしまうので、作成済みのchartオブジェクトそのものを加えるためにアロー関数にしています。
const dom_id = "#svg0001";
const chart = StackedBarChart(d3.sort([{},...], { });
d3.select(dom_id).append(() => chart);
yScaleオブジェクトについて
前述の例ではyScaleオブジェクトは次のようにyTypeから取得しています。
const yScale = yType(yDomain, yRange);
console.log()でyScaleの内容を出力すると次のような関数である事が分かります。
// console.log(yScale); の結果
function scale(x)
yScaleオブジェクトは、yType変数を通して、d3.scaleLinear から生成しているわけですが、d3.scaleLinearのAPIリファレンスを確認しておきます。
ここでは、d3.scaleLinear([[domain,]range])
と紹介されていて、domain, rangeはオプション扱いです。
公式ガイドの方をみると、yScale()関数に、Y軸の値域内の数値を指定すると、yRangeを参照してグラフに設定するのに適切な高さが返ってきます。
// yRange([370,30])で設定済み
console.log(yScale(0)); // → 370
console.log(yScale(1)); // → 30
ここら辺を確認しながらD3.jsは魔法のようにグラフを描くというよりも、描くために必要な手続きを標準化してくれる良いフレームワークを提供しているという印象を持ちました。
繰り返しになりますが、公式Examplesの中にある関数化による記述方法をフレームワークの一部だと捉えて、積極的に利用する方法を学ぶべきだと思っています。
yAxisオブジェクトと反映
console.log()を利用して、yAxis変数を出力してみると次のようになります。
function axis(context)
公式APIガイドのAxisエントリは、d3.axisLeft() 等と、戻り値であるaxis関数について説明しています。
APIリファレンスが読めるようになると、console.log()を駆使しながら、開発スピードが上がると思います。
APIリファレンスを読む
この中で、d3.scaleLinear
の説明を確認すると、continuous
、pow
など複数の戻り値となる関数について説明が併記されています。
d3.scaleLinear()が何を戻り値とするのかは、クリックして説明に進むと記述があります。
# d3.scaleLinear([[domain, ]range])
Constructs a new continuous scale with the specified domain and range, ...
^^^^^^^^^^
ここでcontinuous
を読めば良いことが分かるので、戻って、どんなメソッドがあるのか確認します。
D3 Transformationsによる配列操作
グラフ化したいデータを生成するために、2つのデータソースがあって、次のように入れ子になったmap()を利用したいとします。
const data = data1.map(function(d) {
return data2.map(function(dd) {
return { ... }
});
)};
この出力はネストした配列になります。
[
[
{ key: k, value: v, ... },
...
],
...
]
d3.mergeを利用して、こういった深い配列を浅くすることができます。
d3.merge(data); // → [ { key: k, value: v, ... }, { ... }, ... ]
こうして得られた配列に格納されたデータは、d3.sort() を利用して、value: をキーにして操作するといった操作ができます。
d3.sort(d3.merge(data), d => d.value); // → 同じデータ構造のまま
d3.keys()などdeprecatedとなっているd3-collection
連想操作などのデータ操作のため、古いD3の資料では、d3.keys()
を紹介しているものがありますが、v7には含まれていません。
d3.keys()などを提供していたd3-collectionはdeprecatedとなっていて、現在は、Object.keys()などを利用するようになっています。
- d3-collection (* Deprecated)
ドキュメントを確認すると、v6からdeprecatedとなっているようです。
d3.csv()、d3.json() の利用方法 (v4との非互換性)
参考資料に挙げている、Ordered Barplot Example の例では、v4を利用していますが、その中では、csvファイルの読み込みを次のようなコードで実行しています。
// Parse the Data
d3.csv("https://.../7_OneCatOneNum_header.csv", function(data) {
// processing the "data" object.
});
function(data)の内部に処理を書く方法は、v7では動作しません。
確認していませんが、ドキュメントのとおりであれば、v5以降ではv7と同じ動作のはずです。(d3-fetch: This module is deprecated as of D3 5.0; please use d3-fetch instead.)
awaitは使わずに次のようにthenを利用して、これまでと同じような記述にしています。
d3.json("https://...").then(function(data) {
});
複数のデータソースがある場合には、d3.json()
やd3.csv()
がネストして読み難くなってしまう点がネックかもしれません。
理想的なのは、複数のデータソースへのアクセスを並行処理させて、Promise.all(...).then(function(data) {...});
を使った集約処理を使うべきとは思いますが、いまのところ逐次的な処理でもパフォーマンス上の問題は感じていません。
svg.append("g") から先の処理
出力したいSVGの内部構造をイメージしないと、グラフを正確に描くことが難しい点がD3.jsの難易度を少し上げていると思います。
参考資料にも挙げていますが、SVG 1.1 2nd 仕様書のData Structure、Textの図表には軽く目を通しておいた方がいいと思います。
簡単なグラフでは、<svg></svg>
の中に、<rect>
や、<g>
、<text>
要素が並列に含まれています。
MDN Web DocsのSVG g要素の説明 には次のような例が掲載されています。
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Using g to inherit presentation attributes -->
<g fill="white" stroke="green" stroke-width="5">
<circle cx="40" cy="40" r="25" />
<circle cx="60" cy="60" r="25" />
</g>
</svg>
D3.jsでは、次のコードで同じ操作が可能です。
まず、あらかじめ適当なidを持つタグを追加します。
d3jsのライブラリを読み込み、scriptタグなどで次のようなコードを実行します。
<html>
<head>
<title>MDN Example</title>
</head>
<body>
<div id="mdnexample"></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const svg = d3.select("#mdnexample")
.append("svg")
.attr("viewBox", [0, 0, 100, 100])
.append("g")
.attr("fill", "white")
.attr("stroke", "green")
.attr("stroke-width", 5)
.call(g => g.append("circle")
.attr("cx", 40)
.attr("cy", 40)
.attr("r", 25))
.call(g => g.append("circle")
.attr("cx", 60)
.attr("cy", 60)
.attr("r", 25));
</script>
</body>
</html>
このファイルをfirefoxなどで開けば、MDNのサンプルと同様の図形が描画されるはずです。
ここで呼んでいる.callは、D3公式APIリファレンス - selection.call が呼ばれています。アロー関数の引数になるg
には、直前のappend("g")
で設定されたg要素が入っています。
call()を使うことで、gの中に2つのcircleを並列に配置しています。
まずはサンプルを実際に動作させて、console.log()
を利用しながら、APIリファレンスと対応させて読めるようになるトレーニングを積みたいと思います。
よく分からない場合はmdnexample.htmlをChromeやFirefoxなどで開いた後に、右クリックメニューの検証や調査機能でDOMの内容を確認してください。
ソースを確認するとJavaScriptコードが表示されるだけですが、DOMをみればJavaScriptで<div id="mdnexample"></div>
の内部がどのように変更されているか分かるはずです。
Data操作 - join() or append()
古いサンプルでは、.data(...)
を利用して、配列などのデータを渡した後に、.enter().append("rect")....
のような処理で矩形("rect")等を描画しています。
これは、data(...)にデータを渡した後、.enter()によって各要素に、.append("rect") 以下の処理を呼び出す操作を意図しています。enter()を呼び出さない場合、data(...)に渡したデータが処理されることはありません。
しかし、v7に対応したサンプルでは、enter()
を呼び出していない例が紹介されており、.append("rect")の代りに .join("rect")
を利用しています。
APIによれば、次のようなコードと同じ表記になります。
.join(
enter => enter.append("rect"),
update => update,
exit => exit.remove()
)
つまりappend()だけではなくて、update(), exit() についても、join()だけでまとめて記述することが可能になります。
.enter() → .append() のようなコードは、join() にまとめられることになるので、今後は積極的にこれを使っていくつもりです。
以上