How-To: Controlled Placement and Coordinate Workflows
Mix.install([{:yog_ex, path: "../.."}, {:kino, "~> 0.12.0"}])
alias Yog.Layout
Introduction
Yog.Layout is a pure Elixir coordinate layer that sits below any rendering
backend. It produces a plain Elixir map of the form %{node_id => {x, y}} — no
external processes, no side effects. You can then pipe those coordinates into
Yog.Render.SVG, Yog.Render.DOT, a Phoenix LiveView component, a JSON API
response, or anything else you like.
This means your layout logic is:
- Deterministic — seed-based random algorithms produce identical results across machines, CI runs, and snapshot tests.
- Testable — just assert on map values; no visual diffing required.
- Renderer-agnostic — swap SVG for DOT for Cytoscape.js without touching the layout code.
This guide covers the full controlled-placement workflow:
- Manual placement with fallback strategies
- Grid placement for structured diagrams
- Multipartite (layered) placement for pipelines
- Transform and fit pipeline for multi-view layouts
-
DOT fixed-position output with
neato/fdp - Importing positions from a GraphViz layout run
- Geometry helpers (coming soon)
- When to use GraphViz rank controls vs explicit coordinates
Section 1: Manual Placement
Layout.manual/3 lets you specify exact coordinates for whichever nodes you
care about and delegate the rest to a missing strategy.
# A small service graph: api gateway, two services, one shared db
graph =
Yog.from_unweighted_edges(:directed, [
{:gateway, :auth},
{:gateway, :catalog},
{:auth, :db},
{:catalog, :db}
])
# Pin the nodes we care about precisely; let :db fall to :center
pinned = %{
gateway: {400.0, 50.0},
auth: {200.0, 250.0},
catalog: {600.0, 250.0}
}
pos =
Layout.manual(graph, pinned,
missing: :center,
center: {400.0, 450.0}
)
IO.inspect(pos, label: "positions")
The missing: option accepts several strategies:
| Value | Behaviour |
|---|---|
:error |
Raises if any node lacks a coordinate |
:center |
Places missing nodes at center: |
:ignore |
Omits missing nodes from the result |
:random |
Uniform random inside the default box |
{:random, opts} |
Random with custom width:, height:, center: |
fn id -> {x, y} end |
Custom generator per node |
# Custom generator: stack unknown nodes vertically on the right margin
pos_custom =
Layout.manual(
graph,
%{gateway: {400.0, 50.0}},
missing: fn _id -> {750.0, :rand.uniform() * 500.0} end
)
IO.inspect(Map.keys(pos_custom) |> Enum.sort(), label: "all nodes covered")
Section 2: Grid Placement
Layout.grid/2 places nodes on a deterministic 2D grid from a :rows (or
:columns) specification. Use nil as a placeholder for empty cells.
# 3-column architecture diagram:
# Row 0: [client, nil, loadbalancer]
# Row 1: [api_a, api_b, api_c ]
# Row 2: [nil, db, nil ]
arch_graph =
Yog.undirected()
|> Yog.add_nodes_from([:client, :loadbalancer, :api_a, :api_b, :api_c, :db])
grid_pos =
Layout.grid(arch_graph,
rows: [
[:client, nil, :loadbalancer],
[:api_a, :api_b, :api_c],
[nil, :db, nil]
],
cell: {200.0, 150.0},
origin: {0.0, 0.0}
)
IO.inspect(grid_pos, label: "grid positions")
The result maps every real node to {col * cell_width, row * cell_height}.
nil slots are skipped — they just keep subsequent nodes aligned:
# Verify the loadbalancer is at column 2, row 0 => {400.0, 0.0}
{lx, ly} = grid_pos[:loadbalancer]
IO.puts("loadbalancer => x=#{lx}, y=#{ly}")
# db is at column 1, row 2 => {200.0, 300.0}
{dx, dy} = grid_pos[:db]
IO.puts("db => x=#{dx}, y=#{dy}")
Section 3: Multipartite with Spacing
Layout.multipartite/3 with :direction gives you an explicit layer-gap /
node-gap model — ideal for pipeline and flow-network diagrams.
# Pipeline: source -> [parse, validate] -> [transform, enrich] -> sink
pipeline =
Yog.from_unweighted_edges(:directed, [
{:source, :parse},
{:source, :validate},
{:parse, :transform},
{:validate, :enrich},
{:transform, :sink},
{:enrich, :sink}
])
layers = [
[:source],
[:parse, :validate],
[:transform, :enrich],
[:sink]
]
pipe_pos =
Layout.multipartite(pipeline, layers,
direction: :left_to_right,
layer_gap: 180.0,
node_gap: 100.0,
align_nodes: :center
)
IO.inspect(pipe_pos, label: "pipeline positions")
Key options for the :direction mode:
| Option | Effect |
|---|---|
:direction |
:left_to_right, :right_to_left, :top_to_bottom, :bottom_to_top |
:layer_gap |
Distance (px) between consecutive layers |
:node_gap |
Distance (px) between nodes within a layer |
:align_nodes |
:start, :center, or :end for cross-axis alignment |
:origin |
{x, y} top-left origin of the layout |
Section 4: Transform and Fit Pipeline
This section shows a common multi-view pattern: compute two independent layouts, pack them side-by-side, then fit the combined result into a viewport.
# Two separate subgraphs
graph_a =
Yog.from_unweighted_edges(:undirected, [{:a1, :a2}, {:a2, :a3}, {:a3, :a1}])
graph_b =
Yog.from_unweighted_edges(:undirected, [
{:b1, :b2}, {:b2, :b3}, {:b3, :b4}, {:b4, :b1}
])
# Independent circular layouts
pos_a = Layout.circular(graph_a, radius: 50.0)
pos_b = Layout.circular(graph_b, radius: 70.0)
# Bounding boxes before packing
IO.inspect(Layout.bounds(pos_a), label: "bounds(pos_a) before pack")
IO.inspect(Layout.bounds(pos_b), label: "bounds(pos_b) before pack")
# Pack side-by-side with a 20px gap between bounding boxes
combined = Layout.pack([pos_a, pos_b], direction: :horizontal, gap: 20.0)
IO.inspect(Layout.bounds(combined), label: "bounds(combined) after pack")
# Fit the combined layout into an 800x600 viewport with 20px padding
fitted = Layout.fit(combined, width: 800.0, height: 600.0, padding: 20.0)
IO.inspect(Layout.bounds(fitted), label: "bounds(fitted)")
The full chain reads left-to-right and is entirely functional:
result =
[
Layout.circular(graph_a, radius: 50.0),
Layout.circular(graph_b, radius: 70.0)
]
|> Layout.pack(direction: :horizontal, gap: 20.0)
|> Layout.fit(width: 800.0, height: 600.0, padding: 20.0)
Layout.bounds(result)
Section 5: DOT Fixed-Position Output
Yog.Render.DOT.to_dot/2 accepts a :positions map and a :pin flag. When
positions are supplied, every node gets a pos="x,y" attribute embedded in the
DOT output.
Note: Only the
neatoandfdpGraphviz engines respectpos=attributes and use them as starting or fixed positions. The defaultdotengine uses rank-based layout and ignorespos=entirely. Passpin: trueto lock nodes in place and preventneato/fdpfrom nudging them.
service_graph =
Yog.from_unweighted_edges(:directed, [
{:gateway, :auth},
{:gateway, :catalog},
{:auth, :db},
{:catalog, :db}
])
# Build explicit positions
service_pos = %{
gateway: {400.0, 50.0},
auth: {200.0, 300.0},
catalog: {600.0, 300.0},
db: {400.0, 550.0}
}
dot_opts = %{
Yog.Render.DOT.default_options()
| positions: service_pos,
pin: true,
graph_name: "Services"
}
dot_string = Yog.Render.DOT.to_dot(service_graph, dot_opts)
IO.puts(dot_string)
To render with fixed positions, save dot_string to a file and run:
neato -Tsvg -n services.dot -o services.svg
# or
fdp -Tsvg services.dot -o services.svg
The -n flag tells neato to skip its own layout pass and use the embedded
pos= coordinates directly.
Section 6: GraphViz Layout Import
Layout.graphviz/2 calls the dot (or another) GraphViz engine as an external
process, parses the output, and returns a standard %{node_id => {x, y}} map.
You can then pipe it through any of the transform helpers.
Requires: GraphViz CLI installed (
dot,neato,fdp, …).
import_graph =
Yog.from_unweighted_edges(:directed, [
{1, 2}, {1, 3}, {2, 4}, {3, 4}, {4, 5}
])
try do
pos = Layout.graphviz(import_graph, engine: :dot)
fitted = Layout.fit(pos, width: 800.0, height: 600.0)
IO.inspect(Layout.bounds(fitted), label: "fitted GraphViz layout bounds")
fitted
rescue
RuntimeError ->
IO.puts("GraphViz not available in this environment — skipping.")
%{}
end
Once you have the positions map back in Elixir you can combine it with other
layouts using pack/2, annotate it with translate/3 or scale/2, and render
it via any backend.
Section 7: Geometry Helpers (Rect-Based Renderers)
Yog.Layout coordinates are center-based {x, y} floats. Rect-based renderers (Excalidraw,
canvas, D3, Konva, SVG foreignObject) need bounding boxes and connector endpoints instead.
The geometry helpers bridge that gap without any external dependency.
# Build a small directed graph and compute spring positions
pipeline =
Yog.from_unweighted_edges(:directed, [
{:input, :parse},
{:parse, :validate},
{:validate, :output}
])
pos = Layout.spring(pipeline, iterations: 80, seed: 7)
|> Layout.fit(width: 600.0, height: 200.0, padding: 40.0)
# Convert center positions to bounding rectangles {left_x, top_y, width, height}
node_w = 100.0
node_h = 40.0
rects = Layout.rects(pos, size: {node_w, node_h})
# Inspect the rectangle for the :input node
{x, y, w, h} = rects[:input]
IO.puts("input node rect — x=#{x}, y=#{y}, w=#{w}, h=#{h}")
# Get anchor points on the rect boundary
right_anchor = Layout.anchor(rects[:input], :right)
center_anchor = Layout.anchor(rects[:input], :center)
IO.inspect(right_anchor, label: "right anchor")
IO.inspect(center_anchor, label: "center anchor")
# Compute per-edge connector endpoints (clips to the nearest side midpoint)
edges = [
{:input, :parse},
{:parse, :validate},
{:validate, :output}
]
endpoints = Layout.edge_endpoints(pos, edges, node_size: {node_w, node_h})
for {from_pt, to_pt} <- endpoints do
IO.inspect({from_pt, to_pt}, label: "edge")
end
The :node_size option accepts either a uniform {w, h} tuple or an arity-2 function
fn node_id, {cx, cy} -> {w, h} end for per-node dimensions.
Section 8: When to Use GraphViz Rank Controls vs Explicit Coordinates
Both approaches produce node positions, but they optimise for different goals.
GraphViz rank attributes (rankdir, rank=same)
# Use DOT rank constraints when you want Graphviz to figure out the layout
ranked_opts = %{
Yog.Render.DOT.default_options()
| rankdir: :lr,
ranks: [
{:source, [:gateway]},
{:same, [:auth, :catalog]},
{:sink, [:db]}
]
}
ranked_dot = Yog.Render.DOT.to_dot(service_graph, ranked_opts)
# Print first 400 chars as a sanity check
IO.puts(String.slice(ranked_dot, 0, 400))
GraphViz rank controls are great when:
- You have a DAG and want automatic hierarchical placement
- You need Graphviz’s sophisticated edge routing (curved splines, port attachment)
- You’re happy to depend on an external binary at render time
- The exact pixel positions don’t matter — only the topological order does
Explicit Yog.Layout coordinates
Explicit coordinates are great when:
- You need reproducible, seed-deterministic positions (snapshot tests, docs)
- You’re driving a renderer that isn’t Graphviz (SVG, canvas, web, PDF)
- You’re building an interactive diagram editor where users drag nodes
-
You want to combine multiple independent subgraphs with precise spacing using
pack/2 - You need no external binary dependency (pure Elixir, works in CI)
The two approaches are also composable: import positions from Layout.graphviz/2
to get a rough automatic layout, then refine specific nodes with Layout.manual/3.
Appendix: All Layout Algorithms Quick Reference
| Algorithm | Function | Best For |
|---|---|---|
| Circular |
circular/2 |
Cycles, symmetric small graphs |
| Random |
random/2 |
Baseline checks, initial states |
| Spring |
spring/2 |
Social networks, general undirected |
| Tutte |
tutte/3 |
Planar graphs, zero edge-crossing embedding |
| Shell |
shell/3 |
Core-periphery, hierarchical groups |
| Multipartite |
multipartite/3 |
Pipelines, bipartite, neural nets |
| Grid |
grid/2 |
Architecture diagrams, structured layouts |
| Manual |
manual/3 |
Fully controlled, pixel-exact placement |
| GraphViz |
graphviz/2 |
Import external engine results into Elixir |
SVG Rendering Examples
All algorithms produce the same shape of output — a positions map — so the SVG rendering code is always the same wrapper:
# Build a wheel graph for demonstration
demo_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}
])
# Circular layout
pos_circ = Layout.circular(demo_graph, radius: 1.0)
Kino.HTML.new(Yog.Render.SVG.to_svg(demo_graph, pos_circ, node_color: "#3b82f6"))
# Spring layout (force-directed)
pos_spring = Layout.spring(demo_graph, iterations: 100, seed: 42)
Kino.HTML.new(Yog.Render.SVG.to_svg(demo_graph, pos_spring, node_color: "#10b981"))
# Shell layout (hub in centre, cycle on outer ring)
pos_shell = Layout.shell(demo_graph, [[0], [1, 2, 3, 4, 5, 6]], radii: [0.2, 1.0])
Kino.HTML.new(Yog.Render.SVG.to_svg(demo_graph, pos_shell, node_color: "#f59e0b"))
# Tutte layout (boundary pinned, hub at barycenter)
pos_tutte = Layout.tutte(demo_graph, [1, 2, 3, 4, 5, 6], iterations: 50, radius: 5.0)
Kino.HTML.new(Yog.Render.SVG.to_svg(demo_graph, pos_tutte, node_color: "#8b5cf6"))
# Multipartite layout (layered columns)
layered_graph =
Yog.from_unweighted_edges(:undirected, [{0, 1}, {0, 2}, {1, 3}, {2, 3}])
pos_multi =
Layout.multipartite(layered_graph, [[0], [1, 2], [3]],
direction: :left_to_right,
layer_gap: 150.0,
node_gap: 100.0,
align_nodes: :center
)
Kino.HTML.new(Yog.Render.SVG.to_svg(layered_graph, pos_multi, node_color: "#ef4444"))