Genkanテックブログ

データビジュアライゼーションライブラリD3.js講座①

初めまして。KOSKA代表兼エンジニア(メインはフロントエンドとインフラ)の曽根です。 今日は第一回技術ブログということで、弊社のフロントを支える技術D3.jsの解説記事を書こうと思います。 今日は基礎的な話ですが、今後はclippathなどを使ったかなり複雑なグラフの作り方も連載していこうと思います。

Genkanはこのようにデータビジュアライゼーションに強いフロントエンド開発をしたい方、またそういったデザインに挑戦してみたいデザイナーの方を募集中です! 興味があれば是非ご連絡ください!

D3.js講座①

GenkanとD3.js

弊社システムの「Genkan」は簡単なセンサーを使って現場のデータをリアルタイムに金額化していくシステムで、そのためにはもちろんセンサーアルゴリズムやデータ整合性を担保するバックエンドシステム開発は重要であるが、弊社のシステムに最も重要な要素の一つが「いかにわかりやすいグラフを作るか」である。

弊社は2018年10月創業なのでまだ半年しか経っていないが、その間にすでに3回のデザイン刷新が行われており、その度にグラフの形も変わっていった。

例えば初期は下のようなグラフを作っていた

Genkanグラフ画像1

現在では例えば生産量を表示するだけでも

現在の生産量と、それが目標に対して何パーセントなのかということと、前日に比べどの程度なのかを一度に乗せたグラフや Genkan生産量グラフ画像

前日と現在の生産量を1時間、1日単位で比較できるグラフ Genkan単位時間あたり生産量グラフ

このように、様々な形式のグラフが登場している。 これらは全て毎回弊社スーパーデザイナーの三田地(@HiroshiMitachi)が僕らの 「こういう意味をグラフに込めたいんだけどどう表現すればいいんだろう?」 という無茶振りに対して完璧なグラフをデザインしてくれているおかげである。(いつもありがとうございます)

デザイナーがここまでいいものを作ってくるとフロントエンドエンジニアの僕としては「どんな難しいグラフでも作ってやろう」という気分になる。 ただ、もう一番最初の段階でchartjsではカスタム性が全く足りない(Y軸がある数値以下のエリアは赤、それ以上は紫にするエリアグラフなんて要件絶対不可能・・・)

達成するならばグラフを1から作るしかない!ということで、弊社が採用したのがd3.jsであった。

そのため、今日はD3を使ったグラフ作成術を1からまとめた記事を書いていく。

d3.js(ver4)でグラフを一から作っていく

d3.js

d3.jsはビジュアライゼーションライブラリで、基本的にはsvgを操作するためのツールである。 d3.jsを利用したグラフライブラリが多いためグラフライブラリと勘違いされやすいが、絵をかいたりアニメーションしたりといったことに強い。

d3でグラフを作る前に

たいていのことはchartjsで可能である。

今回グラフに注釈をつけたり、複数グラフ組み合わせたり、自由にお絵描きできるようにする必要があったのでd3を学んだが、その辺も2.0になって複数グラフ可になったし、chart-js-annotationというライブラリで注釈つけたり(結構しょぼいが)できるようになった。 d3も意外と簡単だが、そこまで学ばなくてもchartjsは使えるので、本当に今の要件がchartjsで達成できないのか考える必要がある。

ちなみに弊社の場合は現在システムに存在するほぼ全てのグラフにおいてchartjsだとデザイナーさんの考えてくれたデザインを再現するのが不可能に近いのでこの採用は正解であった。

d3を覚えるにはまずsvgの仕組みを覚えなければならない

そんなに重くないのだが、svgを理解しないとd3は扱いづらい。基本的にはsvgを操作して描画することが多いからである。 SVGとはScalable Vector Graphics(スケーラブル・ベクター・グラフィックス)の略で、pngやjpgのような画像の形式の一つのようなものだが、特徴的なのはxmlで描画内容が書いてあるところである。 svgは以下のようにhtmlの中にsvgタグを用いて宣言する

