vis.jsを使ってキャラクターのネットワークを描画する

概要

vis.jsを使って、pixiv上に投稿された東方Projectのキャラクターがどのキャラクターと一緒に描かれているかをネットワークとして出力するページを作りました。
東方キャラクターネットワーク図

イントロ

機械学習の教師データに使うために、pixiv上の東方Projectのイラストを収集しました。
目的は教師データとして利用することでしたが、データ自体面白くて色々見ていたら、その結果の一つをネットワークで出力したいと思い、調べたところネットワーク描画ライブラリのvis.jsを使うことにしました。
ちなみにvis.js使うと決めた段階ではjavascriptなんて一切書いたことありませんでした。変数や関数をキャメル記法で書く習慣すら今回で初めて知った。

ネットワーク化するデータ

対象のデータは収集したイラストのうち、キャラクターが二人以上が描かれたイラストです。
ネットワークのノードを各キャラクターとして、有向グラフの隣接行列の要素に、始点ノードのキャラクターのイラスト数のうち、終点ノードのキャラクターと一緒に描かれているイラスト数の割合をエッジの重みとして格納します。 (始点ノードのキャラクターと終点ノードのキャラクターが一緒に描かれているイラスト数) / (始点ノードのキャラクターを含む二人以上のキャラクターが描かれているイラスト総数)です。
(キャラクター数)×(キャラクター数)の二次元正方行列となり、対角成分は0とします。
3人以上のキャラが描かれているイラストもあるため、各行の合計は1以上となります。

実装

公式ExampleのDynamic Data - Importing from Gephi (JSON)やPerformanceあたりを参考にして作成しました。
Dynamic Data - Importing from Gephi (JSON)のように、ネットワークのデータ(node, edgeのデータ)をjsonとして作成しておき、それを読み込んで描画するようにします。
jsonファイルにはノードとエッジの情報を書いておきます。

データ

{
    "nodes": [
        {
            "id": 1,
            "title": "博麗霊夢",
            "shape": "image",
            "image": "./images/node_1.gif",
            "borderWidth": 0
        },
        {
            "id": 2,
            "title": "霧雨魔理沙",
            "shape": "image",
            "image": "./images/node_2.gif",
            "borderWidth": 0
        },

        // ### 中略 ###
    
    ],
    "edges": [
        {
            "from": 1,
            "to": 1,
            "width": 0.0,
            "title": "博麗霊夢 to 博麗霊夢: 0.0",
            "arrows": "to",
            "arrowStrikethrough": false,
            "value": 0.0,
            "hidden": true,
            "physics": false
        },
        {
            "from": 1,
            "to": 2,
            "width": 0.5063060748163851,
            "title": "博麗霊夢 to 霧雨魔理沙: 0.5063060748163851",
            "arrows": "to",
            "arrowStrikethrough": false,
            "value": 2.5315303740819255,
            "hidden": false,
            "physics": true
        },
        {
            "from": 1,
            "to": 3,
            "width": 0.00044966917196633904,
            "title": "博麗霊夢 to ShinGyoku: 0.00044966917196633904",
            "arrows": "to",
            "arrowStrikethrough": false,
            "value": 0.002248345859831695,
            "hidden": true,
            "physics": false
        },
        // ### 中略 ###
    ]
}

ドキュメントのNetworkのnodesとedgesのoptionから使うものをjsonに書いておきます。

nodesで使っているoptionを説明すると、idはノードには必須で参照(主にエッジからの)に使います。文字列で良いので別にキャラ名でも良いんですが、ユニークな整数でも与えておきます。
titleはノードにマウスオーバーやタップしたときに表示される文字列です。キャラクターの名前を入れます。
shapeはノードの形を指定します。 今回はキャラクターの画像を表示するようにしたいので、imageを指定します。
shapeimagecirlularImageを指定したとき、にその画像のパスをimageに格納します。キャラクターの画像はAI_Mebiusさんのドット絵を使用します。(AI_Mebiusさんのホームページ)
borderWidthはノードの周りの境界幅(マージン)指定をします。エッジの終点が画像(ノード)の端よりどのくらい離れるかを表します。デフォルトの1でも大して見栄えは変わらないですが0を指定します。

