Large-Scale Graph Layouts & 2D Visualization with Zog ⚡
Mix.install([
# {:zog, "~> 0.6.0"},
{:zog, path: Path.expand("~/repos/elixir/zog")},
{:kino, "~> 0.12"},
{:jason, "~> 1.4"}
])
1. Overview & Zog Layout Architecture
Network visualization is critical for understanding graph topologies, community modularity, and structural clustering. Traditional BEAM graph layouts either rely on slow pure-Elixir iterative loops or require external command-line tools like Graphviz.
Zog provides high-performance, native 2D layout algorithms backed by Zig engines:
- Pivot-MDS (High-Dimensional Embedding): Sub-second classical multidimensional scaling using distances from $k$ well-separated pivot nodes ($O(k(V + E))$). Capable of embedding $100,000+$ nodes in under a second.
- Multi-Level Coarsening: Macro-layout via Louvain/Leiden super-nodes, Vogel/sunflower spiral prolongation, and local Barnes-Hut force refinement.
- Spring / Force-Directed: Fruchterman-Reingold physical model with exact $O(V^2)$ repulsion or $O(V \log V)$ Barnes-Hut quadtree spatial acceleration.
- Geometric Layouts: Uniform Circular, Shell (concentric $k$-core shells), Multipartite (hierarchical layers), Grid, and Random.
- Planar Barycentric Embedding: Tutte's planar embedding via Gauss-Seidel relaxation.
- Packed Binary Streaming: Compact little-endian float buffers (
<<x::float-32, y::float-32>>) suitable for WebGL / Canvas / Sigma.js integrations.
2. Ingesting Real-World Social Circles (SNAP ego-Facebook)
Let's test our layout engine on the SNAP ego-Facebook social network (4,039 nodes, 88,234 edges). If the dataset is not already present, we download and decompress it:
{:ok, txt_path} = Zog.Dataset.fetch_snap(:facebook)
Kino.Markdown.new("""
> **Dataset Ready**: `#{txt_path}` (~4,039 users, 88,234 friendship connections).
""")
Now let's ingest the graph into native memory:
alias Zog.IO, as: ZogIO
alias Zog.ResourceGraph
{load_time_micro, graph} =
:timer.tc(fn ->
ZogIO.load(txt_path, directed: false, integer_labels: true)
end)
node_count = ResourceGraph.node_count(graph)
edge_count = ResourceGraph.edge_count(graph)
undirected_edges = div(edge_count, 2)
Kino.Markdown.new("""
### ⚡ Graph Loaded into Native Memory
- **Nodes**: `#{node_count}`
- **Undirected Friendship Connections**: `#{undirected_edges}`
- **Stored Directed Half-Edges**: `#{edge_count}`
- **Native Ingestion Time**: **#{Float.round(load_time_micro / 1000, 2)} ms**
""")
3. Detecting Communities for Cluster Coloring
To visualize structural communities, let's partition the network using native Louvain modularity optimization:
{time_louvain_micro, communities} =
:timer.tc(fn ->
ResourceGraph.louvain(graph)
end)
# Find top communities
top_comms =
communities
|> Enum.group_by(fn {_node, comm_id} -> comm_id end)
|> Enum.map(fn {comm_id, members} -> {comm_id, length(members)} end)
|> Enum.sort_by(&elem(&1, 1), :desc)
Kino.Markdown.new("""
### 🎨 Community Detection (Louvain)
- **Time**: **#{Float.round(time_louvain_micro / 1000, 2)} ms**
- **Discovered Communities**: `#{length(top_comms)}`
- **Top 5 Largest Circles**: `#{inspect(Enum.take(top_comms, 5))}`
""")
4. Benchmark: Layout Engine Performance Comparison
Let's benchmark the layout algorithms side-by-side on this 4,039-node network:
# 1. Circular
{t_circ, _} = :timer.tc(fn -> ResourceGraph.layout_circular(graph) end)
# 2. Shell (grouped by Louvain community)
{t_shell, _} =
:timer.tc(fn ->
shells =
communities
|> Enum.group_by(fn {_node, c} -> c end, fn {node, _} -> node end)
|> Map.values()
ResourceGraph.layout_shell(graph, shells)
end)
# 3. Pivot-MDS (k = 50 pivots)
{t_pmds, _} =
:timer.tc(fn ->
ResourceGraph.layout_pivot_mds(graph, pivots: 50, seed: 42)
end)
# 4. Multi-Level Coarsening (Louvain macro + 20 Barnes-Hut refine iterations)
{t_ml, _} =
:timer.tc(fn ->
ResourceGraph.layout_multi_level(graph, refine_iterations: 20, seed: 42)
end)
# 5. Spring with Barnes-Hut Quadtree (50 iterations)
{t_bh, _} =
:timer.tc(fn ->
ResourceGraph.layout_spring(graph, barnes_hut: true, iterations: 50, seed: 42)
end)
benchmark_rows = [
%{
"Algorithm" => "Pivot-MDS (HDE, 50 pivots)",
"Execution Time (ms)" => Float.round(t_pmds / 1000, 2),
"Throughput (nodes/s)" => round(node_count / (t_pmds / 1_000_000)),
"Complexity" => "O(k · (V + E))",
"Best For" => "Instant macro projection for massive networks (10⁵+)"
},
%{
"Algorithm" => "Multi-Level Coarsening",
"Execution Time (ms)" => Float.round(t_ml / 1000, 2),
"Throughput (nodes/s)" => round(node_count / (t_ml / 1_000_000)),
"Complexity" => "O(C² + V log V)",
"Best For" => "Untangled modular cluster separation"
},
%{
"Algorithm" => "Spring (Barnes-Hut Quadtree, 50 iters)",
"Execution Time (ms)" => Float.round(t_bh / 1000, 2),
"Throughput (nodes/s)" => round(node_count / (t_bh / 1_000_000)),
"Complexity" => "O(iters · V log V)",
"Best For" => "Equilibrium force-directed physical models"
},
%{
"Algorithm" => "Shell Layout (by Community)",
"Execution Time (ms)" => Float.round(t_shell / 1000, 2),
"Throughput (nodes/s)" => round(node_count / (t_shell / 1_000_000)),
"Complexity" => "O(V)",
"Best For" => "Concentric hierarchy and k-core visualization"
},
%{
"Algorithm" => "Circular Layout",
"Execution Time (ms)" => Float.round(t_circ / 1000, 2),
"Throughput (nodes/s)" => round(node_count / (t_circ / 1_000_000)),
"Complexity" => "O(V)",
"Best For" => "Uniform chord / circular overview"
}
]
Kino.Layout.grid(
[
Kino.Markdown.new("""
### 🏆 2D Layout Benchmark Results (N = 4,039, E = 88,234)
"""),
Kino.DataTable.new(benchmark_rows, name: "Layout Performance Benchmark")
],
columns: 1
)
5. Packed Binary Streaming (format: :binary)
Zog supports direct binary coordinate output. Instead of constructing $4,000+$ Elixir map keys and float tuples, Zig returns a single contiguous memory buffer containing packed little-endian 32-bit floats:
$$\text{Buffer} = \Big\langle \underbrace{x_0, y_0}{8\text{ bytes}}, \underbrace{x_1, y_1}{8\text{ bytes}}, \dots, \underbrace{x_{N-1}, y_{N-1}}_{8\text{ bytes}} \Big\rangle$$
bin = ResourceGraph.layout_pivot_mds(graph, pivots: 50, format: :binary)
byte_len = byte_size(bin)
expected_len = node_count * 8
Kino.Markdown.new("""
### 📦 Binary Streaming Verification
- **Packed Buffer Size**: `#{byte_len}` bytes (exact match with `#{node_count} × 8 bytes` = `#{expected_len}`).
- **Memory Footprint**: Only **#{Float.round(byte_len / 1024, 2)} KB**!
- **Compact Binary Format**: Decodable in JavaScript via `new Float32Array(buffer)` without coordinate JSON parsing.
""")
6. Interactive Livebook Canvas Visualizer
Let's build an interactive HTML5 Canvas visualizer. Select the layout algorithm below to re-render the network in real-time:
layout_selector =
Kino.Input.select(
"Layout Algorithm",
[
multi_level: "Multi-Level Coarsening (Louvain + Barnes-Hut)",
pivot_mds: "Pivot-MDS (High-Dimensional Embedding)",
spring: "Spring Force-Directed (Barnes-Hut, 30 iters)",
circular: "Circular Layout",
shell: "Shell Layout (by Community)"
]
)
Kino.Layout.grid([layout_selector], columns: 1)
selected_algo = Kino.Input.read(layout_selector)
# Canvas dimensions
canvas_w = 800
canvas_h = 600
# Compute layout
{layout_time_micro, pos_map} =
:timer.tc(fn ->
case selected_algo do
:multi_level ->
ResourceGraph.layout_multi_level(graph,
width: canvas_w - 80,
height: canvas_h - 80,
center: {canvas_w / 2, canvas_h / 2},
refine_iterations: 20,
seed: 42
)
:pivot_mds ->
ResourceGraph.layout_pivot_mds(graph,
pivots: 50,
width: canvas_w - 80,
height: canvas_h - 80,
center: {canvas_w / 2, canvas_h / 2},
seed: 42
)
:spring ->
ResourceGraph.layout_spring(graph,
barnes_hut: true,
iterations: 30,
width: canvas_w - 80,
height: canvas_h - 80,
center: {canvas_w / 2, canvas_h / 2},
seed: 42
)
:circular ->
ResourceGraph.layout_circular(graph,
radius: min(canvas_w, canvas_h) * 0.42,
center: {canvas_w / 2, canvas_h / 2}
)
:shell ->
shells =
communities
|> Enum.group_by(fn {_node, c} -> c end, fn {node, _} -> node end)
|> Map.values()
ResourceGraph.layout_shell(graph, shells,
center: {canvas_w / 2, canvas_h / 2}
)
end
end)
Zog.Kino.render(graph, pos_map,
edge_file: txt_path,
color_by: communities,
title: "Visualizing #{node_count} Nodes & 88,234 Edges",
subtitle: "(Algorithm: #{selected_algo}) • Layout computed in #{Float.round(layout_time_micro / 1000, 2)} ms",
width: canvas_w,
height: canvas_h
)
7. Ultra-Scale Demo: Pivot-MDS on 50,000 Nodes
How does Pivot-MDS perform on an order-of-magnitude larger network? Let's generate a synthetic 50,000-node ring-plus-chord graph and run Pivot-MDS:
large_n = 50_000
IO.puts("Generating synthetic graph with #{large_n} nodes...")
# Build a deterministic ring + modular chord graph in SoA
{t_gen, large_graph} =
:timer.tc(fn ->
Enum.reduce(0..(large_n - 1), Zog.undirected(), fn i, acc ->
# Ring topology
acc = Zog.add_edge(acc, i, rem(i + 1, large_n), 1.0)
# Random chords
target = rem(i * 7919 + 31, large_n)
Zog.add_edge(acc, i, target, 1.0)
end)
end)
IO.puts("Running Pivot-MDS (k = 30 pivots) on #{large_n} nodes...")
{t_pmds_large, pos_large} =
:timer.tc(fn ->
Zog.layout_pivot_mds(large_graph, pivots: 30, format: :binary, seed: 42)
end)
# Extract edges for GPU line rendering
{from_ids, to_ids, _} = Zog.to_edge_arrays(large_graph)
large_edges_bin =
Enum.zip(from_ids, to_ids)
|> Enum.filter(fn {u, v} -> u < v end)
|> Enum.map(fn {u, v} -> <<u::unsigned-32-little, v::unsigned-32-little>> end)
|> IO.iodata_to_binary()
b64_large_edges = Base.encode64(large_edges_bin)
large_edge_count = div(byte_size(large_edges_bin), 8)
Kino.Markdown.new("""
### 🚀 Pivot-MDS on 50,000 Nodes & #{large_edge_count} Edges
- **Graph Generation**: `#{Float.round(t_gen / 1000, 2)} ms`
- **Pivot-MDS Execution**: **#{Float.round(t_pmds_large / 1000, 2)} ms**
- **Throughput**: **#{round(large_n / (t_pmds_large / 1_000_000))} nodes/sec**
- **Result**: Complete 2D coordinates embedded in **sub-second time**!
""")
8. WebGL Hardware-Accelerated Rendering (50,000 Nodes & 100,000 Edges at 60 FPS)
Traditional DOM or SVG elements choke when rendering more than 1,000 elements.
With Zog's format: :binary, the 50,000 {x, y} float coordinates and 100,000 edge indices can be represented as compact buffers for WebGL VBO/EBO upload:
webgl_canvas_w = 800
webgl_canvas_h = 600
# Stream packed binary coordinates and edge indices directly into GPU VBO/EBO
Zog.Kino.render(large_graph, pos_large,
edges: large_edges_bin,
engine: :webgl,
title: "WebGL GPU Shaders: 50,000 Nodes & #{large_edge_count} Edges",
subtitle: "Compact VBO/EBO Buffers • 60 FPS GPU Pipeline",
width: webgl_canvas_w,
height: webgl_canvas_h
)
9. Clean Native Memory
Always release native allocations when done:
ResourceGraph.destroy(graph)
Kino.Markdown.new("✓ Native memory successfully freed.")