<svg width="500" height="50">
</svg>

このsvgタグにより、この中ではsvgで使われるオブジェクトを宣言できる

<svg width=500 height=200>
    <circle cx="50" cy="50" r="10"/>
    <line x1=10 y1=80 x2=100 y2=100 stroke="black"/>
    <rect x=10 y=110 width= 90 height=50 fill= "red"/>
    <text x=10 y= 190 fill="darkgrey" font-size=25>test</text>
</svg>

このようなコードはこのような図になる

test

基本的にはsvgはhtmlと変わらない。注意してほしいのはスタイルで、上のように直接書くこともできるし、クラスやidを付けて、cssファイルにすることもできる。 直接書く場合はstyle="fill:red;"と書いても問題はないが、styleを入れないでhtmlプロパティとしても使える。 cssにするときの注意点としては、svgの時だけいくつかプロパティ名が変わることである。例えば background-colorfillというプロパティだし、border-colorstrokeになる。cssが効かないときはこれを疑う。

また注意点として座標が直感に反するというものもある。 svgの中では(0,0)が左上、x座標は右に行くほど大きいので普段と変わらないが、y座標は下に行くほど大きくなる。

(0,0) (300,0) (0,100)

d3を使ってsvgをいじる

上ではsvgを自ら書いたが、d3を使う場合svgからすべてコードで生み出す。 svgJQueryの書き方にかなり近く、メソッドチェーンでDOMを操作することができる(後述するがこれがreactと相性が悪い原因でもある)

<script>
    const svg = d3.select("body")
                   .append("svg")
                   .attr("width",500)
                   .attr("height",200)
       
</script>

これをbodyタグに埋め込めば先ほどのsvgをタグを埋め込んだことになる。 一応解説すると、 d3.select("body")によってJQueryのようにDOMをいじることができる。 .append("svg")によってsvgタグのDOMを追加、attr.("width",500)によってsvgタグのスタイルをいじっている。 さらにこれらはd3オブジェクトを返すので、これを変数svgに格納することで同じように様々なsvg操作ができる。

先ほどの画像を再現してみる

svg.append("circle").attr("cx",50).attr("cy",50).attr("r",10)
svg.append("line").attr("x1",10).attr("y1",80).attr("x2",100).attr("y2",100).attr("stroke","black")
svg.append("rect").attr("x",10).attr("y",110).attr("width",90).attr("height",50).attr("fill","red")
svg.append("text").attr("x",10).attr("y",190).attr("fill","darkgray").attr("font-size",25).text("test")

見ればわかると思うが、基本的にはappendしたsvgに対してさらにcirclerect,line,textを追加し、その後スタイルを設定している。

これでd3の基本的な動作は理解できたはず。ただこれだとただのSVGJQueryである。 d3の本領はグラフ描画やアニメーションなどで発揮される。

d3でグラフを作成する。

例えばd3を使わず散布図グラフを自作すると必要な要件として様々なものがある。

  1. データに合わせて散布図のx座標とy座標を決め、それを基にcircleを描画する
  2. データに合わせてx軸、y軸を調整する
  3. 散布図とx軸、y軸の位置がpx単位で合うように設定する
  4. データが更新された場合、アニメーションして変更する

このような要件を自動化してくれるのがd3の最も強い点である。 そのために必要な概念がいくつかある。

  • データバインディングをするdataメソッド
  • スケールという概念
  • axisの設定
  • d3の持つデータ更新メソッド(enter(),exit(),transition()等)

dataメソッド

まずはこのようなグラフを作る

散布図1

条件としては

  • x軸は0~9

  • y軸は0~900

  • y=100*xの直線に沿うように散布図を配置する

このようにグラフを作る場合、まず元となるデータの配列をビジュアル化する仕組みを作る

まずは元となるデータを生み出す

d3にはjsoncsvのパース機能まであるのでデータを扱うのは非常に容易だが、今回は動的に配列を作る。d3.range()メソッドを使うと、0から引数にとった数字までの配列を生み出す

