Ruby
JavaScript
HTML5
d3.js
kaminari

Life is Tech ! Advent Calendar 1日目 データビジュアライゼーションに挑戦!

More than 1 year has passed since last update.

今年も始まりました! Life is Tech ! メンター アドベントカレンダー!今年の初日の記事を担当するのは、主にWebサービスプログラミングコースのメンターの、うーぴょん です!ᕱ⑅ᕱ
これからクリスマスまでの毎日LiTのメンターが各々の技術・経験を活かしてさまざまな分野の記事を書いて行きます!
よろしくお願いしますー!

さて、うーぴょんは去年もアドベントカレンダーに参戦していて、「Life is Tech ! Advent Calendar 2日目 jsで顔認識して遊んで見た!」を書きました!一番得意な言語のRubyを差し置いてJavascriptの記事だったりします。今年こそRubyで何かするぞっと言いたいところですが、今年もJavascriptを使ってデータビジュアライゼーションに挑戦します!
なんでデータビジュアライゼーション?ただやりたかったからです!ただRubyも途中で参戦します!

前置きがとても長いですが、本題の方に入って行きましょう!

D3.jsを使ってデータビジュアライゼーションしてみよう!

目次

  1. What's データビジュアライゼーション
  2. プロジェクトを作成する
  3. D3.jsを使ってみる
  4. データを取ってこよう
  5. データを可視化しよう

What's データビジュアライゼーション

さっきからデータビジュアライゼーションと何度も言っていますが、データビジュアライゼーションとは、膨大なデータをわかりやすく図やグラフに直すことです!そのまんまですね!
さてデータビジュアライゼーションの意味がわかったところで必要なものを考えましょう。
まず、HTMLタグやSVGをデータを元に大きさや長さを変えてDOM生成してグラフぽく見せる技術力...
こんなものを何にも頼らず自力で実装したらクリスマスが終わってしまいます。はい、データビジュアライゼーションが得意なライブラリがもちろんあります。
d3.jsを使いましょう。
ライブラリが決まったら、さぁ作成・・・とも行きません!データビジュアライゼーションという以上データが必要になります。
必要なものがわかってきたところで作成を進めて行きましょう!

プロジェクトを作成する

このようなファイル構造でプロジェクトを作りました。

  / ┐
    ├ index.html
    └ javascripts ┐
                  └ application.js

index.htmlにもコードを書きます。

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Advent Calendar 2017</title>
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="./javascripts/application.js"></script>
</head>
<body>
</body>
</html>

これで準備は完了しました!

d3.jsを使ってみる

使ってみる その1

とりあえずd3.jsが使えなければ進まないので使いながら慣れて行きます。
selectを使うと当てはまる最初の要素が取得できます。引数はjQuery同様にCSSセレクタです。

/javascripts/application.js
window.addEventListener('load', () => {
  d3.select('body').style('background-color', '#000');  // #1
});

d3.select('body')によってbody要素を取得しstyle('background-color', '#000')で背景色を黒くしています。ここでindex.htmlにアクセスすると真っ黒な画面になっているはずです。
これだけでは面白くないので複数の要素を一度に操作して見ましょう!上記コードのコメント#1のついた行の下に次のコードを加えます。

/javascripts/application.js
d3.selectAll('p').style('color', () => `hsl(${Math.random() * 360}, 100%, 50%)`);  // #2

index.htmlbodyの中にも要素を加えます。

index.html(body内)
<p>A</p>
<p>B</p>
<p>C</p>
<p>D</p>
<p>E</p>
<p>F</p>
<p>G</p>
<p>H</p>
<p>I</p>
<p>J</p>

ここまでできた状態で実行すると次のようにカラフルな文字が表示されます!(色はランダムなので、異なるかもしれません)
スクリーンショット 2017-11-28 19.54.59.png

selectAllを使うと条件を満たす要素を全て取得できます。文字色を変えるのにHSL色空間というものを使っています。細かい説明は省略しますが、この色空間の方がランダムな色を出しやすいです。

使ってみる その2

今のところデータは一度も登場していません。そろそろデータを絡めてみましょう!
#2の行のコードを次の3行に書き換えましょう!

/javascripts/application.js
d3.selectAll('p')
  .data([10, 15, 20, 25, 30, 35, 40, 45, 50, 55])
  .style('font-size', d => `${d}px`)  // #3
  .style('color', () => `hsl(${Math.random() * 360}, 100%, 50%)`); //#4

