Universe

A CeTZ-based library for drawing labeled rectangular nodes and routed edges — the building blocks for block diagrams, flowcharts, and architecture drawings in Typst.

Instead of placing boxes by hand with raw coordinates, proxim lets you describe layout relationally: put this node north of that one, inside a container, or between two others. Edges between nodes support straight lines and several orthogonal routing strategies, with optional labels.

Examples

Microkernel diagram L4Re diagram

Usage

This package provides canvas(...) as a replacement for cetz.canvas(...) to enable the coordinate resolvers of this package. Inside the canvas, node and edge can be used (see below), but also all existing CeTZ primitives.

Nodes

node(origin, body, ..style) draws a labeled rectangle on the canvas.

Absolute placement

#canvas({
  node((0, 0), [Hello], name: "a")
  node((3, 0), [World], name: "b", fill: silver)
})

Relative placement — adjacent to another node

Use north-of, south-of, east-of, west-of (and diagonal variants) to place a node next to an existing one. The value can be a name string, a (name, gap) pair, or a (name, gap, align) triple:

#canvas({
  node((0, 0),                  [Start],  name: "s")
  node((east-of:  ("s", .4cm)), [Right],  name: "r")
  node((north-of: ("s", .4cm)), [Top],    name: "t")
  // Align the new node's left border with "s"'s left border:
  node((north-of: ("s", .4cm, "left")), [Top-left])
})

To place relative to a specific anchor point on the reference node, use the same "name.anchor" syntax as edges:

#canvas({
  node((0, 0), [Box], name: "box", width: 2cm, height: 1cm)
  // Place the new node west of box's north-west corner.
  node((west-of: "box.north-west"), [Corner], width: 1cm, height: .5cm)
})

With a plain name like "box", proxim places relative to the node border in the selected direction. With "box.anchor", proxim places relative to that exact anchor point instead. Alignment options such as "left" and "top" are only supported for plain element names, not explicit anchor references.

Relative placement — inside a container

Use in-north, in-south, in-east, in-west (and corner variants) to pin a child node to an inner edge of a parent. Width and height may be given as ratios relative to the container:

#canvas({
  node((0, 0), [], name: "box", width: 5cm, height: 5cm)
  node((in-north:      ("box", .1cm)), [N],  fill: silver, width: 1.2cm, height: .6cm)
  node((in-south-west: ("box", .1cm)), [SW], fill: silver, width: 40%,   height: 20%)
})

Placement between two coordinates

#canvas({
  node((-2.5, 0), [Left],  name: "l")
  node(( 2.5, 0), [Right], name: "r")
  node((between: ("l", "r")), [Mid], width: 1.5cm, height: .8cm, fill: silver)
  node((between: ("l.north", "r.south")), [Anchors], width: 1.7cm, height: .8cm)
})

Edges

edge(..points, ..style) draws a line between two coordinates or named node anchors.

Straight edge

#canvas({
  node((-2.5, 0), [A], name: "a")
  node(( 2.5, 0), [B], name: "b")
  node(( 0,  -2), [C], name: "c")

  edge("a.east",  "b.west",  mark: (end: ">"))
  edge("a.south", "c.north", mark: (end: ">"), stroke: blue)
  edge("b.south", "c.north", mark: (end: ">"), stroke: red)
})

Horizontal / vertical single-segment routing

edge("a.north-east", "b.west",  routing: "horizontal", mark: (end: ">"))
edge("c.north",      "a.south", routing: "vertical",   mark: (end: ">"), stroke: red)

shift offsets the line perpendicular to its direction (useful for parallel edges):

edge("a.north-east", "b.west", routing: "horizontal", shift: -.3cm, stroke: blue, mark: (end: ">"))

2-segment orthogonal routing

Routes the edge with a single elbow. 2w-north/2w-south go vertical first to the destination’s y coordinate, then horizontal; 2w-east/2w-west go horizontal first to the destination’s x coordinate, then vertical:

edge("a.south", "b.west", routing: "2w-south", mark: (end: ">"))
edge("a.north", "b.east", routing: "2w-north", shift: .3cm, mark: (end: ">"), stroke: blue)

shift offsets the two route segments. For 2w-north/2w-south, shift: (a, b) means horizontal shift a for the first segment and vertical shift b for the second. For 2w-east/2w-west, it means vertical shift a for the first segment and horizontal shift b for the second. A scalar applies to both. Labels on 2w-* routes are positioned along the second segment.

edge("a.north", "b.east", routing: "2w-north", shift: (.3cm, -.2cm), mark: (end: ">"))

3-segment orthogonal routing

Routes the edge out in a given direction, runs a horizontal or vertical middle segment, then turns back to the destination. Use the explicit 3w-* routing names. bend controls how far the route extends before turning. auto (the default) uses half the x distance for 3w-north/3w-south when the endpoints share y and otherwise half the y distance; 3w-east/3w-west analogously use half the y distance when the endpoints share x and otherwise half the x distance. bend: "same-dir" keeps both outer legs moving in the routing direction, while bend: "opposite-dir" returns to the starting axis:

edge("a.south", "b.south", routing: "3w-south", bend: .5, mark: (end: ">"))
edge("a.east",  "c.east",  routing: "3w-east",  bend: .8, mark: (end: ">"), stroke: red)
edge("a.south", "c.north", routing: "3w-south", bend: "same-dir", mark: (end: ">"), stroke: blue)
edge("a.south", "c.north", routing: "3w-south", bend: "opposite-dir", mark: (end: ">"), stroke: green)

shift offsets each endpoint along the middle segment. Pass an array for independent per-endpoint control:

edge("a.south", "b.south", routing: "3w-south", shift: (-.2, .2), mark: (end: ">"))

Quadratic Bezier routing

Use routing: "bezier" to draw a quadratic Bezier curve between two points or node anchors. By default, proxim chooses a single control point automatically by offsetting the midpoint along a canonical normal. The default is unchanged: mostly horizontal edges bow north, mostly vertical edges bow east. Use control: (dir: ...) to choose the automatic bend direction explicitly, control: (dir: ..., dist: ...) to also set the bend distance, or override the control point entirely with control:

edge("a.east", "b.west", routing: "bezier", mark: (end: ">"))
edge("a.east", "b.west", routing: "bezier", control: (dir: "south"), stroke: red, mark: (end: ">"))
edge("a.east", "b.west", routing: "bezier", control: (dir: "south", dist: 1cm), stroke: green, mark: (end: ">"))
edge("c.east", "d.west", routing: "bezier", control: (1.5, 2), stroke: blue, mark: (end: ">"))

Edge labels

// Label at 50% along the edge, on the north side (default)
edge("a.east", "b.west", label: [A to B], mark: (end: ">"))

// Label at 25%, south side (negative dist = south for horizontal edge)
edge("c.east", "d.west",
  label: [25% south],
  label-pos: (25%, -0.3),
  mark: (end: ">"),
)

// Rotate the label to follow the selected edge segment
edge("a.south", "c.north",
  label: [diagonal],
  label-angle: auto,
  label-pos: (60%, 0.2),
  mark: (end: ">"),
)

// On Bezier edges, auto uses the tangent angle at the label position
edge("a.east", "b.west",
  routing: "bezier",
  label: [curved],
  label-angle: auto,
  label-pos: (60%, 0.2),
  mark: (end: ">"),
)

Running the tests

The test suite compiles each .typ file in tests/ and compares the output PNG against a reference in tests/ref/. It requires Python 3, Pillow, and the typst CLI.

pip install Pillow
python3 tests/run_tests.py                           # run all tests
python3 tests/run_tests.py node-basic edge-straight  # run specific tests
python3 tests/run_tests.py --update                  # regenerate reference images