フューチャー技術ブログ

mxGraphで階層グラフを可視化する

はじめに

こんにちは、TIGコアテクノロジーユニットの平岡です。

この記事は、Vis Networkで階層グラフを可視化するの続編となります。未読の方は、是非そちらの記事もご覧下さい。

前回の記事では、JavaScript製のグラフ可視化ライブラリについて概観しました。また、その1つであるVis Networkについて紹介し、階層グラフの可視化を行いました。

Vis Networkはcanvasでの高速な描画が可能で 1、階層グラフをライトに表示・加工する場合は非常に有用ですが、大きな階層グラフを表示する場合にエッジの交差が多くなってしまうという課題があることを見てきました。

この記事では上述の課題を解決するためにmxGraphを用いて階層グラフの可視化を行います。mxGraphは階層グラフのレイアウト計算にSugiyama Algorithm 2を利用しており、階層グラフを綺麗に表示することが可能です。下の画像は前回の記事で描画した階層グラフを両ライブラリ間で比較したものですが、mxGraphの方がエッジの交差が少なく見やすいレイアウトになっていることがわかります。

vis-vs-mxgraph.png

mxGraphとは

概要

mxGraphには以下のような特徴があります。

  • JavaScript製のグラフ可視化ライブラリ
  • 描画方式はSVG
  • diagrams.net(旧 draw.io)で利用されています
  • Apache License 2.0
  • 自動レイアウト計算にSugiyama Algorithmを利用しており、綺麗な階層グラフを表示できます

注意

本家は2020年11月9日にメンテ終了し、 3現在は有志がメンテを行っているようです。

mxGraphの使い方

階層グラフの描画

mxGraphの自動レイアウト計算を利用して階層グラフを描画してみましょう。

まずは、グラフを表示する領域を確保し、mxGraphのインスタンスを生成します。

// グラフを表示する領域を確保
const container = document.getElementById('container')
// グラフのインスタンス生成
const graph = new mxGraph(container)

graph.getDefaultParent()はノードやエッジを追加する際に必要(後述)なので取得しておきます。また、階層グラフを自動レイアウトで計算するために、mxHierarchicalLayoutのインスタンスを生成します。

// グラフにノードやエッジを追加する際に必要
const parent = graph.getDefaultParent()
// 今回は階層グラフを自動レイアウトで描画したいため、レイアウトのインスタンスを生成
const layout = new mxHierarchicalLayout(graph)

最後に、ノードやエッジの追加とレイアウト計算を行いましょう。

// グラフの形状やデザインの変更を行う(グラフモデルを変更する)際は
// beginUpdate -> グラフモデル変更 -> endUpdate の順に行う
graph.getModel().beginUpdate()

try {
// tryブロックの中でグラフの形状やデザインの変更を行う

// ノード追加
const v1 = graph.insertVertex(parent, null, '1', null, null, 30, 30, null)
const v2 = graph.insertVertex(parent, null, '2', null, null, 30, 30, null)
const v3 = graph.insertVertex(parent, null, '3', null, null, 30, 30, null)
const v4 = graph.insertVertex(parent, null, '4', null, null, 30, 30, null)
const v5 = graph.insertVertex(parent, null, '5', null, null, 30, 30, null)
const v6 = graph.insertVertex(parent, null, '6', null, null, 30, 30, null)
const v7 = graph.insertVertex(parent, null, '7', null, null, 30, 30, null)
const v8 = graph.insertVertex(parent, null, '8', null, null, 30, 30, null)
const v9 = graph.insertVertex(parent, null, '9', null, null, 30, 30, null)
const v10 = graph.insertVertex(parent, null, '10', null, null, 30, 30, null)
const v11 = graph.insertVertex(parent, null, '11', null, null, 30, 30, null)
const v12 = graph.insertVertex(parent, null, '12', null, null, 30, 30, null)

// エッジ追加
graph.insertEdge(parent, null, null, v1, v3, null)
graph.insertEdge(parent, null, null, v1, v2, null)
graph.insertEdge(parent, null, null, v2, v4, null)
graph.insertEdge(parent, null, null, v2, v5, null)
graph.insertEdge(parent, null, null, v3, v6, null)
graph.insertEdge(parent, null, null, v3, v8, null)
graph.insertEdge(parent, null, null, v6, v7, null)
graph.insertEdge(parent, null, null, v6, v9, null)
graph.insertEdge(parent, null, null, v4, v10, null)
graph.insertEdge(parent, null, null, v4, v11, null)
graph.insertEdge(parent, null, null, v5, v12, null)
graph.insertEdge(parent, null, null, v3, v4, null)
// 追加したノード・エッジに基づいてレイアウトの自動計算を行う
layout.execute(parent)
} finally {
graph.getModel().endUpdate()
}

