How-To: Controlled Placement and Coordinate Workflows

Copy Markdown View Source
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:

  1. Manual placement with fallback strategies
  2. Grid placement for structured diagrams
  3. Multipartite (layered) placement for pipelines
  4. Transform and fit pipeline for multi-view layouts
  5. DOT fixed-position output with neato/fdp
  6. Importing positions from a GraphViz layout run
  7. Geometry helpers (coming soon)
  8. 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:

ValueBehaviour
:errorRaises if any node lacks a coordinate
:centerPlaces missing nodes at center:
:ignoreOmits missing nodes from the result
:randomUniform random inside the default box
{:random, opts}Random with custom width:, height:, center:
fn id -> {x, y} endCustom 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:

OptionEffect
:direction:left_to_right, :right_to_left, :top_to_bottom, :bottom_to_top
:layer_gapDistance (px) between consecutive layers
:node_gapDistance (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 neato and fdp Graphviz engines respect pos= attributes and use them as starting or fixed positions. The default dot engine uses rank-based layout and ignores pos= entirely. Pass pin: true to lock nodes in place and prevent neato/fdp from 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

AlgorithmFunctionBest For
Circularcircular/2Cycles, symmetric small graphs
Randomrandom/2Baseline checks, initial states
Springspring/2Social networks, general undirected
Tuttetutte/3Planar graphs, zero edge-crossing embedding
Shellshell/3Core-periphery, hierarchical groups
Multipartitemultipartite/3Pipelines, bipartite, neural nets
Gridgrid/2Architecture diagrams, structured layouts
Manualmanual/3Fully controlled, pixel-exact placement
GraphVizgraphviz/2Import 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"))