On this page

Drawing functions

draw, edge-halves, and to-cetz-edge-halves are top-level functions exported by lib.typ. They turn an already laid-out graph into CeTZ content and expose the edge geometry used by custom renderers.

Concepts

Drawing

Draw styling is Typst-native. Pass dictionaries or callbacks to draw; edge callbacks receive the merged scope, edge statements, source/sink half-edge records, and the edge index:

#let edge-label(edge) = text(fill: rgb("#" + edge.color))[#edge.display-label]
#let source-style(edge) = (stroke: red + 0.5pt)
#let sink-style(edge) = (stroke: blue + 0.5pt)
#draw(layout(g), edge-label: edge-label, source-style: source-style, sink-style: sink-style)

Edge labels are drawn with CeTZ content at the layout label position. Use edge-label-style: (anchor: "south"), or an edge-data callback returning a style dictionary, to choose which point of the label is anchored there.

Add overlays with draw-after: (g, bounds) => { ... }. The callback receives the positioned graph and its rendered bounds before overlays and canvas padding. The numeric left, right, top, bottom, width, and height fields use graph units; top is the greatest y coordinate. Bounds include drawn shapes, curves, and label boxes, following CeTZ’s convention of excluding extra stroke thickness. An empty drawing supplies zeros for every field. Overlay elements share unit and can enlarge the final canvas without affecting graph layout. draw-after also accepts an array of CeTZ elements or none.

#draw(layout(g), draw-after: (g, bounds) => {
  cetz.draw.rect(
    (bounds.left - 0.2, bounds.bottom - 0.2),
    (bounds.right + 0.2, bounds.top + 0.2),
    stroke: gray,
  )
})

Marks can follow graph orientation as a first-class draw option. Put the same mark layer on both halves and set mark-orientation: "edge"; default-oriented edges mark the source half, reversed edges mark the sink half with the marker flipped, and undirected edges suppress the mark. Use mark-position: "center" or a numeric ratio from 0 to 1, then mark-shift for a signed arc-length adjustment along the derived path. This positioning is independent of whether the layer is straight or patterned. "center-if-dangling" centers a dangling mark but keeps an orientation-selected paired mark at the source/sink split point. Heads span a chord between two points on the full carrier, not a tangent. Triangle and straight heads place their geometric tip and the center of their back on the curve; other marks use their declared tip/base anchors. Centered heads straddle the requested arc position, so the chord midpoint can lie off the curve. Near endpoints the sampling interval moves inward, and short carriers compress the head to fit. Longitudinal fitting preserves the head’s width and stroke thickness. Interior marks overlay the unshortened curve. With shortening enabled, filled end heads meet the shaft at the inward contact; straight open heads keep the shaft running to their tip.

#let oriented-arrow = (
  stroke: black + 0.7pt,
  mark: (end: (symbol: ">", fill: black, anchor: "center", shorten-to: auto), scale: 0.75),
  mark-position: "center-if-dangling",
  mark-orientation: "edge",
)
#draw(layout(g), source-style: oriented-arrow, sink-style: oriented-arrow)

Reference

  • edge-halves()
  • to-cetz-edge-halves()
  • draw()

edge-halves

Split a laid-out graph edge into source and sink half-edge paths.

The returned dictionary has source, sink, curve, and split-gap. The split point is the edge layout point, so a zero-gap pair joins smoothly there. split-gap reports the effective gap when a requested half-gap is longer than either half-edge.

Parameters

edge-halves(
edge: dictionary ,
nodes: array ,
omega: float ,
source-outset: int float ,
sink-outset: int float ,
split-gap: int float ,
accuracy: float
) -> dictionary
edge

dictionary

Edge record returned by graph.edges(layout(g)).

nodes

array

Node records returned by graph.nodes(layout(g)).

omega

float

Hobby curl used for the source-to-edge and edge-to-sink curves.

Default: 1.0

source-outset

int or float

Arc-length trim applied at the source node side.

Default: 0

sink-outset

int or float

Arc-length trim applied at the sink node side.

Default: 0

split-gap

int or float

Total arc-length gap centered on the edge layout point.

Default: 0

accuracy

float

Arc-length accuracy used while trimming.

Default: 0.001

to-cetz-edge-halves

