How-To: Graph Layouts and Rendering Guide

Copy Markdown View Source
Mix.install([
  {:yog_ex, "~> 0.99.0"},
  {:kino, "~> 0.12.0"}
])

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.

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)

Summary

Yog.Layout gives you the mathematical foundation to position nodes, and Yog.Render.SVG provides a clean, dependency-free visualizer in Elixir.