GitLab Repo

amachine.am_visualization.am_draw

  1from pathlib import Path
  2from typing import Any
  3import tempfile, webbrowser, os
  4
  5import graphviz
  6from matplotlib import colormaps
  7from matplotlib.colors import to_hex
  8
  9def view_graph(GV):
 10    svg_bytes = GV.pipe(format="svg")
 11
 12    html = """<!DOCTYPE html>
 13<html>
 14<head>
 15<style>
 16  * { margin: 0; padding: 0; box-sizing: border-box; }
 17  body { background: #ffffff; overflow: hidden; width: 100vw; height: 100vh; }
 18  #viewport { width: 100%; height: 100%; overflow: hidden; cursor: grab; }
 19  #viewport.dragging { cursor: grabbing; }
 20  #container { transform-origin: 0 0; display: inline-block; }
 21  #hint {
 22    position: fixed; bottom: 12px; right: 12px;
 23    color: rgb(180,180,180); font: 12px monospace; pointer-events: none;
 24  }
 25</style>
 26</head>
 27<body>
 28<div id="viewport">
 29  <div id="container">
 30""" + svg_bytes.decode("utf-8") + """
 31  </div>
 32</div>
 33<div id="hint">scroll: zoom &nbsp;|&nbsp; drag: pan &nbsp;|&nbsp; dbl-click: reset</div>
 34<script>
 35  const viewport = document.getElementById("viewport");
 36  const container = document.getElementById("container");
 37
 38  let scale = 1, tx = 0, ty = 0;
 39  let dragging = false, ox = 0, oy = 0;
 40
 41  function apply() {
 42    container.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
 43  }
 44
 45  function fitToWindow() {
 46    const svg = container.querySelector("svg");
 47    const r = svg.getBoundingClientRect();
 48    const vw = viewport.clientWidth, vh = viewport.clientHeight;
 49    scale = Math.min(vw / r.width, vh / r.height) * 0.95;
 50    tx = (vw - r.width * scale) / 2;
 51    ty = (vh - r.height * scale) / 2;
 52    apply();
 53  }
 54
 55  window.addEventListener("load", fitToWindow);
 56
 57  viewport.addEventListener("wheel", e => {
 58    e.preventDefault();
 59    const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
 60    const rect = viewport.getBoundingClientRect();
 61    const mx = e.clientX - rect.left;
 62    const my = e.clientY - rect.top;
 63    tx = mx - (mx - tx) * factor;
 64    ty = my - (my - ty) * factor;
 65    scale *= factor;
 66    apply();
 67  }, { passive: false });
 68
 69  viewport.addEventListener("mousedown", e => {
 70    dragging = true;
 71    ox = e.clientX - tx;
 72    oy = e.clientY - ty;
 73    viewport.classList.add("dragging");
 74  });
 75  window.addEventListener("mousemove", e => {
 76    if (!dragging) return;
 77    tx = e.clientX - ox;
 78    ty = e.clientY - oy;
 79    apply();
 80  });
 81  window.addEventListener("mouseup", () => {
 82    dragging = false;
 83    viewport.classList.remove("dragging");
 84  });
 85
 86  viewport.addEventListener("dblclick", fitToWindow);
 87</script>
 88</body>
 89</html>"""
 90
 91    with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, dir=".") as f:
 92        f.write(html)
 93        path = f.name
 94
 95    webbrowser.open(f"file://{path}")
 96    input("Press Enter to continue...")
 97    os.unlink(path)
 98
 99ClassParam = str | tuple[int, str]