tryブロックの中身を詳しく見てみましょう。ノードの追加は

const v1 = graph.insertVertex(parent, null, '1', null, null, 30, 30, null)

のように行います。各引数の説明は以下のとおりです。

  • 第1引数:先程取得したparent
  • 第2引数:ノードを一意に識別するためのID(指定なしの場合は自動的に割り当て)
  • 第3引数:ノードのラベル
  • 第4,5引数:ノードのx座標・y座標(後でレイアウト自動計算する場合はダミーの値でOK)
  • 第6,7引数:ノードの幅・高さ
  • 第8引数:ノードのスタイル

また、エッジの追加は

graph.insertEdge(parent, null, null, v1, v3, null)

のように行います。各引数の説明は以下のとおりです。

  • 第1引数:先程取得したparent
  • 第2引数:エッジを一意に識別するためのID(指定なしの場合は自動的に割り当て)
  • 第3引数:エッジのラベル
  • 第4,5引数:エッジの始点・終点
  • 第6引数:エッジのスタイル

最後に、レイアウト計算を呼び出します。

// 追加したノード・エッジに基づいてレイアウトの自動計算を行う
layout.execute(parent)

以上で、画像のように階層グラフが描画できました。

graph.png

ノードの形状や色の変更

続いて、ノードの形状や色をカスタマイズしてみましょう。
(紹介するソースコード全体はEdit fiddle - JSFiddle - Code Playgroundで確認できます。)

graph.insertVertex()の第8引数でノードのstyleを指定できます。また、styleに名前を付けて適用させることも可能です。

// ノード追加
const v1 = graph.insertVertex(parent, null, '1', null, null, 30, 30, 'shape=cylinder')
const v2 = graph.insertVertex(parent, null, '2', null, null, 30, 30, 'shape=triangle')
const v3 = graph.insertVertex(parent, null, '3', null, null, 30, 30, 'shape=cloud')
const v4 = graph.insertVertex(parent, null, '4', null, null, 30, 30, 'shape=hexagon')
const v5 = graph.insertVertex(parent, null, '5', null, null, 30, 30, 'shape=rectangle')
const v6 = graph.insertVertex(parent, null, '6', null, null, 30, 30, 'shape=ellipse')
const v7 = graph.insertVertex(parent, null, '7', null, null, 30, 30, 'shape=doubleEllipse')
const v8 = graph.insertVertex(parent, null, '8', null, null, 30, 30, 'shape=rhombus')
const v9 = graph.insertVertex(parent, null, '9', null, null, 30, 30, 'fillColor=orange')

// styleに名前をつけることもできる
const defaultNodeStyle = graph.getStylesheet().getDefaultVertexStyle()
const style = mxUtils.clone(defaultNodeStyle)
style['shape'] = 'actor'
graph.getStylesheet().putCellStyle('myFavoriteStyle', style)
const v10 = graph.insertVertex(parent, null, '10', null, null, 30, 30, 'myFavoriteStyle')

const v11 = graph.insertVertex(parent, null, '11', null, null, 30, 30, null)
const v12 = graph.insertVertex(parent, null, '12', null, null, 30, 30, null)
ノードの形状や色が変更された階層グラフ

tooltip

次は、ノードやエッジにマウスを当てた際にtooltipを表示させてみましょう。

(紹介するソースコード全体はEdit fiddle - JSFiddle - Code Playgroundで確認できます。)

まずは、tooltipを有効にしましょう。

// tooltipを有効にします
graph.setTooltips(true)

すると、ノードのラベルがtooltipで表示されました。

tooltip-default.png

ラベル以外のテキストをtooltipで表示させたい場合は、graph.getTooltipForCellメソッドをoverrideすれば良いです。
(mxGraphでは、ノードやエッジをmxCellクラスで扱います。graph.getTooltipForCellは、このmxCellを引数として表示したいtooltipを返すメソッドです。デフォルトでは先程のようにラベルがtooltipで表示されます)

