├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── .travis.yml ├── LICENSE.md ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── examples ├── basic.dot ├── basic.ex ├── basic.png ├── html_records.dot ├── html_records.ex ├── html_records.png ├── shapes.dot ├── shapes.ex ├── shapes.png ├── structs.dot ├── structs.ex └── structs.png ├── lib ├── graphvix.ex └── graphvix │ ├── dot_helpers.ex │ ├── graph.ex │ ├── html_record.ex │ ├── record.ex │ ├── record │ └── port.ex │ ├── record_subset.ex │ └── subgraph.ex ├── mix.exs ├── mix.lock └── test ├── graphvix ├── dot_helpers_test.exs ├── graph_test.exs ├── html_record_test.exs ├── record_test.exs └── subgraph_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-20.04 14 | env: 15 | MIX_ENV: test 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | name: "[${{matrix.otp}}/${{matrix.elixir}}] CI Tests" 18 | strategy: 19 | matrix: 20 | otp: [22, 23, 24, 25] 21 | elixir: ["1.13.4", "1.14.0"] 22 | exclude: 23 | - otp: 22 24 | elixir: "1.14.0" 25 | steps: 26 | - uses: actions/checkout@v2.4.0 27 | 28 | - uses: erlef/setup-beam@v1 29 | with: 30 | otp-version: ${{matrix.otp}} 31 | elixir-version: ${{matrix.elixir}} 32 | 33 | - name: mix-cache 34 | uses: actions/cache@v2 35 | id: mix-cache 36 | with: 37 | path: | 38 | deps 39 | _build 40 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} 41 | 42 | - name: mix local 43 | run: | 44 | mix local.rebar --force 45 | mix local.hex --force 46 | 47 | - name: mix compile 48 | run: | 49 | mix deps.get 50 | mix deps.compile 51 | mix compile 52 | if: steps.mix-cache.outputs.cache-hit != 'true' 53 | 54 | - name: mix checks 55 | run: | 56 | mix deps.unlock --check-unused 57 | mix format --check-formatted 58 | 59 | - name: mix test 60 | run: mix test 61 | 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | graphvix.store 20 | *.dot 21 | *.pdf 22 | *.png 23 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.14.0 2 | erlang 25.0.4 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.5 4 | - 1.6 5 | - 1.7 6 | otp_release: 7 | - 19.0 8 | - 20.3 9 | matrix: 10 | include: 11 | - elixir: 1.8 12 | otp_release: 20.3 13 | - elixir: 1.8 14 | otp_release: 21.0 15 | script: mix test 16 | env: 17 | - MIX_ENV=test 18 | before_install: 19 | - sudo apt-get install graphviz 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | **Copyright (c) 2016 Michael Berkowitz** 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graphvix 2 | 3 | [![Build Status](https://travis-ci.org/mikowitz/graphvix.svg?branch=master)](https://travis-ci.org/mikowitz/graphvix) 4 | 5 | Graphviz in Elixir 6 | 7 | ## Installation 8 | 9 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 10 | 11 | 1. Add `graphvix` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [{:graphvix, "~> 1.0.0"}] 16 | end 17 | ``` 18 | 19 | # Usage 20 | 21 | See [the wiki](https://github.com/mikowitz/graphvix/wiki/Examples) for examples. 22 | 23 | ## API Overview 24 | 25 | * Create a new graph 26 | 27 | `Graphvix.Graph.new/0` 28 | 29 | * Add a vertex to a graph 30 | 31 | `Graphvix.Graph.add_vertex/2` 32 | `Graphvix.Graph.add_vertex/3` 33 | 34 | * Add an edge between two vertices 35 | 36 | `Graphvix.Graph.add_edge/3` 37 | `Graphvix.Graph.add_edge/4` 38 | 39 | * Create a vertex with type `record` 40 | 41 | `Graphvix.Record.new/1` 42 | `Graphvix.Record.new/2` 43 | 44 | * Add a record vertex to a graph 45 | 46 | `Graphvix.Graph.add_record/2` 47 | 48 | * Create a vertex using HTML table markup 49 | 50 | `Graphvix.HTMLRecord.new/1` 51 | `Graphvix.HTMLRecord.new/2` 52 | 53 | * Add an HTML table vertex to a graph 54 | 55 | `Graphvix.Graph.add_html_record/2` 56 | 57 | * Save a graph to disk in `.dot` format 58 | 59 | `Graphvix.Graph.write/2` 60 | 61 | * Save and compile a graph (defaults to `.png`) 62 | 63 | `Graphvix.Graph.compile/2` 64 | `Graphvix.Graph.compile/3` 65 | 66 | * Save, compile and open a graph (defaults to `.png` and your OS's default image viewer) 67 | 68 | `Graphvix.Graph.graph/2` 69 | `Graphvix.Graph.graph/3` 70 | 71 | ## Basic Usage 72 | 73 | 1. Alias the necessary module for ease of use 74 | 75 | ```elixir 76 | alias Graphvix.Graph 77 | ``` 78 | 79 | 1. Create a new graph. 80 | 81 | ```elixir 82 | graph = Graph.new() 83 | ``` 84 | 85 | 1. Add a simple vertex with a label 86 | 87 | ```elixir 88 | {graph, vertex_id} = Graph.add_vertex(graph, "vertex label") 89 | ``` 90 | 91 | 1. Add a vertex with a label and attributes 92 | 93 | ```elixir 94 | {graph, vertex2_id} = Graph.add_vertex( 95 | graph, 96 | "my other vertex", 97 | color: "blue", shape: "diamond" 98 | ) 99 | ``` 100 | 101 | 1. Add an edge between two existing vertices 102 | 103 | ```elixir 104 | {graph, edge_id} = Graph.add_edge( 105 | graph, 106 | vertex_id, vertex2_id, 107 | label: "Edge", color: "green" 108 | ) 109 | ``` 110 | 111 | 1. Add a cluster containing one or more nodes 112 | 113 | ```elixir 114 | {graph, cluster_id} = Graph.add_cluster(graph, [vertex_id, vertex2_id]) 115 | ``` 116 | 117 | ## Records 118 | 119 | 1. Alias the necessary module for ease of use 120 | 121 | ```elixir 122 | alias Graphvix.Record 123 | ``` 124 | 125 | 1. Create a simple record that contains only a row of cells 126 | 127 | ```elixir 128 | record = Record.new(Record.row(["a", "b", "c"])) 129 | ``` 130 | 131 | * A record with a top-level row can also be created by just passing a list 132 | 133 | ```elixir 134 | record = Record.new(["a", "b", "c"]) 135 | ``` 136 | 1. Create a record with a single column of cells 137 | 138 | ```elixir 139 | record = Record.new(Record.column(["a", "b", "c"])) 140 | ``` 141 | 142 | 1. Create a record with nested rows and columns 143 | 144 | ```elixir 145 | import Graphvix.Record, only: [column: 1, row: 1] 146 | 147 | record = Record.new(row([ 148 | "a", 149 | column([ 150 | "b", 151 | row(["c", "d", "e"]), 152 | "f" 153 | ]), 154 | "g" 155 | ]) 156 | ``` 157 | 158 | ### Ports 159 | 160 | 1. Ports can be attached to record cells by passing a tuple of `{port_name, label}` 161 | 162 | ```elixir 163 | import Graphvix.Record, only: [column: 1, row: 1] 164 | 165 | record = Record.new(row([ 166 | {"port_a", "a"}, 167 | column([ 168 | "b", 169 | row(["c", {"port_d", "d"}, "e"]), 170 | "f" 171 | ]), 172 | "g" 173 | ]) 174 | ``` 175 | 176 | 1. Edges can be drawn from specific ports on a record 177 | 178 | ```elixir 179 | {graph, record_id} = Graph.add_record(graph, record) 180 | 181 | {graph, _edge_id} = Graph.add_edge({record_id, "port_a"}, vertex_id) 182 | 183 | ``` 184 | 185 | ## HTML Table Records 186 | 187 | 1. Alias the necessary modules for ease of use 188 | 189 | ```elixir 190 | alias Graphvix.HTMLRecord 191 | ``` 192 | 193 | 1. Create a simple table 194 | 195 | ```elixir 196 | record = HTMLRecord.new([ 197 | tr([ 198 | td("a"), 199 | td("b"), 200 | td("c") 201 | ]), 202 | tr([ 203 | td("d", port: "port_d"), 204 | td("e"), 205 | td("f") 206 | ]) 207 | ]) 208 | ``` 209 | 210 | 1. Or a more complex table 211 | 212 | ```elixir 213 | record = HTMLRecord.new([ 214 | tr([ 215 | td("a", rowspan: 3), 216 | td("b", colspan: 2), 217 | td("f", rowspan: 3) 218 | ]), 219 | tr([ 220 | td("c"), 221 | td("d") 222 | ]) 223 | tr([ 224 | td("e", colspan: 2) 225 | ]) 226 | ]) 227 | ``` 228 | 229 | Cells can also use the `font/2` and `br/0` helper methods to add font styling and forced line breaks. See 230 | the documentation for `Graphvix.HTMLRecord` for examples. 231 | 232 | ## Output 233 | 234 | 1. Convert the graph to DOT format 235 | 236 | ```elixir 237 | Graph.to_dot(graph) 238 | """ 239 | digraph G { 240 | cluster c0 { 241 | v0 [label="vertex label"] 242 | v1 [label="my other vertex",color="blue",shape="diamond"] 243 | 244 | v0 -> v1 [label="Edge",color="green"] 245 | } 246 | } 247 | """ 248 | ``` 249 | 1. Save the graph to a .dot file, with an optional filename 250 | 251 | ```elixir 252 | Graph.write(graph, "first_graph") #=> creates "first_graph.dot" 253 | ``` 254 | 255 | 1. Compile the graph to a .png or .pdf using the `dot` command 256 | 257 | ```elixir 258 | ## creates first_graph.dot and first_graph.png 259 | Graph.compile(graph, "first_graph") 260 | 261 | ## creates first_graph.dot and first_graph.pdf 262 | Graph.compile(graph, "first_graph", :pdf) 263 | ``` 264 | 265 | 1. Compile the graph using the `dot` command and open the resulting file 266 | 267 | ```elixir 268 | ## creates first_graph.dot and first_graph.pdf; opens first_graph.png 269 | Graph.graph(graph, "first_graph") 270 | 271 | ## creates first_graph.dot and first_graph.pdf; opens first_graph.pdf 272 | Graph.graph(graph, "first_graph", :pdf) 273 | ``` 274 | 275 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :graphvix, :compiler, "dot" 4 | 5 | import_config "#{Mix.env()}.exs" 6 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :mix_test_watch, 4 | clear: true, 5 | tasks: [ 6 | "test", 7 | "credo --all --strict", 8 | "docs" 9 | ] 10 | 11 | config :graphvix, :compiler, "echo" 12 | -------------------------------------------------------------------------------- /examples/basic.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | size="4,4" 4 | 5 | v0 [label="main",shape="box"] 6 | v1 [label="parse"] 7 | v2 [label="execute"] 8 | v3 [label="init"] 9 | v4 [label="cleanup"] 10 | v5 [label="make a 11 | string"] 12 | v6 [label="printf"] 13 | v7 [label="compare",shape="box",style="filled",color=".7 .3 1.0"] 14 | 15 | v0 -> v1 [weight="8"] 16 | v1 -> v2 17 | v2 -> v7 [color="red"] 18 | v0 -> v3 [style="dotted"] 19 | v0 -> v6 [style="bold",label="100 times"] 20 | v0 -> v4 21 | v2 -> v5 22 | v2 -> v6 23 | v3 -> v5 24 | 25 | } -------------------------------------------------------------------------------- /examples/basic.ex: -------------------------------------------------------------------------------- 1 | alias Graphvix.Graph 2 | 3 | graph = Graph.new 4 | 5 | graph = Graph.set_graph_property(graph, :size, "4,4") 6 | 7 | {graph, main} = Graph.add_vertex(graph, "main", shape: "box") 8 | {graph, parse} = Graph.add_vertex(graph, "parse") 9 | {graph, execute} = Graph.add_vertex(graph, "execute") 10 | {graph, init} = Graph.add_vertex(graph, "init") 11 | {graph, cleanup} = Graph.add_vertex(graph, "cleanup") 12 | {graph, make_string} = Graph.add_vertex(graph, "make a\nstring") 13 | {graph, printf} = Graph.add_vertex(graph, "printf") 14 | {graph, compare} = Graph.add_vertex(graph, "compare", shape: "box", style: "filled", color: ".7 .3 1.0") 15 | 16 | {graph, _} = Graph.add_edge(graph, main, parse, weight: 8) 17 | {graph, _} = Graph.add_edge(graph, parse, execute) 18 | {graph, _} = Graph.add_edge(graph, execute, compare, color: "red") 19 | {graph, _} = Graph.add_edge(graph, main, init, style: "dotted") 20 | {graph, _} = Graph.add_edge(graph, main, printf, style: "bold", label: "100 times") 21 | {graph, _} = Graph.add_edge(graph, main, cleanup) 22 | {graph, _} = Graph.add_edge(graph, execute, make_string) 23 | {graph, _} = Graph.add_edge(graph, execute, printf) 24 | {graph, _} = Graph.add_edge(graph, init, make_string) 25 | 26 | Graph.write(graph, "examples/basic.dot") 27 | -------------------------------------------------------------------------------- /examples/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikowitz/graphvix/f44aa9d5d5d2d8a19af657e5b3be55304c382c88/examples/basic.png -------------------------------------------------------------------------------- /examples/html_records.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | v0 [label=< 4 | 5 | 6 | 7 | 8 | 9 |
leftmid dleright
>,shape="plaintext"] 10 | v1 [label=< 11 | 12 | 13 | 14 | 15 |
onetwo
>,shape="plaintext"] 16 | v2 [label=< 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
hello
world
bgh
cde
f
>,shape="plaintext"] 32 | 33 | v0:f1 -> v1:f0 34 | v0:f2 -> v2:here 35 | 36 | } -------------------------------------------------------------------------------- /examples/html_records.ex: -------------------------------------------------------------------------------- 1 | alias Graphvix.{Graph, HTMLRecord} 2 | import Graph.HTMLRecord, only: [tr: 1, td: 1, td: 2] 3 | 4 | graph = Graph.new() 5 | 6 | top_record = HTMLRecord.new([ 7 | tr([ 8 | td("left", port: "f0"), 9 | td("mid dle", port: "f1"), 10 | td("right", port: "f2"), 11 | ]) 12 | ], border: 0, cellborder: 1, cellspacing: 0) 13 | 14 | one_two_record = HTMLRecord.new([ 15 | tr([ 16 | td("one", port: "f0"), 17 | td("two", port: "f1") 18 | ]) 19 | ], border: 0, cellborder: 1, cellspacing: 0) 20 | 21 | hello_record = HTMLRecord.new([ 22 | tr([ 23 | td("hello
world", rowspan: 3), 24 | td("b", colspan: 3), 25 | td("g", rowspan: 3), 26 | td("h", rowspan: 3) 27 | ]), 28 | tr([ 29 | td("c"), 30 | td("d", port: "here"), 31 | td("e") 32 | ]), 33 | tr([ 34 | td("f", colspan: 3) 35 | ]) 36 | ], border: 0, cellborder: 1, cellspacing: 0) 37 | 38 | {graph, top} = Graph.add_html_record(graph, top_record) 39 | {graph, one_two} = Graph.add_html_record(graph, one_two_record) 40 | {graph, hello} = Graph.add_html_record(graph, hello_record) 41 | 42 | {graph, _} = Graph.add_edge(graph, {top, "f1"}, {one_two, "f0"}) 43 | {graph, _} = Graph.add_edge(graph, {top, "f2"}, {hello, "here"}) 44 | 45 | Graph.write(graph, "examples/html_records.dot") 46 | -------------------------------------------------------------------------------- /examples/html_records.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikowitz/graphvix/f44aa9d5d5d2d8a19af657e5b3be55304c382c88/examples/html_records.png -------------------------------------------------------------------------------- /examples/shapes.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | size="4,4" 4 | 5 | v0 [label="a",shape="polygon",sides="5",peripheries="3",color="lightblue",style="filled"] 6 | v1 [label="b"] 7 | v2 [label="hello world",shape="polygon",sides="4",skew=".4"] 8 | v3 [label="d",shape="invtriangle"] 9 | v4 [label="e",shape="polygon",sides="4",distortion=".7"] 10 | 11 | v0 -> v1 12 | v1 -> v2 13 | v1 -> v3 14 | 15 | } -------------------------------------------------------------------------------- /examples/shapes.ex: -------------------------------------------------------------------------------- 1 | alias Graphvix.Graph 2 | 3 | graph = Graph.new() 4 | 5 | graph = Graph.set_graph_property(graph, :size, "4,4") 6 | 7 | {graph, a} = Graph.add_vertex(graph, "a", shape: "polygon", sides: 5, peripheries: 3, color: "lightblue", style: "filled") 8 | {graph, b} = Graph.add_vertex(graph, "b") 9 | {graph, c} = Graph.add_vertex(graph, "hello world", shape: "polygon", sides: 4, skew: ".4") 10 | {graph, d} = Graph.add_vertex(graph, "d", shape: "invtriangle") 11 | {graph, e} = Graph.add_vertex(graph, "e", shape: "polygon", sides: 4, distortion: ".7") 12 | 13 | {graph, _} = Graph.add_edge(graph, a, b) 14 | {graph, _} = Graph.add_edge(graph, b, c) 15 | {graph, _} = Graph.add_edge(graph, b, d) 16 | 17 | Graph.write(graph, "examples/shapes.dot") 18 | -------------------------------------------------------------------------------- /examples/shapes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikowitz/graphvix/f44aa9d5d5d2d8a19af657e5b3be55304c382c88/examples/shapes.png -------------------------------------------------------------------------------- /examples/structs.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | v0 [label=" left | mid\ dle | right",shape="record"] 4 | v1 [label=" one | two",shape="record"] 5 | v2 [label="hello\nworld | { b | { c | d | e } | f } | g | h",shape="record"] 6 | 7 | v0 -> v1 8 | v0 -> v2 9 | 10 | } -------------------------------------------------------------------------------- /examples/structs.ex: -------------------------------------------------------------------------------- 1 | alias Graphvix.{Graph, Record, RecordSubset} 2 | 3 | graph = Graph.new() 4 | 5 | top_record = Record.new([{"f0", "left"}, {"f1", "mid\\ dle"}, {"f2", "right"}]) 6 | one_two_record = Record.new([{"f0", "one"}, {"f1", "two"}]) 7 | hello_record = Record.new([ 8 | "hello\\nworld", 9 | Record.column([ 10 | "b", 11 | Record.row(["c", {"here", "d"}, "e"]), 12 | "f" 13 | ]), 14 | "g", 15 | "h" 16 | ]) 17 | 18 | {graph, top} = Graph.add_record(graph, top_record) 19 | {graph, one_two} = Graph.add_record(graph, one_two_record) 20 | {graph, hello} = Graph.add_record(graph, hello_record) 21 | 22 | {graph, _} = Graph.add_edge(graph, top, one_two) 23 | {graph, _} = Graph.add_edge(graph, top, hello) 24 | 25 | Graph.write(graph, "examples/structs.dot") 26 | -------------------------------------------------------------------------------- /examples/structs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikowitz/graphvix/f44aa9d5d5d2d8a19af657e5b3be55304c382c88/examples/structs.png -------------------------------------------------------------------------------- /lib/graphvix.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix do 2 | @moduledoc """ 3 | `Graphvix` provides an Elixir interface to [Graphviz](http://www.graphviz.org/) 4 | notation. 5 | 6 | With `Graphvix` you can iteratively construct directed graphs, save them to 7 | disk in `.dot` format, and print them. 8 | 9 | ### Create a new graph 10 | 11 | iex> graph = Graph.new(edge: [style: "dotted"]) 12 | 13 | ### Add vertices 14 | 15 | iex> {graph, v1} = Graph.add_vertex(graph, "first vertex", color: "blue") 16 | iex> {graph, v2} = Graph.add_vertex(graph, "second vertex", color: "red") 17 | iex> {graph, v3} = Graph.add_vertex(graph, "hello", shape: "square") 18 | 19 | ### Add vertices with nested record structure 20 | 21 | iex> record = Graphvix.Record.new(row(["a", {"port_b", "b"}, col(["c", "d", "e"])])) 22 | iex> {graph, v4} = Graph.add_record(graph, record) 23 | 24 | ### Add vertices with HTML-style tables 25 | 26 | iex> html_record = Graphvix.HTMLRecord.new([tr([td("a", port: "port_a", colspan: 2)]), tr([td("b"), td(["c", br(), "d"])])]) 27 | iex> {graph, v5} = Graph.add_record(graph, html_record) 28 | 29 | ### Group vertices into subgraphs and clusters 30 | 31 | iex> {graph, cluster1} = Graph.add_cluster(graph, [v1, v3, v5], style: "filled") 32 | 33 | ### Connect vertices and their ports with edges 34 | 35 | iex> {graph, e1} = Graph.add_edge(graph, v1, v2) 36 | iex> {graph, e2} = Graph.add_edge(graph, v1, {v4, "port_b"}) 37 | iex> {graph, e3} = Graph.add_edge(graph, {v5, "port_a"}, v3, color: "red") 38 | 39 | 40 | ### Write the graph to a `.dot` file on your local filesystem 41 | 42 | iex> Graph.write(graph, "my_graph") #=> Creates `my_graph.dot` 43 | 44 | See the `Graphvix.Graph` documentation for more detailed usage examples. 45 | """ 46 | end 47 | -------------------------------------------------------------------------------- /lib/graphvix/dot_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.DotHelpers do 2 | @moduledoc """ 3 | This module contains a set of helper methods for converting Elixir graph data 4 | into its DOT representation. 5 | """ 6 | 7 | @doc """ 8 | Convert top-level node and edge properties for a graph or subgraph into 9 | correct DOT notation. 10 | 11 | ## Example 12 | 13 | iex> graph = Graph.new(edge: [color: "green", style: "dotted"], node: [color: "blue"]) 14 | iex> DotHelpers.global_properties_to_dot(graph) 15 | ~S( node [color="blue"] 16 | edge [color="green",style="dotted"]) 17 | 18 | """ 19 | def global_properties_to_dot(graph) do 20 | [:node, :edge] 21 | |> Enum.map(&_global_properties_to_dot(graph, &1)) 22 | |> compact() 23 | |> case do 24 | [] -> nil 25 | global_props -> Enum.join(global_props, "\n") 26 | end 27 | end 28 | 29 | @doc """ 30 | Converts a list of attributes into a properly formatted list of DOT attributes. 31 | 32 | ## Examples 33 | 34 | iex> DotHelpers.attributes_to_dot(color: "blue", shape: "circle") 35 | ~S([color="blue",shape="circle"]) 36 | 37 | """ 38 | def attributes_to_dot([]), do: nil 39 | 40 | def attributes_to_dot(attributes) do 41 | [ 42 | "[", 43 | attributes 44 | |> Enum.map_join(",", fn {key, value} -> 45 | attribute_to_dot(key, value) 46 | end), 47 | "]" 48 | ] 49 | |> Enum.join("") 50 | end 51 | 52 | @doc """ 53 | Convert a single atribute to DOT format for inclusion in a list of attributes. 54 | 55 | ## Examples 56 | 57 | iex> DotHelpers.attribute_to_dot(:color, "blue") 58 | ~S(color="blue") 59 | 60 | There is one special case this function handles, which is the label for a record 61 | using HTML to build a table. In this case the generated HTML label must be 62 | surrounded by a set of angle brackets `< ... >` instead of double quotes. 63 | 64 | iex> DotHelpers.attribute_to_dot(:label, "
") 65 | "label=<
>" 66 | 67 | """ 68 | def attribute_to_dot(:label, value = " _) do 69 | ~s(label=<#{value}>) 70 | end 71 | 72 | def attribute_to_dot(key, value) do 73 | ~s(#{key}="#{value}") 74 | end 75 | 76 | @doc """ 77 | Indent a single line or block of text. 78 | 79 | An optional second argument can be provided to tell the function how deep 80 | to indent (defaults to one level). 81 | 82 | ## Examples 83 | 84 | iex> DotHelpers.indent("hello") 85 | " hello" 86 | 87 | iex> DotHelpers.indent("hello", 3) 88 | " hello" 89 | 90 | iex> DotHelpers.indent("line one\\n line two\\nline three") 91 | " line one\\n line two\\n line three" 92 | 93 | """ 94 | def indent(string, depth \\ 1) 95 | 96 | def indent(string, depth) when is_bitstring(string) do 97 | string 98 | |> String.split("\n") 99 | |> indent(depth) 100 | |> Enum.join("\n") 101 | end 102 | 103 | def indent(list, depth) when is_list(list) do 104 | Enum.map(list, fn s -> String.duplicate(" ", depth) <> s end) 105 | end 106 | 107 | @doc """ 108 | Maps a collection of vertices or nodes to their correct DOT format. 109 | 110 | 111 | The first argument is a reference to an ETS table or the list of results 112 | from an ETS table. The second argument is the function used to format 113 | each element in the collection. 114 | """ 115 | def elements_to_dot(table, formatting_func) when is_reference(table) or is_integer(table) do 116 | table |> :ets.tab2list() |> elements_to_dot(formatting_func) 117 | end 118 | 119 | def elements_to_dot(list, formatting_func) when is_list(list) do 120 | list 121 | |> sort_elements_by_id() 122 | |> Enum.map(&formatting_func.(&1)) 123 | |> compact() 124 | |> return_joined_list_or_nil() 125 | end 126 | 127 | @doc """ 128 | Returns nil if an empty list is passed in. Returns the elements of the list 129 | joined by the optional second parameter (defaults to `\n` otherwise. 130 | 131 | ## Examples 132 | 133 | iex> DotHelpers.return_joined_list_or_nil([]) 134 | nil 135 | 136 | iex> DotHelpers.return_joined_list_or_nil([], "-") 137 | nil 138 | 139 | iex> DotHelpers.return_joined_list_or_nil(["a", "b", "c"]) 140 | "a\\nb\\nc" 141 | 142 | iex> DotHelpers.return_joined_list_or_nil(["a", "b", "c"], "-") 143 | "a-b-c" 144 | 145 | """ 146 | def return_joined_list_or_nil(list, joiner \\ "\n") 147 | def return_joined_list_or_nil([], _joiner), do: nil 148 | 149 | def return_joined_list_or_nil(list, joiner) do 150 | Enum.join(list, joiner) 151 | end 152 | 153 | @doc """ 154 | Takes a list of elements returned from the vertex or edge table and sorts 155 | them by their ID. 156 | 157 | This ensures that vertices and edges are written into the `.dot` file in the 158 | same order they were added to the ETS tables. This is important as the order 159 | of vertices and edges in a `.dot` file can ultimately affect the final 160 | layout of the graph. 161 | """ 162 | def sort_elements_by_id(elements) do 163 | Enum.sort_by(elements, fn element -> 164 | [[_ | id] | _] = Tuple.to_list(element) 165 | id 166 | end) 167 | end 168 | 169 | @doc """ 170 | Removes all `nil` elements from an list. 171 | 172 | ## Examples 173 | 174 | iex> DotHelpers.compact([]) 175 | [] 176 | 177 | iex> DotHelpers.compact(["a", nil, "b", nil, 1]) 178 | ["a", "b", 1] 179 | 180 | """ 181 | def compact(enum), do: Enum.reject(enum, &is_nil/1) 182 | 183 | ## Private 184 | 185 | defp _global_properties_to_dot(%{global_properties: global_props}, key) do 186 | with props <- Keyword.get(global_props, key) do 187 | case length(props) do 188 | 0 -> nil 189 | _ -> indent("#{key} #{attributes_to_dot(props)}") 190 | end 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/graphvix/graph.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.Graph do 2 | @moduledoc """ 3 | Models a directed graph that can be written to disk and displayed using 4 | [Graphviz](http://www.graphviz.org/) notation. 5 | 6 | Graphs are created by 7 | 8 | * adding vertices of various formats to a graph 9 | * `add_vertex/3` 10 | * `add_record/2` 11 | * `add_html_record/2` 12 | * connecting them with edges 13 | * `add_edge/4` 14 | * grouping them into subgraphs and clusters, 15 | * `add_subgraph/3` 16 | * `add_cluster/3` 17 | * providing styling to all these elements 18 | * `set_graph_property/3` 19 | * `set_global_properties/3` 20 | 21 | They can then be 22 | 23 | * written to disk in `.dot` format 24 | * `write/2` 25 | * compiled and displayed in any number of image formats (`Graphvix` defaults to `.png`) 26 | * `compile/3` 27 | * `show/2` 28 | 29 | """ 30 | import Graphvix.DotHelpers 31 | 32 | alias Graphvix.{HTMLRecord, Record} 33 | 34 | defstruct digraph: nil, 35 | global_properties: [node: [], edge: []], 36 | graph_properties: [], 37 | subgraphs: [] 38 | 39 | @type digraph :: {:digraph, reference(), reference(), reference(), boolean()} 40 | @type t :: %__MODULE__{ 41 | digraph: digraph(), 42 | global_properties: keyword(), 43 | graph_properties: keyword(), 44 | subgraphs: list() 45 | } 46 | 47 | @compiler Application.compile_env(:graphvix, :compiler, "dot") 48 | 49 | @doc """ 50 | Creates a new `Graph` struct. 51 | 52 | A `Graph` struct consists of an Erlang `digraph` record, a list of subgraphs, 53 | and two keyword lists of properties. 54 | 55 | ## Examples 56 | 57 | iex> graph = Graph.new() 58 | iex> Graph.to_dot(graph) 59 | ~S(digraph G { 60 | 61 | }) 62 | 63 | iex> graph = Graph.new(graph: [size: "4x4"], node: [shape: "record"]) 64 | iex> Graph.to_dot(graph) 65 | ~S(digraph G { 66 | 67 | size="4x4" 68 | 69 | node [shape="record"] 70 | 71 | }) 72 | 73 | """ 74 | def new(attributes \\ []) do 75 | digraph = :digraph.new() 76 | [_, _, ntab] = _digraph_tables(digraph) 77 | true = :ets.insert(ntab, {:"$sid", 0}) 78 | 79 | %__MODULE__{ 80 | digraph: digraph, 81 | global_properties: [ 82 | node: Keyword.get(attributes, :node, []), 83 | edge: Keyword.get(attributes, :edge, []) 84 | ], 85 | graph_properties: Keyword.get(attributes, :graph, []) 86 | } 87 | end 88 | 89 | @doc """ 90 | 91 | Destructures the references to the ETS tables for vertices, edges, and 92 | neighbours from the `Graph` struct. 93 | 94 | ## Examples 95 | 96 | iex> graph = Graph.new() 97 | iex> Graph.digraph_tables(graph) 98 | [ 99 | #Reference<0.4011094290.3698196484.157076>, 100 | #Reference<0.4011094290.3698196484.157077>, 101 | #Reference<0.4011094290.3698196484.157078> 102 | ] 103 | 104 | """ 105 | def digraph_tables(%__MODULE__{digraph: graph}), do: _digraph_tables(graph) 106 | 107 | defp _digraph_tables({:digraph, vtab, etab, ntab, _}) do 108 | [vtab, etab, ntab] 109 | end 110 | 111 | @doc """ 112 | Adds a vertex to `graph`. 113 | 114 | The vertex's label text is the argument `label`, and additional attributes 115 | can be passed in as `attributes`. It returns a tuple of the updated graph 116 | and the `:digraph`-assigned ID for the new vertex. 117 | 118 | ## Examples 119 | 120 | iex> graph = Graph.new() 121 | iex> {_graph, vid} = Graph.add_vertex(graph, "hello", color: "blue") 122 | iex> vid 123 | [:"$v" | 0] 124 | 125 | """ 126 | def add_vertex(graph, label, attributes \\ []) do 127 | next_id = get_and_increment_vertex_id(graph) 128 | attributes = Keyword.put(attributes, :label, label) 129 | vertex_id = [:"$v" | next_id] 130 | vid = :digraph.add_vertex(graph.digraph, vertex_id, attributes) 131 | {graph, vid} 132 | end 133 | 134 | @doc """ 135 | Add an edge between two vertices in a graph. 136 | 137 | It takes 3 required arguments and one optional. The first argument is the graph, 138 | the second two arguments are the tail and head of the edge respectively, and the 139 | fourth, optional, argument is a list of layout attributes to apply to the edge. 140 | 141 | The arguments for the ends of the edge can each be either the id of a vertex, or 142 | a tuple of a vertex id and a port name to attach the edge to. This second 143 | option is only valid with `Record` or `HTMLRecord` vertices. 144 | 145 | ## Examples 146 | 147 | iex> graph = Graph.new() 148 | iex> {graph, v1id} = Graph.add_vertex(graph, "start") 149 | iex> {graph, v2id} = Graph.add_vertex(graph, "end") 150 | iex> {_graph, eid} = Graph.add_edge(graph, v1id, v2id, color: "green") 151 | iex> eid 152 | [:"$e" | 0] 153 | 154 | """ 155 | def add_edge(graph, out_from, in_to, attributes \\ []) 156 | 157 | def add_edge(graph, {id = [:"$v" | _], port}, in_to, attributes) do 158 | add_edge(graph, id, in_to, Keyword.put(attributes, :outport, port)) 159 | end 160 | 161 | def add_edge(graph, out_from, {id = [:"$v" | _], port}, attributes) do 162 | add_edge(graph, out_from, id, Keyword.put(attributes, :inport, port)) 163 | end 164 | 165 | def add_edge(graph, out_from, in_to, attributes) do 166 | eid = :digraph.add_edge(graph.digraph, out_from, in_to, attributes) 167 | {graph, eid} 168 | end 169 | 170 | @doc """ 171 | Group a set of vertices into a subgraph within a graph. 172 | 173 | In addition to the graph and the vertex ids, you can pass attributes for 174 | `node` and `edge` to apply common styling to the vertices included 175 | in the subgraph, as well as the edges between two vertices both in the subgraph. 176 | 177 | ## Examples 178 | 179 | iex> graph = Graph.new() 180 | iex> {graph, v1id} = Graph.add_vertex(graph, "start") 181 | iex> {graph, v2id} = Graph.add_vertex(graph, "end") 182 | iex> {_graph, sid} = Graph.add_subgraph( 183 | ...> graph, [v1id, v2id], 184 | ...> node: [shape: "triangle"], 185 | ...> edge: [style: "dotted"] 186 | ...> ) 187 | iex> sid 188 | "subgraph0" 189 | 190 | """ 191 | def add_subgraph(graph, vertex_ids, properties \\ []) do 192 | _add_subgraph(graph, vertex_ids, properties, false) 193 | end 194 | 195 | @doc """ 196 | Group a set of vertices into a cluster in a graph. 197 | 198 | In addition to the graph and the vertex ids, you can pass attributes 199 | for `node` and `edge` to apply common styling to the vertices included 200 | in the cluster, as well as the edges between two vertices both in the cluster. 201 | 202 | The difference between a cluster and a subgraph is that a cluster can also 203 | accept attributes to style the cluster, such as a border, background color, 204 | and custom label. These attributes can be passed as top-level attributes in 205 | the final keyword list argument to the function. 206 | 207 | ## Example 208 | 209 | iex> graph = Graph.new() 210 | iex> {graph, v1id} = Graph.add_vertex(graph, "start") 211 | iex> {graph, v2id} = Graph.add_vertex(graph, "end") 212 | iex> {_graph, cid} = Graph.add_cluster( 213 | ...> graph, [v1id, v2id], 214 | ...> color: "blue", label: "cluster0", 215 | ...> node: [shape: "triangle"], 216 | ...> edge: [style: "dotted"] 217 | ...> ) 218 | iex> cid 219 | "cluster0" 220 | 221 | In `.dot` notation a cluster is specified, as opposed to a subgraph, by 222 | giving the cluster an ID that begins with `"cluster"` as seen in the example 223 | above. Contrast with `Graphvix.Graph.add_subgraph/3`. 224 | 225 | """ 226 | def add_cluster(graph, vertex_ids, properties \\ []) do 227 | _add_subgraph(graph, vertex_ids, properties, true) 228 | end 229 | 230 | @doc """ 231 | Add a vertex built from a `Graphvix.Record` to the graph. 232 | 233 | iex> graph = Graph.new() 234 | iex> record = Record.new(["a", "b", "c"]) 235 | iex> {_graph, rid} = Graph.add_record(graph, record) 236 | iex> rid 237 | [:"$v" | 0] 238 | 239 | See `Graphvix.Record` for details on `Graphvix.Record.new/2` 240 | and the complete module API. 241 | """ 242 | def add_record(graph, record) do 243 | label = Record.to_label(record) 244 | attributes = Keyword.put(record.properties, :shape, "record") 245 | add_vertex(graph, label, attributes) 246 | end 247 | 248 | @doc """ 249 | Add a vertex built from a `Graphvix.HTMLRecord` to the graph. 250 | 251 | iex> graph = Graph.new() 252 | iex> record = HTMLRecord.new([ 253 | ...> HTMLRecord.tr([ 254 | ...> HTMLRecord.td("start"), 255 | ...> HTMLRecord.td("middle"), 256 | ...> HTMLRecord.td("end"), 257 | ...> ]) 258 | ...> ]) 259 | iex> {_graph, rid} = Graph.add_html_record(graph, record) 260 | iex> rid 261 | [:"$v" | 0] 262 | 263 | See `Graphvix.HTMLRecord` for details on `Graphvix.HTMLRecord.new/2` 264 | and the complete module API. 265 | """ 266 | def add_html_record(graph, record) do 267 | label = HTMLRecord.to_label(record) 268 | attributes = [shape: "plaintext"] 269 | add_vertex(graph, label, attributes) 270 | end 271 | 272 | @doc """ 273 | Converts a graph to its representation using `.dot` syntax. 274 | 275 | ## Example 276 | 277 | iex> graph = Graph.new(node: [shape: "triangle"], edge: [color: "green"], graph: [size: "4x4"]) 278 | iex> {graph, vid} = Graph.add_vertex(graph, "a") 279 | iex> {graph, vid2} = Graph.add_vertex(graph, "b") 280 | iex> {graph, vid3} = Graph.add_vertex(graph, "c") 281 | iex> {graph, eid} = Graph.add_edge(graph, vid, vid2) 282 | iex> {graph, eid2} = Graph.add_edge(graph, vid, vid3) 283 | iex> {graph, clusterid} = Graph.add_cluster(graph, [vid, vid2]) 284 | iex> Graph.to_dot(graph) 285 | ~S(digraph G { 286 | 287 | size="4x4" 288 | 289 | node [shape="triange"] 290 | edge [color="green"] 291 | 292 | subgraph cluster0 { 293 | v0 [label="a"] 294 | v1 [label="b"] 295 | 296 | v0 -> v1 297 | } 298 | 299 | v2 [label="c"] 300 | 301 | v1 -> v2 302 | 303 | }) 304 | 305 | For more expressive examples, see the `.ex` and `.dot` files in the `examples/` directory of 306 | Graphvix's source code. 307 | """ 308 | def to_dot(graph) do 309 | [ 310 | "digraph G {", 311 | graph_properties_to_dot(graph), 312 | global_properties_to_dot(graph), 313 | subgraphs_to_dot(graph), 314 | vertices_to_dot(graph), 315 | edges_to_dot(graph), 316 | "}" 317 | ] 318 | |> Enum.reject(&is_nil/1) 319 | |> Enum.join("\n\n") 320 | end 321 | 322 | @doc """ 323 | Writes a `Graph` to a named file in `.dot` format 324 | 325 | ``` 326 | iex> Graph.write(graph, "my_graph") 327 | ``` 328 | 329 | will write a file named `"my_graph.dot"` to your current working directory. 330 | 331 | `filename` works as expected in Elixir. Filenames beginning with `/` define 332 | an absolute path on your file system. Filenames otherwise define a path relative 333 | to your current working directory. 334 | """ 335 | def write(graph, filename) do 336 | File.write(filename <> ".dot", to_dot(graph)) 337 | end 338 | 339 | @doc """ 340 | Writes the graph to a `.dot` file and compiles it to the specified output 341 | format (defaults to `.png`). 342 | 343 | The following code creates the files `"graph_one.dot"` and `"graph_one.png"` 344 | in your current working directory. 345 | 346 | ``` 347 | iex> Graph.compile(graph, "graph_one") 348 | ``` 349 | 350 | This code creates the files `"graph_one.dot"` and `"graph_one.pdf"`. 351 | 352 | ``` 353 | iex> Graph.compile(graph, "graph_one", :pdf) 354 | ``` 355 | 356 | `filename` works as expected in Elixir. Filenames beginning with `/` define 357 | an absolute path on your file system. Filenames otherwise define a path relative 358 | to your current working directory. 359 | """ 360 | def compile(graph, filename, format \\ :png) do 361 | :ok = write(graph, filename) 362 | 363 | {output, 0} = 364 | System.cmd(@compiler, [ 365 | "-T", 366 | "#{format}", 367 | filename <> ".dot", 368 | "-o", 369 | filename <> ".#{format}" 370 | ]) 371 | 372 | {:ok, String.trim(output)} 373 | end 374 | 375 | @doc """ 376 | Write a graph to file, compile it, and open the resulting image in your 377 | system's default image viewer. 378 | 379 | The following code will write the contents of `graph` to `"graph_one.dot"`, 380 | compile the file to `"graph_one.png"` and open it. 381 | 382 | ``` 383 | iex> Graph.show(graph, "graph_one") 384 | ``` 385 | 386 | `filename` works as expected in Elixir. Filenames beginning with `/` define 387 | an absolute path on your file system. Filenames otherwise define a path relative 388 | to your current working directory. 389 | """ 390 | def show(graph, filename) do 391 | :ok = write(graph, filename <> ".dot") 392 | :ok = compile(graph, filename) 393 | {_, 0} = System.cmd("open", [filename <> ".png"]) 394 | :ok 395 | end 396 | 397 | @doc """ 398 | Adds a top-level graph property. 399 | 400 | These attributes affect the overall layout of the graph at a high level. 401 | Use `set_global_properties/3` to modify the global styling for vertices 402 | and edges. 403 | 404 | ## Example 405 | 406 | iex> graph = Graph.new() 407 | iex> graph.graph_properties 408 | [] 409 | iex> graph = Graph.set_graph_property(graph, :rank_direction, "RL") 410 | iex> graph.graph_properties 411 | [ 412 | rank_direction: "RL" 413 | ] 414 | 415 | """ 416 | def set_graph_property(graph, key, value) do 417 | new_properties = Keyword.put(graph.graph_properties, key, value) 418 | %{graph | graph_properties: new_properties} 419 | end 420 | 421 | @doc """ 422 | Sets a property for a vertex or edge that will apply to all vertices or edges 423 | in the graph. 424 | 425 | *NB* `:digraph` uses `vertex` to define the discrete points in 426 | a graph that are connected via edges, while Graphviz and DOT use the word 427 | `node`. `Graphvix` attempts to use "vertex" when the context is constructing 428 | the data for the graph, and "node" in the context of formatting and printing 429 | the graph. 430 | 431 | ## Example 432 | 433 | ``` 434 | iex> graph = Graph.new() 435 | iex> {graph, vid} = Graph.add_vertex(graph, "label") 436 | iex> graph = Graph.set_global_property(graph, :node, shape: "triangle") 437 | ``` 438 | 439 | When the graph is drawn, the vertex whose id is `vid`, and any other vertices 440 | added to the graph, will have a triangle shape. 441 | 442 | Global properties are overwritten by properties added by a subgraph or cluster: 443 | 444 | ``` 445 | {graph, subgraph_id} = Graph.add_subgraph(graph, [vid], shape: "hexagon") 446 | ``` 447 | 448 | Now when the graph is drawn the vertex `vid` will have a hexagon shape. 449 | 450 | Properties written directly to a vertex or edge have the highest priority 451 | of all. The vertex created below will have a circle shape despite the global 452 | property set on `graph`. 453 | 454 | ``` 455 | {graph, vid2} = Graph.add_vertex(graph, "this is a circle!") 456 | ``` 457 | 458 | """ 459 | def set_global_properties(graph, attr_for, attrs \\ []) do 460 | Enum.reduce(attrs, graph, fn {k, v}, g -> 461 | _set_global_property(g, attr_for, [{k, v}]) 462 | end) 463 | end 464 | 465 | ## PRIVATE 466 | 467 | defp _set_global_property(graph, attr_for, [{key, value}]) do 468 | properties = Keyword.get(graph.global_properties, attr_for) 469 | new_props = Keyword.put(properties, key, value) 470 | new_properties = Keyword.put(graph.global_properties, attr_for, new_props) 471 | %{graph | global_properties: new_properties} 472 | end 473 | 474 | defp subgraphs_to_dot(graph) do 475 | case graph.subgraphs do 476 | [] -> 477 | nil 478 | 479 | subgraphs -> 480 | subgraphs 481 | |> Enum.map_join("\n\n", &Graphvix.Subgraph.to_dot(&1, graph)) 482 | end 483 | end 484 | 485 | defp vertices_to_dot(graph) do 486 | [vtab, _, _] = digraph_tables(graph) 487 | 488 | elements_to_dot(vtab, fn {vid = [_ | id], attributes} -> 489 | case in_a_subgraph?(vid, graph) do 490 | true -> 491 | nil 492 | 493 | false -> 494 | [ 495 | "v#{id}", 496 | attributes_to_dot(attributes) 497 | ] 498 | |> compact() 499 | |> Enum.join(" ") 500 | |> indent() 501 | end 502 | end) 503 | end 504 | 505 | defp edge_side_with_port(v_id, nil), do: "v#{v_id}" 506 | defp edge_side_with_port(v_id, port), do: "v#{v_id}:#{port}" 507 | 508 | defp edges_to_dot(graph) do 509 | [_, etab, _] = digraph_tables(graph) 510 | 511 | elements_to_dot(etab, fn edge = {_, [:"$v" | v1], [:"$v" | v2], attributes} -> 512 | case edge in edges_contained_in_subgraphs(graph) do 513 | true -> 514 | nil 515 | 516 | false -> 517 | v_out = edge_side_with_port(v1, Keyword.get(attributes, :outport)) 518 | v_in = edge_side_with_port(v2, Keyword.get(attributes, :inport)) 519 | attributes = attributes |> Keyword.delete(:outport) |> Keyword.delete(:inport) 520 | 521 | ["#{v_out} -> #{v_in}", attributes_to_dot(attributes)] 522 | |> compact() 523 | |> Enum.join(" ") 524 | |> indent() 525 | end 526 | end) 527 | end 528 | 529 | defp get_and_increment_vertex_id(graph) do 530 | [_, _, ntab] = digraph_tables(graph) 531 | [{:"$vid", next_id}] = :ets.lookup(ntab, :"$vid") 532 | true = :ets.delete(ntab, :"$vid") 533 | true = :ets.insert(ntab, {:"$vid", next_id + 1}) 534 | next_id 535 | end 536 | 537 | defp get_and_increment_subgraph_id(graph) do 538 | [_, _, ntab] = digraph_tables(graph) 539 | [{:"$sid", next_id}] = :ets.lookup(ntab, :"$sid") 540 | true = :ets.delete(ntab, :"$sid") 541 | true = :ets.insert(ntab, {:"$sid", next_id + 1}) 542 | next_id 543 | end 544 | 545 | defp in_a_subgraph?(vertex_id, graph) do 546 | vertex_id in vertex_ids_in_subgraphs(graph) 547 | end 548 | 549 | defp vertex_ids_in_subgraphs(%__MODULE__{subgraphs: subgraphs}) do 550 | Enum.reduce(subgraphs, [], fn c, acc -> 551 | acc ++ c.vertex_ids 552 | end) 553 | end 554 | 555 | defp edges_contained_in_subgraphs(graph = %__MODULE__{subgraphs: subgraphs}) do 556 | [_, etab, _] = digraph_tables(graph) 557 | edges = :ets.tab2list(etab) 558 | 559 | Enum.filter(edges, fn {_, vid1, vid2, _} -> 560 | Enum.any?(subgraphs, fn subgraph -> 561 | Graphvix.Subgraph.both_vertices_in_subgraph?(subgraph.vertex_ids, vid1, vid2) 562 | end) 563 | end) 564 | end 565 | 566 | defp graph_properties_to_dot(%{graph_properties: []}), do: nil 567 | 568 | defp graph_properties_to_dot(%{graph_properties: properties}) do 569 | properties 570 | |> Enum.map_join("\n", fn {k, v} -> 571 | attribute_to_dot(k, v) 572 | end) 573 | |> indent 574 | end 575 | 576 | defp _add_subgraph(graph, vertex_ids, properties, is_cluster) do 577 | next_id = get_and_increment_subgraph_id(graph) 578 | subgraph = Graphvix.Subgraph.new(next_id, vertex_ids, is_cluster, properties) 579 | new_graph = %{graph | subgraphs: graph.subgraphs ++ [subgraph]} 580 | {new_graph, subgraph.id} 581 | end 582 | end 583 | -------------------------------------------------------------------------------- /lib/graphvix/html_record.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.HTMLRecord do 2 | @moduledoc """ 3 | Models a graph vertex that uses HTML to generate a table-shaped record. 4 | 5 | # Table structure 6 | 7 | The Graphviz API allows the basic table-related HTML elements: 8 | 9 | * `` 10 | * `` 11 | * `
` 12 | 13 | and the `Graphvix` API provides the parallel functions: 14 | 15 | * `new/2` 16 | * `tr/1` 17 | * `td/2` 18 | 19 | ## Example 20 | 21 | iex> import Graphvix.HTMLRecord, only: [tr: 1, td: 1, td: 2] 22 | iex> record = HTMLRecord.new([ 23 | iex> tr([ 24 | ...> td("a"), 25 | ...> td("b") 26 | ...> ]), 27 | ...> tr([ 28 | ...> td("c"), 29 | ...> td("d") 30 | ...> ]), 31 | ...> ]) 32 | iex> HTMLRecord.to_label(record) 33 | ~S( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
ab
cd
) 43 | 44 | # Ports 45 | 46 | As with `Graphvix.Record` vertices, port names can be attached to cells. With 47 | `HTMLRecord` vertices, this is done by passing a `:port` key as one of the 48 | attributes in the second argument keyword list for `td/2`. 49 | 50 | iex> import Graphvix.HTMLRecord, only: [tr: 1, td: 1, td: 2] 51 | iex> record = HTMLRecord.new([ 52 | iex> tr([td("a"), td("b")]), 53 | ...> tr([td("c", port: "port_c"), td("d")]), 54 | ...> ]) 55 | iex> HTMLRecord.to_label(record) 56 | ~S( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
ab
cd
) 66 | 67 | In addition to `:port`, values for existing HTML keys 68 | 69 | * `border` 70 | * `cellpadding` 71 | * `cellspacing` 72 | 73 | can be added to cells, and 74 | 75 | * `border` 76 | * `cellborder` 77 | * `cellpadding` 78 | * `cellspacing` 79 | 80 | can be added to the table at the top-level to style the table and cells. 81 | 82 | # Text formatting 83 | 84 | Aside from structuring the table, two elements are available for formatting 85 | the content of the cells 86 | 87 | * `` 88 | * `
` 89 | 90 | with corresponding `Graphvix.HTMLRecord` functions 91 | 92 | * `font/2` 93 | * `br/0` 94 | 95 | In addition to contents as its first argument, `font/2` can take a keyword list 96 | of properties as its optional second argument. 97 | 98 | iex> import Graphvix.HTMLRecord, only: [tr: 1, td: 1, td: 2, br: 0, font: 2] 99 | iex> record = HTMLRecord.new([ 100 | iex> tr([td("a"), td(["b", br(), font("B", color: "red", point_size: 100)])]), 101 | ...> tr([td("c"), td("d")]), 102 | ...> ]) 103 | iex> HTMLRecord.to_label(record) 104 | ~S( 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
ab
B
cd
) 114 | 115 | 116 | While maintaining proper nesting (each element contains both opening and closing 117 | tags within its enclosing element), these elements may be nested as desired, 118 | including nesting entire tables inside of cells. 119 | 120 | """ 121 | 122 | defstruct rows: [], 123 | attributes: [] 124 | 125 | @type t :: %__MODULE__{ 126 | rows: [__MODULE__.tr()], 127 | attributes: keyword() 128 | } 129 | @type tr :: %{cells: __MODULE__.cells()} 130 | 131 | @type br :: %{tag: :br} 132 | @type font :: %{tag: :font, cell: __MODULE__.one_or_more_cells(), attributes: keyword()} 133 | @type td :: %{label: __MODULE__.one_or_more_cells(), attributes: keyword()} 134 | 135 | @type cell :: 136 | String.t() 137 | | __MODULE__.br() 138 | | __MODULE__.font() 139 | | __MODULE__.td() 140 | | __MODULE__.t() 141 | 142 | @type cells :: [__MODULE__.cell()] 143 | 144 | @type one_or_more_cells :: __MODULE__.cell() | [__MODULE__.cell()] 145 | 146 | alias Graphvix.HTMLRecord 147 | import Graphvix.DotHelpers, only: [indent: 1] 148 | 149 | @doc """ 150 | Returns a new `HTMLRecord` which can be turned into an HTML table vertex. 151 | 152 | It takes two arguments. The first is a list of table rows all returned from 153 | the `tr/1` function. 154 | 155 | The second is an optional keyword list of attributes to apply to the table as 156 | a whole. Valid keys for this list are: 157 | 158 | * `align` 159 | * `bgcolor` 160 | * `border` 161 | * `cellborder` 162 | * `cellpadding` 163 | * `cellspacing` 164 | * `color` 165 | * `columns` 166 | * `fixedsize` 167 | * `gradientangle` 168 | * `height` 169 | * `href` 170 | * `id` 171 | * `port` 172 | * `rows` 173 | * `sides` 174 | * `style` 175 | * `target` 176 | * `title` 177 | * `tooltip` 178 | * `valign` 179 | * `width` 180 | 181 | ## Example 182 | 183 | iex> import HTMLRecord, only: [tr: 1, td: 1] 184 | iex> HTMLRecord.new([ 185 | ...> tr([ 186 | ...> td("a"), 187 | ...> td("b") 188 | ...> ]), 189 | ...> tr([ 190 | ...> td("c"), 191 | ...> td("d") 192 | ...> ]) 193 | ...> ], border: 1, cellspacing: 0, cellborder: 1) 194 | %HTMLRecord{ 195 | rows: [ 196 | %{cells: [ 197 | %{label: "a", attributes: []}, 198 | %{label: "b", attributes: []}, 199 | ]}, 200 | %{cells: [ 201 | %{label: "c", attributes: []}, 202 | %{label: "d", attributes: []}, 203 | ]} 204 | ], 205 | attributes: [ 206 | border: 1, 207 | cellspacing: 0, 208 | cellborder: 1 209 | ] 210 | } 211 | 212 | """ 213 | def new(rows, attributes \\ []) when is_list(rows) do 214 | %__MODULE__{rows: rows, attributes: attributes} 215 | end 216 | 217 | @doc """ 218 | A helper method to generate a row of a table. 219 | 220 | It takes a single argument, which is a list of cells returned by the `td/2` 221 | helper function. 222 | """ 223 | def tr(cells) when is_list(cells) do 224 | %{cells: cells} 225 | end 226 | 227 | @doc """ 228 | A helper method to generate a single cell of a table. 229 | 230 | The first argument is the contents of the cell. It can be a plain string or 231 | a list of other elements. 232 | 233 | The second argument is an optional keyword list of attributes to apply to the 234 | cell. Valid keys include: 235 | 236 | * `align` 237 | * `balign` 238 | * `bgcolor` 239 | * `border` 240 | * `cellpadding` 241 | * `cellspacing` 242 | * `color` 243 | * `colspan` 244 | * `fixedsize` 245 | * `gradientangle` 246 | * `height` 247 | * `href` 248 | * `id` 249 | * `port` 250 | * `rowspan` 251 | * `sides` 252 | * `style` 253 | * `target` 254 | * `title` 255 | * `tooltip` 256 | * `valign` 257 | * `width` 258 | 259 | See the module documentation for `Graphvix.HTMLRecord` for usage examples in context. 260 | """ 261 | def td(label, attributes \\ []) do 262 | %{label: label, attributes: attributes} 263 | end 264 | 265 | @doc """ 266 | Creates a `
` element as part of a cell in an `HTMLRecord` 267 | 268 | A helper method that creates a `
` HTML element as part of a table cell. 269 | 270 | See the module documentation for `Graphvix.HTMLRecord` for usage examples in context. 271 | """ 272 | def br, do: %{tag: :br} 273 | 274 | @doc """ 275 | Creates a `` element as part of a cell in an `HTMLRecord` 276 | 277 | A helper method that creates a `
` HTML element as part of a table cell. 278 | 279 | The first argument to `font/2` is the contents of the cell, which can itself 280 | be a plain string or a list of nested element functions. 281 | 282 | The second, optional argument is a keyword list of attributes to determine 283 | the formatting of the contents of the ``. Valid keys for this list are 284 | 285 | * `color` 286 | * `face` 287 | * `point_size` 288 | 289 | ## Example 290 | 291 | iex> HTMLRecord.font("a", color: "blue", face: "Arial", point_size: 10) 292 | %{tag: :font, cell: "a", attributes: [color: "blue", face: "Arial", point_size: 10]} 293 | 294 | """ 295 | def font(cell, attributes \\ []) do 296 | %{tag: :font, cell: cell, attributes: attributes} 297 | end 298 | 299 | @doc """ 300 | Converts an `HTMLRecord` struct into a valid HTML-like string. 301 | 302 | The resulting string can be passed to `Graphvix.Graph.add_vertex/3` as a label 303 | for a vertex. 304 | 305 | ## Example 306 | 307 | iex> import HTMLRecord, only: [tr: 1, td: 1] 308 | iex> record = HTMLRecord.new([ 309 | ...> tr([ 310 | ...> td("a"), 311 | ...> td("b") 312 | ...> ]), 313 | ...> tr([ 314 | ...> td("c"), 315 | ...> td("d") 316 | ...> ]) 317 | ...> ], border: 1, cellspacing: 0, cellborder: 1) 318 | iex> HTMLRecord.to_label(record) 319 | ~S( 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 |
ab
cd
) 329 | 330 | """ 331 | def to_label(%__MODULE__{rows: rows, attributes: attributes}) do 332 | [ 333 | "", 334 | Enum.map(rows, &tr_to_label/1), 335 | "
" 336 | ] 337 | |> List.flatten() 338 | |> Enum.join("\n") 339 | end 340 | 341 | ## Private 342 | 343 | defp tr_to_label(%{cells: cells}) do 344 | [ 345 | "", 346 | Enum.map(cells, &td_to_label/1), 347 | "" 348 | ] 349 | |> List.flatten() 350 | |> Enum.join("\n") 351 | |> indent 352 | end 353 | 354 | defp td_to_label(%{label: label, attributes: attributes}) do 355 | [ 356 | "", 357 | label_to_string(label), 358 | "" 359 | ] 360 | |> Enum.join("") 361 | |> indent() 362 | end 363 | 364 | defp attributes_for_label(attributes) do 365 | case attributes do 366 | [] -> 367 | "" 368 | 369 | attrs -> 370 | " " <> 371 | (attrs 372 | |> Enum.map_join(" ", fn {k, v} -> 373 | ~s(#{hyphenize(k)}="#{v}") 374 | end)) 375 | end 376 | end 377 | 378 | defp hyphenize(name) do 379 | name |> to_string |> String.replace("_", "-") 380 | end 381 | 382 | defp label_to_string(list) when is_list(list) do 383 | list |> Enum.map_join("", &label_to_string/1) 384 | end 385 | 386 | defp label_to_string(%{tag: :br}), do: "
" 387 | 388 | defp label_to_string(%{tag: :font, cell: cell, attributes: attributes}) do 389 | [ 390 | "", 391 | label_to_string(cell), 392 | "" 393 | ] 394 | |> Enum.join("") 395 | end 396 | 397 | defp label_to_string(table = %HTMLRecord{}) do 398 | HTMLRecord.to_label(table) 399 | end 400 | 401 | defp label_to_string(string) when is_bitstring(string), do: string 402 | end 403 | -------------------------------------------------------------------------------- /lib/graphvix/record.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.Record do 2 | @moduledoc """ 3 | Models a graph vertex that has a shape of `record`. 4 | 5 | A record's label can be a single string, a single row or column, or a nested 6 | alternation of rows and columns. 7 | 8 | Once a record is created by `Graphvix.Record.new/2` it can be added to a graph using 9 | `Graphvix.Graph.add_record/2`. 10 | 11 | See `new/2` for more complete usage examples. 12 | 13 | ## Example 14 | 15 | iex> import Record, only: [column: 1] 16 | iex> graph = Graph.new() 17 | iex> record = Record.new(["a", "B", column(["c", "D"])], color: "blue") 18 | iex> {graph, _rid} = Graph.add_record(graph, record) 19 | iex> Graph.to_dot(graph) 20 | ~s(digraph G {\\n\\n v0 [label="a | B | { c | D }",shape="record",color="blue"]\\n\\n}) 21 | 22 | """ 23 | 24 | defstruct body: nil, 25 | properties: [] 26 | 27 | alias __MODULE__ 28 | alias Graphvix.RecordSubset 29 | 30 | @type body :: String.t() | [any()] | RecordSubset.t() 31 | @type t :: %__MODULE__{body: Record.t(), properties: keyword()} 32 | 33 | @doc """ 34 | Returns a new `Graphvix.Record` struct that can be added to a graph as a vertex. 35 | 36 | ## Examples 37 | 38 | A record's can be a simple text label 39 | 40 | iex> record = Record.new("just a plain text record") 41 | iex> Record.to_label(record) 42 | "just a plain text record" 43 | 44 | or it can be a single row or column of strings 45 | 46 | iex> import Record, only: [row: 1] 47 | iex> record = Record.new(row(["a", "b", "c"])) 48 | iex> Record.to_label(record) 49 | "a | b | c" 50 | 51 | iex> import Record, only: [column: 1] 52 | iex> record = Record.new(column(["a", "b", "c"])) 53 | iex> Record.to_label(record) 54 | "{ a | b | c }" 55 | 56 | or it can be a series of nested rows and columns 57 | 58 | iex> import Record, only: [row: 1, column: 1] 59 | iex> record = Record.new( 60 | ...> row([ 61 | ...> "a", 62 | ...> column([ 63 | ...> "b", "c", "d" 64 | ...> ]), 65 | ...> column([ 66 | ...> "e", 67 | ...> "f", 68 | ...> row([ 69 | ...> "g", "h", "i" 70 | ...> ]) 71 | ...> ]) 72 | ...> ]) 73 | ...> ) 74 | iex> Record.to_label(record) 75 | "a | { b | c | d } | { e | f | { g | h | i } }" 76 | 77 | passing a plain list defaults to a row 78 | 79 | iex> record = Record.new(["a", "b", "c"]) 80 | iex> Record.to_label(record) 81 | "a | b | c" 82 | 83 | Each cell can contain a plain string, or a string with a port attached, 84 | allowing edges to be drawn directly to and from that cell, rather than the 85 | vertex. Ports are created by passing a tuple of the form `{port_name, label}` 86 | 87 | iex> record = Record.new(["a", {"port_b", "b"}]) 88 | iex> Record.to_label(record) 89 | "a | b" 90 | 91 | A second, optional argument can be passed specifying other formatting and 92 | styling properties for the vertex. 93 | 94 | iex> record = Record.new(["a", {"port_b", "b"}, "c"], color: "blue") 95 | iex> graph = Graph.new() 96 | iex> {graph, _record_id} = Graph.add_record(graph, record) 97 | iex> Graph.to_dot(graph) 98 | ~s(digraph G {\\n\\n v0 [label="a | b | c",shape="record",color="blue"]\\n\\n}) 99 | 100 | 101 | """ 102 | def new(body, properties \\ []) 103 | 104 | def new(string, properties) when is_bitstring(string) do 105 | %__MODULE__{body: string, properties: properties} 106 | end 107 | 108 | def new(list, properties) when is_list(list) do 109 | %__MODULE__{body: Graphvix.RecordSubset.new(list), properties: properties} 110 | end 111 | 112 | def new(row_or_column = %Graphvix.RecordSubset{}, properties) do 113 | %__MODULE__{body: row_or_column, properties: properties} 114 | end 115 | 116 | @doc """ 117 | A helper method that takes a list of cells and returns them as a row inside a 118 | `Graphvix.Record` struct. 119 | 120 | The list can consist of a mix of string labels or tuples of cell labels + 121 | port names. 122 | 123 | This function provides little functionality on its own. See the documentation 124 | for `Graphvix.Record.new/2` for usage examples in context. 125 | """ 126 | def row(cells) do 127 | %RecordSubset{cells: cells, is_column: false} 128 | end 129 | 130 | @doc """ 131 | A helper method that takes a list of cells and returns them as a column inside a 132 | `Graphvix.Record` struct. 133 | 134 | The list can consist of a mix of string labels or tuples of cell labels + 135 | port names. 136 | 137 | This function provides little functionality on its own. See the documentation 138 | for `Graphvix.Record.new/2` for usage examples in context. 139 | """ 140 | def column(cells) do 141 | %RecordSubset{cells: cells, is_column: true} 142 | end 143 | 144 | @doc false 145 | def to_label(record) 146 | 147 | def to_label(%{body: string}) when is_bitstring(string) do 148 | string 149 | end 150 | 151 | def to_label(%{body: subset = %RecordSubset{}}) do 152 | RecordSubset.to_label(subset, true) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/graphvix/record/port.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.Record.Port do 2 | @moduledoc """ 3 | [Internal] Models a `Graphvix.Record` cell with a port name attached. 4 | 5 | See `Graphvix.Record.new/2` for a more complete documentation of using 6 | ports with cells. 7 | """ 8 | 9 | defstruct body: nil, 10 | port_name: nil 11 | 12 | @doc false 13 | def new(body, port_name) do 14 | %__MODULE__{body: body, port_name: port_name} 15 | end 16 | 17 | @doc false 18 | def to_label(port) do 19 | "<#{port.port_name}> #{port.body}" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/graphvix/record_subset.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.RecordSubset do 2 | @moduledoc """ 3 | [Internal] Models a row or a column as part of a `Graphvix.Record` vertex. 4 | 5 | The functionality provided by this module is internal. See the documentation 6 | for `Graphvix.Record`. 7 | """ 8 | 9 | defstruct cells: [], 10 | is_column: false 11 | 12 | alias __MODULE__ 13 | 14 | @type t :: %__MODULE__{cells: [RecordSubset.cell()], is_column: boolean()} 15 | @type cell_with_port :: {String.t(), String.t()} 16 | @type cell :: String.t() | RecordSubset.cell_with_port() | RecordSubset.t() 17 | 18 | @doc false 19 | def new(cells, is_column \\ false) do 20 | %__MODULE__{cells: cells, is_column: is_column} 21 | end 22 | 23 | @doc false 24 | def to_label(subset, top_level \\ false) 25 | 26 | def to_label(%{cells: cells, is_column: false}, _top_level = true) do 27 | cells |> Enum.map_join(" | ", &_to_label/1) 28 | end 29 | 30 | def to_label(%{cells: cells}, _top_level) do 31 | "{ " <> (cells |> Enum.map_join(" | ", &_to_label/1)) <> " }" 32 | end 33 | 34 | defp _to_label(string) when is_bitstring(string), do: string 35 | 36 | defp _to_label({port, string}) do 37 | "<#{port}> #{string}" 38 | end 39 | 40 | defp _to_label(subset = %__MODULE__{}), do: __MODULE__.to_label(subset) 41 | end 42 | -------------------------------------------------------------------------------- /lib/graphvix/subgraph.ex: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.Subgraph do 2 | @moduledoc """ 3 | [Internal] Models a subgraph or cluster for inclusion in a graph. 4 | 5 | The functions included in this module are for internal use only. See 6 | 7 | * `Graphvix.Graph.add_subgraph/3` 8 | * `Graphvix.Graph.add_cluster/3` 9 | 10 | for the public interface for creating and including subgraphs and clusters. 11 | """ 12 | 13 | import Graphvix.DotHelpers 14 | 15 | defstruct id: nil, 16 | vertex_ids: [], 17 | global_properties: [node: [], edge: []], 18 | subgraph_properties: [], 19 | is_cluster: false 20 | 21 | @doc false 22 | def new(id, vertex_ids, is_cluster \\ false, properties \\ []) do 23 | node_properties = Keyword.get(properties, :node, []) 24 | edge_properties = Keyword.get(properties, :edge, []) 25 | subgraph_properties = properties |> Keyword.delete(:node) |> Keyword.delete(:edge) 26 | 27 | %Graphvix.Subgraph{ 28 | id: id_prefix(is_cluster) <> "#{id}", 29 | is_cluster: is_cluster, 30 | vertex_ids: vertex_ids, 31 | global_properties: [ 32 | node: node_properties, 33 | edge: edge_properties 34 | ], 35 | subgraph_properties: subgraph_properties 36 | } 37 | end 38 | 39 | @doc false 40 | def to_dot(subgraph, graph) do 41 | [vtab, _, _] = Graphvix.Graph.digraph_tables(graph) 42 | vertices_from_graph = :ets.tab2list(vtab) 43 | 44 | [ 45 | "subgraph #{subgraph.id} {", 46 | global_properties_to_dot(subgraph), 47 | subgraph_properties_to_dot(subgraph), 48 | subgraph_vertices_to_dot(subgraph.vertex_ids, vertices_from_graph), 49 | subgraph_edges_to_dot(subgraph, graph), 50 | "}" 51 | ] 52 | |> List.flatten() 53 | |> compact() 54 | |> Enum.map_join("\n\n", &indent/1) 55 | end 56 | 57 | @doc false 58 | def subgraph_edges_to_dot(subgraph, graph) do 59 | subgraph 60 | |> edges_with_both_vertices_in_subgraph(graph) 61 | |> sort_elements_by_id() 62 | |> elements_to_dot(fn {_, [:"$v" | v1], [:"$v" | v2], attributes} -> 63 | "v#{v1} -> v#{v2} #{attributes_to_dot(attributes)}" |> String.trim() |> indent 64 | end) 65 | end 66 | 67 | @doc false 68 | def both_vertices_in_subgraph?(vertex_ids, vid1, vid2) do 69 | vid1 in vertex_ids && vid2 in vertex_ids 70 | end 71 | 72 | ## Private 73 | 74 | defp subgraph_vertices_to_dot(subgraph_vertex_ids, vertices_from_graph) do 75 | subgraph_vertex_ids 76 | |> vertices_in_this_subgraph(vertices_from_graph) 77 | |> sort_elements_by_id() 78 | |> elements_to_dot(fn {[_ | id], attributes} -> 79 | [ 80 | "v#{id}", 81 | attributes_to_dot(attributes) 82 | ] 83 | |> compact 84 | |> Enum.join(" ") 85 | |> indent 86 | end) 87 | end 88 | 89 | defp vertices_in_this_subgraph(subgraph_vertex_ids, vertices_from_graph) do 90 | vertices_from_graph 91 | |> Enum.filter(fn {vid, _attributes} -> vid in subgraph_vertex_ids end) 92 | end 93 | 94 | defp subgraph_properties_to_dot(%{subgraph_properties: properties}) do 95 | properties 96 | |> Enum.map(fn {key, value} -> 97 | indent(attribute_to_dot(key, value)) 98 | end) 99 | |> compact() 100 | |> return_joined_list_or_nil() 101 | end 102 | 103 | defp edges_with_both_vertices_in_subgraph(%{vertex_ids: vertex_ids}, graph) do 104 | [_, etab, _] = Graphvix.Graph.digraph_tables(graph) 105 | edges = :ets.tab2list(etab) 106 | 107 | Enum.filter(edges, fn {_, vid1, vid2, _} -> 108 | both_vertices_in_subgraph?(vertex_ids, vid1, vid2) 109 | end) 110 | end 111 | 112 | defp id_prefix(_is_cluster = true), do: "cluster" 113 | defp id_prefix(_is_cluster = false), do: "subgraph" 114 | end 115 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.1.0" 5 | @maintainers [ 6 | "Michael Berkowitz" 7 | ] 8 | @links %{ 9 | github: "https://github.com/mikowitz/graphvix" 10 | } 11 | 12 | def project do 13 | [ 14 | app: :graphvix, 15 | version: @version, 16 | description: "Elixir interface for Graphviz", 17 | package: [ 18 | maintainers: @maintainers, 19 | licenses: ["MIT"], 20 | links: @links 21 | ], 22 | elixir: "~> 1.13", 23 | build_embedded: Mix.env() == :prod, 24 | start_permanent: Mix.env() == :prod, 25 | deps: deps() 26 | ] 27 | end 28 | 29 | # Configuration for the OTP application 30 | # 31 | # Type "mix help compile.app" for more information 32 | def application do 33 | [ 34 | extra_applications: [:logger] 35 | ] 36 | end 37 | 38 | # Dependencies can be Hex packages: 39 | # 40 | # {:mydep, "~> 0.3.0"} 41 | # 42 | # Or git/path repositories: 43 | # 44 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 45 | # 46 | # Type "mix help deps" for more examples and options 47 | defp deps do 48 | [ 49 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 50 | {:ex_doc, "~> 0.31", only: :dev, runtime: false}, 51 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 52 | {:stream_data, "~> 1.0", only: :test} 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 5 | "ex_doc": {:hex, :ex_doc, "0.32.2", "f60bbeb6ccbe75d005763e2a328e6f05e0624232f2393bc693611c2d3ae9fa0e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "a4480305cdfe7fdfcbb77d1092c76161626d9a7aa4fb698aee745996e34602df"}, 6 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 7 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 8 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 11 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 13 | "stream_data": {:hex, :stream_data, "1.0.0", "c1380747a4650902732696861d5cb66ad3cb1cc93f31c2c8498bf87cddbabe2d", [:mix], [], "hexpm", "acd53e27c66c617d466f42ec77a7f59e5751f6051583c621ccdb055b9690435d"}, 14 | } 15 | -------------------------------------------------------------------------------- /test/graphvix/dot_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.DotHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Graphvix.{DotHelpers, Graph} 5 | doctest DotHelpers 6 | end 7 | -------------------------------------------------------------------------------- /test/graphvix/graph_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.GraphTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | alias Graphvix.{Graph, HTMLRecord, Record} 6 | 7 | doctest Graph, 8 | except: [ 9 | new: 1, 10 | digraph_tables: 1, 11 | to_dot: 1, 12 | write: 2, 13 | compile: 3, 14 | show: 2, 15 | set_global_properties: 3 16 | ] 17 | 18 | import HTMLRecord, only: [tr: 1, td: 1, td: 2] 19 | 20 | property "generating a graph with a vertex" do 21 | check all(label <- string(:ascii, min_length: 3)) do 22 | graph = Graph.new() 23 | {graph, _vid} = Graph.add_vertex(graph, label, color: "blue") 24 | 25 | assert Graph.to_dot(graph) == 26 | """ 27 | digraph G { 28 | 29 | v0 [label="#{label}",color="blue"] 30 | 31 | } 32 | """ 33 | |> String.trim() 34 | end 35 | end 36 | 37 | property "generating a graph with a record node" do 38 | check all( 39 | labels <- list_of(string(:ascii, min_length: 3), min_length: 2, max_length: 5), 40 | label <- string(:ascii, min_length: 3), 41 | color <- string(:ascii, min_length: 3) 42 | ) do 43 | record = Record.new(labels, color: "blue") 44 | graph = Graph.new() 45 | {graph, v0} = Graph.add_record(graph, record) 46 | {graph, v1} = Graph.add_vertex(graph, label, color: color) 47 | {graph, _eid} = Graph.add_edge(graph, v0, v1) 48 | 49 | assert Graph.to_dot(graph) == 50 | """ 51 | digraph G { 52 | 53 | v0 [label="#{Enum.join(labels, " | ")}",shape="record",color="blue"] 54 | v1 [label="#{label}",color="#{color}"] 55 | 56 | v0 -> v1 57 | 58 | } 59 | """ 60 | |> String.trim() 61 | end 62 | end 63 | 64 | property "generating a graph with edges to record ports" do 65 | check all([l1, l2, p1, l3] <- list_of(string(:ascii, min_length: 3), length: 4)) do 66 | graph = Graph.new() 67 | record = Record.new([l1, {p1, l2}]) 68 | {graph, v0} = Graph.add_record(graph, record) 69 | {graph, v1} = Graph.add_vertex(graph, l3) 70 | {graph, _eid} = Graph.add_edge(graph, {v0, p1}, v1) 71 | 72 | assert Graph.to_dot(graph) == 73 | """ 74 | digraph G { 75 | 76 | v0 [label="#{l1} | <#{p1}> #{l2}",shape="record"] 77 | v1 [label="#{l3}"] 78 | 79 | v0:#{p1} -> v1 80 | 81 | } 82 | """ 83 | |> String.trim() 84 | end 85 | end 86 | 87 | property "generating a graph with edges to HTML record ports" do 88 | check all([l1, l2, p1, l3] <- list_of(string(:ascii, min_length: 3), length: 4)) do 89 | graph = Graph.new() 90 | 91 | record = 92 | HTMLRecord.new([ 93 | tr([ 94 | td(l1), 95 | td(l2, port: p1) 96 | ]) 97 | ]) 98 | 99 | {graph, v0} = Graph.add_html_record(graph, record) 100 | {graph, v1} = Graph.add_vertex(graph, l3) 101 | {graph, _eid} = Graph.add_edge(graph, {v0, p1}, v1) 102 | 103 | assert Graph.to_dot(graph) == 104 | """ 105 | digraph G { 106 | 107 | v0 [label=< 108 | 109 | 110 | 111 | 112 |
#{l1}#{l2}
>,shape="plaintext"] 113 | v1 [label="#{l3}"] 114 | 115 | v0:#{p1} -> v1 116 | 117 | } 118 | """ 119 | |> String.trim() 120 | end 121 | end 122 | 123 | property "adding a subgraph" do 124 | check all(label <- string(:ascii, min_length: 3)) do 125 | graph = Graph.new() 126 | {graph, vid} = Graph.add_vertex(graph, label, color: "blue") 127 | {graph, _cid} = Graph.add_subgraph(graph, [vid]) 128 | 129 | [subgraph] = graph.subgraphs 130 | assert subgraph.id == "subgraph0" 131 | assert subgraph.vertex_ids == [vid] 132 | end 133 | end 134 | 135 | property "adding a cluster" do 136 | check all(label <- string(:ascii, min_length: 3)) do 137 | graph = Graph.new() 138 | {graph, vid} = Graph.add_vertex(graph, label, color: "blue") 139 | {graph, _cid} = Graph.add_cluster(graph, [vid]) 140 | 141 | [cluster] = graph.subgraphs 142 | assert cluster.id == "cluster0" 143 | assert cluster.vertex_ids == [vid] 144 | end 145 | end 146 | 147 | property "generating graphs with global properties" do 148 | check all( 149 | color <- string(:ascii, min_length: 3), 150 | color2 <- string(:ascii, min_length: 3), 151 | e_label <- string(:printable, min_length: 5) 152 | ) do 153 | graph = Graph.new() 154 | graph = Graph.set_global_properties(graph, :node, color: color) 155 | graph = Graph.set_global_properties(graph, :edge, color: color2, label: e_label) 156 | 157 | assert Graph.to_dot(graph) == 158 | """ 159 | digraph G { 160 | 161 | node [color="#{color}"] 162 | edge [label="#{e_label}",color="#{color2}"] 163 | 164 | } 165 | """ 166 | |> String.trim() 167 | end 168 | end 169 | 170 | property "adding an edge" do 171 | check all( 172 | label1 <- string(:ascii, min_length: 3), 173 | label2 <- string(:ascii, min_length: 3) 174 | ) do 175 | graph = Graph.new() 176 | {graph, v1} = Graph.add_vertex(graph, label1) 177 | {graph, v2} = Graph.add_vertex(graph, label2) 178 | {graph, _e1} = Graph.add_edge(graph, v1, v2) 179 | {_, _, etab, _, _} = graph.digraph 180 | assert length(:ets.tab2list(etab)) == 1 181 | end 182 | end 183 | 184 | property "dot format for a graph with edges" do 185 | check all( 186 | label1 <- string(:ascii, min_length: 3), 187 | label2 <- string(:ascii, min_length: 3) 188 | ) do 189 | graph = Graph.new() 190 | {graph, v1} = Graph.add_vertex(graph, label1) 191 | {graph, v2} = Graph.add_vertex(graph, label2) 192 | {graph, _e1} = Graph.add_edge(graph, v1, v2, color: "blue") 193 | 194 | assert Graph.to_dot(graph) == 195 | """ 196 | digraph G { 197 | 198 | v0 [label="#{label1}"] 199 | v1 [label="#{label2}"] 200 | 201 | v0 -> v1 [color="blue"] 202 | 203 | } 204 | """ 205 | |> String.trim() 206 | end 207 | end 208 | 209 | property "dot format for a graph with a subgraph" do 210 | check all( 211 | label1 <- string(:ascii, min_length: 3), 212 | label2 <- string(:ascii, min_length: 3) 213 | ) do 214 | graph = Graph.new() 215 | {graph, v1} = Graph.add_vertex(graph, label1) 216 | {graph, v2} = Graph.add_vertex(graph, label2) 217 | {graph, _e1} = Graph.add_edge(graph, v1, v2, color: "blue") 218 | 219 | {graph, _cid} = 220 | Graph.add_subgraph(graph, [v1], style: "filled", color: "blue", node: [shape: "Msquare"]) 221 | 222 | assert Graph.to_dot(graph) == 223 | """ 224 | digraph G { 225 | 226 | subgraph subgraph0 { 227 | 228 | node [shape="Msquare"] 229 | 230 | style="filled" 231 | color="blue" 232 | 233 | v0 [label="#{label1}"] 234 | 235 | } 236 | 237 | v1 [label="#{label2}"] 238 | 239 | v0 -> v1 [color="blue"] 240 | 241 | } 242 | """ 243 | |> String.trim() 244 | end 245 | end 246 | 247 | property "dot format for a graph with a cluster" do 248 | check all( 249 | label1 <- string(:ascii, min_length: 3), 250 | label2 <- string(:ascii, min_length: 3) 251 | ) do 252 | graph = Graph.new() 253 | {graph, v1} = Graph.add_vertex(graph, label1) 254 | {graph, v2} = Graph.add_vertex(graph, label2) 255 | {graph, _e1} = Graph.add_edge(graph, v1, v2, color: "blue") 256 | 257 | {graph, _cid} = 258 | Graph.add_cluster(graph, [v1], style: "filled", color: "blue", node: [shape: "Msquare"]) 259 | 260 | assert Graph.to_dot(graph) == 261 | """ 262 | digraph G { 263 | 264 | subgraph cluster0 { 265 | 266 | node [shape="Msquare"] 267 | 268 | style="filled" 269 | color="blue" 270 | 271 | v0 [label="#{label1}"] 272 | 273 | } 274 | 275 | v1 [label="#{label2}"] 276 | 277 | v0 -> v1 [color="blue"] 278 | 279 | } 280 | """ 281 | |> String.trim() 282 | end 283 | end 284 | 285 | property "dot format for a graph with clusters and subgraphs" do 286 | check all( 287 | label1 <- string(:ascii, min_length: 3), 288 | label2 <- string(:ascii, min_length: 3), 289 | label3 <- string(:ascii, min_length: 3), 290 | label4 <- string(:ascii, min_length: 3) 291 | ) do 292 | graph = Graph.new() 293 | {graph, v1} = Graph.add_vertex(graph, label1) 294 | {graph, v2} = Graph.add_vertex(graph, label2) 295 | {graph, v3} = Graph.add_vertex(graph, label3) 296 | {graph, v4} = Graph.add_vertex(graph, label4) 297 | {graph, _e} = Graph.add_edge(graph, v1, v2, color: "blue") 298 | {graph, _e} = Graph.add_edge(graph, v2, v3) 299 | {graph, _e} = Graph.add_edge(graph, v3, v4) 300 | 301 | {graph, _cid} = 302 | Graph.add_cluster(graph, [v1], style: "filled", color: "blue", node: [shape: "Msquare"]) 303 | 304 | {graph, _cid} = 305 | Graph.add_subgraph(graph, [v2, v3], node: [shape: "square"], edge: [color: "green"]) 306 | 307 | assert Graph.to_dot(graph) == 308 | """ 309 | digraph G { 310 | 311 | subgraph cluster0 { 312 | 313 | node [shape="Msquare"] 314 | 315 | style="filled" 316 | color="blue" 317 | 318 | v0 [label="#{label1}"] 319 | 320 | } 321 | 322 | subgraph subgraph1 { 323 | 324 | node [shape="square"] 325 | edge [color="green"] 326 | 327 | v1 [label="#{label2}"] 328 | v2 [label="#{label3}"] 329 | 330 | v1 -> v2 331 | 332 | } 333 | 334 | v3 [label="#{label4}"] 335 | 336 | v0 -> v1 [color="blue"] 337 | v2 -> v3 338 | 339 | } 340 | """ 341 | |> String.trim() 342 | end 343 | end 344 | 345 | test ".write/2" do 346 | g = Graph.new() 347 | 348 | :ok = Graph.write(g, "g") 349 | 350 | {:ok, content} = File.read("g.dot") 351 | 352 | :ok = File.rm("g.dot") 353 | 354 | assert content == 355 | """ 356 | digraph G { 357 | 358 | } 359 | """ 360 | |> String.trim() 361 | end 362 | 363 | test ".compile/2" do 364 | g = Graph.new() 365 | 366 | {:ok, output} = Graph.compile(g, "g") 367 | 368 | assert output == "-T png g.dot -o g.png" 369 | end 370 | 371 | test ".compile/3" do 372 | g = Graph.new() 373 | 374 | {:ok, output} = Graph.compile(g, "g", :pdf) 375 | 376 | assert output == "-T pdf g.dot -o g.pdf" 377 | end 378 | end 379 | -------------------------------------------------------------------------------- /test/graphvix/html_record_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.HTMLRecordTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | alias Graphvix.HTMLRecord 6 | import HTMLRecord, only: [tr: 1, td: 1, td: 2] 7 | doctest HTMLRecord 8 | 9 | test "generating a basic HTML label" do 10 | cell1 = td("left") 11 | cell2 = td("mid dle") 12 | cell3 = td("right") 13 | row = tr([cell1, cell2, cell3]) 14 | 15 | record = HTMLRecord.new([row]) 16 | 17 | assert HTMLRecord.to_label(record) == 18 | """ 19 | 20 | 21 | 22 | 23 | 24 | 25 |
leftmid dleright
26 | """ 27 | |> String.trim() 28 | end 29 | 30 | test "generating an HTML label with col and rowspans" do 31 | row1_cell1 = td("hello
world", rowspan: 3) 32 | row1_cell2 = td("b", colspan: 3) 33 | row1_cell3 = td("g", rowspan: 3) 34 | row1_cell4 = td("h", rowspan: 3) 35 | 36 | row1 = 37 | tr([ 38 | row1_cell1, 39 | row1_cell2, 40 | row1_cell3, 41 | row1_cell4 42 | ]) 43 | 44 | row2_cell1 = td("c") 45 | row2_cell2 = td("d") 46 | row2_cell3 = td("e") 47 | 48 | row2 = 49 | tr([ 50 | row2_cell1, 51 | row2_cell2, 52 | row2_cell3 53 | ]) 54 | 55 | row3_cell1 = td("f", colspan: 3) 56 | 57 | row3 = tr([row3_cell1]) 58 | 59 | record = HTMLRecord.new([row1, row2, row3]) 60 | 61 | assert HTMLRecord.to_label(record) == 62 | """ 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
hello
world
bgh
cde
f
79 | """ 80 | |> String.trim() 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/graphvix/record_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.RecordTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | alias Graphvix.{Graph, Record, RecordSubset} 6 | 7 | doctest Record 8 | 9 | property "generating a simple record" do 10 | check all(label <- string(:ascii, min_length: 3)) do 11 | record = Record.new(label) 12 | assert Record.to_label(record) == label 13 | end 14 | end 15 | 16 | property "generating a basic record with a single row" do 17 | check all(labels <- list_of(string(:ascii, min_length: 3), min_length: 2, max_length: 5)) do 18 | record = Record.new(labels) 19 | assert Record.to_label(record) == Enum.join(labels, " | ") 20 | end 21 | end 22 | 23 | property "generating a record as a column" do 24 | check all(labels <- list_of(string(:ascii, min_length: 3), min_length: 2, max_length: 5)) do 25 | record = Record.new(RecordSubset.new(labels, true)) 26 | assert Record.to_label(record) == "{ #{Enum.join(labels, " | ")} }" 27 | end 28 | end 29 | 30 | property "generating a nested record starting with a row" do 31 | check all([l1, l2, l3, l4, l5] <- list_of(string(:ascii, min_length: 3), length: 5)) do 32 | record = 33 | Record.new( 34 | RecordSubset.new([ 35 | l1, 36 | RecordSubset.new([l2, l3, l4], true), 37 | l5 38 | ]) 39 | ) 40 | 41 | assert Record.to_label(record) == "#{l1} | { #{l2} | #{l3} | #{l4} } | #{l5}" 42 | end 43 | end 44 | 45 | property "multi-nested record" do 46 | check all( 47 | [l1, l2, l3, l4, l5, l6, l7, l8] <- list_of(string(:ascii, min_length: 3), length: 8) 48 | ) do 49 | record = 50 | Record.new( 51 | RecordSubset.new([ 52 | l1, 53 | RecordSubset.new( 54 | [l2, RecordSubset.new([l3, l4, RecordSubset.new([l5, l6], true)]), l7], 55 | true 56 | ), 57 | l8 58 | ]) 59 | ) 60 | 61 | assert Record.to_label(record) == 62 | "#{l1} | { #{l2} | { #{l3} | #{l4} | { #{l5} | #{l6} } } | #{l7} } | #{l8}" 63 | end 64 | end 65 | 66 | property "basic record with named ports" do 67 | check all( 68 | [l1, l2, l3] <- list_of(string(:ascii, min_length: 3), length: 3), 69 | port_name <- string(:ascii, min_length: 3) 70 | ) do 71 | record = Record.new(RecordSubset.new([l1, {port_name, l2}, l3])) 72 | assert Record.to_label(record) == "#{l1} | <#{port_name}> #{l2} | #{l3}" 73 | end 74 | end 75 | 76 | property "multi-nested record with ports" do 77 | check all( 78 | [l1, l2, l3, l4, l5, l6, l7, l8, p1, p2] <- 79 | list_of(string(:ascii, min_length: 3), length: 10) 80 | ) do 81 | record = 82 | Record.new( 83 | RecordSubset.new( 84 | [ 85 | {p1, l1}, 86 | RecordSubset.new([ 87 | l2, 88 | RecordSubset.new([l3, l4, RecordSubset.new([l5, {p2, l6}])], true), 89 | l7 90 | ]), 91 | l8 92 | ], 93 | true 94 | ) 95 | ) 96 | 97 | assert Record.to_label(record) == 98 | "{ <#{p1}> #{l1} | { #{l2} | { #{l3} | #{l4} | { #{l5} | <#{p2}> #{l6} } } | #{l7} } | #{l8} }" 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/graphvix/subgraph_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Graphvix.SubgraphTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Graphvix.Subgraph 5 | doctest Subgraph 6 | end 7 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------