Draw the two halves of a laid-out graph edge through CeTZ.

source-style applies from the source node to the edge layout point, and sink-style applies from the edge layout point to the sink node.

Parameters

to-cetz-edge-halves(
edge: dictionary ,
nodes: array ,
unit: int float length ratio ,
omega: float ,
source-outset: int float ,
sink-outset: int float ,
split-gap: int float ,
accuracy: float ,
source-style: dictionary ,
sink-style: dictionary
) -> content
edge

dictionary

Edge record returned by graph.edges(layout(g)).

nodes

array

Node records returned by graph.nodes(layout(g)).

unit

int or float or length or ratio

Coordinate length for one graph-layout unit. Numbers are interpreted as em.

Default: 1

omega

float

Hobby curl used for the source-to-edge and edge-to-sink curves.

Default: 1.0

source-outset

int or float

Arc-length trim applied at the source node side.

Default: 0

sink-outset

int or float

Arc-length trim applied at the sink node side.

Default: 0

split-gap

int or float

Total arc-length gap centered on the edge layout point.

Default: 0

accuracy

float

Arc-length accuracy used while trimming.

Default: 0.001

source-style

dictionary

CeTZ style for the source half edge.

Default: (:)

sink-style

dictionary

CeTZ style for the sink half edge.

Default: (:)

draw

Draw a graph object with CeTZ.

