yutaponのブログ

javascript界隈の興味あるネタを備忘録的に残しておく場所

Backbone.js+D3.jsでデータの可視化【準備編】

先週はnode.jsでニコ動のランキング情報をJSON取得するコードを書きましたが、
今回はそのデータを使って棒グラフを書くってところまでやります。
(d3.jsの事前知識は公式のチュートリアルを読んだくらいです。)

普通にd3.js使っても面白く無いので、Backbone.jsとRequireJS使ってやってみました。

ソースを全部上げるには大きくなってきたのでgithubに置いときました。
yussk/nicoranking2d3 · GitHub
※まだまだ未完成、なので準備編。

現状で出力されるグラフはこんな感じ。(マイリスの絶対数のグラフ)
f:id:sskyu:20140330200627j:plain
しょぼい。


はじめに

先週書いたnode.js側のソースを少し修正してます。
アクセスのたびに100件の動画詳細を取りに行くXHRを防ぐため、
2回目以降はローカルのjsonを取得するよう変更しました。

タグ情報を返す部分も文字列型とオブジェクト型が混合してたので
オブジェクトのリストを返すように修正しました。

フロントの構成としてはBowerでパッケージ管理しつつRequireJSでモジュール管理することにしました。
前に書いた2つの記事を合わせたような構成になってます。
RequireJSの導入から使い方(Bowerにも触れてみる) - yutaponのブログ
Backbone.jsに入門してみる【Router編】 - yutaponのブログ


RequireJSでd3.jsを使えるようにする

まずはbower使ってインストールします。

$ bower install --save d3js

.bowerrc によって私の場合は public/bower_components 以下にインストールされました。


次はRequireJSのconfigでd3.jsを使えるようにしましょう。
基本的に外部のモジュールはpathsにてモジュールIDを定義するようにします。
また、d3.jsはまだAMD対応していないらしいので、shimでグローバル変数を定義してあげます。

// @file app.js

requirejs.config({
    baseUrl : '/javascripts',  // モジュール読み込みのbaseUrlを指定する

    paths : {
        jquery : '/bower_components/jquery/dist/jquery.min',

        underscore : '/bower_components/underscore/underscore',

        backbone : '/bower_components/backbone/backbone',

        // d3js, moduleIDは d3 とした
        d3 : '/bower_components/d3js/build/d3.v3.min',

        hbs : '/bower_components/require-handlebars-plugin/hbs'
    },

    shim : {
        underscore : {
            exports : '_'
        },
        // d3としてグローバル変数に追加する
        d3 : {
            exports : 'd3'
        }
    }
});


define([
    'backbone',
    'modules/index/index'
], function (Backbone) {

    var IndexController = require('modules/index/index');

    var indexController = new IndexController();
});



ControllerView

ここでいうControllerViewとは、モジュール(画面)単位でトップレベルにいるViewのことを指してます。
localhost:3000にアクセスするとindexモジュールが呼ばれるので、
indexモジュールを表示するために何やかんやするのがIndexControllerViewになります。

ソースはこんな感じ。

// @file index.js <modules/index>

define([
    'backbone',
    'modules/index/model/nico',
    'modules/index/view/d3',
], function (Backbone) {
    'use strict';

    // collections
    var NicoCollection = require('modules/index/model/nico');

    // views
    var D3View = require('modules/index/view/d3');

    var IndexView = Backbone.View.extend({
        initialize : function initialize() {
            var self = this;
            this._collection = new NicoCollection();

            this._d3view = null;

            this._collection.fetch({
                success : function success(collection, response, options) {
                    self._d3view = new D3View({ collection: collection });
                }
            });

            // bind events
            this.listenTo(this._collection, 'sync', this.show);
        },
        render : function render() {
            this.$el.html(this._d3view.render().$el);
            return this;
        },
        show : function show() {
            $('article').html(this.render().$el);
            this._d3view.createSVG(); // d3.select()がDOM上しか走査しないのでこのタイミングで呼び出す
        }
    });

    return IndexView;
});


何やってるかというと、インスタンス作られたタイミングで
collection通して通信して、通信が完了したらshow()を呼び出してDOMに要素を追加してSVGを作成してます。


通信部分

通信はBackbone.Collectionを使ってやってます。
ソースはこれ。

// @file nico.js <modules/index/model>

define(['backbone'], function (Backbone) {
    'use strict';

    var NicoModel = Backbone.Model.extend({
        parse : function (data) {
            // 値が文字列だとd3でうまく扱えないので数値型に変換しておく
            data.mylist_counter = Number(data.mylist_counter);
            data.view_counter   = Number(data.view_counter);
            data.comment_num    = Number(data.comment_num);
            return data;
        }
    });

    var NicoCollection = Backbone.Collection.extend({
        model : NicoModel,
        url   : '/nico',
        parse : function parse(json) {
            return json.ranking;
        }
    });

    return NicoCollection;
});