cellがエッジか否かの判定は、graph.getModel().isEdge(cell)で行うことができます。

// tooltipで表示させたい内容を設定します
graph.getTooltipForCell = cell => {
if (graph.getModel().isEdge(cell)) {
const srcNodeLabel = cell.source.getValue()
const dstNodeLabel = cell.target.getValue()
return `this edge is directed from node ${srcNodeLabel} to node ${dstNodeLabel}`
} else {
const nodeLabel = cell.getValue()
return `node ${nodeLabel}`
}
}
tooltip.gif

イベント

イベント処理の例として、クリックしたノードの色をオレンジに変更してみましょう。

(紹介するソースコード全体はEdit fiddle - JSFiddle - Code Playgroundで確認できます。)

graph.addListenerメソッドでイベント発火時の処理を設定することができます。
イベント一覧はmxEventに記載されています。

// クリックしたノードをオレンジ色に変更します
graph.addListener('click', (sender, evt) => {
const cell = evt.getProperty('cell')
if (graph.getModel().isVertex(cell)) {
graph.setCellStyles('fillColor', 'orange', [cell])
}
})
event.gif

SVG形式でexport

描画したグラフをexportすることができます。
SVG形式でexportする例をEdit fiddle - JSFiddle - Code Playgroundで確認できます。

ブラウザ上で描画したグラフをファイルとして保存できるのは凄く便利ですね。
SVG形式なので、業務で扱うような大きいグラフでも潰れずに表示することができるのも良いです。
(Vis Networkの場合はcanvasで描画しているため、ファイル出力してもブラウザで見えている範囲だけの画像となり、大きなグラフを鮮明に表示することが難しかったです)

// グラフをexportするためのボタンを配置します
const button = document.createElement('button');
mxUtils.write(button, 'export')
mxEvent.addListener(button, 'click', () => {
exportGraph()
})
document.getElementById('export-button').appendChild(button)

function exportGraph() {
const svg = createSvg()
const blob = new Blob([svg], {
'type': 'svg/plain'
})
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'graph.svg'
link.click()
}

function createSvg() {
const bounds = graph.getGraphBounds()
const offset = 1

// SVGのルート要素を作る
const root = mxUtils.createXmlDocument().createElementNS(mxConstants.NS_SVG, 'svg')
root.setAttribute('xmlns', mxConstants.NS_SVG)
root.setAttribute('xmlns:xlink', mxConstants.NS_XLINK)
root.setAttribute('width', `${Math.ceil(bounds.width) + 2*offset}px`)
root.setAttribute('height', `${Math.ceil(bounds.height) + 2*offset}px`)
root.setAttribute('version', '1.1')

const svgCanvas = new mxSvgCanvas2D(root)
// グラフが端に寄らないように平行移動する
svgCanvas.translate(offset, offset)
const imgExport = new mxImageExport()
// グラフを記述
imgExport.drawState(graph.getView().getState(graph.getModel().root), svgCanvas)

return mxUtils.getPrettyXml(root)
}

Vis Network vs mxGraph

業務でVis NetworkとmxGraphを両方使ってみて大きく異なっていた点を紹介します。

階層グラフのエッジ間の交差

冒頭で書いたとおりです。mxGraphの方がエッジ間の交差が少なく、見やすいレイアウトになっています。

vis-vs-mxgraph.png

エッジがノードを貫通する場合

下の画像はVis Networkの自動レイアウトで階層グラフを描画したものです。
(ソースコードはこちら)

このグラフのオレンジ色のエッジに注目してみましょう。

一見すると、ノード1からノード3へ伸びるエッジとノード3からノード10へ伸びるエッジがあるように見えます。
しかし、ソースコードを見ると分かる通り、実際には後者のエッジはノード1からノード10へ伸びたものです。道中でノード3を貫通しているために、あたかもノード3から伸びているように見えてしまいます。
このように、Vis Networkにおいてエッジがノードを貫通する場合にはエッジの始点がどこなのか判別しにくくなるという課題があります。

エッジがノードを貫通する図