const dataSet = d3.range(10).map(elem=>({x: elem, y:elem*100}))

これで

[
    {x:0,y:0},
    {x:1,y:10},
...
    ,{x:9,y:900}
]

の配列が生み出された。このデータをもとに配列散布図を作る。

配列から散布図の点を生み出す

この時使うのがデータバインディングメソッドであるdata() 以下のように書く

const svg = d3.select("body")
                .append("svg")
                .attr("width",500)
                .attr("height",200)
const dataSet = d3.range(10).map(elem=>({x: elem, y:elem*100}))

svg
.selectAll("circle")//<-バインドするためにこれから追加するDOMのセレクタを取得
.data(dataSet)//<-バインド。これ以降のメソッドはバインドされたオブジェクトを受け取れる
.enter()//<-selectAllで取得したDOMに対してデータが追加されている場合、このメソッドで検知する。詳しくは後述
.append("circle")//<-circleオブジェクトをappend
.attr("cx",(d)=>(d.x))//<- 配列のxをそのままx座標へ
.attr("cy",(d)=>(d.y))//<- 配列のyをそのままy座標へ
.attr("r",2)

ポイントとなるのは data(),enter(),attr(property,function)という書き方である

  • data() このメソッドによって引数にもらった変数を基に既存のDOMに対してd3オブジェクトリファレンスが作られる。つまりselectAllメソッドで取得したDOMとオブジェクトを結びつける役割を担う。 これが効果あるのはデータが変更された時だ。一度appendしたDOMはselectAll()で再び取得できるので、もう一度selectAll("circle").data(newDataSet)のようにやるとDOMを新しいデータに対応するように勝手に表示変更してくれる。

  • enter() data()はあくまで既存のDOM(selectAllで取得したDOM)とデータを結ぶものであり、データが追加されたときはそもそもそのようなDOMがない。 そのため、データに応じてDOMを追加する場合enter()メソッドを使う。 データ配列に対してこれは既存のDOMが少ないとき、まだ存在しない空のDOMのレファレンスを追加してリターンする。つまりこのenter()以降でappendすると、追加されたデータに対しての差分分だけオブジェクトを作り出すことができる。今はよくわからないかもしれないが、アニメーションを行う部分で再び解説する。

  • attr(property,function) attrメソッドは第一引数にプロパティ名を表すstring、第二引数には数字や文字なども置けるが、functionも置くことができる。 このコールバック関数は引数にバインドされたデータのオブジェクトを渡され実行される。そのため例えばこの例における.attr("cx",(d)=>d.x)は、dに{x: (number),y:(number)}という形で渡されるので、xを基にcircleの座標を設定することができる。

ここまで書いて、htmlを表示すると以下のようになる 散布図(miss)

何も移っていないように見えるだろうが左の方に微妙に点が見えていると思う。 なぜならx座標は1px~10px,y座標は0px~900pxという設定になっているからである。 これではx座標はほとんど変化ないし、y座標はheightが500しかないのではみ出でいる。 なのでcsとcyを調整する必要がある。まずはこれをいい感じになるように手動で調整してみる。

const svg = d3.select("body")
                .append("svg")
                .attr("width",500)
                .attr("height",200)
const dataSet = d3.range(10).map(elem=>({x: elem, y:elem*100}))

svg
.selectAll("circle")//<-バインドするためにこれから追加するDOMのセレクタを取得
.data(dataSet)//<-バインド。これ以降のメソッドはバインドされたオブジェクトを受け取れる
.enter()//<-selectAllで取得したDOMに対してデータが追加されている場合、このメソッドで検知する。詳しくは後述
.append("circle")//<-circleオブジェクトをappend
.attr("cx",(d)=>(d.x/10*500))//<- xが最大値と比べて何パーセントなのかを計算し(x/10)それに高さをかける(*500)
.attr("cy",(d)=>(d.y/900*200))//<- yが最大値と比べて何パーセントなのかを計算し(x/900)それに高さをかける(*200)
.attr("r",2)

