Edges & Connections
This guide covers edge routing, markers, labels, connection ports, self-loops, and dangling edges.
Basic edges
Connect two nodes with .edge(fromId, toId). By default, edges are straight lines that stop at the node boundary.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 200);
builder
.node('A').at(50, 100).circle(26).label('Start')
.node('B').at(450, 100).rect(80, 40).label('End')
.edge('A', 'B').arrow().label('Connects to');
builder.mount(document.getElementById('container'));
Edge routing
Three routing modes: straight (default), curved, and orthogonal. Add waypoints with .via(x, y).
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 260);
builder
.node('a1').at(50, 60).circle(22).label('A')
.node('b1').at(250, 60).circle(22).label('B')
.edge('a1', 'b1').arrow().label('straight')
.node('a2').at(50, 140).circle(22).label('C')
.node('b2').at(250, 140).circle(22).label('D')
.edge('a2', 'b2').curved().arrow().label('curved')
.node('a3').at(50, 220).circle(22).label('E')
.node('b3').at(250, 220).circle(22).label('F')
.edge('a3', 'b3').orthogonal().arrow().label('orthogonal')
.node('c1').at(370, 60).circle(22).label('G')
.node('d1').at(560, 220).circle(22).label('H')
.edge('c1', 'd1').curved().via(460, 60).arrow().label('waypoint');
builder.mount(document.getElementById('container'));
| Method | Effect |
|---|---|
.straight() | Straight line (default) |
.curved() | Smooth bezier curve |
.orthogonal() | Right-angle elbows |
.routing(mode) | Programmatic: 'straight', 'curved', or 'orthogonal' |
.via(x, y) | Add a waypoint (chainable) |
Waypoint-aware endpoint anchoring
When an edge has waypoints, the boundary anchor on each node is computed toward the nearest waypoint rather than toward the other node's center. Specifically:
- The source anchor aims toward the first waypoint.
- The target anchor aims toward the last waypoint.
This is essential for edge bundling: multiple edges that share a convergence waypoint will anchor at the exact same perimeter point on the target node, producing a clean fan-in without crossing artifacts.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 260);
// Three source nodes
builder
.node('src1').at(60, 40).circle(22).label('A').fill('#89b4fa')
.node('src2').at(60, 130).circle(22).label('B').fill('#89b4fa')
.node('src3').at(60, 220).circle(22).label('C').fill('#89b4fa')
.node('target').at(400, 130).rect(100, 60, 8).label('Target').fill('#a6e3a1')
// All three edges converge at the same waypoint — anchoring
// at the exact same perimeter point on the target node.
.edge('src1', 'target').via(250, 100).curved().arrow()
.edge('src2', 'target').via(250, 100).curved().arrow()
.edge('src3', 'target').via(250, 100).curved().arrow();
builder.mount(document.getElementById('container'));
When no waypoints are present, endpoint anchoring falls back to the default center-to-center boundary intersection.
Combine waypoints with .curved() routing for smooth bundled edges, or with .straight() for sharp polyline fans.
Edge labels
Place labels at 'start', 'mid', or 'end' positions. Chain multiple .label() calls for multi-label edges (useful for ER diagrams):
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 220);
builder
.node('users').at(80, 110).rect(100, 50).label('Users')
.node('orders').at(340, 60).rect(100, 50).label('Orders')
.node('products').at(340, 170).rect(100, 50).label('Products')
.edge('users', 'orders')
.label('1', { position: 'start' })
.label('places', { position: 'mid' })
.label('_', { position: 'end' })
.arrow()
.edge('orders', 'products')
.curved()
.label('_', { position: 'start' })
.label('contains', { position: 'mid' })
.label('*', { position: 'end' })
.arrow();
builder.mount(document.getElementById('container'));
Edge styling
Style edges with .stroke(), .fill(), .opacity(), .dashed(), .dotted(), and .dash():
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 220);
builder
.node('a1').at(50, 60).circle(22).label('A')
.node('b1').at(250, 60).circle(22).label('B')
.edge('a1', 'b1').stroke('#e74c3c', 3).arrow().label('red 3px')
.node('a2').at(50, 140).circle(22).label('C')
.node('b2').at(250, 140).circle(22).label('D')
.edge('a2', 'b2').stroke('#2ecc71', 2).opacity(0.6).arrow().label('green 60%')
.node('c1').at(370, 60).circle(22).label('E')
.node('d1').at(560, 140).circle(22).label('F')
.edge('c1', 'd1').curved().stroke('#3498db', 4).arrow().label('blue curved');
builder.mount(document.getElementById('container'));
Edge markers (arrowheads)
VizCraft provides 10 marker types. Use .markerEnd(type), .markerStart(type), or the .arrow() shorthand:
- Preview
- Code
import { viz } from 'vizcraft';
// See all 10 marker types in action
const builder = viz().view(620, 340);
builder
.node('a1').at(50, 40).circle(18).label('A')
.node('b1').at(280, 40).circle(18).label('B')
.edge('a1', 'b1').arrow().label('arrow')
// ... (8 more marker types)
.done();
Available marker types: none, arrow, arrowOpen, diamond, diamondOpen, circle, circleOpen, square, bar, halfArrow
Marker colors automatically match the edge's stroke color.
Connection ports
Ports let you control exactly where edges attach on a node.
Explicit ports
Define ports with .port(id, offset) on nodes, then target them with .fromPort(id) / .toPort(id) on edges:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 300);
builder
.node('server').at(120, 150).rect(100, 80, 8)
.label('Server').fill('#DBEAFE').stroke('#1D4ED8', 2)
.port('out-1', { x: 50, y: -20 })
.port('out-2', { x: 50, y: 20 })
.node('db').at(450, 80).cylinder(90, 60)
.label('DB').fill('#DCFCE7').stroke('#166534', 2)
.port('in', { x: -45, y: 0 })
.node('cache').at(450, 230).rect(90, 50, 8)
.label('Cache').fill('#FEF3C7').stroke('#92400E', 2)
.port('in', { x: -45, y: 0 })
.edge('server', 'db').fromPort('out-1').toPort('in').arrow().label('queries')
.edge('server', 'cache').fromPort('out-2').toPort('in').arrow().label('reads');
builder.mount(document.getElementById('container'));
Default ports
Every shape has built-in ports you can reference without explicit .port() calls:
// Rectangle default ports: 'top', 'right', 'bottom', 'left'
builder.edge('A', 'B').fromPort('right').toPort('left').arrow();
Equidistant ports
For evenly distributed ports, use getEquidistantPorts:
import { getEquidistantPorts, toNodePorts } from 'vizcraft';
const ports = toNodePorts(
getEquidistantPorts({ kind: 'rect', w: 120, h: 60 }, 8)
);
const node = builder.node('a').at(100, 100).rect(120, 60);
for (const p of ports) node.port(p.id, p.offset, p.direction);
Perimeter anchors
Pin edges to a specific angle with .fromAngle(deg) / .toAngle(deg) (0° = right, 90° = down):
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 250);
builder
.node('server').at(130, 125).rect(110, 55, 6).label('Server').fill('#89b4fa')
.node('db').at(370, 125).cylinder(90, 55).label('Database').fill('#a6e3a1')
.edge('server', 'db').fromAngle(315).toAngle(200).arrow().label('query').curved()
.edge('db', 'server').fromAngle(160).toAngle(225).arrow().label('result').curved();
builder.mount(document.getElementById('container'));
Self-loop edges
Edges from a node to itself render as bezier loops. Control them with .loopSide() and .loopSize():
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(400, 200);
builder
.node('db').at(200, 100).cylinder(80, 60).fill('#fab387').label('Database')
.edge('db', 'db').loopSide('right').loopSize(60).arrow()
.label('Ping', { position: 'mid', dy: 0, dx: -32 });
builder.mount(document.getElementById('container'));
Dangling edges
A dangling edge has one endpoint at a free coordinate (not attached to a node) — useful for interactive editors showing "in-progress" connections:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 200);
builder
.node('src').at(100, 100).circle(26).label('Source').fill('#89b4fa')
.danglingEdge('drag-preview')
.from('src')
.toAt({ x: 400, y: 100 })
.arrow().dashed().stroke('#6c7086');
builder.mount(document.getElementById('container'));
| Method | Effect |
|---|---|
.danglingEdge(id) | Start a dangling edge (returns EdgeBuilder) |
.from(nodeId) | Attach source to a node |
.to(nodeId) | Attach target to a node |
.fromAt(pos) | Free source coordinate |
.toAt(pos) | Free target coordinate |
Custom edge path resolver
Override how edge paths are computed with builder.setEdgePathResolver():
builder.setEdgePathResolver((edge, scene, defaultResolver) => {
if (!edge.meta?.customRouting) return defaultResolver(edge, scene);
const from = scene.nodes.find((n) => n.id === edge.from);
const to = scene.nodes.find((n) => n.id === edge.to);
if (!from || !to) return defaultResolver(edge, scene);
const cx = (from.pos.x + to.pos.x) / 2;
const cy = Math.min(from.pos.y, to.pos.y) - 80;
return `M ${from.pos.x} ${from.pos.y} Q ${cx} ${cy} ${to.pos.x} ${to.pos.y}`;
});
Resolve edge geometry
Use resolveEdgeGeometry(scene, edgeId) to get the full rendered geometry of an edge in a single call. It handles node lookup, self-loop detection, port/angle/boundary anchors, waypoints, and routing — no need to orchestrate multiple helpers manually.
import { resolveEdgeGeometry } from 'vizcraft';
const geo = resolveEdgeGeometry(scene, 'edge-1');
if (!geo) return; // edge not found or unresolvable
// SVG path
overlayPath.setAttribute('d', geo.d);
// Label positions
positionToolbar(geo.mid); // midpoint
positionSourceLabel(geo.startLabel); // ~15% along path
positionTargetLabel(geo.endLabel); // ~85% along path
// True boundary anchor points (where the edge exits/enters each node)
drawHandle(geo.startAnchor);
drawHandle(geo.endAnchor);
// Waypoints and self-loop flag
geo.waypoints.forEach(drawWaypointDot);
if (geo.isSelfLoop) {
/* handle loop-specific overlays */
}
Returns null when:
- The edge id is not found in the scene.
- A referenced node id does not exist.
- Both endpoints are missing (no
from/toand nofromAt/toAt).
The return type is ResolvedEdgeGeometry, which extends EdgePathResult.
For batch processing, use the lower-level resolveEdgeGeometryFromData(edge, nodesById) to avoid rebuilding the node map on each call:
import { resolveEdgeGeometryFromData } from 'vizcraft';
const nodesById = new Map(scene.nodes.map((n) => [n.id, n]));
for (const edge of scene.edges) {
const geo = resolveEdgeGeometryFromData(edge, nodesById);
if (!geo) continue;
// use geo.d, geo.mid, etc.
}
Found a problem? Open an issue on GitHub.