The graph must already have positions, either from layout or from explicit pos values passed to graph node or edge items. Paired edges without an explicit edge position use the midpoint of their endpoint nodes. Node and edge Typst style dictionaries or callbacks are evaluated and forwarded to CeTZ.


 #let positioned = graph.build({
   graph.node(<left>, label: [left], pos: graph.pos(x: 0, y: 0))
   graph.node(<right>, label: [right], pos: graph.pos(ref: <left>, dx: 2.5, dy: 0))
   graph.edge(graph.source(<left>), graph.sink(<right>))
   graph.edge(graph.source(<right>), <right-out>, pos: graph.pos(ref: <right>, dx: 0.9, dy: 0.7))
 },
   name: "positioned",
 )
 #draw(positioned, source-style: (stroke: black + 0.7pt), sink-style: (stroke: black + 0.7pt))
 

 #let parallel-base-edge = 0
 #let source-patterns = (
   "coil",
   "zigzag",
   "coil",
   "wave",
   "zigzag",
   "coil",
   "wave",
   "zigzag",
 )
 #let sink-patterns = (
   "coil",
   "zigzag",
   "coil",
   "zigzag",
   "coil",
   "wave",
   "coil",
   "wave",
 )
 #let parallel-layer(edge, mark: none) = if edge.eid == parallel-base-edge {
   (
     offset: -0.5,
     length: 1.5,
     ratio: 0.5,
     resolve-length: "min",
     stroke: (paint: rgb("#2f6f4e"), thickness: 1.1pt, cap: "round"),
     mark: mark,
   )
 } else { none }
 #let stack(base, layer) = if layer == none { base } else { (base, layer) }
 #let source-stroke(edge) = if edge.eid == parallel-base-edge {
   (paint: gray, thickness: 0.75pt, cap: "round")
 } else {
   (paint: red, thickness: 1.2pt, cap: "round")
 }
 #let sink-stroke(edge) = if edge.eid == parallel-base-edge {
   (paint: gray, thickness: 0.75pt, cap: "round")
 } else {
   (paint: blue, thickness: 1.2pt, cap: "round", dash: "dotted")
 }
 #let source-style(edge) = {
   let base = (
     stroke: source-stroke(edge),
     pattern: source-patterns.at(edge.eid),
     pattern-amplitude: 0.18,
     pattern-wavelength: 0.55,
     pattern-coil-longitudinal-scale: 1.6,
   )
   stack(base, parallel-layer(edge))
 }
 #let sink-style(edge) = {
   let base = (
     stroke: sink-stroke(edge),
     pattern: sink-patterns.at(edge.eid),
     pattern-amplitude: 0.18,
     pattern-wavelength: 0.55,
     pattern-coil-longitudinal-scale: 1.6,
   )
   stack(base, parallel-layer(edge, mark: (end: (symbol: "straight"), scale: 0.75)))
 }
 #let g = graph.build({
   graph.node(<a>, label: [a hi])
   graph.node(<c>)
   graph.node(<d>)
   graph.node(<e>)
   graph.edge(graph.source(<a>), <ac>, graph.sink(<c>), pos: graph.pos(x: 0, y: 0.75, mode: "pin"))
   graph.edge(graph.source(<c>), <ca>, graph.sink(<a>, compass: "e"))
   graph.edge(graph.source(<c>), <cd>, graph.sink(<d>, compass: "e"))
   graph.edge(graph.source(<e>), <ed>, graph.sink(<d>, compass: "e"))
   graph.edge(graph.source(<e>), <ea>, graph.sink(<a>, compass: "e"))
   graph.edge(graph.source(<d>), <d-out>)
   graph.edge(graph.source(<e>, compass: "e"), <e-out>)
   graph.edge(graph.source(<a>), <a-out>)
 },
   name: "demo",
 )
 #let layed-out = layout(g)
 #let east = subgraph.compass(layed-out, "e")
 #draw(layed-out, subgraph: east, source-style: source-style, sink-style: sink-style)
 

 #let p = graph.build({
   graph.node(<pa>, label: [a], pos: graph.pos(y: 0, mode: "pin"))
   graph.node(<pc>, label: [c], pos: graph.pos(y: 0, mode: "pin"))
   graph.node(<pc1>, label: [c], pos: graph.pos(y: 0, mode: "pin"))
   graph.node(<pc2>, label: [c], pos: graph.pos(y: 0, mode: "pin"))
   graph.edge(graph.source(<pa>), <e0>, graph.sink(<pc>))
   graph.edge(graph.source(<pc2>), <e1>, graph.sink(<pc1>))
 },
   name: "parallel demo",
 )
 #let parallel-edge-style(edge) = (
     offset: -0.5,
     length: 1.4,
     ratio: 0.5,
     resolve-length: "min",
     stroke: (paint: rgb("#2f6f4e"), thickness: 1.1pt, cap: "round"),
   )

 #let focused-base-style(edge) = (
   stroke: (paint: gray, thickness: 0.7pt, cap: "round"),
   pattern: "coil",
   pattern-amplitude: 0.14,
   pattern-wavelength: 0.45,
   pattern-coil-longitudinal-scale: 1.5,
 )
 #let focused-source-style(edge) = (focused-base-style(edge), parallel-edge-style(edge))
 #let focused-sink-style(edge) = (focused-base-style(edge), parallel-edge-style(edge) + (
   mark: (end: ">"),
 ))
 #draw(layout(p, g-center: 0.004, label-steps: 0,), unit: 1.25, source-style: focused-source-style, sink-style: focused-sink-style)
 

 #let g = graph.build({
   graph.node(<a>, pos: graph.pos(x: 0, y: 0, mode: "pin"))
   graph.node(<c>, pos: graph.pos(x: 3, y: 0, mode: "pin"))
   graph.node(<d>, pos: graph.pos(x: 3, y: -1, mode: "pin"))
   graph.edge(graph.source(<a>), <ac>, graph.sink(<c>), orientation: "default")
   graph.edge(graph.source(<a>), <ad>, graph.sink(<d>), orientation: "reversed")
 },
   name: "oriented marks",
 )
 #let arrow = (
   end: (symbol: ">", fill: black, anchor: "center", shorten-to: auto),
   scale: 0.75,
 )
 #let oriented-arrow = (
   stroke: black + 0.7pt,
   mark: arrow,
   mark-position: "center-if-dangling",
   mark-orientation: "edge",
 )
 #draw(layout(g), unit: 1.4, source-style: oriented-arrow, sink-style: oriented-arrow)
 

A style layer may set crossing-under to a visible edge’s integer id or Typst label name and crossing-gap (default 0.55) to the total arc length hidden around each proper centerline intersection. References are resolved independently of edge order. The current layer is split with Kurvst, so patterned phases continue across the hidden spans. A mark on the same layer is emitted once on its uncut carrier, so splitting the painted path neither removes nor duplicates it. Dangling layers and paired layers with one continuous source/sink paint style are supported. On a paired layer both half styles must specify the same target and gap. The cut layer cannot participate in a subgraph underlay. References to self, unknown, or invisible edges are errors. A valid pair with no proper interior intersection is left unchanged.