このようにxとy座標を計算して表示してあげるといい感じになる。 散布図(改良1)

しかし、この計算を手動でやるのはめんどくさい。しかもかなりずれる。小数点の丸めなどを考慮しないといけないからである。 もちろんD3はそこをやってくれるメソッドを持っている。それがscale系のメソッドである。

scale

scaleというのはd3の概念で、言ってみれば入力されたデータと座標のマッピングを助けるものである。 d3の最も強力な機能といっても過言ではない。 xが1~10の間にあり、widthが0~500pxの場合、例えばxが2の場合、2pxにおいたら先ほどのように左端に表示されてしまう。 本来この場合は2*50の100pxの場所にあるのが適切だろう。これをいい感じに計算してくれるのがscale関数。

ver3とver4の違い

d3は少し前にver4へのメジャーアップデートがあったため、ver3で使っていた関数が使えなくなっている。 スケールの書き方も変わっているので注意。

//ver3
d3.scale.linear()
//ver4
d3.scaleLinear()

なのでバージョンに注意しながら使う必要がある。ここではver4の書き方で書く。

domain()range()

d3.scaleLinear().domain()は入力する配列の範囲を指定する。

d3.scaleLinear().domain([0,10])

これによって0~10までの入力があるということになる。 ただ、これだと配列の最大値を必ず確定させなけらばならないのだが、配列が動的の場合d3.max()という関数が使える。 この関数は配列を入れると最大値を返す簡単な関数である。また、d3.max(array,function(element))のようにし、コールバック関数でどのようなものから最大値を選ぶかも選択できる。

d3.scaleLinear().domain([0,d3.max(array)])

また、d3.scaleLinear().range()はpxにしたとき何ピクセルから何ピクセルにするかを決める関数。 これにより先ほどのdomain関数で決めたインプットの範囲をpxのどの範囲にアウトプットするかが決まる。

d3.range()d3.scaleLinear().range()は大きく意味が違うので注意。 d3.range()は引数の数字分配列を作る関数であるが、d3.scaleLinear().range()は出力するピクセルの範囲を指定する関数になる。

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()

このコードによって0,10の範囲にある点を0pxから500pxの間にマッピングする。domainとrange関数の戻り値はscale関数である。 このscale関数に数字を入れるとマッピングされた座標を得ることができる。 nice()丸め誤差をいい感じにしてくる関数。 基本的につけておいた方がいいくらいいい感じにしてくれるナイスな関数。

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()
xScale(5)//<-250

これを使って先ほどのコードを書き替えると

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const dataSet = d3.range(10).map(
      (elem) => {
        return {x: elem, y: elem * 100}
      }
      )

svg
    .selectAll("circle")//<-バインドするためにこれから追加するDOMのセレクタを取得
    .data(dataSet)//<-バインド。これ以降のメソッドはバインドされたオブジェクトを受け取れる
    .enter()//<-selectAllで取得したDOMに対してデータが追加されている場合、このメソッドで検知する。詳しくは後述
    .append("circle")//<-circleオブジェクトをappend
    .attr("cx",(d)=>(d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([0,500]).nice()(d.x)))//<- 0~10を0px~500pxにマッピング
    .attr("cy",(d)=>d3.scaleLinear().domain([0,d3.max(dataSet, (elem)=>elem.y)]).range([0,200]).nice()(d.y))//<- 0~900を0px~200pxにマッピング
    .attr("r",2)

これで前と全くおなじ図が出る。ただし、今度は例え最大値が変わっても全く問題ない。データがランダムになってもスケールをいい感じにしてpxにマッピングしてくれる。 ただこれだと高ければ高いほど下に配置される図ができる。svgにおいてy座標は低ければ低いほど上に行く図になるからである。

これを逆転させるには0~500pxの高さと設定したものを500px~0pxという風に設定するとy軸のマッピングを簡単に逆転させることができる。

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const dataSet = d3.range(10).map(
      (elem) => {
        return {x: elem, y: elem * 100}
      }
      )