dataを使うことでデータを挿入することができます。そのデータを#3の行で呼び出しています。
なので実行すると次のようにカラフルで文字の大きさが次第に大きくなるはずです!
スクリーンショット 2017-11-28 20.05.41.png

データの数とp要素の数が同じことに注目しましょう!

使ってみる その2

前章でd3.jsのざっくりした使い方、そしてデータの数と適応させる要素の数を合わせればいいことがわかりましたね!でも本番ではいくつのデータを相手にするかわからないこと、膨大なデータを扱わないといけないことは多々あります。
なのに事前に要素を必要なだけ準備しなきゃいけないのはとても面倒ですね!
d3.jsにはデータから要素を作成することがもちろんできます!やってみましょう

さっきの4行を次のように書き換えましょう!

/javascripts/application.js
d3.selectAll('p')
  .data(data)
  .enter()
  .append('p')
  .style('font-size', d => `${d.size}px`)
  .style('color', () => `hsl(${Math.random() * 360}, 100%, 50%)`)
  .text(d => d.content);

#1の上に次の行を加えます。

/javascripts/application.js
const data = [
  {content: 'A', size: 10},
  {content: 'B', size: 15},
  {content: 'C', size: 20},
  {content: 'D', size: 25},
  {content: 'E', size: 30},
  {content: 'F', size: 35},
  {content: 'G', size: 40},
  {content: 'H', size: 45},
  {content: 'I', size: 50},
  {content: 'J', size: 55}
];

最後にindex.htmlのbody要素の中を削除して起きましょう!

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Advent Calendar 2017</title>
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="./javascripts/application.js"></script>
</head>
<body>
</body>
</html>

これを実行してもさっきと同様になります!データから要素を作成することができました!
d3.jsの基本はこれで終わりです。まだまだd3.jsにはメソッドがありますが、表現の仕方を定義するものなので一旦説明は割愛します!

おまけ

データを取ってこよう

今回使うデータ

ここまででd3.jsの使い方はおおよそわかりました!ただ肝心のデータがなければ何も始まりません。えっと手元にデータは・・・ありました!アドベントカレンダーに参加するメンターの一覧とコース一覧です!

