Edited at

dot言語を使わずにGraphvizでグラフを描く便利なライブラリ

Graphvizはオープンソースのグラフ描画ソフトです。dot言語というグラフ構造を記述する言語によって書かれたグラフを綺麗に描画してくれます。Graphvizの良いところの一つはdot言語でグラフ構造さえ書けばノードやエッジをどのように配置するかは勝手に決めてくれるところでしょう。この機能があるおかげでグラフの管理や自動生成などが簡単に実現できます。

しかしグラフを描きたいと思っただけなのに新しい言語を覚えるのは大変です。加えてdot言語には変数や関数など抽象化をサポートする機能が乏しく複雑なグラフを描こうとするとコピペが大量に発生して記述が冗長になることもあります。そこでgraphvizというライブラリを使えばHaskellのEDSLとしてグラフ構造を記述することが可能になり、直接Graphvizを通してグラフ画像を生成することができます。必要ならdot言語を生成することも可能です。ちなみにどちらも同じ名前で紛らわしいので以降は小文字でgraphvizと書けばHaskellのライブラリの方を表すこととします。

例えばこんなグラフは

以下のように記述して実行すれば画像ファイルとして生成できます。

hello :: DotGraph Text

hello = digraph' $ do
"Hello" --> "Graphviz"
"Graphviz" --> "World"

main = runGraphviz hello Png "hello.png"

HaskellのEDSLとしてグラフ構造を記述できることのメリットは以下のようなものでしょう。


  • dot言語の文法を知らなくても書ける

  • Haddockを読めば出来ることがだいたい分かる1

  • 型が守ってくれる(dotだと文字列と数値を間違えても無視されたり…)

  • EDSLなのでHaskellの言語機能(変数・関数など)が利用できる

さらにgraphvizは記述したグラフ構造をfglのデータ型に変換できるので作ったグラフの構造を解析することも可能です。

以下では


  1. グラフ構造の書き方

  2. グラフ構造の出力方法

の順にgraphvizの使い方を見ていくことにしましょう。

また記事中のサンプルコードを実行するのに必要な言語拡張とimport文を以下に書いておきます。

