On this page

Layout functions

layout and layout-sequence are top-level functions exported by lib.typ, not members of a public layout module. Their generated entries come from the shared layout.typ implementation and use the public export names.

Concepts

Placements

graph.pos creates a first-class placement. The default mode: "pin" turns a coordinate into a fixed layout constraint and a drawable position. Use mode: "start" when the coordinate should only seed the layout:

#let g = build({
  node(<a>, pos: graph.pos(x: -2, y: 0))
  node(<c>, pos: graph.pos(ref: <a>, dx: 4, dy: 0))
  edge(source(<a>), <a-c>, sink(<c>), pos: graph.pos(x: 0, y: 1.2))
})

graph.group links one coordinate across several nodes or edge control points. A side of "+" keeps the coordinate positive, and "-" keeps it negative. GammaLoop external-edge columns use this to keep incoming and outgoing external legs on opposite sides while pairing rows by a shared y group:

#let edge-items = (
  edge(
    source(<right-ext>),
    sink(<center>),
    pos: graph.pos(x: graph.group("right", side: "+"), y: graph.group("edgee0")),
  ),
  edge(
    source(<center>),
    sink(<left-ext>),
    pos: graph.pos(x: graph.group("left", side: "-"), y: graph.group("edgee0")),
  ),
)

Raw DOT input uses the same placement model through the pos attribute. The standard Graphviz subset is preserved: pos="x,y" is a starting coordinate and pos="x,y!" is pinned. Linnest extends the value with explicit id references and axis entries. In axis entries, ! belongs to that axis: numeric entries without ! are starting coordinates, numeric entries with ! are fixed constraints, and grouped entries must use ! because groups are constraints.

digraph {
  a [id=0 pos="0,0!"]
  b [id=1 pos="ref(node:0)+4,0!"]
  a -> b [id=0 pos="ref(node:1)+0,1!"]
  b -> c [id=1 pos="ref(edge:0)+1,0!"]
  c [id=2 pos="x:2!"]
  d [id=3 pos="x:2!,y:1"]
  ext -> a [id=2 pos="x:@-left!,y:@edge0!"]
}

The DOT grammar is intentionally id-based: ref(node:0) uses a node id and ref(edge:0) uses an edge id. Bare names and implicit edge order are not placement references. The @ syntax denotes grouped coordinate constraints; @+name and @-name keep the grouped coordinate on the positive or negative side respectively.

pos="x,y"                   // start x and y
pos="x,y!"                  // pin x and y
pos="x:<coord>"             // start numeric x
pos="x:<coord>!"            // pin x
pos="y:<coord>!"            // pin y
pos="x:<coord>!,y:<coord>"  // pin x, start numeric y
pos="x:<coord>,y:<coord>!"  // start numeric x, pin y

Layout Model

layout starts from a traversal-tree placement, then optimizes the positions of graph nodes and edge control points. The initial tree spacing is

𝐿=𝜆𝑊𝐻max(𝑛,1)

,

with horizontal spacing 𝜏𝑥𝐿 and vertical spacing 𝜏𝑦𝐿. Here 𝜆 is length-scale, 𝑊 is viewport-w, 𝐻 is viewport-h, 𝜏𝑥 is tree-dx, and 𝜏𝑦 is tree-dy. These fields set the geometry scale for both layout modes.

For deterministic, non-iterative placement, use layout-algo: "tree" or layout-algo: "dot". "tree" places a traversal forest by levels. "dot" uses a directed layered placement for acyclic inputs, falling back to tree placement when the selected edges contain a cycle. Both modes also work on a subgraph:

#let g = parse("digraph partial { a -> b; b -> c; c -> d; d -> a }").at(0)
#let tree = graph.forests(g).at(0)
#let g = layout(g, layout-algo: "tree", subgraph: tree)
#draw(g)

Only nodes touched by the selected subgraph are placed. Edge control points are then resolved from the current node positions, so edges outside the selected subgraph are drawn as straight lines unless their own position constraints say otherwise.

layout-algo: "force" and layout-algo: "anneal" also accept subgraph. For these iterative modes, nodes and edge control points outside the selected subgraph stay fixed and act as boundary points while the selected subgraph is optimized.