100
101_ENGINE_CONFIGS: dict[str, dict[str, str]] = {
102    "dot": {
103        "rankdir": "LR",
104        "ranksep": "0.5",
105        "nodesep": "1.9",
106        "splines": "spline",
107        "constraint": "true",
108        "concentrate": "false",
109        "ratio": "auto",
110    },
111    "neato": {
112        "overlap": "scale",
113        "overlap_scaling": "-4",
114        "esep": "+2.5",
115        "sep": "+1.75",
116        "model": "shortpath",
117        "damping": "0.85",
118        "epsilon": "0.00001",
119        "maxiter": "10000",
120        "start": "5",
121    },
122    "fdp": {
123        "overlap": "prism",
124        "sep": "+1.5",
125        "K": "1.0",
126        "splines": "true",
127        "len": "3.0",
128        "maxiter": "5000",
129    },
130}
131
132_DEFAULT_FILL_COLOR   = "gray"
133_DEFAULT_BORDER_COLOR = "black"
134_PLAIN_BORDER_WIDTH   = 3.0
135_THICK_BORDER_WIDTH   = 24.0
136_DEFAULT_EDGE_WIDTH   = 6.0
137_COLORED_EDGE_WIDTH   = 12.0
138_DEFAULT_ARROW_SIZE   = 1.0
139_COLORED_ARROW_SIZE   = 1.75
140
141def _as_composition_depth_cls(param: ClassParam) -> tuple[int, str]:
142    return (0, param) if isinstance(param, str) else param
143
144
145def _collect_classes(states: list[Any], composition_depth: int, cls: str) -> list[str]:
146    prefix = cls.split("_")[0]
147    found: set[str] = set()
148    for state in states:
149        for label in state.classes[composition_depth]:
150            if label.split("_")[0] == prefix:
151                found.add(label)
152    return sorted(found)
153
154
155def _palette(cmap_name: str, n_cycle: int, count: int) -> list[str]:
156    cmap = colormaps[cmap_name]
157    return [to_hex(cmap(i % n_cycle)) for i in range(count)]
158
159
160def _resolve_color(
161    node_idx: int,
162    c_by: ClassParam | None,
163    class_list: list[str] | None,
164    colors: list[str] | None,
165    sccs: list[list[int]] | None,
166    state: Any,
167    default: str,
168) -> str:
169    if class_list is None:
170        return default
171
172    if sccs and c_by == "strongly_connected_components":
173        for i, component in enumerate(sccs):
174            if node_idx in component:
175                return colors[i % len(colors)]
176        return default
177
178    if c_by is not None:
179        composition_depth, _ = _as_composition_depth_cls(c_by)
180        for cl in state.classes[composition_depth]:
181            try:
182                idx = class_list.index(cl)
183                return colors[idx % len(colors)]
184            except ValueError:
185                continue
186
187    return default
188
189
190def _state_class_prefix(state: Any, composition_depth: int, cls_prefix: str) -> str | None:
191    for cl in state.classes[composition_depth]:
192        if cl.split("_")[0] == cls_prefix:
193            return cl
194    return None
195
196
197def create_digraph(engine: str = "dot") -> graphviz.Digraph:
198
199    graph_attr = {**_ENGINE_CONFIGS.get(engine, {}), "outputorder": "edgesfirst"}
200
201    node_attr = {
202        "shape": "box",
203        "style": "rounded, filled",
204        "fillcolor": "lightblue",
205        "fontname": "Helvetica",
206    }
207
208    edge_attr = {"color": "gray40"}
209
210    return graphviz.Digraph(
211        engine=engine,
212        graph_attr=graph_attr,
213        node_attr=node_attr,
214        edge_attr=edge_attr,
215    )
216
217
218def draw_graph(
219    aM: Any,
220    output_dir: Path | str | None = None,
221    title: str = "am_graph",
222    view: bool = True,
223    format: str = "png",
224    strongly_connected_components: list[list[int]] | None = None,
225    engine: str = "dot",
226    symbols_only: bool = False,
227    no_labels: bool = False,
228    color_nodes_by: ClassParam | None = None,
229    color_borders_by: ClassParam | None = None,
230    color_edges_by: str | None = None,
231    edge_width_by_cross_class: ClassParam | None = None,
232    cluster_by: ClassParam | None = None,
233    symbol_categories: dict[str, int] | None = None,
234) -> None:
235
236    if color_edges_by == "category" and symbol_categories is None:
237        raise ValueError("color_edges_by='category' requires symbol_categories")
238
239    # Normalise optional ClassParams to (composition_depth, cls) tuples up front.
240    if edge_width_by_cross_class is not None:
241        edge_width_by_cross_class = _as_composition_depth_cls(edge_width_by_cross_class)
242    if cluster_by is not None:
243        cluster_by = _as_composition_depth_cls(cluster_by)
244
245    GV = create_digraph(engine=engine)
246
247    # Node-colour configuration
248    
249    border_width   = _PLAIN_BORDER_WIDTH
250    arrowhead      = "normal"
251    node_colors: list[str] | None = None
252    node_classes: list[str] | None = None
253
254    if strongly_connected_components and color_nodes_by == "strongly_connected_components":
255        node_colors  = _palette("tab20_r", 8, len(strongly_connected_components))
256        border_width = _THICK_BORDER_WIDTH
257        arrowhead    = "nonenonenormal"
258    elif color_nodes_by is not None:
259        composition_depth, cls   = _as_composition_depth_cls(color_nodes_by)
260        node_classes = _collect_classes(aM.states, composition_depth, cls)
261        node_colors  = _palette("tab20_r", 8, len(node_classes))
262        border_width = _THICK_BORDER_WIDTH
263        arrowhead    = "nonenonenormal"
264
265    # Border-colour configuration
266    
267    border_colors: list[str] | None = None
268    border_classes: list[str] | None = None
269
270    if strongly_connected_components and color_borders_by == "strongly_connected_components":
271        border_colors = _palette("Set1", 8, len(strongly_connected_components))
272    elif color_borders_by is not None:
273        composition_depth, cls    = _as_composition_depth_cls(color_borders_by)
274        border_classes = _collect_classes(aM.states, composition_depth, cls)
275        border_colors  = _palette("Set1", 8, len(border_classes))
276
277    # Edge-colour configuration
278    
279    default_edge_width = _DEFAULT_EDGE_WIDTH
280    default_arrow_size = _DEFAULT_ARROW_SIZE
281    edge_colors: list[str] | None = None
282    categories: list[int] | None  = None
283
284    if color_edges_by == "category":
285        categories     = list(set(symbol_categories.values()))
286        edge_colors    = _palette("Dark2", 8, len(categories))
287        default_edge_width = _COLORED_EDGE_WIDTH
288        default_arrow_size = _COLORED_ARROW_SIZE
289    elif color_edges_by == "symbol":
290        edge_colors    = _palette("tab20", 20, len(aM.alphabet))
291        default_edge_width = _COLORED_EDGE_WIDTH
292        default_arrow_size = _COLORED_ARROW_SIZE
293
294    # Add nodes
295    
296    for node_idx, state in enumerate(aM.states):
297        fill_color = _resolve_color(
298            node_idx, color_nodes_by,
299            node_classes, node_colors,
300            strongly_connected_components, state,
301            _DEFAULT_FILL_COLOR,
302        )
303        border_color = _resolve_color(
304            node_idx, color_borders_by,
305            border_classes, border_colors,
306            strongly_connected_components, state,
307            _DEFAULT_BORDER_COLOR,
308        )
309
310        node_kw: dict[str, str] = dict(
311            label=state.name or str(node_idx),
312            shape="circle",
313            style="bold,filled",
314            fillcolor=fill_color,
315            color=border_color,
316            width="3.0",
317            fontsize="36",
318            penwidth=str(border_width),
319        )
320
321        my_class: str | None = None
322        if cluster_by is not None:
323            composition_depth, cls = cluster_by
324            my_class = _state_class_prefix(state, composition_depth, cls)
325
326        if my_class is not None:
327            with GV.subgraph(name=f"cluster_{my_class}") as sub:
328                sub.attr(
329                    style="solid, filled",
330                    color="blue",
331                    fillcolor="#f0f8ff",
332                    label=my_class,
333                    margin="90",
334                )
335                sub.node(str(node_idx), **node_kw)
336        else:
337            GV.node(str(node_idx), **node_kw)
338
339    # Add edges
340
341    for tr in aM.transitions:
342        u, v   = tr.origin_state_idx, tr.target_state_idx
343        symbol = aM.alphabet[tr.symbol_idx]
344
345        pr_str     = str(tr.pq) if aM.is_q_weighted else str(round(tr.prob, 4))
346        label_text = symbol if symbols_only else f"{symbol}({pr_str})"
347        fontsize   = "70" if symbols_only else "12"
348
349        if no_labels:
350            html_label = None
351        else:
352            html_label = (
353                f'<<TABLE BORDER="1" COLOR="black" CELLBORDER="0" CELLSPACING="0"'
354                f' CELLPADDING="4" STYLE="ROUNDED" BGCOLOR="white">'
355                f"<TR><TD>{label_text}</TD></TR>"
356                f"</TABLE>>"
357            )
358
359        # Edge colour
360        edge_color = "black"
361        if color_edges_by == "category" and symbol in symbol_categories:
362            cat = symbol_categories[symbol]
363            try:
364                edge_color = edge_colors[categories.index(cat) % 8]
365            except ValueError:
366                pass
367        elif color_edges_by == "symbol":
368            edge_color = edge_colors[tr.symbol_idx % 20]
369
370        # Edge width
371        edge_width = default_edge_width
372        if edge_width_by_cross_class is not None:
373            composition_depth, cls = edge_width_by_cross_class
374            origin_class = _state_class_prefix(aM.states[u], composition_depth, cls)
375            target_class = _state_class_prefix(aM.states[v], composition_depth, cls)
376            if origin_class != target_class:
377                edge_width = default_edge_width * 1.5
378
379        GV.edge(
380            str(u), str(v),
381            label=html_label,
382            headlabel=" ",
383            fontsize=fontsize,
384            fontname="Times-Italic",
385            fontcolor="#1f1f1f",
386            color=edge_color,
387            arrowhead=arrowhead,
388            penwidth=str(edge_width),
389            arrowsize=str(default_arrow_size),
390        )
391
392    # Render
393    if view:
394        view_graph( GV )
395
396    if output_dir is not None:
397        GV.attr(bgcolor="transparent")
398        GV.render(title, directory=output_dir, view=False, format=format, cleanup=True)
def view_graph(GV):
10def view_graph(GV):
11    svg_bytes = GV.pipe(format="svg")
12
13    html = """<!DOCTYPE html>
14<html>
15<head>
16<style>
17  * { margin: 0; padding: 0; box-sizing: border-box; }
18  body { background: #ffffff; overflow: hidden; width: 100vw; height: 100vh; }
19  #viewport { width: 100%; height: 100%; overflow: hidden; cursor: grab; }
20  #viewport.dragging { cursor: grabbing; }
21  #container { transform-origin: 0 0; display: inline-block; }
22  #hint {
23    position: fixed; bottom: 12px; right: 12px;
24    color: rgb(180,180,180); font: 12px monospace; pointer-events: none;
25  }
26</style>
27</head>
28<body>
29<div id="viewport">
30  <div id="container">
31""" + svg_bytes.decode("utf-8") + """
32  </div>
33</div>
34<div id="hint">scroll: zoom &nbsp;|&nbsp; drag: pan &nbsp;|&nbsp; dbl-click: reset</div>
35<script>
36  const viewport = document.getElementById("viewport");
37  const container = document.getElementById("container");
38
39  let scale = 1, tx = 0, ty = 0;
40  let dragging = false, ox = 0, oy = 0;
41
42  function apply() {
43    container.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
44  }
45
46  function fitToWindow() {
47    const svg = container.querySelector("svg");
48    const r = svg.getBoundingClientRect();
49    const vw = viewport.clientWidth, vh = viewport.clientHeight;
50    scale = Math.min(vw / r.width, vh / r.height) * 0.95;
51    tx = (vw - r.width * scale) / 2;
52    ty = (vh - r.height * scale) / 2;
53    apply();
54  }
55
56  window.addEventListener("load", fitToWindow);
57
58  viewport.addEventListener("wheel", e => {
59    e.preventDefault();
60    const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
61    const rect = viewport.getBoundingClientRect();
62    const mx = e.clientX - rect.left;
63    const my = e.clientY - rect.top;
64    tx = mx - (mx - tx) * factor;
65    ty = my - (my - ty) * factor;
66    scale *= factor;
67    apply();
68  }, { passive: false });
69
70  viewport.addEventListener("mousedown", e => {
71    dragging = true;
72    ox = e.clientX - tx;
73    oy = e.clientY - ty;
74    viewport.classList.add("dragging");
75  });
76  window.addEventListener("mousemove", e => {
77    if (!dragging) return;
78    tx = e.clientX - ox;
79    ty = e.clientY - oy;
80    apply();
81  });
82  window.addEventListener("mouseup", () => {
83    dragging = false;
84    viewport.classList.remove("dragging");
85  });
86
87  viewport.addEventListener("dblclick", fitToWindow);
88</script>
89</body>
90</html>"""
91
92    with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, dir=".") as f:
93        f.write(html)
94        path = f.name
95
96    webbrowser.open(f"file://{path}")
97    input("Press Enter to continue...")
98    os.unlink(path)
ClassParam = str | tuple[int, str]
def create_digraph(engine: str = 'dot') -> graphviz.graphs.Digraph:
198def create_digraph(engine: str = "dot") -> graphviz.Digraph:
199
200    graph_attr = {**_ENGINE_CONFIGS.get(engine, {}), "outputorder": "edgesfirst"}
201
202    node_attr = {
203        "shape": "box",
204        "style": "rounded, filled",
205        "fillcolor": "lightblue",
206        "fontname": "Helvetica",
207    }
208
209    edge_attr = {"color": "gray40"}
210
211    return graphviz.Digraph(
212        engine=engine,
213        graph_attr=graph_attr,
214        node_attr=node_attr,
215        edge_attr=edge_attr,
216    )
def draw_graph( aM: Any, output_dir: pathlib.Path | str | None = None, title: str = 'am_graph', view: bool = True, format: str = 'png', strongly_connected_components: list[list[int]] | None = None, engine: str = 'dot', symbols_only: bool = False, no_labels: bool = False, color_nodes_by: str | tuple[int, str] | None = None, color_borders_by: str | tuple[int, str] | None = None, color_edges_by: str | None = None, edge_width_by_cross_class: str | tuple[int, str] | None = None, cluster_by: str | tuple[int, str] | None = None, symbol_categories: dict[str, int] | None = None) -> None:
219def draw_graph(
220    aM: Any,
221    output_dir: Path | str | None = None,
222    title: str = "am_graph",
223    view: bool = True,
224    format: str = "png",
225    strongly_connected_components: list[list[int]] | None = None,
226    engine: str = "dot",
227    symbols_only: bool = False,
228    no_labels: bool = False,
229    color_nodes_by: ClassParam | None = None,
230    color_borders_by: ClassParam | None = None,
231    color_edges_by: str | None = None,
232    edge_width_by_cross_class: ClassParam | None = None,
233    cluster_by: ClassParam | None = None,
234    symbol_categories: dict[str, int] | None = None,
235) -> None:
236
237    if color_edges_by == "category" and symbol_categories is None:
238        raise ValueError("color_edges_by='category' requires symbol_categories")
239
240    # Normalise optional ClassParams to (composition_depth, cls) tuples up front.
241    if edge_width_by_cross_class is not None:
242        edge_width_by_cross_class = _as_composition_depth_cls(edge_width_by_cross_class)
243    if cluster_by is not None:
244        cluster_by = _as_composition_depth_cls(cluster_by)
245
246    GV = create_digraph(engine=engine)
247
248    # Node-colour configuration
249    
250    border_width   = _PLAIN_BORDER_WIDTH
251    arrowhead      = "normal"
252    node_colors: list[str] | None = None
253    node_classes: list[str] | None = None
254
255    if strongly_connected_components and color_nodes_by == "strongly_connected_components":
256        node_colors  = _palette("tab20_r", 8, len(strongly_connected_components))
257        border_width = _THICK_BORDER_WIDTH
258        arrowhead    = "nonenonenormal"
259    elif color_nodes_by is not None:
260        composition_depth, cls   = _as_composition_depth_cls(color_nodes_by)
261        node_classes = _collect_classes(aM.states, composition_depth, cls)
262        node_colors  = _palette("tab20_r", 8, len(node_classes))
263        border_width = _THICK_BORDER_WIDTH
264        arrowhead    = "nonenonenormal"
265
266    # Border-colour configuration
267    
268    border_colors: list[str] | None = None
269    border_classes: list[str] | None = None
270
271    if strongly_connected_components and color_borders_by == "strongly_connected_components":
272        border_colors = _palette("Set1", 8, len(strongly_connected_components))
273    elif color_borders_by is not None:
274        composition_depth, cls    = _as_composition_depth_cls(color_borders_by)
275        border_classes = _collect_classes(aM.states, composition_depth, cls)
276        border_colors  = _palette("Set1", 8, len(border_classes))
277
278    # Edge-colour configuration
279    
280    default_edge_width = _DEFAULT_EDGE_WIDTH
281    default_arrow_size = _DEFAULT_ARROW_SIZE
282    edge_colors: list[str] | None = None
283    categories: list[int] | None  = None
284
285    if color_edges_by == "category":
286        categories     = list(set(symbol_categories.values()))
287        edge_colors    = _palette("Dark2", 8, len(categories))
288        default_edge_width = _COLORED_EDGE_WIDTH
289        default_arrow_size = _COLORED_ARROW_SIZE
290    elif color_edges_by == "symbol":
291        edge_colors    = _palette("tab20", 20, len(aM.alphabet))
292        default_edge_width = _COLORED_EDGE_WIDTH
293        default_arrow_size = _COLORED_ARROW_SIZE
294
295    # Add nodes
296    
297    for node_idx, state in enumerate(aM.states):
298        fill_color = _resolve_color(
299            node_idx, color_nodes_by,
300            node_classes, node_colors,
301            strongly_connected_components, state,
302            _DEFAULT_FILL_COLOR,
303        )
304        border_color = _resolve_color(
305            node_idx, color_borders_by,
306            border_classes, border_colors,
307            strongly_connected_components, state,
308            _DEFAULT_BORDER_COLOR,
309        )
310
311        node_kw: dict[str, str] = dict(
312            label=state.name or str(node_idx),
313            shape="circle",
314            style="bold,filled",
315            fillcolor=fill_color,
316            color=border_color,
317            width="3.0",
318            fontsize="36",
319            penwidth=str(border_width),
320        )
321
322        my_class: str | None = None
323        if cluster_by is not None:
324            composition_depth, cls = cluster_by
325            my_class = _state_class_prefix(state, composition_depth, cls)
326
327        if my_class is not None:
328            with GV.subgraph(name=f"cluster_{my_class}") as sub:
329                sub.attr(
330                    style="solid, filled",
331                    color="blue",
332                    fillcolor="#f0f8ff",
333                    label=my_class,
334                    margin="90",
335                )
336                sub.node(str(node_idx), **node_kw)
337        else:
338            GV.node(str(node_idx), **node_kw)
339
340    # Add edges
341
342    for tr in aM.transitions:
343        u, v   = tr.origin_state_idx, tr.target_state_idx
344        symbol = aM.alphabet[tr.symbol_idx]
345
346        pr_str     = str(tr.pq) if aM.is_q_weighted else str(round(tr.prob, 4))
347        label_text = symbol if symbols_only else f"{symbol}({pr_str})"
348        fontsize   = "70" if symbols_only else "12"
349
350        if no_labels:
351            html_label = None
352        else:
353            html_label = (
354                f'<<TABLE BORDER="1" COLOR="black" CELLBORDER="0" CELLSPACING="0"'
355                f' CELLPADDING="4" STYLE="ROUNDED" BGCOLOR="white">'
356                f"<TR><TD>{label_text}</TD></TR>"
357                f"</TABLE>>"
358            )
359
360        # Edge colour
361        edge_color = "black"
362        if color_edges_by == "category" and symbol in symbol_categories:
363            cat = symbol_categories[symbol]
364            try:
365                edge_color = edge_colors[categories.index(cat) % 8]
366            except ValueError:
367                pass
368        elif color_edges_by == "symbol":
369            edge_color = edge_colors[tr.symbol_idx % 20]
370
371        # Edge width
372        edge_width = default_edge_width
373        if edge_width_by_cross_class is not None:
374            composition_depth, cls = edge_width_by_cross_class
375            origin_class = _state_class_prefix(aM.states[u], composition_depth, cls)
376            target_class = _state_class_prefix(aM.states[v], composition_depth, cls)
377            if origin_class != target_class:
378                edge_width = default_edge_width * 1.5
379
380        GV.edge(
381            str(u), str(v),
382            label=html_label,
383            headlabel=" ",
384            fontsize=fontsize,
385            fontname="Times-Italic",
386            fontcolor="#1f1f1f",
387            color=edge_color,
388            arrowhead=arrowhead,
389            penwidth=str(edge_width),
390            arrowsize=str(default_arrow_size),
391        )
392
393    # Render
394    if view:
395        view_graph( GV )
396
397    if output_dir is not None:
398        GV.attr(bgcolor="transparent")
399        GV.render(title, directory=output_dir, view=False, format=format, cleanup=True)