Parameters

draw(
graph: dictionary ,
scope: dictionary ,
unit: auto int float length ratio ,
title: none auto content string ,
subgraph: none dictionary array ,
debug: bool int ,
show-half-edge-ids: bool ,
node-radius: auto int float array ,
node-min-radius: int float ,
node-label-padding: int float ,
node-fill: any ,
node-stroke: any ,
node-outset: auto int float ,
node-label-style: dictionary ,
node-style: dictionary function none ,
node-label: auto content string function none ,
draw-node: auto function ,
draw-after: none array function ,
edge-stroke: any ,
edge-style: dictionary array function none ,
edge-offset: int float ,
edge-length: none int float ,
edge-ratio: none int float ,
edge-resolve-length: string function ,
edge-accuracy: float ,
edge-optimize: bool ,
edge-split-gap: int float ,
edge-dangling-tangent: auto string ,
source-style: dictionary array function none ,
sink-style: dictionary array function none ,
edge-label: content string function none ,
edge-label-style: dictionary function none ,
edge-omega: float ,
edge-trim-accuracy: float ,
padding: none int float array dictionary ,
debug-edge-radius: int float ,
debug-edge-fill: any ,
debug-edge-stroke: any ,
debug-edge-label-fill: any ,
subgraph-edge-style: dictionary ,
subgraph-edge-underlay: bool
) -> content
graph

dictionary

Graph object with positions from layout or explicit graph API pos fields.

scope

dictionary

Additional values merged into node and edge callback dictionaries.

Default: (:)

unit

auto or int or float or length or ratio

Coordinate length for one graph-layout unit. auto uses any unit stored by graph.style, falling back to 1em. Numbers are interpreted as em.

Default: auto

title

none or auto or content or string

Optional title displayed above the diagram. Use auto for the graph name.

Default: none

subgraph

none or dictionary or array

Optional subgraph or array of subgraphs whose half-edges are shaded. Array entries may be subgraph objects or records like (subgraph: sg, edge-style: (stroke: red + 2pt)). Each selection must have topology compatible with the graph.

Default: none

debug

bool or int

Debug level. 1 enables CeTZ canvas debug; 2 also marks edge positions.

Default: false

show-half-edge-ids

bool

Show h_i beside every half-edge without an explicit endpoint label.

Default: false

node-radius

auto or int or float or array

Default CeTZ node radius. Use auto to fit the node label.

Default: auto

node-min-radius

int or float

Minimum radius used when node-radius is auto.

Default: 0.16

node-label-padding

int or float

Extra canvas-unit padding around labels when node-radius is auto.

Default: 0.08

node-fill

any

Default CeTZ node fill.

Default: white

node-stroke

any

Default CeTZ node stroke.

Default: black

node-outset

auto or int or float

Edge clearance from node centers. auto uses each node circle radius. Increase this when labels extend beyond the circle.

Default: auto

node-label-style

dictionary

Default node-label style forwarded to cetz.draw.content.

Default: (:)

node-style

dictionary or function or none

Node style dictionary or callback. A callback receives node data.

Default: (:)

node-label

auto or content or string or function or none

Node label content or callback. auto uses the node name.

Default: auto

draw-node

auto or function

CeTZ-compatible node drawing callback. auto draws the default circular node and label. A callback receives (node, box) and should return CeTZ draw elements; box contains name, center, width, height, unit, label, label-style, style, radius, and node. Generated cut boundary nodes skip this callback and all node painting; they retain a named position and zero-size anchor box for incident edges.

Default: auto

draw-after

none or array or function

Additional CeTZ elements drawn after the edges, labels, and nodes in the same canvas. A callback receives (graph, bounds) and returns CeTZ elements. bounds contains numeric left, right, top, bottom, width, and height in graph units, enclosing the rendered graph before overlays and canvas padding. It includes shapes, curves, and label boxes; CeTZ does not add stroke thickness to path bounds. top is the greatest y coordinate, and all fields are zero for an empty drawing. Use graph.nodes / graph.edges to inspect the positioned graph. These elements share unit and contribute to canvas bounds, but do not participate in graph layout.

Default: none

edge-stroke

any

Default CeTZ edge stroke.

Default: 0.1em