Set layout-nodes: "fixed" to keep every node at its current pos for this layout pass and move only edge control points. With subgraph, only edges in the selected subgraph are moved; all other edge control points keep their current positions. The fixed-node policy is temporary; the returned graph stores the resulting coordinates as pos, but it does not turn them into persistent pin constraints. This is useful after one node-placement pass when a later pass should route or relax selected edges without disturbing the node layout:

#let g = parse("digraph partial { a [pos=\"0,0\"]; b [pos=\"4,0\"]; c [pos=\"8,0\"]; a -> b; b -> c }").at(0)
#let first = subgraph.bits(g, (true, true, false, false))
#let g = layout(g, layout-algo: "tree", layout-nodes: "fixed", subgraph: first)
#draw(g)

The shared spring/charge model uses the following coefficients:

𝑐vv=𝛽𝐿2𝑐ev=𝛽𝛾ev𝐿2𝑐ee=𝛽𝛾ee𝐿2𝑐center=𝛽𝑔center𝐿2𝑐dangling=𝛽𝛾dangling𝐿2

The Typst parameter names are beta for 𝛽, gamma-ev for 𝛾ev, gamma-ee for 𝛾ee, g-center for 𝑔center, and gamma-dangling for 𝛾dangling. The spring stiffness 𝑘 is k-spring, and the softening constant 𝜀 is eps.

In layout-algo: "anneal", linnest minimizes an energy:

𝐸=𝑖<𝑗12𝑐vv𝑑(𝑣𝑖,𝑣𝑗)+𝜀+𝑖,𝑒𝑐ev𝑑(𝑣𝑖,𝑒)+𝜀+(𝑣,𝑒) incident12𝑘(𝑒𝑑(𝑣,𝑒))2+local edge pairs12𝑐ee𝑑(𝑒𝑖,𝑒𝑗)+𝜀+dangling pairs12𝑐dangling𝑑(𝑒𝑖,𝑒𝑗)+𝜀+𝑖12𝑐center𝑑(𝑣𝑖,0)2+𝑝cross𝑁cross

.

Here 𝑝cross is crossing-penalty and 𝑁cross is the number of detected edge crossings. The quadratic center term pulls nodes toward the origin at every radius. temp, step, seed, steps, epochs, cool, accept-floor, step-shrink, and incremental-energy belong to this simulated annealing mode. crossing-penalty is also anneal-only; force mode does not currently add a crossing force.

In layout-algo: "force", linnest applies the direct forces corresponding to the same vertex-vertex, edge-vertex, incidence spring, local edge-edge, dangling-edge, and center terms. step is the integration step, delta clamps per-step movement, steps and epochs set the iteration budget, cool shrinks the step after each epoch, and early-tol stops when movement is small. z-spring and z-spring-growth are force-only helpers: the integrator gives points temporary z coordinates to break overlaps and pulls them back toward the 2D plane.

directional-force is applied in both modes as an extra bias derived from pin/port direction constraints.

After either graph layout mode, labels are relaxed separately. If 𝐿𝑙 is the label target distance and 𝑞𝑙 is the label repulsion strength, then 𝐿𝑙=𝛼𝑙𝐿 and 𝑞𝑙=𝛽𝑙𝐿2. The Typst names are label-length-scale for 𝛼𝑙 and label-charge for 𝛽𝑙. label-spring is the spring constant pulling each label toward its target. label-layout: "normal" uses a perpendicular offset target. With label-layout: "dangling-tangent", paired edges still use that perpendicular target, but dangling half-edge labels are offset along the edge direction away from the attached node. With label-layout: "fixed-length", the label remains at distance 𝐿𝑙 from the edge point and only rotates around it under repulsive forces. label-steps, label-step, label-early-tol, and label-max-delta-scale control the label relaxation iteration.

Qualified aliases

The exported layouts module exposes the same implementations for callers that prefer qualified access; these aliases link back to the canonical top-level entries below.

layouts.layout

Use layout for the shared signature and parameter reference.

layouts.sequence

Use layout-sequence for the shared multi-pass reference.

Reference

  • layout()
  • layout-sequence()

layout

Apply the linnest layout pass to a graph object.

This is intentionally a second step: construct or parse a graph first, then call layout. Set layout-algo to "force" for deterministic force integration, "anneal" for simulated annealing, "tree" for a traversal tree placement, "dot" for a Graphviz-like layered placement, or "stable-layered" for a stable railroad-inspired layered placement.