{-# LANGUAGE OverloadedStrings #-}

{-# LANGUAGE LambdaCase #-}

import Data.GraphViz.Attributes
import Data.GraphViz.Commands
import Data.GraphViz.Types.Generalised
import Data.GraphViz.Types.Monadic

import Data.Text.Lazy (Text)
import qualified Data.Text.Lazy as Text


グラフ構造の書き方

graphvizにはdot言語のグラフ構造を表現する4つの方法があります2



  • Data.GraphViz.Types.Canonical - 他のグラフ構造からdotのグラフ構造に変換するのに適している


  • Data.GraphViz.Types.Generalised - 既存のdot言語のコードをパースするための表現として適している


  • Data.GraphViz.Types.Graph - 既存のdot言語のグラフ構造を操作する(ノードを足すとか)のに適している


  • Data.GraphViz.Types.Monadic - 一番簡単な表現でありdot言語のグラフ構造を手で書くのに適している

この記事では Data.GraphViz.Types.Monadic に焦点を当てて解説していきます。ドキュメントにも「目を細めて見ればdot言語のようにも見えてくる」と書いてあるぐらいで、既にdot言語を知っている人にも分かりやすい表現でしょう。

グラフ構造を書くことのゴールはDotGraph nという型の値を作ることです。この型を返り値の型として持つ関数として

digraph' :: DotM n a -> DotGraph n

graph' :: DotM n a -> DotGraph n

の2つがあります3digraph'はDirected Graphつまり有向グラフを作成する関数で、graph'は無向グラフを作成する関数です。

グラフ構造を書く作業の大半は、上記いずれの関数も引数としているDotM n aという型の値を作ることになります。DotM n aの値を作る関数を列挙してみましょう。

type Dot n = DotM n ()

-- デフォルトの属性を設定する系
graphAttrs :: Attributes -> Dot n
nodeAttrs :: Attributes -> Dot n
edgeAttrs :: Attributes -> Dot n

-- サブグループを宣言する系
subgraph :: GraphID -> DotM n a -> Dot n
cluster :: GraphID -> DotM n a -> Dot n

-- ノードを宣言する系
node :: n -> Attributes -> Dot n
node' :: n -> Dot n

-- エッジを宣言する系
edge :: n -> n -> Attributes -> Dot n
(-->) :: n -> n -> Dot n

さて、ここまでくれば冒頭に掲載したOpticsの関係を表すグラフ4を描画することが可能です。

lens :: DotGraph Text

lens = digraph' $ do
let nFold = "Fold s a"
nSetter = "Setter s t a b"
nGetter = "Getter s a"
nTraversal = "Traversal s t a b"
nLens = "Lens s t a b"
nReview = "Review s a"
nPrism = "Prism s t a b"
nIso = "Iso s t a b"
nEquality = "Equality s t a b"

nGetter --> nFold
nTraversal -|> nFold
nTraversal --> nSetter
nLens -|> nGetter
nLens --> nTraversal
nPrism --> nTraversal
nPrism -|> nReview
nIso --> nLens
nIso --> nPrism
nEquality --> nIso
where
x -|> y = edge x y [textLabel "s = t, a = b"]

main = runGraphviz lens Png "lens.png"

実行すると、

このような画像ファイルが生成されます。

コードを眺めると


  • ノードIDを変数に入れて使い回している

  • 同じテキストラベルの矢印を関数にして再利用している

のがわかると思います。まだ単純なグラフなので恩恵は少ないかもしれませんが、こうした再利用性の高いコードを書けるのがHaskellのEDSLとして書くことのメリットでしょう。


Attributes

さて、グラフ構造を書く上でもう一つ大事な要素であるAttributesについて解説していきます。

type Attributes = [Attribute]

AttributesAttributeのリストのエイリアスになっています。Attributeはグラフの見た目を制御する値の型で定義を見ると、



非常にたくさんの値が利用できることがわかります(まだまだあります)。さすがGraphvizのほとんど全ての属性とスタイルをカバーしていると銘打っているだけはありますね。この記事ではいくつかのAttributeに絞って解説していきたいと思います。

-- ラベル

textLabel :: Text -> Attribute

-- 色
color :: NamedColor nc => nc -> Attribute

-- ノードの形
shape :: Shape -> Attribute

-- エッジの属性
style :: Style -> Attribute
edgeEnds :: DirType -> Attribute
arrowTo :: Arrow -> Attribute
arrowFrom :: Arrow -> Attribute

以下のような単純なグラフがAttributesを設定することでどう変わっていくかを見てみましょう。

attrTest :: DotGraph Text

attrTest = digraph' $ do
"a" --> "b"
"a" --> "c"
"b" --> "c"


Label

まずtextLabelは標準的なラベルを設定するAttributeです。

attrTest :: DotGraph Text

attrTest = digraph' $ do
node "a" [textLabel "Label for a"]
"a" --> "b"
"a" --> "c"
edge "b" "c" [textLabel "Label for b to c"]

ノードに設定したラベルはノードに表示される文字列を変え、エッジに設定したラベルはエッジの説明を設定しているのが分かると思います。


Color

colorは文字通り色を変更するAttributeです。

attrTest :: DotGraph Text

attrTest = digraph' $ do
node "a" [color Red]
"a" --> "b"
"a" --> "c"
"b" --> "c"

設定するとノードの色が変わりました。color以外にも

bgColor :: NamedColor nc => nc -> Attribute

fillColor :: NamedColor nc => nc -> Attribute
fontColor :: NamedColor nc => nc -> Attribute
penColor :: NamedColor nc => nc -> Attribute

のように細かい部分の色を設定する関数が用意されています。詳しい説明はHaddockを読むと分かりやすいでしょう。


Shape

shapeはノードの形を変えるAttributeです。

attrTest :: DotGraph Text

attrTest = digraph' $ do
node "a" [shape BoxShape]
node "b" [shape Triangle]
node "c" [shape Star]
"a" --> "b"
"a" --> "c"
"b" --> "c"

どのような形が使えるかはGraphvizのドキュメントにまとまっています。対応する値はHaddockを参照してみてください。


Edge Attributes

最後にエッジに関連するAttributesを見ていきましょう。

attrTest :: DotGraph Text

attrTest = digraph' $ do
"a" --> "b"
edge "a" "c" [style dotted]
edge "b" "c" [arrowFrom tee, arrowTo diamond, edgeEnds Both]

点線などのエッジの見た目や矢印の形を変えることができました。Attributeの設定に使用できるStyle, DirType, ArrowHaddockから確認することができます。


Record

レコードはノードをセルに分割することが出来る機能で、ラベルを|で区切ると横に、{|}を使って区切ると縦に分割することができます。

typeClasses :: DotGraph Text

typeClasses = digraph' $ do
nodeAttrs [shape Record]

node "Functor" [textLabel "{Functor|map :: (a -> b) -> f a -> f b}"]
node "Applicative" [textLabel "{Applicative|pure :: a -> f a\n(<*>) :: f (a -> b) -> f a -> f b}"]
node "Monad" [textLabel "{Monad|join :: m (m a) -> m a}"]
node "Foldable" [textLabel "{Foldable|foldMap :: Monoid m => (a -> m) -> t a -> m}"]
node "Traversable" [textLabel "{Traversable|sequenceA :: Applicative f => t (f a) -> f (t a)}"]

"Functor" --> "Applicative"
"Applicative" --> "Monad"
"Functor" --> "Traversable"
"Foldable" --> "Traversable"

レコードは便利ですが特殊な記法をラベルの文字列の中に押し付けてしまっているので視認性が悪いのが欠点です。今回の場合 他にもIDを2回書く必要があったり文字実体参照が読みにくかったり不満なところがいくつかあるので改善してみましょう。

typeClasses :: DotGraph Text

typeClasses = digraph' $ do
nodeAttrs [shape Record]

record "Functor" "map :: (a -> b) -> f a -> f b"
record "Applicative" "pure :: a -> f a\n(<*>) :: f (a -> b) -> f a -> f b"
record "Monad" "join :: m (m a) -> m a"
record "Foldable" "foldMap :: Monoid m => (a -> m) -> t a -> m"
record "Traversable" "sequenceA :: Applicative f => t (f a) -> f (t a)"

"Functor" --> "Applicative"
"Applicative" --> "Monad"
"Functor" --> "Traversable"
"Foldable" --> "Traversable"
where
record :: Text -> Text -> Dot Text
record label desc = node label $
[textLabel $ Text.concat ["{", label, "|", escapeText desc, "}"]]
escapeText :: Text -> Text
escapeText = Text.concatMap $ \case
'"' -> "&quot;"
'&' -> "&amp;"
'\'' -> "&apos;"
'<' -> "&lt;"
'>' -> "&gt;"
els -> Text.singleton els

レコードを組み立てる部分を関数に切り出すことで視認性の高い記述ができるようになりました。このように関数というHaskellの言語機能を利用できるようになるのがEDSLで書くことの利点の一つと言えるでしょう。


グラフ構造の出力方法

これまで記述したグラフ構造は runGraphviz という関数を使って画像ファイルに出力していました。この関数はData.GraphViz.Commandsというモジュールで提供されており

runGraphviz :: PrintDotRepr dg n => dg n -> GraphvizOutput -> FilePath -> IO FilePath

のような型をしています。この GraphvizOutput を変更すればBmpJpegなど出力形式を変えることができ、DotOutputを指定すればdot言語を吐き出すこともできます。しかしDotOuputで出力されるdot言語のファイルはレイアウト情報なども全て含んでおり見やすいものではありません。dot言語のファイルを出力したいときはData.GraphViz.Commands.IOで提供されている、

writeDotFile :: PrintDotRepr dg n => FilePath -> dg n -> IO ()

という関数を利用するのが良いでしょう。EDSLで組み立てたdot言語のファイルを直接出力することができます。

またData.GraphVizで提供されている

graphToDot :: (Ord cl, Graph gr) => GraphvizParams Node nl el cl l -> gr nl el -> DotGraph Node

dotToGraph :: (DotRepr dg Node, Graph gr) => dg Node -> gr Attributes Attributes

という関数を使えばfglというライブラリで提供されているグラフのデータ構造と相互に変換することが可能です。


まとめ

graphvizで提供されているHaskellのEDSLを使うことでdot言語を書かずにHaskellの言語機能を利用しながらグラフを生成することができました。今回紹介した Data.GraphViz.Types.Monadic だけでなく Data.GraphViz.Types.Graph には動的にグラフを生成するメソッドが用意されており、他のデータを参照してグラフを自動生成すようなことも非常に簡単にできると思います。みなさんもぜひgraphvizを使って快適なグラフ描画ライフを体験してみてください!

※この記事は 第20回Haskell-jpもくもく会 のもくもく時間を利用して書かれました。





  1. Haskellでは大体どのライブラリにもHaddockという自動生成されたドキュメントが付属していて、そこに書かれている型や説明を見ることで使い方を把握することができます。例えば明示的なインポートをせずに使えるPreludeのHaddockはこちらです http://hackage.haskell.org/package/base/docs/Prelude.html 



  2. https://hackage.haskell.org/package/graphviz-2999.20.0.3/docs/Data-GraphViz-Types.html 



  3. この記事ではGraphIDを使った例は紹介しません。実際はdigraph, graphという'を伴わない関数もあり、こちらの関数を使えばGraphIDを明示的に与えることが可能です。 



  4. http://hackage.haskell.org/package/lens