d3.js
Rails4

単語の相関関係を可視化するサンプルをRailsとAngularJS+D3.jsで作る

More than 1 year has passed since last update.

単語同士の相関関係、例えば

  • Rubyと関連がある単語ならRuby on Rails
  • PHPと関連がある単語ならCakePHP
  • CakePHPと関連がある単語は、PHPとRuby on Rails

という状態がこんな感じ↓で可視化されてると、色々と仕事で役立ちそうというのがわかって必要になって作っていました。

 2016-01-29 10.03.55.png
 2016-01-29 10.04.05.png

仕事で作った処理のベースとなる部分の処理だけを抜き出してサンプルアプリを作り処理のキモになりそうなところをちょっと解説してみようと思います。

解説にあたって

コード1つ1つの解説は大変なので

  • バックエンド側
  • フロントエンド側

でそれぞれポイントになりそうなコードを引用しながら解説することにします。
ソースコードはGitHubにあります

手元の開発環境など

  • Mac OS X 10.11.2
  • Ruby 2.2.2
    • rbenv利用してインストール
  • Rails 4.2.2
  • AngularJS
    • angularjs-railsを利用。angularjs-rails-1.4.8
  • D3.js
    • 3.5.12
    • minifyされたものをサイトからダウンロードしました

バックエンド側の処理について

バックエンド側の処理でポイントになるのは特定の単語を引数にしてその単語の関連情報をJSONで返す処理をどこかに作ることかと思います。

その処理に関連することについて、いくつか解説していきます。

単語の関連情報を返す処理

  • 単語自体の管理はTechnicalWordモデル
  • 単語同士の関連情報をTechnicalWordRelationモデル

を通じて処理が行えるように定義しつつ、特定の単語に関連する単語を簡単に抽出できるようにしたいと思ったので、TechnicalWordRelationモデルに、relation_words(technical_word)というクラスメソッドを以下のように定義しました。

  def self.relation_words(technical_word)
    source_words = TechnicalWordRelation.includes(:source).includes(:target).where(source_word_id: technical_word.id).map do |word|
      { source_id: word.source.id, source: word.source.name, target_id: word.target.id, target: word.target.name}
    end
    target_words = TechnicalWordRelation.includes(:source).includes(:target).where(target_word_id: technical_word.id).map do |word|
      { source_id: word.source.id, source: word.source.name, target_id: word.target.id, target: word.target.name}
    end
    return source_words + target_words
  end

これにより

technical_word = TechnicalWord.find_by_name('Ruby')
=> #<TechnicalWord:0x007faa116d3bd0
 name: "Ruby"
TechnicalWordRelation.relation_words(technical_word) 
=> [{:source_id=>2, :source=>"Ruby", :target_id=>4, :target=>"Ruby on Rails"}]

というような処理を実現することが出来ます。

特定の単語を検索ワードで受け取る処理

これはTechnicalWordsControllerにrelation_wordsアクションを定義して

  • 検索時の単語を引数にして該当するTechnicalWordが存在するか確認
  • 存在すれば上記でちょっと触れましたがTechnicalWordRelationモデルのクラスメソッドを通じて検索

ということを行うようにしています。

フロントエンド側の実装

フロントエンド側のJavaScriptの構造は以下のようにしてます。

app/assets/javascripts
├── app.js
├── application.js
├── controllers
│   └── technicalWord.js
├── d3.min.js
├── directives
│   └── d3graph.js
└── factories
    ├── d3.js
    └── technicalWord.js

directivesのd3graph.jsがメインとなるD3.js使った描画処理を担当。factoriesのtechnicalWord.jsがRails側との通信処理を担ってます。

D3.jsの処理が一番のポイントかと思うのでそこを重点的に説明していきます

初期化処理

D3.js使ったSVG要素を描画するための初期化処理を以下の行で行ってます。

    // 中略
    link: function(scope, element){
      var width = 960,
          height = 500,
          links = [],
          nodes = [];
      var svg = d3.select(element[0])
            .append('svg')
            .attr('width', width)
            .attr('height', height);
      var force = d3.layout.force()
            .size([width, height])
            .linkDistance(function(link) {
              return 100;
            })
            .nodes(nodes)
            .links(links)
            .linkStrength(0.5)
            .charge(-300)
            .start();
      var link = svg.selectAll('.link'),
          node = svg.selectAll('.node');
      force.on('tick',function tick() {
        link
          .attr('x1', function(d) { return d.source.x; })
          .attr('y1', function(d) { return d.source.y; })
          .attr('x2', function(d) { return d.target.x; })
          .attr('y2', function(d) { return d.target.y; });
        node
          .attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
      });

入力される単語の変化を検知しつつ描画処理を

これはD3.jsというよりはAngularJS使った処理の1つなのですが、何らかの単語が入力されたら、検索処理→単語が存在したらD3.jsの描画を行うようにしたかったので、AngularJSのwatchを以下のように利用してます。

    scope.$watchCollection('relationBaseWord', function(){
        if(scope.relationBaseWord == ''){
          scope.relatinSearchFail = false;
        } else if(!scope.relationBaseWord){
          return;
        } else {
          resetNodesAndLinks();
          scope.render();
        }
      });

画面描画処理のメインとなるrender()について

何らかの単語が入力されたら、検索処理を行うのですそこの処理は

      scope.render = function() {
        var data,
            params = {
              technical_word_name: scope.relationBaseWord
            };
        data = TechnicalWord.relation_words(params);
        renderNodesAndLinks(data);
      };

で行ってます。

directivesのd3graph.jsでは、factoriesのtechnicalWord.jsの機能を利用できるようにしてるので、そこを通じてRails側都の通信処理を

data = TechnicalWord.relation_words(params);

で行ってます。通信完了したら変数dataに格納してそれをrenderNodesAndLinks()に渡して、個々のノードやリンクの描画処理を行うようにしてます。

最初に単語を入力→その後別の単語で再検索する時のポイント

処理的には

    scope.$watchCollection('relationBaseWord', function(){
        // 中略
        } else {
          resetNodesAndLinks();
          scope.render();
        }
      });

のresetNodesAndLinks()になるのですが、ここでは最初

svg.selectAll('*').remove();

のようにして、いったんすべての要素を削除するようにしてました。

これで最初は動作していたので問題ないと思っていたのですが

  • Rubyで検索
  • PHPで検索
  • 再度Rubyで検索

というアクションをすると、ノードの描画はされず、線(リンク)の残骸だけが表示されるような状態になりました。

自分自身の理解が進んでないのですが、今回のような再検索をしたい場合には、selectAll('*').remove()ではなく
リンク、ノードに空の値を入れてリセットしつつ、それぞれに対してexit().remove()を呼び出してあげないと意図したような動作にならなかったのでここは注意が必要なところかもしれません。

最後に

D3.jsの理解がまだ進んでないところがあるので、上手な説明になってない可能性がとても高いので不明なところがあるようでしたら、コメントなどでお知らせいただければと思ってます!