#let g = graph.parse("digraph partial { a -> b;a -> b;a:s -> b:s; b:s -> c:s; c:s -> d:s; d:s -> a:s }").at(0)
#let south = subgraph.compass(g,"s")
#let gf = layout(layout(g, layout-algo: "force"), layout-algo: "tree", layout-nodes: "fixed",subgraph:south)
#let ga = layout(g, layout-algo: "force")
#let gt = layout(layout(g, layout-algo: "tree",layout-roots: (2)), layout-algo: "anneal", layout-nodes: "fixed",gamma-ee:0.1,gamma-ev:.75,beta:5,length-scale:0.4)
#grid(columns: 3, gutter: 2cm, draw(gf), draw(ga), draw(gt))
#let g = graph.parse("digraph partial { a -> b; b -> c; c -> d; d -> a }").at(0)
#let tree = graph.forests(g).at(0)
#let g = layout(g, layout-algo: "tree", subgraph: tree)
#graph.edges(g).map(edge => edge.pos)

Parameters

graph

dictionary

Graph object returned by graph.build or graph.parse.

subgraph

none or bytes

Optional subgraph object to lay out. With "tree", other edges are drawn from the resulting node positions. With "dot" and "stable-layered", the subgraph determines rank constraints, while all paired edges between included nodes get dummy routing vertices and edge positions. With "force" and "anneal", nodes and edges outside the subgraph are fixed boundary points during optimization.

Default: none

viewport-w

float

Width of the layout viewport used to derive the natural spring length. Applies to both "force" and "anneal".

Default: 10.0

viewport-h

float

Height of the layout viewport used to derive the natural spring length. Applies to both "force" and "anneal".

Default: 10.0

tree-dx

float

Horizontal spacing multiplier for traversal-tree and layered placement. For "force" and "anneal", this scales the initial placement.

Default: 0.9

tree-dy

float

Vertical spacing multiplier for traversal-tree and layered placement. For "force" and "anneal", this scales the initial placement.

Default: 1.2

steps

int

Iterations per epoch. In "force" mode this is the number of force integration steps; in "anneal" mode this is the number of proposals per temperature epoch.

Default: int(sys.inputs.at("steps", default: "30"))

seed

int

Seed for deterministic initialization, force-mode jitter, and annealing proposals. Applies to both modes.

Default: int(sys.inputs.at("seed", default: "2"))

step

float

Initial movement scale. "force" multiplies computed forces by this value; "anneal" uses it as a proposal step size in natural spring-length units.

Default: 0.81

step-shrink

float

Anneal-only step shrink factor, applied when an epoch’s acceptance ratio falls below accept-floor.

Default: 0.21

cool

float

Cooling factor applied once per epoch. "anneal" cools temp; "force" shrinks step.

Default: 0.85

accept-floor

float

Anneal-only acceptance-ratio threshold below which step is shrunk by step-shrink.

Default: 0.15

early-tol

float

Force-only early stop threshold for maximum movement in one step, as a multiple of the natural spring length. The annealing schedule stores this value but does not currently use it for stopping.

Default: 1e-6

temp

float

Anneal-only initial temperature used in the Metropolis acceptance test, scaled by natural spring length squared.

Default: 0.3

delta

float

Force-mode maximum movement clamp per point and per step, as a multiple of the natural spring length.

Default: 0.4

beta

float

Base repulsion strength for vertex-vertex interactions. Also scales gamma-ev, gamma-ee, gamma-dangling, and g-center. Applies to both modes through the shared spring energy.

Default: 50.0

k-spring

float

Spring stiffness for node-to-edge incidence lengths. Applies to both modes.

Default: 11.0

g-center

float

Centering strength relative to beta. Applies to both modes.

Default: 0.002

epochs

int

Number of epochs. Both modes run up to steps iterations inside each epoch.

Default: 30

crossing-penalty

float

Anneal-only fixed energy penalty per detected edge crossing. The direct force integrator does not currently add a crossing force.

Default: 30.0

gamma-dangling

float

Repulsion for dangling half edges, relative to beta. Applies to both modes through the shared spring energy.

Default: 5.0

gamma-ee

float

Local edge-edge repulsion, relative to beta. Applies to both modes.

Default: 0.1

directional-force

float

Bias that pushes points in directions implied by pin/port constraints. Force mode treats this as a force and scales it by natural spring length; anneal mode treats it as a dimensionless multiplier on proposal steps. Applies to both modes.

Default: 5.0

label-length-scale

float