edge-style

dictionary or array or function or none

Default logical-edge style dictionary, layers, or callback. An edge’s style: value patches this default; auto delegates and none hides the edge without changing its topology. Stored graph.style edge and endpoint defaults are composed first, followed by this style, record edge style, draw endpoint style, and endpoint-local style. Shared logical-edge callbacks run once before the two halves are composed.

Default: (:)

edge-offset

int or float

Default normal offset for edge paths. Applied to the base edge geometry before patterns; node outsets then trim the shifted path.

Default: 0

edge-length

none or int or float

Maximum visible arc length for centered parallel edge paths. none keeps the full shifted path.

Default: none

edge-ratio

none or int or float

Maximum visible fraction of the full offset path length for centered edge paths. Combined with edge-length according to edge-resolve-length.

Default: none

edge-resolve-length

string or function

Resolve edge-length and edge-ratio. Accepted string values are "min"/"shorter", "max"/"longer", "length"/"fixed", "ratio"/"relative", or "none"/"full". A function receives (offset-path-length, length, ratio).

Default: "min"

edge-accuracy

float

Arc-length accuracy for fitted parallel edge paths.

Default: 0.001

edge-optimize

bool

Let Kurbo optimize the fitted parallel path.

Default: true

edge-split-gap

int or float

Total arc-length gap centered on the source/sink split at the edge layout point. A per-layer split-gap overrides this value.

Default: 0

edge-dangling-tangent

auto or string

Tangent constraint at dangling edge positions. "horizontal" and "vertical" preserve bend while making the corresponding endpoint tangent exact. A per-layer dangling-tangent overrides this value.

Default: auto

source-style

dictionary or array or function or none

Source half-edge style dictionary, array of layer dictionaries, or callback. A "center" or numeric mark on a common source/sink paint layer is placed once on the complete derived layer, so it follows shift. mark-position also accepts a numeric path-length ratio from 0 to 1; mark-shift moves a centered or numeric mark by signed arc length, with positive values moving toward the derived path’s end. This also works on patterned dangling and asymmetric paired layers without resetting pattern phase. mark-position: "center-if-dangling" centers dangling marks while retaining the paired-edge split point selected by mark-orientation. mark-orientation: "edge" makes a mark follow edge.orientation instead of raw path direction; reversed edges move the mark to the sink half and flip it, while undirected edges suppress it. Heads span a chord between two points on the full carrier: triangle and straight heads use their geometric tip and back; other marks use their declared tip/base anchors. Centered heads straddle the requested arc position, so their chord center can lie off the curve. Near endpoints the interval moves inward; the head fits the chord longitudinally while keeping its width. Interior marks overlay the unshortened curve; end shafts meet the appropriate head contact. source-anchor may be a CeTZ anchor name such as "north" or "south" to route this endpoint from a measured node-box anchor. By default, anchored paired edges use two smooth cubic halves through the edge layout point while preserving the endpoint anchor tangents. Set route: "hobby-through" to instead build a single Hobby spline through the source anchor, source guide, edge point, sink guide, and sink anchor, then split source/sink styling at the edge point. route: "direct" keeps the same anchored cubic routing but suppresses the default edge-position Hobby route. route-points: "through" also threads any layout-provided half-edge route points through that same Hobby path. route: "straight-through" draws the two straight force springs from source to edge position and from edge position to sink. pattern-natural-endpoints defaults to false. For built-in coil strings ("coil", "helix", "spring") on complete continuous paths with both endpoints anchored, true constructs a fitted kurvst.coil dictionary in Typst and applies it once via ordinary kurvst.pattern: full-amplitude inward endpoint phases and half-integer coil periods, with exact endpoints and no taper, straight stubs, or connectors. This overrides pattern-phase, bypasses pattern-fit integer fitting, and ignores pattern-endpoint-slope (endpoint-ramp: false). Fitted dictionaries also work directly as pattern without this flag; see the Kurvst manual’s Path Patterns section for the fitting formula and one-pass application settings. Other patterns ignore this flag. Without it, anchored coils use the 75%-wavelength endpoint taper (capped at half the path length), with a squared longitudinal envelope. Without automatic fitting, pattern-fit: true adjusts the wavelength to the nearest whole number of periods on a complete, unbroken edge. pattern-phase is in radians; calc.pi / 2 gives coils matching, gently tapered endpoint phases without straight end sections. pattern-endpoint-slope sets the taper’s initial slope, from 0 to 3. Zero (default) keeps tangential ends; positive values allow angled ends without detaching them. The angle also depends on phase, amplitude, and wavelength. When source-style and sink-style share a pattern and differ only in stroke (for example colour), the whole edge is patterned once, fitted like a single edge, and cut at the edge layout point, so each half keeps its own stroke while the coil runs continuously through the split. Other split-style halves and partially anchored paths, such as crossing-gap fragments, keep the requested wavelength, taper only anchored endpoints, and preserve phase continuity across hidden spans. Neither automatic coil construction nor integer fitting applies. A path with gaps is patterned per continuous run, carrying the phase across each gap. A finite layer can set shift to move along the logical edge, with positive values moving toward its end. label attaches content near the layer midpoint; label-shift (default 0) moves its reference point by signed arc length on that derived path, clamped to the path’s endpoints. Positive values move toward its end without moving or trimming the layer. label-side is auto, "left", "right", or a signed number; auto follows the side selected by ordinary edge-label layout. If a label-side offset has no layout direction yet, both offset and label follow the sign of offset (positive means left). With label-style.anchor omitted or set to auto (also "auto"), the centered label’s actual CeTZ box clears the local tangent line by label-gap, accounting for text bounds, wrapping, padding and rotation. This is not minimum distance to the finite curved shaft. An explicit anchor, including "center", instead sits at the shifted reference point plus label-gap along the chosen normal, without box correction. Negative gaps are clamped to zero. Other label-style fields are forwarded to cetz.draw.content. An attached label replaces the ordinary painted edge label, which may still provide its pre-layout measurement and side. Attached labels do not add separate pre-layout collision constraints. label: auto reuses the ordinary edge label and its content style. label-only: true makes the layer an invisible label carrier: strokes, fills, patterns, marks, crossings and subgraph edge styling cannot paint it. Its geometry still controls label placement. If no visible endpoint layer carries a label, the ordinary edge label is drawn.