続いてedgesで使っているoption。
from, toはエッジの始点と終点となるノードのidを指定します。
widthはエッジに数値を入れることができます。今回はデータの重みとして使い、(キャラクターfromtoが一緒に描かれているイラスト数) / (キャラクターfromのイラスト総数)を格納します。後述のvalueを指定しない場合はこの値がエッジの太さとなります。
titleはノードと同じくマウスオーバーやタップしたときに表示される文字列です。キャラクターの名前と重みを表示させることにします。
arrowsはエッジの形を指定します。toを指定して終点ノードへの矢じりを描画します。
arrowStrikethroughはエッジの矢じりから先を描画するかどうかを指定します。無いほうが見やすいのでfalseにします。
先程widthが描画されるエッジの太さを指定すると書きましたが、valueを設定すると、こちらの値が優先されます。なので、widthには他の演算にも使うための重みを設定して、valueに描画用の数値を設定する使い方をするのかと思います。今回はvalueにはwidthの4倍の数値を格納します(そのくらいの太さが丁度良いので)。
hiddenは表示するかどうかのbooleanです。今回はwidthが0.1以上のエッジをtrue, 0.2未満をfalseに設定します。ループはfalse。
physicsは物理演算に使用するかどうかです。vis.jsの特徴のひとつはネットワークをいい感じに配置してくれる物理エンジンを搭載していることです。表示するエッジは全部trueにして表示しないエッジはfalseにします。hiddenで非表示に設定してもphysicsをtrueのままにしておくと物理演算に使用されるので注意。

jsonファイルを読み込んでネットワーク表示

先程のjsonファイルを読み込んでネットワークとして出力します。

var nodes = new vis.DataSet();
var edges = new vis.DataSet();
var imported;

loadJSON("./data.json", setJsonData, function (err) {
    console.log("error");
});

var data = {
    nodes: nodes,
    edges: edges,
};

var container = document.getElementById('network');

var options = {
    physics:{
    enabled: true,
    solver: "barnesHut",
    barnesHut: {
        centralGravity: 0.6,
        springLength: 150,
        springConstant: 0.01,
    },
    // repulsion:{
    //     centralGravity: 0.2,
    //     springLength: 200,
    //     springConstant: 0.01,
    // },
    }
};

var network = new vis.Network(container, data, options);

network.fit(); // zoom to fit

function loadJSON(path, success, error) {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
        if (xhr.status === 200) {
            success(JSON.parse(xhr.responseText));
        } else {
            error(xhr);
        }
    }
    };
    xhr.open("GET", path, true);
    xhr.send();
}

function setJsonData(jsonData) {
    if (jsonData.nodes === undefined) {
        jsonData = imported;
    } else {
        imported = jsonData;
    }

    nodes.clear();
    edges.clear();

    // add the data to the DataSets.
    nodes.add(jsonData.nodes);
    edges.add(jsonData.edges);
}

分けて見ていきます。

function loadJSON(path, success, error) {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
        if (xhr.status === 200) {
            success(JSON.parse(xhr.responseText));
        } else {
            error(xhr);
        }
    }
    };
    xhr.open("GET", path, true);
    xhr.send();
}

jsonファイルを読み込むのに公式ExampleのDynamic Data - Importing from Gephi (JSON)で使用しているloadJSONを使います。
やってることは単純ですね。jsonファイルの読み込みが成功したら中身をsuccessに送ります。
successには以下の関数を使用します。

function setJsonData(jsonData) {
    if (jsonData.nodes === undefined) {
        jsonData = imported;
    } else {
        imported = jsonData;
    }

    nodes.clear();
    edges.clear();

    // add the data to the DataSets.
    nodes.add(jsonData.nodes);
    edges.add(jsonData.edges);
}

Imported, nodes, edgesはグローバルで宣言する変数です。読み込んだデータを格納するだけです。
残りの部分が読み込んだデータを表示します。

