Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

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 

lotz
実用関数型プログラミング言語 Haskell の情報を発信しています
http://lotz84.github.io/
folio-sec
誰もがかんたんに資産運用することができるサービス「フォリオ」を作っているFinTech系スタートアップ
https://corp.folio-sec.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away