Default: (:)

sink-style

dictionary or array or function or none

Sink half-edge style dictionary, array of layer dictionaries, or callback. A callback receives edge data. mark-position: "center-if-dangling" has the same dangling-edge behavior as for source-style, and mark-orientation: "edge" participates in the same orientation-aware mark placement. sink-anchor may be a CeTZ anchor name such as "north" or "south" to route this endpoint into a measured node-box anchor. route: "direct" has the same meaning as in source-style.

Default: (:)

edge-label

content or string or function or none

Edge label content or callback. A callback receives edge data.

Default: none

edge-label-style

dictionary or function or none

Edge-label style forwarded to cetz.draw.content; use anchor to choose which point of the label is placed at the layout label position. A callback receives edge data.

Default: (:)

edge-omega

float

Hobby curl used at the endpoints of paired edge curves.

Default: 1.0

edge-trim-accuracy

float

Optional style key for anchored source/sink routes. Set anchor-control-distance in source-style or sink-style to override that endpoint’s automatic guide distance in graph units. Per-edge endpoint styles control each end independently; auto is resolved independently. Set it in edge-style to supply a shared distance for both ends. -> auto | int | float Arc-length accuracy for trimming edge curves at node outsets.

Default: 0.001

padding

none or int or float or array or dictionary

CeTZ canvas padding.

Default: 0.4

debug-edge-radius

int or float

Radius for edge-position markers shown at debug >= 2.

Default: 0.08

debug-edge-fill

any

Fill for edge-position markers shown at debug >= 2.

Default: rgb("#ff9f1c")

debug-edge-stroke

any

Stroke for edge-position markers shown at debug >= 2.

Default: rgb("#d72638") + 0.35pt

debug-edge-label-fill

any

Label fill for edge-position markers shown at debug >= 2.

Default: rgb("#7a1020")

subgraph-edge-style

dictionary

Default CeTZ style used to shade half-edges included in subgraph. Individual subgraph records may override this with edge-style.

Default: (stroke: rgb("#ffd166") + 4.5pt)

subgraph-edge-underlay

bool

Draw subgraph shading below the normal half-edge style.

Default: true