svg
    .selectAll("circle")
    .data(dataSet)
    .enter()
    .append("circle")
    .attr("cx",(d)=>(d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([0,500]).nice()(d.x)))
    .attr("cy",(d)=>d3.scaleLinear().domain([0,d3.max(dataSet, (elem)=>elem.y)]).range([200,0]).nice()(d.y))//<- range([0,200])をrange([200,0])に変更
    .attr("r",2)

これで以下のようにいい感じになる。 散布図(改良2)

axis

x軸y軸がないとグラフとして成立しないだろう。ただし、これもスケールを使うことでかなり簡単に書ける。

ver3とver4の違い

これもまたバージョンが違うと大きく書き方が変わる。

//ver3
const xAxis = d3.svg.axis().scale(xScale).orient("bottom")

//ver4
const xAxis = d3.axisBottom(xscale)

ver4はかなり見やすくなった。

軸オブジェクト

軸オブジェクトを作るには、上に書いてあるようにスケールを引数に入れるだけでいい感じに軸を作ることができる。 これが非常に強力で、同じスケールを使うことで軸とグラフの座標を完璧に一致させることができる。 例えばx軸を上の例で作ると、

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()
const xAxis = d3.axisBottom(xScale)

これで軸オブジェクトを作る。ただし、これをグラフに追加するときはひと工夫必要である。svgには軸オブジェクトに相当するタグがない。 そのためgroupという他のタグをひとまとめにするグラフを用意し、それを使って軸を描画する。

group

まず最初に、axisを描く場所を作る。この時groupエレメントを用いて、空の箱のようなものを作成する。

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const group = svg.append("g")

このグループというものは表示するものは何も持っていない。ただし、他のエレメントを囲いグループ化できる。 また、そのグループ全体に座標変換を行うことができるという特徴もある。

さて、軸追加の場合、追加したグループを軸に変換する形になる。

const svg = d3.select("body")
      .append("svg")
      .attr("width",500)
      .attr("height",200);

const xScale = d3.scaleLinear().domain([0,10]).range([0,500]).nice()
const xAxis = d3.axisBottom(xScale)

const group = svg.append("g")
group.call(xAxis)

これで以下のようになる

散布図と軸

ただ、これの問題点はaxisBottom()としてるのにかかわらず、軸が上にあることである。 これはaxisというものはgroupの座標に依存してるからである。それではBottomで何が変わっているかというと、軸の数字が下になっている。 これは下にある軸の時に適切な配置になるように軸の数字をいい感じに配置してくれているのだ。 それではどのように軸を下に置くかというと、groupの座標変換機能を使う。

group.attr('transform', `translate(0,${(180)})`).call(xAxis)

高さが200なので200移動してしまうとsvgからはみ出でしまうので、180下に移動させる。

散布図と軸2

これである程度作れるようになった。同じようにy軸も作って、さらにいい加減はみ出ている部分も調節する。 scaleのrangeをある程度余裕をもって作ればよい。

//データセットを作成
const dataSet = d3.range(10).map(
  (elem) => {
    return {x: elem, y: elem * 100}
  }
)


//幅と高さ設定
const width = 500;
const height = 200;

//それぞれのpaddingを設定
const xPadding= 50;
const yPadding = 20;

//設定した幅と高さのsvgを追加
const svg = d3.select("body")
  .append("svg")
  .attr("width",width)
  .attr("height",height);


// scaleを作成。rangeにpaddingを設定
const xScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([xPadding, width - xPadding]).nice()

const yScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.y)]).range([ height - yPadding,yPadding]).nice()


svg
    .selectAll("circle")
    .data(dataSet)
    .enter()
    .append("circle")
    .attr("cx",(d)=>xScale(d.x))
    .attr("cy",(d)=>yScale(d.y))
    .attr("r",2)

// padding分を考慮して軸を移動させる
svg.append("g").attr('transform', `translate(0,${(height - yPadding)})`).call(d3.axisBottom(xScale))

