Search code examples
graphvizpydot

How to enforce grid layout in graphviz/pydot?


tl;dr: How do I make graphviz stick to a grid layout of nodes?

I'm trying to draw a "full causal graph" for a time series. This means that I have some graph with Units and Time indices repeating in the time direction.

I want to draw the graph with Graphviz since it is programmatic. I don't know the number of Units, nor the number of Time steps. This will be varied as the project continues. I might also want to adjust colors, stroke widths etc programatically as a vizualisation of machine learning models.

To make the diagram readable, I have a few layout considerations to take into account:

  • Units aligned in rows
  • Time indices in columns
  • The edges in the graph repeat periodically (in the image below orange is vertical, blue is 1 time step wide, brown is two time step wide)

Therefore, I'm trying to replicate this powerpoint mockup. What i'm trying to draw

To accomplish this, I've got inspiration from a few 'SO posts and added subgraphs with rank=same and also invisible edges. This post shows it: https://stackoverflow.com/a/49736304/4050510

From other SO posts, I've been able to order my nodes in the way I like. The current output is like below. Since I'm using pydot, the python code and the dot code is quite ugly. I'll link to it on request.

enter image description here

As you see, it all works except a few quirks:

1) The invisible nodes are not aligned with the visible nodes 1) The orange arrows are bent, since they are colliding with the invisible arrows

Is there any way to make Graphviz deal with this elegantly? How do I force the grid layout, and how do I make the orange arrows straight?


Pydot source code for plot above

import io
import pydot 
import matplotlib.image as img
import matplotlib.pyplot as plt


def render_pydot(g: pydot.Dot, prog):
    # noinspection PyUnresolvedReferences
    png_bytes = g.create(prog=prog, format="png")
    bytes_as_inmemory_file = io.BytesIO(png_bytes)
    img2 = img.imread(bytes_as_inmemory_file)
    plt.figure()
    plt.imshow(img2, aspect='equal')
    plt.axis(False)
    plt.grid(False)
    plt.show()


def create_dot_for_timeseries_with_pydot():
    """Generate a dot object for a static 'full time series'"""
    g = pydot.Dot(rankdir='LR')

    units = ["Alfa", "Beta", "Gamma"]
    time_steps = list(range(0, 5))  # five steps, two invisible
    for t in time_steps:
        sg = pydot.Subgraph(rank="same", rankdir="TB")
        for u, _ in enumerate(units):

            # create nodes
            this_node_name = f"{t}_{u}"
            opts = {'name': this_node_name,
                    'label': this_node_name
                    }
            if t not in time_steps[1:-1]:
                opts['style'] = 'invis'
                opts['color'] = 'gray70'
            n = pydot.Node(**opts)

            # create invisible edges to enforce order vertically and horizontally
            # https://stackoverflow.com/q/44274518/4050510
            if u != 0:
                prev = f"{t}_{u - 1}"
                e = pydot.Edge(src=prev, dst=this_node_name,
                               style='invis',
                               color="gray70",
                               weight=1000)
                sg.add_edge(e)

            if t in time_steps[:-1]:
                next = f"{t + 1}_{u}"
                g.add_edge(pydot.Edge(src=this_node_name, dst=next,
                                      style="invis",
                                      color="gray70", weight=1000))

            sg.add_node(n)
        g.add_subgraph(sg)

        # Draw lag 0 effects
        if t in time_steps[1:-1]:
            g.add_edge(pydot.Edge(f"{t}_{0}", f"{t}_{1}", color="orange"))

        # Draw lag 1 effects
        if t in time_steps[:-1]:
            for u, _ in enumerate(units):
                g.add_edge(pydot.Edge(f"{t}_{u}", f"{t + 1}_{u}", color="blue"))
            g.add_edge(pydot.Edge(f"{t}_{0}", f"{t + 1}_{1}", color="blue"))
            g.add_edge(pydot.Edge(f"{t}_{1}", f"{t + 1}_{2}", color="blue"))

        # Draw lag 2 effects
        if t in time_steps[:-2]:
            g.add_edge(pydot.Edge(f"{t}_{0}", f"{t + 2}_{1}", color="brown"))

    return g


g = create_dot_for_timeseries_with_pydot()
print(g) # print the dot document as text for inspection
render_pydot(g, prog='dot') # show the image

Generated DOT code from above python file