var nodes = new vis.DataSet();
var edges = new vis.DataSet();
var imported;

loadJSON("./data.json", setJsonData, function (err) {
    console.log("error");
});

var data = {
    nodes: nodes,
    edges: edges,
};

var container = document.getElementById('network');

var options = {
    physics:{
    enabled: true,
    solver: "barnesHut",
    barnesHut: {
        centralGravity: 0.6,
        springLength: 150,
        springConstant: 0.005,
    },
    // repulsion:{
    //     centralGravity: 0.2,
    //     springLength: 200,
    //     springConstant: 0.01,
    // },
    }
};

var network = new vis.Network(container, data, options);

network.fit(); // zoom to fit

optionsで設定してるのは物理エンジンで使用するソルバーとそのパラメータで、いくつか試した結果このくらいの数値が良さそうでした。
ソルバーはrepulsionでも良かったんですが、barnesHutが一番軽いとのことなのでこっちを採用。
nodesedgesjsonからのデータを格納し、optionsにいくつかの設定をしてnetworkインスタンス化します。
あとはhtml側で<div id="network"></div>とでもして、networkを表示するだけです。

とりあえず出力してみます。

できたー!!
ズームしたりドラッグしたり出来ます。

楽しい

表示するエッジの重みを指定できるようにする

これだけでも良いですが、ちょっと機能を追加してみます。
公式ExampleのPerformanceでは、ノードの数をテキストボックスから入力しネットワークを生成し直すというスクリプトがあります。
これをちょっといじって、表示するエッジの重みの閾値をスライダーで変化させてネットワークに反映させる、ということをしてみます。

<form onsubmit="updateThreshold(); return false;">
    <label for="edgeThreshold" class="form-label"></label>
    <input type="range" class="form-range" min="0.00" max="2.0" step="0.01" value="0.1301 id="edgeThreshold">
    辺の表示閾値: <span id="value">0.2000</span>

    <script type="text/javascript">
        var elem = document.getElementById('edgeThreshold');
        var target = document.getElementById('value');
        var rangeValue = function (elem, target) {
            return function(evt){
                var val = elem.value;
                target.innerHTML = ((10**val)/100).toFixed(4)
            }
        }
        elem.addEventListener('input', rangeValue(elem, target));
    </script>

    <input type="button" value="適用" onclick="updateThreshold()" />
</form>

レンジスライダー(bootstrapのform-rangeを使ってます)を置いて、その値をedgeThresholdに送ります。
エッジの表示する閾値の初期値を0.2に設定しているのですが、エッジの重みはほとんどが0.2未満です。
例えばスライダーで0.01から1.0まで動かせるようにすると、変化量が線形だと使いにくいので底10の対数をとって、左端が0.01、真ん中が0.1、右端が1.0となるように変換します。
ボタンを置いてクリックしたときに閾値をネットワークに反映させます。

function updateThreshold() {
    let edgeThreshold = document.getElementById("edgeThreshold").value;
    let threshold = ((10**edgeThreshold)/100);
    // console.log(threshold);
    
    let data = edges.map(
        function(edge){
            if (edge.width < threshold){
                edge.hidden = true;
                edge.physics = false;
            } else{
                edge.hidden = false;
                edge.physics = true;
            }
            return edge;
    });

    edges.update(data);
}

スライダーの数値をエッジの重みと比較し、閾値未満ならhiddenをtrue、physicsをfalseにして閾値以上なら逆に設定します。
edgesを更新することで、ネットワークにも反映されます。
ただし、処理には結構時間がかかります。
こんな感じ。

完成したページはこちら。
東方キャラクターネットワーク図

感想など

イラストの分析から始め、結果をネットワークとして出力して公開するとは自分でも思いませんでした。
今回扱ったデータは単純な分析結果としても面白いですが、可視化、そして動かせるのはさらに楽しいですね。
キャラクターの画像はAI_Mebiusさんの公開されているドット絵を使用させていただきました。ありがとうございます。(AI_Mebiusさんのホームページ)