Edge-label target offset as a multiple of the graph spring length. Label layout runs after both graph layout modes.

Default: 0.6

label-spring

float

Spring strength pulling each label toward its target offset in the spring-based label layouts.

Default: 23.0

label-charge

float

Repulsion strength between labels and graph points, scaled by spring length squared. Label layout runs after both modes.

Default: 3.0

label-steps

int

Maximum number of post-layout label relaxation steps. Set to 0 to skip label placement. Applies after both modes.

Default: 20

label-layout

string

Edge-label relaxation model. "normal" uses a perpendicular offset, "dangling-tangent" uses the edge direction for dangling half-edge labels and a perpendicular offset for paired edges, and "fixed-length" keeps each label at a fixed distance from its edge point and only lets that segment rotate.

Default: "normal"

label-step

float

Label relaxation step size. Applies after both modes.

Default: 0.15

label-early-tol

float

Label relaxation early stop threshold. Applies after both modes.

Default: 1e-3

label-max-delta-scale

float

Label movement clamp as a multiple of spring length. Applies after both modes.

Default: 0.5

gamma-ev

float

Edge-vertex repulsion, relative to beta. Applies to both modes.

Default: 0.01

eps

float

Softening epsilon used in inverse-square force/energy terms. Applies to both modes.

Default: 1e-4

incremental-energy

bool

Whether annealing updates cached energy by local deltas. This is anneal-only; force mode computes direct forces instead of energies.

Default: true

layout-algo

string

Layout algorithm. Use "force" for direct force integration, "anneal" for simulated annealing against the spring energy, "tree" for a traversal-tree placement, "dot" for a Graphviz-like layered placement, or "stable-layered" for a stable railroad-inspired layered placement.

Default: "force"

layout-nodes

string

Node movement policy. "layout" lets the layout algorithm move nodes. "fixed" keeps every node at its current position for this layout pass and only moves edge control points. With subgraph, only edges in the subgraph are moved; other edge control points stay at their current positions.

Default: "layout"

layout-direction

string

Direction for traversal-tree and layered rank placement. "down" places increasing ranks downward; "right" swaps the layout axes so increasing ranks go left-to-right and measured node/label widths reserve rank-axis space.

Default: "down"

rank-align

string

Alignment of real nodes inside a rank along the rank axis. "center" keeps node centers aligned, while "start" / "left" and "end" / "right" align the corresponding measured node-box side.

Default: "center"

layout-roots

array

Ordered node indices used as preferred roots for "tree", "dot", and "stable-layered". Roots outside the selected node set are ignored. Remaining components are laid out afterward in graph order.

Default: ()

rank-same

array

Subgraphs whose incident nodes should share a dot/stable-layered rank. These are layout hints supplied by Typst rather than parsed graph structure.

Default: ()

route-edge-weight

float

Dot/stable-layered also honors a node statement layout-rank as an exact non-negative integer rank. Nodes with the same layout-rank are placed on the same horizontal layer, and larger ranks are placed lower. Relative layout weight for paired edges outside the dot/stable-layered rank subgraph. Lower values make these edges guide routing without dominating the rank tree.

Default: 0.15

route-exit-weight

float

Extra horizontal straightening weight for the first or last segment of an edge with source-route-exit or sink-route-exit set to a vertical side.

Default: 4.0

route-label-width-scale

float

Multiplier for measured edge-label width when sizing non-rank dummy routing vertices in dot/stable-layered layout.

Default: 1.0

route-label-width-cap

float

Maximum non-rank dummy label width as a multiple of tree-dx. Set to 0 or a negative value to disable the cap.

Default: 2.0

z-spring

float

Force-only spring pulling temporary z coordinates back toward the layout plane. Higher values keep the visible 2D forces from being hidden by the temporary 3D symmetry-breaking offsets.

Default: 2.0

z-spring-growth

float

Force-only per-epoch multiplier for z-spring.

Default: 1.0

length-scale

float

Natural spring-length multiplier. This scales the graph’s preferred edge length and dimensional force/energy terms so changing only this value mostly zooms the result instead of retuning the force ratios. Applies to both modes.

Default: 0.35

layout-sequence

Apply multiple layout passes in order.

Each pass is a dictionary of named arguments accepted by layout, excluding the graph itself.

Parameters

graph

dictionary

Graph object returned by graph.build or graph.parse.

passes

array

Array of layout option dictionaries.