Plotting

Plotting requires loading a Makie backend before use. Node placement requires NetworkLayout.jl or Sugiyama.jl (or bring your own layout). Below we use CairoMakie:

using CausalStructures
using CairoMakie
using NetworkLayout
using Sugiyama
General-purpose by design

plot deliberately imposes no domain conventions of its own. There are many conventions in the literature, such as boxing conditioned variables, dashing latent variables, representing <-> as a dashed arc, or colouring exposures and outcomes. Rather than supporting any particular convention by default, plot exposes the underlying capabilities (styling, Makie themes, layouts) and leaves the choice of convention to the caller or a downstream package.

Basic usage

Pass any CausalGraph to plot. Every edge mark is supported natively:

unknown = UNKNOWN("A <-> B o-> C o-- A")
plot(unknown)

Let us plot the DAG from Figure 6.5 of Peters et al. (2017):

dag = DAG(
    "C --> X, A --> X + K, X --> F + D, K --> Y, D --> Y + G, Y --> H"
)
plot(dag)

plot returns a FigureAxisPlot, so fig, ax, plt = plot(dag) gives you back the Figure, its Axis, and the plot itself - and plt is reactive:

fig, ax, plt = plot(dag)
plt.node_color[] = :salmon
fig

Styling breaks down into four areas, covered below:

For a project-wide default you can use a Makie theme:

Makie.set_theme!(CausalGraphPlot = (node_color = :lightblue, linewidth = 2))

edge_color/edge_label_color also pick up the active theme's linecolor/textcolor; node_color/node_label_color stay fixed and should be set together if you want a dark node/label pairing, e.g. Makie.set_theme!(CausalGraphPlot = (node_color = :gray10, node_label_color = :white)).

Layout

The layout keyword controls node placement and defaults to :sugiyama: for a DAG if Sugiyama.jl is loaded, else :stress.

plot(dag; layout = :spring)

We provide these short-hand names for convenience. All except :sugiyama come from NetworkLayout.jl, while :sugiyama comes from Sugiyama.jl:

layoutAlgorithm
:springFruchterman-Reingold force-directed
:stressStress majorization
:sfdpScalable Force-Directed Placement
:spectralSpectral layout
:shellConcentric shells
:squaregridSquare grid
:sugiyamaSugiyama layout (DAGs only)

layout also accepts explicit positions instead of a Symbol: either a Dict of (x, y) pairs keyed by node name, or a Vector of them in the order returned by nodes(cg):

plot(dag; layout = Dict(
    :A => (0, 1), :C => (0, -1), :K => (1, 1), :X => (1, -1),
    :D => (2, -1), :F => (2, -2), :Y => (3, 0), :G => (3, 1), :H => (4, 0),
))
Tweaking a layout by hand

You can compute a starting layout using layout, and then manually adjust a few nodes:

positions = layout(dag, :spring)
positions[:A] = (0.0, 2.0)
plot(dag; layout = positions)
Sugiyama positions without Sugiyama routing

Sugiyama.jl implements it's own routing via dummy nodes. If you prefer the automatic routing with Bezier curves, you can pass layout = layout(dag_layered, :sugiyama) in plot:

dag_layered = DAG("A --> X, A --> B, X --> Y, B --> Y, A --> Y")
plot(dag_layered; layout = layout(dag_layered, :sugiyama))

Styling nodes

Each node style argument accepts either a scalar (applied to all nodes) or a Dict{Symbol, <value>} keyed by node name, with :default as a fallback.

KeywordDefaultControls
node_color:whitefill color
node_strokecolor:blackborder color
node_strokewidth2.0border line width
node_linestylenothing (solid)border line style
node_shape:circlenode outline shape
node_radiusnothing (text-fit, per node)size of each node
node_padding10.0clearance kept around each node's label when node_radius is nothing
arrow_size0.4 × node-count-based referencelength of arrowhead triangles
circle_size0.28 × node-count-based referenceradius of open-circle endpoints

Combine color, border, and shape to highlight a node:

plot(dag;
    node_color = Dict(:A => :salmon, :default => :lightblue),
    node_strokecolor = Dict(:A => :crimson, :default => :navy),
    node_shape = Dict(:K => :square, :default => :circle),
)

node_shape is one of :circle (the default), :square, :ellipse, or :rect; the latter two mainly exist to fit an oblong label. node_linestyle styles the border, e.g. to mark a latent variable:

plot(dag;
    node_linestyle = Dict(:X => :dash),
    node_strokecolor = Dict(:X => :gray50, :default => :black),
)

Text-fit node sizing

By default (node_radius = nothing), each node is sized to fit its own label:

longlabels = DAG("Exposure --> Mediator --> Y_outcome")
plot(longlabels; node_shape = Dict(:Exposure => :ellipse))

Alternatively, you can pass node_radius explicitly to control the size yourself:

plot(dag; node_radius = 0.06)

Styling edges

Each edge style argument accepts either a scalar or a Dict keyed by (and follows this precedence):

  1. a CausalEdge for one exact edge, e.g. bidirected(:X, :Y)
  2. a (src, dst) tuple for the node pair, in either order
  3. an edge-type symbol (:directed, :undirected, :bidirected, :partially_directed, :partially_undirected, :partial)
  4. :default as a fallback
KeywordDefaultControls
edge_color:blackline / marker color
arrow_fillnothingarrowhead fill color
linewidth1.5line width
edge_linestylenothing (solid)line style
curvaturenothinghow far the edge bows

Let's style some edges by type:

admg = ADMG("X --> Y, X <-> Z, Z --> Y")

plot(admg;
    edge_color = Dict(:directed => :steelblue, :bidirected => :crimson),
    linewidth  = Dict(:bidirected => 2.5, :default => 1.5),
)

arrow_fill is the arrowhead's fill color; nothing (the default) matches the edge's own resolved edge_color, so arrowheads render solid. Pass a transparent color for a hollow, outline-only arrowhead:

plot(dag; arrow_fill = :transparent)

edge_linestyle styles the line itself, e.g. to dash <-> edges:

plot(admg; edge_linestyle = Dict(:bidirected => :dash))

Targeting specific edges

A tuple key can be used to change something for a specific edge:

plot(dag;
    edge_color = Dict((:A, :X) => :red, :default => :black),
)

A tuple key uses an unordered node pair. However, an ADMG may carry both X --> Y and X <-> Y, and a tuple key would then apply to both of them. To distinguish them a CausalEdge can be used instead:

shared = ADMG("X --> Y, X <-> Y")

plot(shared;
    edge_color = Dict(bidirected(:X, :Y) => :crimson, :default => :steelblue),
)
Symmetric edges

Symmetric edges are stored in a canonical order, so bidirected(:Y, :X) is the same key as bidirected(:X, :Y).

Notice the automatic routing of the edges above! See more about it below.

Curvature and automatic routing

curvature bows an edge into an arc instead of drawing it straight. Positive values bow to the left as seen travelling from src to dst, negative to the right.

plot(admg; curvature = Dict(:bidirected => -0.3))

An edge whose straight src --> dst line would pass too close to a non-incident node is automatically bent around it as a Bezier curve, rather than being drawn straight through it. Edges with nothing in their way are always drawn straight.

detour = DAG("A --> X + Y, X --> Y")

plot(detour; layout = [(0, 0), (1, 0), (2, 0)])

curvature can also be used to disable this behavior:

plot(detour;
    layout = [(0, 0), (1, 0), (2, 0)],
    curvature = Dict(directed(:A, :Y) => 0.0),
)

Explicit edge paths

Normally, edges are drawn as straight lines, except when automatic curvature is needed. You can override the edge path explicitly with edge_paths by providing intermediate points for an edge. For example, the edge K --> Y below is drawn through the point (0.5, -0.25):

positions = layout(dag, :spring)

plot(dag;
    layout = positions,
    edge_paths = Dict((:K, :Y) => [positions[:K], (0.5, -0.25), positions[:Y]]),
)

Labels and titles

Labels

Each label style argument accepts either a scalar or a Dict{Symbol, <value>} keyed by node name, with :default as a fallback (same resolution rules as node styling).

KeywordDefaultControls
node_labelsnothingtext drawn in each node
node_label_color:blacknode label text color
node_label_fontsize14.0node label font size
node_label_font:regularnode label font

By default each node is labelled with its own name. node_labels can be used to overwrite this; node sizing accounts for multi-line labels, so the nodes grow to fit:

plot(DAG("A0 --> L1 --> A1 --> Y, A0 --> Y + A1");
    node_labels = Dict(
        :A0 => "Treatment\nat baseline",
        :L1 => "Confounder\nat time 1",
        :A1 => "Treatment\nat time 1",
    ),
)

