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, for x/y, a drawable position. Use mode: "start" when the coordinate should only seed the layout, or use graph.pin(value) / graph.start(value) to choose a mode for one axis:
#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))
})Auxiliary z is force-layout depth in the same numeric layout units as x/y, not a Typst length or a rendered coordinate. Both graph.build and graph.map accept graph.pos(z: graph.pin(2)) for a hard raw-depth pin and graph.pos(z: graph.start(2)) for a movable initial depth. A bare graph.pos(z: 2) follows mode, defaulting to "pin":
#let g = graph.map(g,
node: node => (pos: graph.pos(z: graph.pin(2))),
edge: edge => (pos: graph.pos(z: graph.start(-1))),
)A z-only placement leaves XY coordinates, constraints, and partial-position flags alone, including automatic edge midpoint seeds. Omitting z preserves existing depth metadata during unrelated XY patches. ref, dx, and dy affect XY only; z groups, relative depth references, dz, and non-finite z values are not supported.
Node and edge statements expose "pos-z" as finite numeric text and "pos-z-mode" as "pin" or "start". These describe the supplied raw pin or seed; output pos remains a two-coordinate x/y record. The force solver multiplies raw depth by a global scale that flattens to zero: a hard raw pin remains fixed even when its effective depth is zero. Other layout backends do not use z. Auxiliary depth does not change draw order or provide an over/under rendering layer.
graph.group links one XY coordinate across several nodes or edge control points. A side of “+” keeps the coordinate positive, and “-” keeps it negative. Use start to seed the shared coordinate without fixing it. Matching node and edge groups are one degree of freedom during force and annealing layouts, so repulsion and springs act on their combined force rather than being reconciled afterward. If several members supply starts for one group, their mean initializes the shared coordinate. 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: "+", start: 4), 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 yLayout Model
layout starts from a traversal-tree placement, then optimizes the positions of graph nodes and edge control points. The initial tree spacing is
,
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. Isolated subgraph layout uses the number of selected incident nodes for ; other layouts use the full graph node count.
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. The default solver.subgraph-mode: "fixed-boundary" keeps nodes and edge control points outside the selection fixed while retaining their spring, repulsion, centroid, and crossing interactions. Set solver: (subgraph-mode: "isolated") to omit unselected half edges, nodes, control points, and labels from those interactions. Selected halves of otherwise paired edges become dangling boundaries of the isolated solver domain. Complement coordinates and labels remain unchanged, and an empty selection is a no-op. An isolated selection cannot split a grouped coordinate; expand the selection or remove that group.
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)Related controls can be supplied as shallow semantic records. These records only name graph-layout concepts; they do not select a drawing domain or style:
#let common = layouts.options(
spring: (strength: 8, length: 0.5),
repulsion: (edge-node: 0.05, dangling: 2),
constraints: (side-strength: 5),
labels: (steps: 0),
solver: (algorithm: "force", steps: 50),
)
#let spacious = layouts.options(
base: common,
spring: (length: 0.7),
repulsion: (dangling-centroid: 3),
)
#let g = layout(g, ..spacious)layouts.options performs a sparse update: the second spring record above changes length without losing the inherited strength. Grouped fields override their corresponding flat arguments. Flat arguments remain useful for numerical experiments and controls that do not have a semantic grouping.
The shared spring/charge model uses the following coefficients:
The Typst parameter names are beta for , gamma-ev for , gamma-ee for , g-center for , gamma-dangling for , and gamma-dangling-centroid for . The spring stiffness is k-spring, and the softening constant is eps.
In layout-algo: "anneal", linnest minimizes an energy:
.
Here is the mean node position. The centroid term pushes every dangling endpoint away from that mean; its equal-and-opposite reaction is shared over the nodes so it introduces no net force. is crossing-penalty and 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, dangling-centroid, and center terms. step is the integration step, delta clamps per-step movement, steps and epochs set the iteration budget, and cool shrinks the step after each epoch. early-tol can stop the run when movement is small only after the effective depth scale reaches zero; small movement or a cooled-to-zero step cannot skip flattening.
The force-only depth-scale (default 1.0) and flattening-end (default 0.5) control auxiliary depth. depth-scale must be finite and non-negative; flattening-end must be finite and in 0..=1. They can be supplied as flat arguments or in solver: (depth-scale: 1.0, flattening-end: 0.5); grouped values override flat ones. The old depth-spring controls are not aliases.
Effective depth is scale * raw-z. The integrator starts with auxiliary raw depths to break overlaps and smoothly reduces scale from depth-scale to zero over the initial flattening-end fraction of the total steps × epochs iteration budget. For normalized progress u from 0 to 1 over that interval, the smoothstep scale is depth-scale * (1 - u)^2 * (1 + 2 * u). The remaining iterations relax projected overlaps with effective depth exactly zero, so repulsion cannot be satisfied by invisible separation. flattening-end: 0 or depth-scale: 0 starts in 2D; flattening-end: 1 still evaluates the final iteration on the exact plane.
There is a single original iteration budget and cooling schedule: flattening neither adds a second phase nor restarts step. Unpinned raw depths stay bounded by the initial spread and supplied depth magnitudes; hard raw-depth pins remain fixed even after their effective depth has flattened to zero. XY pins, shared coordinates, and fixed subgraph boundaries apply throughout; label layout runs only after this single graph pass. Output positions remain 2D, and auxiliary z does not alter draw order. Flattening is exact, while force convergence remains limited by the iteration budget and movement tolerance.
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 . 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", relaxation keeps the label at distance from the edge point and only rotates it under repulsive forces. label-steps, label-step, label-early-tol, and label-max-delta-scale control the label relaxation iteration.
graph.style measures node and label sizes before layout; those sizes enter repulsion during label relaxation. For labels with measured width and height, a subsequent box-collision pass starts from the relaxed positions. It keeps each position when its box clears node boxes and previously placed label boxes, otherwise searching nearby candidates. This correction may change the distance from the edge point, including in "fixed-length" mode.
Repulsion considers edge layout points, and box correction considers nodes and labels; neither guarantees clearance from complete edge curves or drawing layers such as momentum arrows. Attached path labels may also be positioned again during drawing, as described above.
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
- options()
- layout()
- layout-sequence()
options
Construct reusable semantic layout options or sparsely update existing ones.
Each option group is a plain dictionary. Supplying base preserves every field not mentioned by a patch, including fields inside a patched group. Pass the result to layout with argument spreading.
#let common = options(
spring: (strength: 8, length: 0.5),
repulsion: (edge-node: 0.05, dangling: 2),
solver: (algorithm: "force", steps: 50),
)
#let spacious = options(
base: common,
spring: (length: 0.7),
repulsion: (dangling-centroid: 3),
)
#let g = layout(g, ..spacious)Parameters
..patches:
arguments ,base:
dictionary ) ->
dictionary ..patches
arguments
Semantic option-group patches.
base
dictionary
Existing option dictionary to update.
Default: (:)
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.
Force layout uses one steps × epochs iteration budget and one cooling schedule. Effective auxiliary depth is scale * raw-z: scale follows a smoothstep from depth-scale to zero over the initial flattening-end fraction of that budget. Remaining iterations are planar, without restarting step or cooling. early-tol can stop the run only after scale reaches zero. Unpinned raw depths remain bounded; hard raw-depth pins are preserved even when their effective depth is zero. Output positions remain 2D, and auxiliary depth does not change draw order; label layout runs after the single graph pass.
#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 ,spring:
none dictionary ,repulsion:
none dictionary ,constraints:
none dictionary ,labels:
none dictionary ,solver:
none dictionary ,subgraph:
none dictionary ,viewport-w:
float ,viewport-h:
float ,tree-dx:
float ,tree-dy:
float ,steps:
int ,seed:
int ,step:
float ,step-shrink:
float ,cool:
float ,accept-floor:
float ,early-tol:
float ,temp:
float ,delta:
float ,beta:
float ,k-spring:
float ,g-center:
float ,epochs:
int ,crossing-penalty:
float ,gamma-dangling:
float ,gamma-dangling-centroid:
float ,gamma-ee:
float ,directional-force:
float ,label-length-scale:
float ,label-spring:
float ,label-charge:
float ,label-steps:
int ,label-layout:
string ,label-step:
float ,label-early-tol:
float ,label-max-delta-scale:
float ,gamma-ev:
float ,eps:
float ,incremental-energy:
bool ,layout-algo:
string ,layout-nodes:
string ,layout-direction:
string ,rank-align:
string ,layout-roots:
array ,rank-same:
array ,route-edge-weight:
float ,route-exit-weight:
float ,route-label-width-scale:
float ,route-label-width-cap:
float ,depth-scale:
float ,flattening-end:
float ,length-scale:
float ) ->
dictionary graph
dictionary
Graph object returned by graph.build or graph.parse.
spring
none or dictionary
Semantic incidence-spring options. Supported fields are strength and length. These override the corresponding flat parameters below.
Default: none
repulsion
none or dictionary
Semantic repulsion options. Supported fields are strength, centering, edge-node, edge-edge, dangling, dangling-centroid, and softening. These override the corresponding flat parameters below.
Default: none
constraints
none or dictionary
Semantic constraint options. Supported fields are side-strength, node-movement, direction, rank-alignment, roots, and same-rank. These override the corresponding flat parameters below.
Default: none
labels
none or dictionary
Semantic label-layout options. Supported fields are distance, spring, repulsion, steps, model, step, tolerance, and max-movement. These override the corresponding flat parameters below.
Default: none
solver
none or dictionary
Semantic solver options. Supported fields are algorithm, steps, epochs, seed, step, step-shrink, cooling, acceptance-floor, tolerance, temperature, max-movement, incremental-energy, subgraph-mode, crossing-penalty, depth-scale, and flattening-end. These override the corresponding flat parameters below.
Default: none
subgraph
none or dictionary
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", solver.subgraph-mode: "fixed-boundary" keeps outside points fixed but interacting; "isolated" excludes them from the solver and label-layout interactions. The selection must have compatible topology, and isolated selections cannot cross grouped coordinates.
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 within the single depth-flattening schedule; in "anneal" mode this is the number of proposals per temperature epoch.
Default: 30
seed
int
Seed for deterministic initialization, force-mode jitter, and annealing proposals. Applies to both modes.
Default: 2
step
float
Initial movement scale. "force" multiplies computed forces by this value, with no restart when depth reaches zero; "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, eligible only after effective depth scale reaches zero. 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, gamma-dangling-centroid, 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. Force mode shares this budget and its cooling schedule across depth flattening and the remaining planar relaxation.
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-dangling-centroid
float
Repulsion of every dangling endpoint from the current node centroid, relative to beta. The equal-and-opposite reaction is shared over the nodes, avoiding translational drift. Applies to both modes.
Default: 0.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
Subgraph objects whose incident nodes should share a dot/stable-layered rank; node-index arrays and callbacks receiving the graph are also accepted. 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
depth-scale
float
Force-only initial multiplier of raw auxiliary depth, finite and >= 0. Effective z is this smoothly decreasing scale times raw z, not a spring pulling raw coordinates toward the plane. A value of 0 starts in 2D; flattening preserves hard raw-depth pins while removing their invisible separation from the visible 2D force calculation.
Default: 1.0
flattening-end
float
Force-only fraction of the total steps × epochs iteration budget over which the depth scale reaches zero by smoothstep, finite and in 0..=1. Zero starts in 2D; 1 still evaluates the final iteration on the exact plane. Neither value adds a phase or restarts the cooling schedule.
Default: 0.5
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 ,passes:
array ) ->
dictionary graph
dictionary
Graph object returned by graph.build or graph.parse.
passes
array
Array of layout option dictionaries.