Skip to main content

Layout

Node layout relies on a few concepts: direction, lane, align_with, and the circular / tree modes. In all of these you provide no coordinates — the engine places each node in relative ratios to the container, which makes the animation responsive without manual calculations. Even graph mode, for arbitrary node-link diagrams, places nodes automatically (minimizing edge crossings) — you only add x / y when you want to pin a specific node.

direction

Eight possible values for direction (at the root level of the spec). Four arrange free-form nodes along a flow, circular puts them on a ring, tree lays out a binary tree, graph auto-places an arbitrary graph, and circuit draws an electrical schematic:

Loading…

The four linear directions share exactly the same spec: only the direction value changes. Here is the code for the previews above (the circular mode, which is structurally different, is detailed below):

{
direction: 'left-to-right', // or 'right-to-left' | 'top-to-bottom' | 'bottom-to-top'
nodes: [
{ id: 'a', type: 'server', text: 'A', lane: 1 },
{ id: 'b', type: 'server', text: 'B', lane: 2 },
{ id: 'c', type: 'server', text: 'C', lane: 3 },
],
connections: [
{ from: 'a', to: 'b', style: 'animated' },
{ from: 'b', to: 'c', style: 'animated' },
],
}

Grids and lanes

In linear mode (the first four directions), the lane property controls the node's position along the flow. Nodes that share the same lane are stacked on the transverse axis.

{
direction: 'left-to-right',
nodes: [
{ id: 'web1', type: 'server', lane: 2 },
{ id: 'web2', type: 'server', lane: 2 }, // same column as web1
{ id: 'db', type: 'database', lane: 3 },
],
}

Both axes share one spacing step, measured in pixels, and the grid is centred on the stage. Two neighbours are therefore the same distance apart whether they sit side by side or one above the other — and that distance does not change with the number of nodes. Add a lane and the diagram grows within the stage rather than redistributing itself across it.

Transverse alignment (align_with)

align_with allows you to align a node on the transverse axis of another, identified by its id. Handy for aligning two nodes from different lanes:

{
nodes: [
{ id: 'client', type: 'laptop', lane: 1 },
{ id: 'api', type: 'server', lane: 2 },
{ id: 'cache', type: 'database', lane: 3, align_with: 'api' },
],
}

Ignored in circular mode.

Edge convergence (merge_edges)

When several links attach to the same face of a node, they meet at a single anchor point by default: a many-to-one flow (a flood, a load balancer, a hub) visually converges instead of spreading out. Set merge_edges: false on a node to fan out its links instead — each one gets its own attachment point along the face, ordered to reduce crossings. Use it when a node has several distinct neighbours on the same side and you want to tell the links apart.

Loading…
{
direction: 'left-to-right',
nodes: [
{ id: 'c1', type: 'laptop', text: 'Client', lane: 1 },
{ id: 'c2', type: 'laptop', text: 'Client', lane: 1 },
{ id: 'c3', type: 'laptop', text: 'Client', lane: 1 },
// merge_edges: false → the four links fan out across the server's face.
{ id: 'srv', type: 'server', text: 'Server', lane: 2, merge_edges: false },
],
connections: [
{ from: 'c1', to: 'srv', style: 'animated' },
{ from: 'c2', to: 'srv', style: 'animated' },
{ from: 'c3', to: 'srv', style: 'animated' },
],
}

The decision is per node face: in c → srv, the links converge at srv only if srv merges, and leave c spread only if c fans out. Multiple links between the same two nodes keep their parallel tracks regardless, so bidirectional request/response flows stay legible.

Connection points on round nodes (ports)

A circle is connected on its outline, not on four cardinal sides: each edge meets the circle exactly where it points, aiming at the centre. This is why graphs and binary trees look organic — the parent→child and spoke→hub edges touch the disc head-on instead of leaving a gap at the corners. It is the default for every circle; no field is needed.

Set ports to a positive integer to expose exactly that many attach points, spread evenly around the outline. Each edge then snaps to the nearest one, and edges pointing at the same slot merge onto it — a discrete look, e.g. a routing node with a fixed number of terminals. ports: 'direct' restores the default (infinitely many points, i.e. the most direct path).

Loading…
{
direction: 'circular',
nodes: [
// ports: 4 → the eight spokes snap to the hub's four evenly-spread points
// (N/E/S/W); with 'direct' each spoke would meet the hub on its own radius.
{ id: 'hub', type: 'circle', body: 'H', main: true, ports: 4 },
{ id: 'n1', type: 'circle', body: '1' },
// …n2 … n8
],
connections: [
{ from: 'n1', to: 'hub', path: 'straight', arrow_head: 'none' },
// …one per spoke
],
}

ports only affects round nodes; other node types keep the cardinal-side anchoring (and their merge_edges behaviour). A round node's ports still respect the bidirectional spread, so a request/response pair between two circles stays on two distinct points.