mentor.json(抜粋)
[
  {
    "name": "うーぴょん",
    "course": ["Webサービスプログラミング", "Webデザイン", "Minecraftプログラミング", "IoT", "ゲームクリエイター"]
  },
  {
    "name": "こばとん",
    "course": ["iPhone", "Webデザイン", "Webサービスプログラミング"]
  },

course.json(抜粋)
[
  "iPhone",
  "Android",
  "Unity",

Let's スクレイピング

このままでもいいですがメンターのQiitaアカウントのアイコンがあると華やかになりそうですね!えっとまずカレンダーにアクセスしてURLを1つづつ取得・・・は流石に面倒なのでやめましょう。こういう大変なことこそプログラミングに任せるのが良いでしょう!
カレンダーのページをスクレイピングしてアイコンのURLの一覧を返します。スクレイピングとはWebサイトの情報を抽出する操作のことです。
RubyのNokogiriというライブラリが便利なので、そちらを使います。
スクレイピング先のHTML構造を調査した結果、adventCalendarCalendar_authorIconクラスを取得していくのが良いようです。ただこの記事を執筆している時点ではまだ空きがあるのでそこを考慮した実装にします!

スクリーンショット 2017-11-28 21.41.58.png

カレンダー1マスにadventCalendarCalendar_dayクラスが付与されているので、そちらでループを回します。それでもしadventCalendarCalendar_authorIconがあればそのsrc属性を読めばよく、なければnilを返します。

そんなわけで出来上がったのがこちら。

app.rb
require 'open-uri'
require 'json'
require 'nokogiri'

html = Nokogiri::HTML.parse(open('https://qiita.com/advent-calendar/2017/lit-mentor'), nil, 'UTF-8') # HTMLの読み込み

puts html.css('.adventCalendarCalendar_day').map { |grid| grid.at('.adventCalendarCalendar_authorIcon')&.attr('src') }.to_json # ここでURLを取得

あとは結果をjsonで保存します。
$ ruby app.rb > icon.json

無事にデータを取ることができました!ここからが本番です!

データを可視化しよう!

ここまでできたらあとは可視化するだけです。今回はforce-directed graphと呼ばれるグラフを使います!このグラフは点同士を結ぶ線が極力重ならないようにしたグラフです。d3.jsではforceSimulation()を使うことで簡単に実現できます。

とりあえず描画させてみる

まずapplication.jsを次のように書き換えます。

/javascripts/application.js
window.addEventListener('load', () => {
  // グラフの描画部分の取得と設定
  const forceGraph = d3.select('#force-graph');
  const forceSimulation = d3.forceSimulation()
                          .force('link', d3.forceLink())
                          .force('charge', d3.forceManyBody())
                          .force('center', d3.forceCenter(+forceGraph.attr('width') / 2, +forceGraph.attr('height') / 2));
  const data = {
    nodes: [
      {size: 2},
      {size: 2},
      {size: 2},
      {size: 5},
      {size: 2},
      {size: 2},
      {size: 2},
      {size: 5},
      {size: 10}
    ],
    links: [
      {source: 0, target: 3},
      {source: 1, target: 3},
      {source: 2, target: 3},
      {source: 4, target: 7},
      {source: 5, target: 7},
      {source: 6, target: 7},
      {source: 3, target: 8},
      {source: 7, target: 8},
    ]
  };
  const lines = forceGraph.selectAll('line')
                        .data(data.links)
                        .enter()
                        .append('line')
                        .attr('stroke-width', 0.3)
                        .attr('stroke', '#ddd');
  const nodes = forceGraph.selectAll('circle')
                        .data(data.nodes)
                        .enter()
                        .append('circle')
                        .attr('r', (d) => d.size * 2)
                        .attr('fill', 'green')

  // ノード(点)の登録
  forceSimulation.nodes(data.nodes)
                 .on('tick', () => {
                   lines.attr('x1', d => d.source.x)
                        .attr('y1', d => d.source.y)
                        .attr('x2', d => d.target.x)
                        .attr('y2', d => d.target.y)
                   nodes.attr('cx', d => d.x)
                        .attr('cy', d => d.y);
                 });
  // エッジ(点と点を結ぶ線)の設定
  forceSimulation.force('link')
                 .links(data.links)
                 .distance(() => 50);
});

SVGと呼ばれるベクタイメージ(演算によって図形を作画する画像形式)を使っていますが、基本は一番最初にやったサンプルと変わりません。

index.htmlのbodyに次の1行を追加します。

index.html
<svg id="force-graph" width="800" height="480"></svg>

ここまでできたら一旦動かしてみます!下のようなグラフが生成されれば大丈夫です!
スクリーンショット 2017-11-28 22.19.37.png
あとはデータを置き換えて行きましょう!

データを取得するメソッドの作成

今回使うjsonを取得してパースします。application.jsの最初の行に関数を定義します。Promiseを使って非同期に処理させると綺麗に実装できます。

/js/application.js
const getData = () => {
  const urls = [
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/c4adc1e3005d96f1afcbe491bfe9d61aaa7584d3/course.json',
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/c4adc1e3005d96f1afcbe491bfe9d61aaa7584d3/icon.json',
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/c4adc1e3005d96f1afcbe491bfe9d61aaa7584d3/mentor.json'
               ];

  return Promise.all(urls.map(url => fetch(url).then(r => r.json())));
};

素直に手動でマージしたjsonを使えば苦労はないのですが、なんか負けた気がするのでこんな実装になりました。

データを反映させる

とりあえず、コースとメンターのノードを完成させます。これも素直に手動でマージすればいいのですが、あえてゴリゴリ行きましょう!
application.jsのwindow.AddEventListenerを次のように変更します!

/js/application.js
const getData = () => {
  const urls = [
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/20f5c816bbdad1cab84bae33d53a7ee99f6951af/course.json',
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/20f5c816bbdad1cab84bae33d53a7ee99f6951af/icon.json',
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/20f5c816bbdad1cab84bae33d53a7ee99f6951af/mentor.json'
               ];

  return Promise.all(urls.map(url => fetch(url).then(r => r.json())));
};

window.addEventListener('load', () => {
  let forceGraph = d3.select('#force-graph');
  let forceSimulation = d3.forceSimulation()
                          .force('link', d3.forceLink())
                          .force('charge', d3.forceManyBody())
                          .force('center', d3.forceCenter(+forceGraph.attr('width') / 2, +forceGraph.attr('height') / 2));
  getData().then(r => {
    const [courses, icons, mentors] = r;
    mentors.forEach(m => m.type = 'mentor');
    const data = courses.map(c => new Object({name: c, type: 'course'})).concat(mentors);
    const links = mentors.map((m, i) => m.courses.map(c => new Object({source: 17 + i, target: courses.findIndex(c2 => c == c2)}))).reduce((r, x) => r.concat(x), []);
    const lines = forceGraph.selectAll('line')
                          .data(links)
                          .enter()
                          .append('line')
                          .attr('stroke-width', 0.3)
                          .attr('stroke', '#ddd');
    const nodes = forceGraph.selectAll('circle')
                          .data(data)
                          .enter()
                          .append('circle')
                          .attr('r', d => (d.type == 'mentor') ? '5' : '10')
                          .attr('fill', 'green')

    forceSimulation.nodes(data)
                   .on('tick', () => {
                     lines.attr('x1', d => d.source.x)
                          .attr('y1', d => d.source.y)
                          .attr('x2', d => d.target.x)
                          .attr('y2', d => d.target.y)
                     nodes.attr('cx', d => d.x)
                          .attr('cy', d => d.y);
                   });
    forceSimulation.force('link')
                   .links(links)
                   .distance(() => 50);
  });
});

これを実行すると次のようになります!なんかそれっぽくなりましたがよくわかりません!
スクリーンショット 2017-11-28 23.33.47.png

さらに見やすく

せっかくiconのデータを持ってきたので使いましょう!コースに関してはキャラクターを入れるのが良いのでしょうが、著作権の絡みがややこしそうなので、今回は名前を入れるにとどめておきます。getData().thenの部分を次のように書き換えます。

/js/application.js
  getData().then(r => {
    const [courses, icons, mentors] = r;
    mentors.forEach((m, i) => m.iconUrl = icons[i]);
    const data = courses.map(c => new Object({name: c})).concat(mentors);
    const links = mentors.map((m, i) => m.courses.map(c => new Object({source: 17 + i, target: courses.findIndex(c2 => c == c2)}))).reduce((r, x) => r.concat(x), []);
    const lines = forceGraph.selectAll('line')
                          .data(links)
                          .enter()
                          .append('line')
                          .attr('stroke-width', 0.3)
                          .attr('stroke', '#ddd');
    const nodes1 = forceGraph.selectAll('circle')
                             .data(data.slice(0, 17))
                             .enter()
                             .append('circle')
                             .attr('r', '20')
                             .attr('fill', 'green');
    const nodes2 = forceGraph.selectAll('image')
                             .data(data.slice(17))
                             .enter()
                             .append('image')
                             .attr('xlink:href', d => d.iconUrl)
                             .attr('width', '25')
                             .attr('height', '25');
    const nodes3 = forceGraph.selectAll('text')
                             .data(data.slice(0, 17))
                             .enter()
                             .append('text')
                             .text(d => d.name)
                             .attr('text-anchor', 'middle');

    forceSimulation.nodes(data)
                   .on('tick', () => {
                     lines.attr('x1', d => d.source.x)
                          .attr('y1', d => d.source.y)
                          .attr('x2', d => d.target.x)
                          .attr('y2', d => d.target.y)
                     nodes1.attr('cx', d => d.x)
                           .attr('cy', d => d.y);
                     nodes2.attr('x', d => d.x)
                           .attr('y', d => d.y);
                     nodes3.attr('x', d => d.x)
                           .attr('y', d => d.y);
                   });
    forceSimulation.force('link')
                   .links(links)
                   .distance(() => 100);
  });

出来上がったものがこちらになります。
スクリーンショット 2017-11-29 1.07.59.png
いい感じですね!コース間の繋がりを示すとさらに楽しそうです!

おまけ

せっかくなのでマウスでつまんで動かせるようにしましょう!これで最後なので全行出しておきます!

/js/application.js
const getData = () => {
  const urls = [
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/20f5c816bbdad1cab84bae33d53a7ee99f6951af/course.json',
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/20f5c816bbdad1cab84bae33d53a7ee99f6951af/icon.json',
                 'https://gist.githubusercontent.com/sasurai-usagi3/5a4d00c55ab2b8573edae6407a8d1c85/raw/20f5c816bbdad1cab84bae33d53a7ee99f6951af/mentor.json'
               ];

  return Promise.all(urls.map(url => fetch(url).then(r => r.json())));
};

window.addEventListener('load', () => {
  let forceGraph = d3.select('#force-graph');
  let forceSimulation = d3.forceSimulation()
                          .force('link', d3.forceLink())
                          .force('charge', d3.forceManyBody())
                          .force('center', d3.forceCenter(+forceGraph.attr('width') / 2, +forceGraph.attr('height') / 2));
  getData().then(r => {
    const [courses, icons, mentors] = r;
    mentors.forEach((m, i) => m.iconUrl = icons[i]);
    const data = courses.map(c => new Object({name: c})).concat(mentors);
    const links = mentors.map((m, i) => m.courses.map(c => new Object({source: 17 + i, target: courses.findIndex(c2 => c == c2)}))).reduce((r, x) => r.concat(x), []);
    const lines = forceGraph.selectAll('line')
                          .data(links)
                          .enter()
                          .append('line')
                          .attr('stroke-width', 0.3)
                          .attr('stroke', '#ddd');
    const nodes1 = forceGraph.selectAll('circle')
                             .data(data.slice(0, 17))
                             .enter()
                             .append('circle')
                             .attr('r', '20')
                             .attr('fill', 'green')
                             .call(d3.drag()
                                     .on('start', d => {
                                       if (!d3.event.active) {
                                         forceSimulation.alphaTarget(0.3).restart();
                                       }
                                       d.fx = d.x;
                                       d.fy = d.y;
                                     })
                                     .on('drag', d => {
                                       d.fx = d3.event.x;
                                       d.fy = d3.event.y;
                                     })
                                     .on('end', d => {
                                       if (!d3.event.active) {
                                         forceSimulation.alphaTarget(0);
                                       }
                                       d.fx = null;
                                       d.fy = null;
                                     })
                             );
    const nodes2 = forceGraph.selectAll('image')
                             .data(data.slice(17))
                             .enter()
                             .append('image')
                             .attr('xlink:href', d => d.iconUrl)
                             .attr('width', '25')
                             .attr('height', '25')
                             .call(d3.drag()
                                     .on('start', d => {
                                       if (!d3.event.active) {
                                         forceSimulation.alphaTarget(0.3).restart();
                                       }
                                       d.fx = d.x;
                                       d.fy = d.y;
                                     })
                                     .on('drag', d => {
                                       d.fx = d3.event.x;
                                       d.fy = d3.event.y;
                                     })
                                     .on('end', d => {
                                       if (!d3.event.active) {
                                         forceSimulation.alphaTarget(0);
                                       }
                                       d.fx = null;
                                       d.fy = null;
                                     })
                             );
    const nodes3 = forceGraph.selectAll('text')
                             .data(data.slice(0, 17))
                             .enter()
                             .append('text')
                             .text(d => d.name)
                             .attr('text-anchor', 'middle')
                             .call(d3.drag()
                                     .on('start', d => {
                                       if (!d3.event.active) {
                                         forceSimulation.alphaTarget(0.3).restart();
                                       }
                                       d.fx = d.x;
                                       d.fy = d.y;
                                     })
                                     .on('drag', d => {
                                       d.fx = d3.event.x;
                                       d.fy = d3.event.y;
                                     })
                                     .on('end', d => {
                                       if (!d3.event.active) {
                                         forceSimulation.alphaTarget(0);
                                       }
                                       d.fx = null;
                                       d.fy = null;
                                     })
                             );

    forceSimulation.nodes(data)
                   .on('tick', () => {
                     lines.attr('x1', d => d.source.x)
                          .attr('y1', d => d.source.y)
                          .attr('x2', d => d.target.x)
                          .attr('y2', d => d.target.y)
                     nodes1.attr('cx', d => d.x)
                           .attr('cy', d => d.y);
                     nodes2.attr('x', d => d.x)
                           .attr('y', d => d.y);
                     nodes3.attr('x', d => d.x)
                           .attr('y', d => d.y);
                   });
    forceSimulation.force('link')
                   .links(links)
                   .distance(() => 100);
  });
});

完成するとこんな感じ!楽しいですね!
名称未設定.gif

成果物

まとめ

Javascriptを駆使することでとてもデータを見やすく加工することができました!Web界隈は進歩が常に早いので今後も目が離せませんね!

明日は、とんとんこばとんでおなじみ(ということにしておく)の こばとん が何か面白い記事を発表してくれます!お楽しみに!