node_label_color and node_label_fontsize style the label text itself:

plot(dag;
    node_label_color = Dict(:A => :crimson, :default => :black),
    node_label_fontsize = 18,
)

Edge labels

Each edge label style argument accepts either a scalar or a Dict for per-edge overrides, using the same keying rules as other edge styling (a CausalEdge, a (src, dst) tuple, an edge-type symbol, or :default).

KeywordDefaultControls
edge_labelsnothingtext drawn along each edge
edge_label_color:blackedge label text color
edge_label_fontsize12.0edge label font size
edge_label_font:regularedge label font
edge_label_shift0.5position along the edge, 0 (source) to 1 (destination)
edge_label_distancenothingperpendicular gap (pixels) from the edge; nothing scales with edge_label_fontsize
edge_label_rotationnothingtext angle in radians; nothing follows the edge's own angle
plot(dag; edge_labels = Dict(directed(:K, :Y) => "hello"))

By default the label follows the edge's own angle, while edge_label_shift/edge_label_distance move it along/off that path:

plot(dag;
    edge_labels = Dict(directed(:K, :Y) => "hi"),
    edge_label_shift = 0.75,
    edge_label_distance = 12,
)

For a steep or curved edge, following the edge's angle can leave the label hard to read; edge_label_rotation overrides it with a fixed angle instead:

plot(dag;
    edge_labels = Dict(directed(:A, :X) => "steep"),
    edge_label_rotation = 0.0,
)

Titles

Pass title to add a plot title (nothing by default, i.e. no title). title_fontsize and title_color style it; left as nothing, they fall back to the current Makie theme's axis-title defaults. title_gap (default 4.0, points) controls the spacing between the title and the graph.

plot(dag; title = "My DAG", title_fontsize = 20, title_color = :navy)

Figure size and margins

KeywordDefaultControls
outer_margin16padding (pixels) around the whole figure
title_gap4.0gap (points) between title and the graph
fig_size(600, 450)figure size in pixels (width, height)
stretch_to_fig_sizefalsestretch the layout to fill an uneven fig_size
plot(dag; fig_size = (800, 600))
Large graphs need a bigger `fig_size`

The default (600, 450) is sized for small examples. As the number of nodes grows, labels and edges get cramped and can overlap; increase fig_size (and node_radius/node_label_fontsize if needed) to keep larger causal graphs readable.

Uneven `fig_size` and `stretch_to_fig_size`

Node positions keep the layout's own aspect ratio by default, so depending on the chosen fig_size you can get a lot of empty space in the plot. Pass stretch_to_fig_size = true to disable this.

Composing into an existing figure

So far we've only used plot, which builds its own Figure and Axis for you. If you already have an Axis (say, one panel of a bigger figure), plot!(ax, cg; kwargs...) draws into that instead, with the same keywords as plot above except outer_margin, title_gap, fig_size, and stretch_to_fig_size, since those size the figure plot builds for you.

This is how you put two graphs side by side, or mix one in with other plots:

fig = Figure(size = (900, 400))
plot!(Axis(fig[1, 1]; aspect = DataAspect()), dag)
plot!(Axis(fig[1, 2]; aspect = DataAspect()), admg; node_color = :salmon)
Makie.hidedecorations!.(fig.content)
Makie.hidespines!.(fig.content)
fig

Combining options

Here we plot a PAG where we combine a bunch of the styling options from above:

pag = PAG(
    "C o-> X, D --> G + Y, X --> D + F, Y --> H, K o-> X, K --> Y"
)

plot(
    pag;
    layout           = :spring,
    node_color       = Dict(:X => :skyblue, :Y => :gold, :default => :whitesmoke),
    node_strokecolor = Dict(:X => :royalblue, :Y => :darkorange, :default => :slategray),
    node_shape       = Dict(:K => :square, :default => :circle),
    node_linestyle   = Dict(:K => :dash, :default => nothing),
    edge_color       = Dict(:partially_directed => :royalblue, :default => :darkslategray),
    edge_linestyle   = Dict(:partially_directed => :dash),
    curvature        = Dict((:K, :Y) => 0.3),
    edge_labels      = Dict((:K, :X) => "cool"),
    node_label_color = Dict(:X => :navy, :Y => :saddlebrown, :default => :black),
    title            = "A cool PAG",
    title_fontsize   = 18,
    title_color      = :navy,
    fig_size         = (700, 500),
)