digraph G {
rankdir=LR;
splines=False;
"0_0" -> "1_0"  [color=gray70, style=invis, weight=1000];
"0_1" -> "1_1"  [color=gray70, style=invis, weight=1000];
"0_2" -> "1_2"  [color=gray70, style=invis, weight=1000];
subgraph  {
rank=same;
rankdir=TB;
"0_0" [color=gray70, label="0_0", style=invis];
"0_0" -> "0_1"  [color=gray70, style=invis, weight=1000];
"0_1" [color=gray70, label="0_1", style=invis];
"0_1" -> "0_2"  [color=gray70, style=invis, weight=1000];
"0_2" [color=gray70, label="0_2", style=invis];
}
"0_0" -> "1_0"  [color=blue];
"0_1" -> "1_1"  [color=blue];
"0_2" -> "1_2"  [color=blue];
"0_0" -> "1_1"  [color=blue];
"0_1" -> "1_2"  [color=blue];
"0_0" -> "2_1"  [color=brown];
"1_0" -> "2_0"  [color=gray70, style=invis, weight=1000];
"1_1" -> "2_1"  [color=gray70, style=invis, weight=1000];
"1_2" -> "2_2"  [color=gray70, style=invis, weight=1000];
subgraph  {
rank=same;
rankdir=TB;
"1_0" [label="1_0"];
"1_0" -> "1_1"  [color=gray70, style=invis, weight=1000];
"1_1" [label="1_1"];
"1_1" -> "1_2"  [color=gray70, style=invis, weight=1000];
"1_2" [label="1_2"];
}
"1_0" -> "1_1"  [color=orange];
"1_0" -> "2_0"  [color=blue];
"1_1" -> "2_1"  [color=blue];
"1_2" -> "2_2"  [color=blue];
"1_0" -> "2_1"  [color=blue];
"1_1" -> "2_2"  [color=blue];
"1_0" -> "3_1"  [color=brown];
"2_0" -> "3_0"  [color=gray70, style=invis, weight=1000];
"2_1" -> "3_1"  [color=gray70, style=invis, weight=1000];
"2_2" -> "3_2"  [color=gray70, style=invis, weight=1000];
subgraph  {
rank=same;
rankdir=TB;
"2_0" [label="2_0"];
"2_0" -> "2_1"  [color=gray70, style=invis, weight=1000];
"2_1" [label="2_1"];
"2_1" -> "2_2"  [color=gray70, style=invis, weight=1000];
"2_2" [label="2_2"];
}
"2_0" -> "2_1"  [color=orange];
"2_0" -> "3_0"  [color=blue];
"2_1" -> "3_1"  [color=blue];
"2_2" -> "3_2"  [color=blue];
"2_0" -> "3_1"  [color=blue];
"2_1" -> "3_2"  [color=blue];
"2_0" -> "4_1"  [color=brown];
"3_0" -> "4_0"  [color=gray70, style=invis, weight=1000];
"3_1" -> "4_1"  [color=gray70, style=invis, weight=1000];
"3_2" -> "4_2"  [color=gray70, style=invis, weight=1000];
subgraph  {
rank=same;
rankdir=TB;
"3_0" [label="3_0"];
"3_0" -> "3_1"  [color=gray70, style=invis, weight=1000];
"3_1" [label="3_1"];
"3_1" -> "3_2"  [color=gray70, style=invis, weight=1000];
"3_2" [label="3_2"];
}
"3_0" -> "3_1"  [color=orange];
"3_0" -> "4_0"  [color=blue];
"3_1" -> "4_1"  [color=blue];
"3_2" -> "4_2"  [color=blue];
"3_0" -> "4_1"  [color=blue];
"3_1" -> "4_2"  [color=blue];
subgraph  {
rank=same;
rankdir=TB;
"4_0" [color=gray70, label="4_0", style=invis];
"4_0" -> "4_1"  [color=gray70, style=invis, weight=1000];
"4_1" [color=gray70, label="4_1", style=invis];
"4_1" -> "4_2"  [color=gray70, style=invis, weight=1000];
"4_2" [color=gray70, label="4_2", style=invis];
}
}

Solution

  • I think the trick in this case is to specify the full (grid-)graph and then make the unwanted parts invisible. Here is a minimal example for your case. (I have just left out the colors.)

    digraph{
    
    # Columns
    subgraph {
    "0_0" [style=invis]
    "0_1" [style=invis]
    "0_2" [style=invis]
    }
    
    subgraph  {
    "1_0"
    "1_1"
    "1_2"
    }
    
    subgraph  {
    "2_0"
    "2_1"
    "2_2"
    }
    
    subgraph  {
    "3_0"
    "3_1"
    "3_2"
    }
    
    subgraph  {
    "4_0" [style=invis]
    "4_1" [style=invis]
    "4_2" [style=invis]
    }
    
    # Rows
    subgraph {
    rank=same
    "0_0"
    "1_0"
    "2_0"
    "3_0"
    "4_0"
    }
    
    subgraph {
    rank=same
    "0_1"
    "1_1"
    "2_1"
    "3_1"
    "4_1"
    }
    
    subgraph {
    rank=same
    "0_2"
    "1_2"
    "2_2"
    "3_2"
    "4_2"
    }
    
    # Straight edges
    "0_0" -> "1_0"
    "0_1" -> "1_1"
    "0_2" -> "1_2"
    
    "1_0" -> "2_0"
    "1_1" -> "2_1"
    "1_2" -> "2_2"
    
    "2_0" -> "3_0"
    "2_1" -> "3_1"
    "2_2" -> "3_2"
    
    "3_0" -> "4_0"
    "3_1" -> "4_1"
    "3_2" -> "4_2"
    
    "0_0" -> "0_1" [style=invis]
    "1_0" -> "1_1"
    "2_0" -> "2_1"
    "3_0" -> "3_1"
    "4_0" -> "4_1" [style=invis]
    
    "0_1" -> "0_2" [style=invis]
    "1_1" -> "1_2" [style=invis]
    "2_1" -> "2_2" [style=invis]
    "3_1" -> "3_2" [style=invis]
    "4_1" -> "4_2" [style=invis]
    
    
    #  Diagonal edges
    "0_0" -> "1_1"
    "0_0" -> "2_1"
    "1_0" -> "3_1"
    "2_0" -> "4_1"
    "0_1" -> "1_2"
    "1_1" -> "2_2"
    "2_1" -> "3_2"
    "3_1" -> "4_2"
    }
    

    Graphviz output