// axisLeftを使う。そのうえで軸の位置を移動させる
svg.append("g").attr('transform', `translate(${(xPadding)},0)`).call(d3.axisLeft(yScale))

ようやくまともなグラフが完成した。

散布図完成

長い闘いだったが、これでようやくまともなグラフが作れるようになった。

さて、次はデータを更新していく。

データ更新(アニメーション)

例えば、ランダムにデータを更新していく。グラフがアニメーションして更新されたいと考えることがあるだろう。

その場合d3は非常に強力なメソッドを持っている。transition()である。これを利用するとデータが更新されたときいい感じにアニメーションされる。

まずデータを更新できるように、軸の


// クラスに x axisを追加する
svg.append("g").attr("class","x axis").attr('transform', `translate(0,${(height - yPadding)})`).call(d3.axisBottom(xScale))


// クラスに y axisを追加する
svg.append("g").attr("class","y axis").attr('transform', `translate(${(xPadding)},0)`).call(d3.axisLeft(yScale))

データ更新用関数を作る。transitionはデータバインディングをした後に行うとデータからアニメーションが適応される。

function changeData(){

    //データセットを作成
    const dataSet = d3.range(10).map(
      (elem) => {
        return {x: elem, y: elem * 100 * Math.random()}
      }
    )
    
    const svg = d3.select("svg")

    // scaleを作成。rangeにpaddingを設定
    const xScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.x)]).range([xPadding, width - xPadding]).nice()

    const yScale= d3.scaleLinear().domain([0,d3.max(dataSet,(elem)=>elem.y)]).range([ height - yPadding,yPadding]).nice()
    

    svg
        .selectAll("circle")
        .data(dataSet)
        .transition()//アニメーション追加
        .duration(1000)
        .attr("cx",(d)=>xScale(d.x))
        .attr("cy",(d)=>yScale(d.y))
        .attr("r",2)

    svg.select(".x.axis").transition().duration(1000).call(d3.axisBottom(xScale))//アニメーション追加
    svg.select(".y.axis").transition().duration(1000).call(d3.axisLeft(yScale))//アニメーション追加
}

どうせならボタンを押したら更新されるようにする。 適当にbuttonを置いて関数実行させる。

<button onclick="changeData()">change</button>

散布図アニメーション

enter(),exit()

上の場合、データの長さが変わったときにその変更は無視される。つまり最初が10個しかない場合、更新されたデータが11個だった場合、グラフの点は増えずに位置だけ変わる。 そもそもtransitionというのは既存DOMとデータバインディングの差にアニメーションを適用していくものなので、DOMがないものには適用できない。 よってそれをするためにはほかに必要なことがある。

まずうまくやるためには先に差分のDOMを追加し、その後transitionを行う。この時に使えるのがenter()だ。 先ほどから何回か説明しているがこのenter()はデータが追加されたことを検知するもので、差分分のd3オブジェクトリファレンスが返される

svg
    .selectAll("circle")
    .data(dataSet)
    .enter()
    .append("circle")//<-先に差分を追加(座標は(0,0))


svg
    .selectAll("circle")
    .data(dataSet)
    .transition()//アニメーション追加
    .duration(1000)
    .attr("cx",(d)=>xScale(d.x))
    .attr("cy",(d)=>yScale(d.y))
    .attr("r",2)

svg.select(".x.axis").call(d3.axisBottom(newXScale))//アニメーション追加
svg.select(".y.axis").call(d3.axisLeft(newYScale))//アニメーション追加

これで座標0,0から点が追加されていくようなアニメーションになる。

また、exit()も同じような使い方だが、これは逆にデータが減ったときのものとなる。 このようにしてデータが増減しても、変更されてもうまくアニメーションすることができる。

終わり

今回は基本である

  • SVGの基本
  • dataメソッド
  • scaleメソッド
  • axis
  • データ更新(enter(), exit())

等についての記事を書いた。 次回はもっと複雑な描画をするためにはどうするのかということと、ReactでのD3.jsの使い方について書く予定。