Advanced Techniques
Power-user features for building editors, dynamic visualizations, and complex diagrams.
Self-loop edges
Edges that connect a node to itself. Control placement and size 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').done()
.edge('db', 'db').loopSide('right').loopSize(60).arrow()
.label('Ping', { position: 'mid', dy: 0, dx: -32 }).done();
builder.mount(document.getElementById('container'));
Freeform perimeter anchors
Control the exact angle at which edges leave or arrive at nodes using .fromAngle(deg) and .toAngle(deg):
viz()
.node('a')
.at(100, 100)
.circle(30)
.node('b')
.at(300, 100)
.circle(30)
// Edge leaves A from bottom (90°), enters B from top (270°)
.edge('a', 'b')
.fromAngle(90)
.toAngle(270)
.arrow()
.build();
Angle convention: 0 = right, 90 = down, 180 = left, 270 = up.
When fromAngle/toAngle is set, it takes precedence over fromPort/toPort and auto boundary resolution. You can mix: .fromPort('right').toAngle(180).
computeNodeAnchorAtAngle
For advanced use (custom renderers), resolve where an angle intersects a shape's perimeter:
import { computeNodeAnchorAtAngle } from 'vizcraft';
const point = computeNodeAnchorAtAngle(node, 45); // { x, y }
angleBetween
Return the angle (VizCraft degrees) from one point to another. Useful for computing
fromAngle / toAngle manually:
import { angleBetween } from 'vizcraft';
const angle = angleBetween(sourceNode.pos, targetNode.pos);
eb.fromAngle(angle).toAngle(angle + 180);
Auto straight-line edges
When two nodes are not axis-aligned, using fixed angles like fromAngle(90) / toAngle(270) forces the edge to exit straight down and enter straight up — but the resulting path is diagonal and skewed. .straightLine() finds the bounding-box overlap between the two nodes and routes the edge through it — producing a perfectly vertical edge when nodes overlap horizontally, or a perfectly horizontal edge when they overlap vertically. When there is no overlap it falls back to a center-to-center diagonal:
- Preview
- Code
import { viz } from 'vizcraft';
const b = viz().view(520, 260);
// Fixed angles — edge exits bottom, enters top (skewed when offset)
b.node('a1').at(80, 60).rect(70, 40, 6).fill('#fca5a5').label('A');
b.node('b1').at(140, 190).rect(70, 40, 6).fill('#fca5a5').label('B');
b.edge('a1', 'b1').fromAngle(90).toAngle(270).arrow().stroke('#dc2626');
// .straightLine() — same layout, perfectly straight edge
b.node('a2').at(330, 60).rect(70, 40, 6).fill('#86efac').label('C');
b.node('b2').at(390, 190).rect(70, 40, 6).fill('#86efac').label('D');
b.edge('a2', 'b2').straightLine().arrow().stroke('#16a34a');
b.mount(document.getElementById('container'));
You can also apply it to just one end:
.edge('a', 'b').straightLineFrom().arrow() // source end only
.edge('a', 'b').straightLineTo().arrow() // target end only
The declarative form accepts straightLine in EdgeOptions:
.edge('a', 'b', { straightLine: true, arrow: true })
If you also set an explicit fromAngle or toAngle, it takes precedence over the
auto-computed value for that end.
Equidistant port distribution
getEquidistantPorts(shape, count?) computes N points equally spaced by perimeter arc length along any shape. Useful for port-based editors, circuit diagrams, and dynamic port placement:
- Preview
- Code
import { viz, getEquidistantPorts, toNodePorts } from 'vizcraft';
const shape = { kind: 'cylinder', w: 120, h: 80 };
const ports = getEquidistantPorts(shape, 10);
const nodePorts = toNodePorts(ports);
const b = viz().view(600, 350);
const db = b.node('db').at(300, 175).cylinder(120, 80)
.label('Database').fill('#DBEAFE').stroke('#1D4ED8', 2);
for (const p of nodePorts) db.port(p.id, p.offset, p.direction);
b.node('svc1').at(80, 60).rect(80,40,6).label('Svc A');
b.edge('svc1', 'db').toPort('p8').arrow();
b.mount(document.getElementById('container'));
Each EquidistantPort includes:
| Field | Description |
|---|---|
id | Stable identifier (p0, p1, …) |
angle | Angle from center in degrees |
t | Parametric proportion along perimeter [0, 1) |
x, y | Offset from node center |
Use toNodePorts() to convert to standard NodePort[] for the builder.
Runtime node resizing
Resize nodes at runtime without full DOM reconciliation:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz()
.view(400, 200)
.node('dynamic', { rect: { w: 100, h: 50 }, at: { x: 200, y: 100 } })
.node('left', { circle: { r: 20 }, at: { x: 50, y: 100 } })
.edge('left', 'dynamic', { arrow: true });
builder.mount(container);
// Later: resize + patch
builder.resizeNode('dynamic', { w: 180, h: 80 });
builder.patchRuntime(container);
Incremental scene mutations
Add, update, and remove nodes/edges at runtime:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz()
.view(600, 300)
.node('root', { circle: { r: 30 }, at: { x: 100, y: 150 } });
builder.mount(container, { panZoom: true });
// Mutate imperatively
builder.addNode({
id: 'n1',
pos: { x: 250, y: 150 },
shape: { kind: 'rect', w: 80, h: 40 },
});
builder.addEdge({ id: 'e1', from: 'root', to: 'n1', arrow: 'end' });
builder.commit(container);
Z-ordering
Control overlapping node draw order with zIndex:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz()
.view(600, 300)
.node('back', {
circle: { r: 50 },
at: { x: 250, y: 150 },
zIndex: 1,
fill: 'blue',
})
.node('front', {
circle: { r: 50 },
at: { x: 300, y: 150 },
zIndex: 5,
fill: 'green',
})
.node('dynamic', {
circle: { r: 60 },
at: { x: 275, y: 100 },
zIndex: 0,
fill: 'red',
});
builder.mount(container);
// Bring to front at runtime
builder.node('dynamic', { zIndex: 10 });
builder.patchRuntime(container);
Plugins
Extend VizCraft with reusable plugins. A plugin is a function that receives a VizBuilder and can mutate the scene, register behaviors, or execute utilities:
import { viz, VizPlugin } from 'vizcraft';
const watermarkPlugin: VizPlugin<{ text: string }> = (builder, options) => {
builder.node('watermark', {
at: { x: 50, y: 50 },
rect: { w: 100, h: 30 },
label: options?.text ?? 'Watermark',
zIndex: 999,
opacity: 0.5,
});
};
const builder = viz().view(400, 200);
builder
.node('a', { circle: { r: 20 }, at: { x: 150, y: 100 } })
.use(watermarkPlugin, { text: 'Draft Version' });
Event hooks
Plugins can listen for lifecycle events with builder.on():
const exportPlugin: VizPlugin = (builder) => {
builder.on('mount', ({ container, controller }) => {
const btn = document.createElement('button');
btn.innerText = 'Download PNG';
btn.onclick = () => {
// Export builder.svg() to PNG
};
btn.style.position = 'absolute';
btn.style.top = '10px';
btn.style.right = '10px';
container.appendChild(btn);
});
};
Serialization & SVG export
Build a VizScene
const scene = builder.build();
// scene is a plain data object: { nodes: [], edges: [], viewBox, ... }
Export SVG string
const svgString = builder.svg();
// Include runtime state (animation frame, overlay positions)
const animatedSvg = builder.svg({ includeRuntime: true });
Found a problem? Open an issue on GitHub.