NicoCollectionのインスタンス作ってfetch()すると
'/nico' に向けてGETリクエストが飛びます。
Node.js側ではこのURLにGETリクエスト飛んできたら
ランキングのJSONを返すようにしていて、

{
    "ranking": [
        { "hoge": "fuga" },
        { "hoge": "piyo" }
    ]
}

みたいな感じで最大100件のオブジェクトが配列に入ってきます。
parseでサーバーから受け取った生のJSONから配列部分を返却し、
配列の個数分modelを生成しています。


D3.jsでグラフを描画する部分を管理するView

ControllerViewをもう一度見てみると、Collectionの通信が終わったら
D3ViewというViewのインスタンスを生成していて、
オプションでCollectionを渡しています。
これでD3Viewの中では this.collection にて通信後のデータにアクセスできる訳です。

D3Viewのソースはこちら。

// @file d3.js <modules/index/view>

define([
    'backbone',
    'd3',
    'hbs!modules/index/hbs/index'
], function (
    Backbone,
    d3,
    hbsIndex
) {
    'use strict';

    var D3View = Backbone.View.extend({
        className : 'd3-main',

        render : function render() {
            this.$el.html(hbsIndex());
            return this;
        },

        /**
         * SVGを作成する
         * this.$elをDOMツリーに追加後実行すること
         * @public
         */
        createSVG : function createSVG() {
            // データ
            var dataset  = this._parseData();
            var baseType = 'mylist';  // グラフ描画に使うデータを指定する

            // 定数
            var w = 500,
                h = 1500,
                barPadding = 4,
                wPadding = 30,
                hPadding = 10,
                len = dataset.length;

            // svg生成
            var svg = d3.select('.d3-svgWrapper').append('svg')
                .attr('width', w)
                .attr('height', h);

            // scale生成
            var wScale = d3.scale.linear()
                .domain([0, d3.max(dataset, function (d) {
                    return d[baseType];
                })])
                .range([0, w - (wPadding * 2)]);

            // グラフ描画
            svg.selectAll('rect')
                .data(dataset)
                .enter()
                .append('rect')
                .attr('x', wPadding)
                .attr('y', function (d, i) {
                    return i * ((h - hPadding) / len) + hPadding;
                })
                .attr('width', function (d) {
                    return wScale(d[baseType]);
                })
                .attr('height', h / len - barPadding)
                .attr('fill', 'teal');

            // rank描画
            svg.selectAll('text')
                .data(dataset)
                .enter()
                .append('text')
                .text(function (d) {
                    return d.rank;
                })
                .attr('x', 0)
                .attr('y', function (d, i) {
                    // TODO: 位置調整するカオスな数式を可読性高くする
                    return i * ((h - hPadding) / len) + hPadding + (h / len - hPadding) + barPadding;
                })
                .attr('class', 'd3-bar-rank');
        },

        /**
         * collectionから描画に必要なデータを抽出して返す
         * 単にtoJSON()して返すとデータ量多そうなので取捨選択する
         * @return {Array}
         */
        _parseData : function _parseData() {
            var dataset = [];

            _.each(this.collection.models, function (model) {
                var data = {};

                data.mylist  = model.get('mylist_counter');
                data.view    = model.get('view_counter');
                data.comment = model.get('comment_num');
                data.rank    = model.get('rank');
                data.title   = model.get('title');

                dataset.push(data);
            }, this);

            return dataset;
        }
    });

    return D3View;
});


いろいろやってますが、d3.jsのチュートリアルが分かれば読めるはず。
これを実行すると冒頭のグラフが出力されます。


重要な部分はscaleの使い方でしょうか。
グラフを描画できる領域は限られているので、入力に対する出力値を描画できる範囲内に丸めないといけません。
そんな時に使うのがscaleです。

// scale生成
var wScale = d3.scale.linear()
    .domain([0, d3.max(dataset, function (d) {
        return d[baseType];
    })])
    .range([0, w - (wPadding * 2)]);


d3.scale.linear()でスケールを設定するオブジェクトを生成して、
.domain()で入力値の幅を配列で指定し、
.range()で出力値の幅を配列で指定しています。

range()ではsvgの描画領域のサイズを指定してますが、
グラフの色を動的に変更したい場合などは、range([0, 255]) みたいに指定してあげると良さそうです。


おわりに

チュートリアルを読んでても思ったけど、動的にスタイルを調整するのが地味に難しい。

d3.select()がちょっと不満。
第一引数にCSSセレクタ渡して、第二引数にjQueryオブジェクト渡すとそこから要素を探索してくれるようになると嬉しい。

達成したいグラフにはまだまだ遠いので続きます。


参考にした記事

D3 入門 | スコット・マレイ | alignedleft
- これ読めばだいたい分かった気になってしまう。とても分かりやすい。
svg要素の基本的な使い方まとめ
- いろんなグラフの作り方が解説されてる。超助かる。