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:
- left-to-right
- right-to-left
- top-to-bottom
- bottom-to-top
- circular
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.
- merge_edges: true (default)
- merge_edges: false
{
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).
- ports: 'direct' (default)
- ports: 4
{
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' },
],
}
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' },
],
}
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' },
],
}
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,
circuitunlocks 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'sa/b, a battery's+/-, a transistor'sbase/collector/emitter. Aconnectiontargets 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,connectionsdefault 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(fractions0..1), like a hand-authoredgraph; usejunctiondots for the corners.
{
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).