Powered by AppSignal & Oban Pro

How-To: Graph Layouts and Rendering Guide

livebooks/how_to/layout_guide.livemd

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:

  1. How to use Yog.Layout to compute coordinates for all 6 layout types: Circular, Random, Spring (Force-Directed), Tutte, Shell, and Multipartite.
  2. How to draw these layouts dynamically as interactive SVG graphics inside Livebook using Yog.Render.SVG.
  3. 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.