How-To: Graph Layouts and Rendering Guide
Mix.install([
{:yog_ex, github: "code-shoily/yog_ex", branch: "main"},
{:kino, "~> 0.12.0"},
{:vega_lite, "~> 0.1.8"},
{:kino_vega_lite, "~> 0.1.11"}
])
Introduction
Graph layouts are algorithms that compute a 2D coordinate position for each node in a graph. While standard renderers like Graphviz (DOT) or Mermaid calculate layouts internally using external binaries or JavaScript engines, Yog.Layout calculates coordinates directly in pure Elixir.
Having the calculated coordinates (%{node_id => {x, y}}) inside Elixir gives you complete control over how to render and animate your graph.
In this guide, you will learn:
-
How to use
Yog.Layoutto compute coordinates for all 6 layout types: Circular, Random, Spring (Force-Directed), Tutte, Shell, and Multipartite. -
How to draw these layouts dynamically as interactive SVG graphics inside Livebook using
Yog.Render.SVG. -
How to plot layouts using Vega-Lite and
Yog.Render.VegaLite.
1. Calculating Layout Coordinates
First, let’s create a sample graph. We will build a simple “wheel graph” (a central hub node connected to a surrounding cycle).
alias Yog.Layout
# Build a wheel graph with 6 outer nodes and a central hub (node 0)
graph =
Yog.from_unweighted_edges(:undirected, [
{0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6},
{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 6}, {6, 1}
])
We can now compute the positions of the nodes using any of the available layout functions.
Circular Layout
Uniformly spaces nodes along the circumference of a circle.
Layout.circular(graph, radius: 10.0, center: {0.0, 0.0})
Random Layout
Uniformly distributes nodes inside a bounding box.
Layout.random(graph, width: 2.0, height: 2.0, seed: 42)
Spring Layout (Fruchterman-Reingold)
A force-directed layout where connected nodes attract (springs) and all nodes repel (charge).
For large graphs, computing repulsive forces between all pairs of nodes can be slow ($O(V^2)$ complexity). You can enable the Barnes-Hut approximation ($O(V \log V)$ complexity) by passing barnes_hut: true and an optional opening-angle threshold theta (default is 0.5):
Layout.spring(graph, iterations: 50, seed: 42, barnes_hut: true, theta: 0.5)
Tutte Layout (Barycentric Planar)
Embeds a planar graph with zero edge crossings by pinning a convex boundary and placing interior nodes at the barycenter of their neighbors.
# Pin nodes [1, 2, 3, 4, 5, 6] to the boundary circle, placing hub node 0 in the center
Layout.tutte(graph, [1, 2, 3, 4, 5, 6], iterations: 50, radius: 5.0)
Shell Layout (Concentric Circles)
Groups nodes into concentric circles.
# Place hub node 0 in the inner shell, cycle nodes in the outer shell
Layout.shell(graph, [[0], [1, 2, 3, 4, 5, 6]], radii: [0.2, 1.0])
Multipartite Layout (Layered Rows/Columns)
Arranges partitioned nodes in parallel rows or columns.
# Grouping into layers: layer 0: [0], layer 1: [1, 2], layer 2: [3, 4, 5, 6]
Layout.multipartite(graph, [[0], [1, 2], [3, 4, 5, 6]], align: :vertical, width: 4.0)
2. Rendering Graph Layouts in SVG (using Kino SVG)
Yog.Render.SVG converts a layout positions map and a graph into a raw XML/SVG string. Since this outputs a standard string, it can be rendered in Livebook by wrapping it in Kino.HTML.new/1.
Visualizing Circular Layout
pos = Layout.circular(graph, radius: 1.0)
svg = Yog.Render.SVG.to_svg(graph, pos, node_color: "#ef4444")
Kino.HTML.new(svg)
Visualizing Spring Force-Directed Layout
pos = Layout.spring(graph, iterations: 100, seed: 12)
svg = Yog.Render.SVG.to_svg(graph, pos, node_color: "#10b981")
Kino.HTML.new(svg)
Visualizing Tutte Layout
pos = Layout.tutte(graph, [1, 2, 3, 4, 5, 6], iterations: 50)
svg = Yog.Render.SVG.to_svg(graph, pos, node_color: "#8b5cf6")
Kino.HTML.new(svg)
Visualizing Multigraphs with Parallel Edges and Self-Loops
SVG rendering is especially powerful for multigraphs because parallel edges and self-loops require mathematically calculated Bézier curves. To lay out a multigraph, we collapse it to a simple graph to generate positions, and then render the full multigraph. Yog.Render.SVG handles parallel edge spacing automatically.
# Create a multigraph with multiple edges and a self-loop
multi =
Yog.Multi.undirected()
|> Yog.Multi.add_node(:a)
|> Yog.Multi.add_node(:b)
|> Yog.Multi.add_node(:c)
|> Yog.Multi.add_node(:d)
|> Yog.Multi.add_node(:e)
{multi, _} = Yog.Multi.add_edge(multi, :a, :b, "e1")
{multi, _} = Yog.Multi.add_edge(multi, :a, :b, "e2")
{multi, _} = Yog.Multi.add_edge(multi, :a, :b, "e3")
{multi, _} = Yog.Multi.add_edge(multi, :b, :c, "e4")
{multi, _} = Yog.Multi.add_edge(multi, :c, :d, "e5")
{multi, _} = Yog.Multi.add_edge(multi, :a, :d, "e6")
{multi, _} = Yog.Multi.add_edge(multi, :b, :d, "e7")
{multi, _} = Yog.Multi.add_edge(multi, :e, :a, "e8")
{multi, _} = Yog.Multi.add_edge(multi, :c, :c, "loop")
# Calculate layout on the collapsed simple graph representation
simple_graph = Yog.Multi.to_simple_graph(multi)
pos = Layout.spring(simple_graph, iterations: 220, seed: 33)
# Render the full multigraph showing parallel edges and self-loops
svg = Yog.Render.SVG.to_svg(multi, pos, node_color: "#f43f5e")
Kino.HTML.new(svg)
Visualizing Directed Graphs with Arrowheads
For directed graphs, Yog.Render.SVG renders direction arrows. To prevent arrowheads from being hidden under the node circles, the renderer automatically calculates the boundary intersection of the target node and truncates the edge line at the node boundary.
# Create a simple directed cycle graph
directed_graph = Yog.from_unweighted_edges(:directed, [
{1, 2}, {2, 3}, {3, 4}, {4, 1}
])
pos = Layout.circular(directed_graph)
# Render with arrowheads indicating edge direction
svg = Yog.Render.SVG.to_svg(directed_graph, pos, node_color: "#0ea5e9")
Kino.HTML.new(svg)
3. Rendering in Vega-Lite (using Kino Vega-Lite)
Yog.Render.VegaLite builds a multi-layered %VegaLite{} plot specification. We plot edges as line segment layers and nodes as scatter circles.
Visualizing Bipartite Layout (Multipartite)
Let’s construct a layered feedforward bipartite graph and lay it out vertically:
bipartite_graph =
Yog.from_unweighted_edges(:undirected, [
{"Input 1", "Hidden 1"}, {"Input 1", "Hidden 2"},
{"Input 2", "Hidden 1"}, {"Input 2", "Hidden 2"},
{"Input 3", "Hidden 2"}
])
# Define layers
layers = [
["Input 1", "Input 2", "Input 3"],
["Hidden 1", "Hidden 2"]
]
pos = Layout.multipartite(bipartite_graph, layers, align: :vertical, width: 5.0, height: 4.0)
Yog.Render.VegaLite.to_spec(bipartite_graph, pos, node_color: "#f59e0b")
Visualizing Concentric Shell Layout
Let’s group a larger cycle graph with a core star graph inside, and render it using a shell layout:
shell_graph =
Yog.from_unweighted_edges(:undirected, [
# Core star graph
{0, 1}, {0, 2}, {0, 3},
# Outer ring
{4, 5}, {5, 6}, {6, 7}, {7, 8}, {8, 4},
# Cross connections
{1, 4}, {2, 6}, {3, 8}
])
# Define shells
shells = [[0, 1, 2, 3], [4, 5, 6, 7, 8]]
pos = Layout.shell(shell_graph, shells, radii: [0.23, 0.10])
Yog.Render.VegaLite.to_spec(shell_graph, pos, node_color: "#ec4899")
4. SVG vs. Vega-Lite Rendering Comparison
When choosing between Yog.Render.SVG and Yog.Render.VegaLite, consider the characteristics of your graph:
| Feature |
Yog.Render.SVG |
Yog.Render.VegaLite |
|---|---|---|
| Output Type |
Raw SVG String (wrapped in Kino.HTML.new/1) |
%VegaLite{} specification (interactive plot) |
| Simple Graphs | Supported | Supported |
| Multigraphs (Parallel Edges) | Yes (rendered as curved parallel Bézier paths) | No (parallel edges will overlap as straight lines) |
| Self-Loops | Yes (rendered as loop Bézier curves) | No (loops will render as zero-length lines) |
| Directed Arrowheads | Yes (automatic offset to sit perfectly at node border) | No (no native line marker/offset support) |
| Interactivity | Basic hover effects (via CSS styling) | Advanced (pan, zoom, tooltips, selections) |
Choose SVG rendering for multigraphs, directed graphs with arrows, or when precise control over edge geometry is required. Choose Vega-Lite rendering for simple undirected graphs where interactive zooming or panning is preferred.