mxGraphの場合はどうでしょうか。下の画像は、上と同じ階層グラフをmxGraphの自動レイアウトで描画したものです。レイアウト計算が賢いため、そもそもエッジがノードに重ならずに描画されました。(ソースコードはこちら)

エッジがノードに重ならずに描画される図

比較のために、mxGraphで自動レイアウトを使わずに描画し、エッジがノードと重なる例を見てみます。
以下のグラフはノード1からノード2、ノード1からノード3へのエッジが出ています。ノード1からノード3へのエッジは道中でノード2の上を通るので、エッジの始点の判別がしやすいことがわかります。
(ソースコードはこちら)

ノード1からノード2、ノード1からノード3へのエッジが出ている図

描画速度

描画速度はどうでしょうか?
Vis Networkはcanvas, mxGraphはSVGで描画しているため、Vis Networkの方が速いことが予想されます。以下では簡単に性能比較を行ってみます。

  • 下記のような一本道のグラフを自動レイアウトで描画するために要する時間を色々なノード数(10個,100個,1000個,2000個,4000個の5種類)に対して計測する
一本道のグラフ
  • 計測値は、3回測定して平均を取ったもの(単位:ミリ秒)を採用する

計測結果は以下のようになりました。

ノード数 Vis NetWork mxGraph
10 33.40 43.12
100 103.52 181.98
1000 742.94 1491.80
2000 1429.83 2906.09
4000 - 6900.84

ノード数が2000以下の範囲では、Vis Networkの方が概ね2倍程度速く描画できることがわかりました。

なお、Vis Networkでノード数4000の場合はMaximum call stack size exceededエラーが出たため空欄になっています。 5

mxGraphの描画速度を改善することはできないのでしょうか?
実は、何行かコードに追加するだけで、ある程度の改善が可能です。 6

追加したコードとその周辺を以下に載せます。次の2つの改善を行っています。

  • グラフモデルの更新が完了したときに初めて描画を行うようにする
  • ignoreStringSizeを有効にする
// 高速化その1:グラフモデル更新中は描画をOFFにする
graph.getView().setRendering(false)

// 高速化その2:ignoreStringSizeを有効にする
mxText.prototype.ignoreStringSize = true

// グラフの形状やデザインの変更を行う(グラフモデルを変更する)際は
// beginUpdate -> グラフモデル変更 -> endUpdate の順に行う
graph.getModel().beginUpdate()

try {
// tryブロックの中でグラフの形状やデザインの変更を行う
// (中略)
} finally {
graph.getModel().endUpdate()
}

// 描画をオンに戻す
graph.getView().setRendering(true)
graph.refresh()

高速化を施したmxGraphも含めて、計測結果を再掲します。Vis Networkの1.5倍程度まで改善することができました。

ノード数 Vis NetWork mxGraph(高速化なし) mxGraph(高速化あり)
10 33.40 43.12 31.78
100 103.52 181.98 144.06
1000 742.94 1491.80 1128.03
2000 1429.83 2906.09 2122.71
4000 - 6900.84 3477.48

まとめ

mxGraphの自動レイアウトを用いて階層グラフの可視化を行い、複雑な階層グラフが綺麗に描画できることを紹介しました。

また、Vis NetworkとmxGraphの両方を業務で扱ってみて得た知見についても紹介しました。

コアテクノロジーユニットでは、現在チームメンバーを募集しています。
私たちと一緒にテクノロジーで設計、開発、テストの高品質・高生産性を実現する仕組みづくりをしませんか?

興味がある方はお気軽に技術ブログTwitterや会社採用HPへ、連絡をお待ちしております。

https://www.future.co.jp/recruit/


  1. 1.フューチャー発のOSSであるCheetah Gridも高速に描画するためにcanvasを使用しています。興味がある方はVue.jsで最速に始めるCheetah GridCheetahGrid+Vue.jsをエンプラで使ってみたを御覧ください
  2. 2.階層グラフの可視化Layered graph drawing - Wikipediaなどに詳しい説明があります。
  3. 3.アーカイブされ、issueが閲覧できなくなってしまいました…
  4. 5.実装の詳細は確認できていませんが、自動レイアウト計算の実装で再帰関数を使っており、再帰の深さが一定値を超えたためエラーが出たと推測されます。
  5. 6.この記事で紹介する改善策は以前に本家のissueで見かけて知ったのですが、現在は閲覧できなくなってしまいました...