Circular mode

In circular, the node marked main: true is placed in the center; the others are distributed equidistantly on a circle. lane and align_with are ignored — only the main node structures the layout.

{
direction: 'circular',
nodes: [
{ id: 'gateway', type: 'server', text: 'Gateway', icon: 'nginx', main: true },
{ id: 'auth', type: 'server', text: 'Auth', icon: 'dotnet' },
{ id: 'orders', type: 'server', text: 'Orders', icon: 'node' },
{ id: 'billing', type: 'server', text: 'Billing', icon: 'java' },
{ id: 'search', type: 'server', text: 'Search', icon: 'python' },
],
connections: [
{ from: 'auth', to: 'gateway', style: 'animated' },
{ from: 'orders', to: 'gateway', style: 'animated' },
{ from: 'billing', to: 'gateway', style: 'animated' },
{ from: 'search', to: 'gateway', style: 'animated' },
],
}
Loading…

Tree mode

In tree, the spec carries a tree block describing a binary tree: a root id and, per node, its left / right child. The engine lays out each node by its in-order rank (horizontal) and its depth (vertical), and draws the parent→child edges automatically — you provide no connections. lane and align_with are ignored; the tree block is the single source of truth for both positions and edges.

{
direction: 'tree',
tree: {
root: '8',
children: {
'8': { left: '3', right: '13' },
'3': { left: '1', right: '6' },
'13': { right: '17' },
},
},
nodes: [
{ id: '8', type: 'circle', body: '8' },
{ id: '3', type: 'circle', body: '3' },
{ id: '13', type: 'circle', body: '13' },
{ id: '1', type: 'circle', body: '1' },
{ id: '6', type: 'circle', body: '6' },
{ id: '17', type: 'circle', body: '17' },
],
}
Loading…

Because the structure lives in one place, it can be restructured at runtime: the rotate_subtree action performs a tree rotation around a pivot, and the engine re-lays-out and re-routes the edges from the same model — the nodes glide to their new depths. This is what powers the AVL and red-black tree demos.

Styling the edges

The auto-drawn edges are stylable from the same tree block — you never recreate them as connections. Set a default for every edge with tree.edge_style, and override it per edge with tree.edges, keyed by the child node id (each node has a single parent edge, so the child names it — and the style follows the node through a rotate_subtree). The per-edge entry merges over the default, field by field. Tree edges default to a straight path with no arrow head (unlike the bezier default of ordinary connections).

{
direction: 'tree',
tree: {
root: '8',
children: {
'8': { left: '3', right: '13' },
'3': { left: '1', right: '6' },
},
// Default for every parent→child edge:
edge_style: { path: 'step' },
// Override one edge (keyed by the child id):
edges: {
'6': { style: 'dashed', color: 'crimson', text: 'x' },
},
},
nodes: [
/* … */
],
}

The available fields mirror a connection: style, path, color, arrow_head, text and highlighted.

Graph mode

graph is the mode for an arbitrary graph — a node-link diagram with no natural flow, ring or tree order (a road network, or a weighted graph for a Dijkstra / A* / minimum-spanning-tree walkthrough). You describe only the edges; nodes are placed automatically. The engine runs a deterministic force-directed layout that minimizes edge crossings (it tries several seeded placements, keeps the clearest, then does a short local search to remove any remaining crossing), so the same spec always renders the same graph and resizing the player never reshuffles it. lane, align_with and main are ignored.

Edges are ordinary connections: make them undirected with arrow_head: 'none', weight them with text, and recolour them at runtime with set_color to light up a path or a spanning tree.

{
direction: 'graph',
// No coordinates: the four nodes are auto-placed to avoid crossings.
nodes: [
{ id: 'A', type: 'circle', body: 'A' },
{ id: 'B', type: 'circle', body: 'B' },
{ id: 'C', type: 'circle', body: 'C' },
{ id: 'D', type: 'circle', body: 'D' },
],
connections: [
{ from: 'A', to: 'B', text: '7', path: 'straight', arrow_head: 'none' },
{ from: 'A', to: 'C', text: '2', path: 'straight', arrow_head: 'none' },
{ from: 'B', to: 'C', text: '3', path: 'straight', arrow_head: 'none' },
{ from: 'B', to: 'D', text: '4', path: 'straight', arrow_head: 'none' },
{ from: 'C', to: 'D', text: '6', path: 'straight', arrow_head: 'none' },
],
}
Loading…

Pinning a node

Add x and y (each a fraction of the stage, 0..1) to pin a node as a fixed anchor; the auto-placement of every other node then routes around it. Handy to fix a source on the left and a target on the right, or to lock the overall orientation while the rest stays automatic:

nodes: [
{ id: 'A', type: 'circle', body: 'A', x: 0.1, y: 0.5 }, // pinned (source)
{ id: 'B', type: 'circle', body: 'B' }, // auto-placed
{ id: 'C', type: 'circle', body: 'C' }, // auto-placed
{ id: 'D', type: 'circle', body: 'D', x: 0.9, y: 0.5 }, // pinned (target)
];

The full Shortest path — Dijkstra demo in the gallery builds on this: it settles nodes one by one and recolours the shortest-path edges as it walks the graph.

Circuit mode (electrical schematics)

circuit is the mode for an electrical schematic. It differs from a dataflow diagram in three ways, and the engine adapts on all three:

  • Components with named terminals. Alongside the pictograms and shapes, circuit unlocks a family of schematic symbols — resistor, capacitor, inductor, battery, dc_source / ac_source, diode, led, transistor_npn, switch, lamp, ground, junction, ammeter… (see the full list in nodes). Each exposes fixed terminals you wire to by name: a resistor's a / b, a battery's + / -, a transistor's base / collector / emitter. A connection targets a terminal with the "node:pin" syntax ("R1:a", "battery:+"). Terminals rotate with the component, so a vertical resistor (rotation: 90) has its terminals top and bottom. Such a top/bottom-wired component draws its label to a side (left or right, toward the outside) instead of below, so the text never sits on the outgoing vertical wire.
  • Wires, not arrows. In circuit, connections default to an orthogonal wire (path: 'step') with no arrow head — you don't repeat those on every link.
  • Placement (no coordinates needed for the common cases). With no coordinates, the engine auto-arranges two shapes: a single loop (every node has two wires) becomes a rectangle (components on a non-top edge are auto-rotated so the wires stay straight); a connected feed-forward network (a logic diagram — inputs → gates → outputs) is laid out left-to-right in layers. For anything else (a series-parallel branch network, a disconnected gallery of examples), place the components yourself with x / y (fractions 0..1), like a hand-authored graph; use junction dots for the corners.
Loading…
{
direction: 'circuit',
// No coordinates: the four components form one loop → auto-arranged rectangle.
nodes: [
{ id: 'batt', type: 'battery', value: 9, unit: 'V' },
{ id: 'sw', type: 'switch', closed: true },
{ id: 'R1', type: 'resistor', value: 220, unit: 'Ω' },
{ id: 'led', type: 'led', text: 'LED' },
],
connections: [
// Wires between named terminals — orthogonal, no head, by default.
{ from: 'batt:+', to: 'sw:a' },
{ from: 'sw:b', to: 'R1:a' },
{ from: 'R1:b', to: 'led:a' },
{ from: 'led:b', to: 'batt:-' },
],
timeline: [
// Charge dots ride the whole loop (conventional current, + → −):
{
type: 'flow',
route: ['batt:+', 'sw:a', 'sw:b', 'R1:a', 'R1:b',
'led:a', 'led:b', 'batt:-'],
color: '#f59e0b',
duration: 6000, // ms per lap — slow enough to read
keep_until_end: true,
},
],
}

Two actions are dedicated to circuits: flow animates the current around a route, and toggle opens or closes a switch. Component labels use value + unit (9 V, 220 Ω), and lighting a lamp/LED is just a set_color or highlight. The full Electrical circuit demo in the gallery closes the switch, then energizes the loop.

Diagonal (45°) wires

By default wires are strictly horizontal/vertical. Set diagonal_wires: true on the spec to draw them octilinearly: each corner is mitered into an exact 45° segment (only 45 / 135 / 225 / 315°). The miter is maximised, so a long L or a staircase collapses into one clean diagonal, while a wire between aligned terminals stays straight; a short stub is kept at each pin so the wire still leaves the component perpendicularly, and any miter that would cross a body falls back to the right angle. Override it per wire with diagonal — handy to force one bus orthogonal in an otherwise diagonal schematic (or the reverse).

{
direction: 'circuit',
diagonal_wires: true, // whole schematic goes 45° where it helps
connections: [
{ from: 'g1:y', to: 'g2:a' }, // 45° feedback (inherits diagonal_wires)
{ from: 'g1:y', to: 'Q', diagonal: false }, // this one forced orthogonal
],
}

The SR latch demo in the gallery uses diagonal_wires: its two cross-coupled feedback wires read as clean diagonals while the input/output buses stay straight.

Responsive scaling

The component calculates a "cell" (smallest distance between two nodes, in px) which drives:

  • a global scale factor (--rdfa-scale): icons and fonts are larger in full screen, smaller if space is tight;
  • the maximum width of panels and packets (--rdfa-maxw) so that they never overflow onto neighbors;
  • the position of each node, bounded by its measured size to avoid overflowing the canvas.

The density prop ('compact' | 'comfortable' | 'spacious') modulates these settings to adapt the visual to a dense context (sidebar) or airy context (full screen).