vizcraft
v1.13.0
Published
A fluent, type-safe SVG scene builder for composing nodes, edges, animations, and overlays with incremental DOM updates and no framework dependency.
Maintainers
Readme
VizCraft
📖 Full documentation: docs here
A declarative, builder-based library for creating animated SVG network visualizations and algorithm demos.
VizCraft is designed to make creating beautiful, animated node-link diagrams and complex visualizations intuitive and powerful. Whether you are building an educational tool, explaining an algorithm, or just need a great looking graph, VizCraft provides the primitives you need.
✨ Features
- Fluent Builder API: Define your visualization scene using a readable, chainable API.
- Grid System: Built-in 2D grid system for easy, structured layout of nodes.
- Auto-Layout Algorithms: Built-in circular and grid layouts, custom sync/async algorithm support (e.g. ELK), and a
getNodeBoundingBoxutility for robust layout integration. - Two Animation Systems: Lightweight registry/CSS animations (e.g. edge
flow) and data-only timeline animations (AnimationSpec). - Framework Agnostic: The core logic is pure TypeScript and can be used with any framework or Vanilla JS.
- Custom Overlays: Create complex, custom UI elements that float on top of your visualization.
- Dangling Edges: Create edges with free endpoints for drag-to-connect interactions.
- Text Badges: Pin 1–2 character indicators (class kind, status, etc.) to any corner of a node.
📦 Installation
npm install vizcraft
# or
pnpm add vizcraft
# or
yarn add vizcraft🚀 Getting Started
You can use the core library directly to generate SVG content or mount to a DOM element.
import { viz } from 'vizcraft';
const builder = viz().view(800, 600);
builder
.node('a')
.at(100, 100)
.circle(15)
.label('A')
.node('b')
.at(400, 100)
.circle(15)
.label('B')
.edge('a', 'b')
.arrow();
const container = document.getElementById('viz-basic');
if (container) builder.mount(container);More walkthroughs and examples: docs here.
📚 Documentation (Topics)
Full documentation site: docs here
Docs topics (same as the sidebar):
Run the docs locally (monorepo):
pnpm install
pnpm -C packages/docs start📖 Core Concepts
The Builder (VizBuilder)
The heart of VizCraft is the VizBuilder. It allows you to construct a VizScene which acts as the blueprint for your visualization.
For exporting frame snapshots during data-only playback, you can export an SVG that includes runtime overrides:
const svg = builder.svg({ includeRuntime: true });b.view(width, height) // Set the coordinate space
.grid(cols, rows) // (Optional) Define layout grid
.node(id) // Start defining a node
.edge(from, to); // Start defining an edgePlugins
Extend the builder's functionality seamlessly using .use(). Plugins are functions that take the builder instance and optional configuration, allowing you to encapsulate reusable behaviors, export utilities, or composite nodes.
import { viz, VizPlugin } from 'vizcraft';
const watermarkPlugin: VizPlugin<{ text: string }> = (builder, opts) => {
builder.node('watermark', {
at: { x: 50, y: 20 },
rect: { w: 100, h: 20 },
label: opts?.text ?? 'Draft',
opacity: 0.5,
});
};
viz()
.view(800, 600)
.node('n1', { circle: { r: 20 } })
.use(watermarkPlugin, { text: 'Confidential' })
.build();Event Hooks
Plugins (or your own code) can also tap into the builder's lifecycle using .on(). This is particularly useful for interactive plugins that need to append HTML elements (like export buttons or tooltips) after VizCraft mounts the SVG to the DOM.
const exportUiPlugin: VizPlugin = (builder) => {
// Listen for the 'mount' event to inject a button next to the SVG
builder.on('mount', ({ container }) => {
const btn = document.createElement('button');
btn.innerText = 'Download PNG';
btn.onclick = () => {
/* export logic */
};
// Position the button absolutely over the container
btn.style.position = 'absolute';
btn.style.top = '10px';
btn.style.right = '10px';
container.appendChild(btn);
});
};Declarative Options Overloads
You can also configure nodes and edges in a single declarative call by passing an options object:
// Declarative — pass all options at once, returns VizBuilder
b.node('a', {
at: { x: 100, y: 100 },
rect: { w: 80, h: 40 },
fill: 'steelblue',
label: 'A',
})
.node('b', { circle: { r: 20 }, at: { x: 300, y: 100 }, label: 'B' })
.edge('a', 'b', { arrow: true, stroke: 'red', dash: 'dashed' })
.build();Both NodeOptions and EdgeOptions types are exported for full type-safety. See the Essentials docs for the complete options reference.
Nodes
Nodes are the primary entities in your graph. They can have shapes, labels, and styles.
b.node('n1')
.at(x, y) // Absolute position
// OR
.cell(col, row) // Grid position
.circle(radius) // Circle shape
.rect(w, h, [rx]) // Rectangle (optional corner radius)
.diamond(w, h) // Diamond shape
.cylinder(w, h, [arcHeight]) // Cylinder (database symbol)
.hexagon(r, [orientation]) // Hexagon ('pointy' or 'flat')
.ellipse(rx, ry) // Ellipse / oval
.arc(r, start, end, [closed]) // Arc / pie slice
.blockArrow(len, bodyW, headW, headLen, [dir]) // Block arrow
.callout(w, h, [opts]) // Speech bubble / callout
.cloud(w, h) // Cloud / thought bubble
.cross(size, [barWidth]) // Cross / plus sign
.cube(w, h, [depth]) // 3D isometric cube
.path(d, w, h) // Custom SVG path
.document(w, h, [wave]) // Document (wavy bottom)
.note(w, h, [foldSize]) // Note (folded corner)
.parallelogram(w, h, [skew]) // Parallelogram (I/O)
.star(points, outerR, [innerR]) // Star / badge
.trapezoid(topW, bottomW, h) // Trapezoid
.triangle(w, h, [direction]) // Triangle
.label('Text', { dy: 5 }) // Label with offset
.richLabel((l) => l.text('Hello ').bold('World')) // Rich / mixed-format label
.image(href, w, h, opts?) // Embed an <image> inside the node
.icon(id, opts?) // Embed an icon from the icon registry (see registerIcon)
.svgContent(svg, opts) // Embed inline SVG content inside the node
.fill('#f0f0f0') // Fill color
.stroke('#333', 2) // Stroke color and optional width
.opacity(0.8) // Opacity
.dashed() // Dashed border (8, 4)
.dotted() // Dotted border (2, 4)
.dash('12, 3, 3, 3') // Custom dash pattern
.shadow() // Drop shadow (default: dx=2 dy=2 blur=4)
.shadow({ dx: 4, dy: 4, blur: 10, color: 'rgba(0,0,0,0.35)' }) // Custom shadow
.sketch() // Sketch / hand-drawn look (SVG turbulence filter)
.sketch({ seed: 42 }) // Sketch with explicit seed for deterministic jitter
.class('css-class') // Custom CSS class
.data({ ... }) // Attach custom data
.port('out', { x: 50, y: 0 }) // Named connection port
.container(config?) // Mark as container / group node
.parent('containerId') // Make child of a container
.compartment(id, cb?) // Add a UML-style compartment section
.collapsed(state?) // Collapse to header-only (compact mode)
.collapseIndicator(opts) // Customise or hide the collapse chevron
.collapseAnchor(anchor) // 'top' | 'center' (default) | 'bottom'Compartmented Nodes
Divide a node into horizontal sections — ideal for UML class diagrams:
b.node('user')
.at(250, 110)
.rect(200, 0, 6)
.fill('#f5f5f5')
.stroke('#333')
.compartment('name', (c) => c.label('User').height(36))
.compartment('attrs', (c) =>
c.label('- id: number\n- name: string', {
fontSize: 12,
textAnchor: 'start',
})
)
.compartment('methods', (c) =>
c.label('+ getName()\n+ setName()', { fontSize: 12, textAnchor: 'start' })
)
.done();The node height auto-sizes to fit all compartments. Each section is separated by a divider line in the rendered SVG.
Per-entry interactivity
Use .entry(id, text, opts?) inside a compartment callback to make each line independently clickable, hoverable, and styled:
b.node('service')
.at(250, 125)
.rect(220, 0, 6)
.compartment('name', (c) => c.label('UserService').height(36))
.compartment('methods', (c) => {
c.entry('create', '+ createUser()', {
onClick: () => console.log('create clicked'),
tooltip: 'Creates a new user record',
style: { fontWeight: 'bold' },
});
c.entry('find', '+ findById(id)');
})
.done();Each entry also accepts padding (uniform number or { top, bottom }) for vertical spacing and className for custom CSS targeting.
Entries and labels are mutually exclusive within a compartment. Hovered entries receive the CSS class viz-entry-hover. hitTest() returns entryId alongside compartmentId for entry-based compartments.
Collapsed / compact mode
Use .collapsed() to show only the first compartment (header) while keeping all data intact — useful for compact UML overviews:
b.node('cls')
.rect(160, 0, 6)
.compartment('name', (c) => c.label('MyClass').height(36))
.compartment('attrs', (c) => c.label('- field: string'))
.collapsed() // only header shown; triangle indicator rendered
.done();The node auto-sizes to the first compartment height. A collapse indicator triangle is rendered. The group receives the CSS class viz-node-collapsed.
Add .onClick(handler) on a compartment to wire up interactive collapse/expand with a toggle() helper:
b.node('cls')
.rect(160, 0, 6)
.compartment('name', (c) =>
c
.label('MyClass')
.height(36)
.onClick((ctx) => ctx.toggle({ animate: 200 }))
)
.compartment('attrs', (c) => c.label('- field: string'))
.done();Customise the indicator with .collapseIndicator() — change its colour, hide it, or supply custom SVG:
.collapseIndicator({ color: 'crimson' }) // custom colour
.collapseIndicator(false) // hide entirely
.collapseIndicator({ render: (collapsed) => `<text>${collapsed ? '▶' : '▼'}</text>` })Control which edge stays fixed during the animation with .collapseAnchor():
.collapseAnchor('top') // top edge fixed, grows/shrinks downward
.collapseAnchor('center') // symmetric (default)
.collapseAnchor('bottom') // bottom edge fixed, grows/shrinks upwardYou can also pass anchor per-toggle: ctx.toggle({ animate: 200, anchor: 'top' }).
Container / Group Nodes
Group related nodes into visual containers (swimlanes, sub-processes, etc.).
b.node('lane')
.at(250, 170)
.rect(460, 300)
.label('Process Phase')
.container({ headerHeight: 36 });
b.node('step1').at(150, 220).rect(100, 50).parent('lane');
b.node('step2').at(350, 220).rect(100, 50).parent('lane');Container children are nested inside the container <g> in the SVG and follow the container when moved at runtime.
Edges
Edges connect nodes and can be styled, directed, or animated.
All edges are rendered as <path> elements supporting three routing modes.
b.edge('n1', 'n2')
.arrow() // Add an arrowhead
.straight() // (Default) Straight line
.label('Connection')
.richLabel((l) => l.text('p').sup('95').text(' = ').bold('10ms'))
.animate('flow'); // Add animation
// Curved edge
b.edge('a', 'b').curved().arrow();
// Orthogonal (right-angle) edge
b.edge('a', 'c').orthogonal().arrow();
// Waypoints — intermediate points the edge passes through
b.edge('x', 'y').curved().via(150, 50).via(200, 100).arrow();
// Arbitrary edge metadata (for routing flags, categories, etc.)
b.edge('a', 'b').meta({ customRouting: true, padding: 10 });
// Override edge path computation with a resolver hook
b.setEdgePathResolver((edge, scene, defaultResolver) => {
if (edge.meta?.customRouting) {
// Return an SVG path `d` string
return `M 0 0 L 10 10`;
}
return defaultResolver(edge, scene);
});
// Per-edge styling (overrides CSS defaults)
b.edge('a', 'b').stroke('#ff0000', 3).fill('none').opacity(0.8);
// Dashed, dotted, and custom dash patterns
b.edge('a', 'b').dashed().stroke('#6c7086'); // dashed line
b.edge('a', 'b').dotted(); // dotted line
b.edge('a', 'b').dash('12, 3, 3, 3').stroke('#cba6f7'); // custom pattern
// Sketch / hand-drawn edges
b.edge('a', 'b').sketch(); // sketchy look
// Multi-position edge labels (start / mid / end)
b.edge('a', 'b')
.label('1', { position: 'start' })
.label('*', { position: 'end' })
.arrow();
// Rich text labels (mixed formatting)
b.edge('a', 'b')
.richLabel((l) => l.text('p').sup('95').text(' ').bold('12ms'))
.arrow();
// Edge markers / arrowhead types
b.edge('a', 'b').markerEnd('arrowOpen'); // Open arrow (inheritance)
b.edge('a', 'b').markerStart('diamond').markerEnd('arrow'); // UML composition
b.edge('a', 'b').markerStart('diamondOpen').markerEnd('arrow'); // UML aggregation
b.edge('a', 'b').arrow('both'); // Bidirectional arrows
b.edge('a', 'b').markerStart('circleOpen').markerEnd('arrow'); // Association
// Self-loops (exits and enters the same node)
b.edge('n1', 'n1').loopSide('right').loopSize(40).arrow();
// Straight-line edges via bounding-box overlap (vertical when nodes overlap
// horizontally, horizontal when they overlap vertically)
b.edge('a', 'b').straightLine().arrow(); // both ends
b.edge('a', 'b').straightLineFrom().arrow(); // source end only
// Angle utility for manual perimeter anchoring
import { angleBetween } from 'vizcraft';
const angle = angleBetween(nodeA.pos, nodeB.pos);
b.edge('a', 'b')
.fromAngle(angle)
.toAngle(angle + 180);
b.edge('a', 'b').markerEnd('bar'); // ER cardinality
// Connection ports — edges attach to specific points on nodes
b.node('srv')
.at(100, 100)
.rect(80, 60)
.port('out-1', { x: 40, y: -15 })
.port('out-2', { x: 40, y: 15 });
b.node('db').at(400, 100).cylinder(80, 60).port('in', { x: -40, y: 0 });
b.edge('srv', 'db').fromPort('out-1').toPort('in').arrow();
// Default ports (no .port() needed) — every shape has built-in ports
b.edge('a', 'b').fromPort('right').toPort('left').arrow();
// Equidistant port distribution — stable, location-based IDs
import { getEquidistantPorts, toNodePorts, findPortNearest } from 'vizcraft';
const ports = getEquidistantPorts({ kind: 'rect', w: 120, h: 60 }, 8);
// → [{ id: 'top-0', … }, { id: 'top-1', … }, { id: 'right-0', … }, …]
const nodePorts = toNodePorts(ports); // → NodePort[] ready for node.ports
// Snap to nearest port (node-local coordinates)
const nearest = findPortNearest(node, clickX - node.pos.x, clickY - node.pos.y);
if (nearest) b.edge('a', 'b').toPort(nearest.id);
// Dangling edges — one or both endpoints at a free coordinate
b.danglingEdge('preview').from('srv').toAt({ x: 300, y: 200 }).arrow().dashed();
// Declarative dangling edge
b.danglingEdge('e1', { from: 'srv', toAt: { x: 300, y: 200 }, arrow: true });Resolving edge geometry
resolveEdgeGeometry(scene, edgeId) resolves all rendered geometry for an edge in a single call — anchor points, SVG path, midpoint, label positions, waypoints, and self-loop detection:
import { resolveEdgeGeometry } from 'vizcraft';
const geo = resolveEdgeGeometry(scene, 'edge-1');
if (!geo) return; // edge not found or unresolvable
overlayPath.setAttribute('d', geo.d); // SVG path
positionToolbar(geo.mid); // midpoint
drawHandle(geo.startAnchor); // true boundary exit point
drawHandle(geo.endAnchor); // true boundary entry point
positionSourceLabel(geo.startLabel); // ~15% along path
positionTargetLabel(geo.endLabel); // ~85% along path
geo.waypoints.forEach(drawDot); // waypoints
if (geo.isSelfLoop) {
/* ... */
} // self-loop flag| Method | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| .straight() | Direct line (default). With waypoints → polyline. |
| .curved() | Smooth bezier curve. With waypoints → Catmull-Rom spline. |
| .orthogonal() | Right-angle elbows. |
| .routing(mode) | Set mode programmatically. |
| .via(x, y) | Add an intermediate waypoint (chainable). Waypoints also influence endpoint anchoring — the source boundary anchor aims toward the first waypoint and the target anchor aims toward the last, enabling clean edge bundling. |
| .label(text, opts?) | Add a text label. Chain multiple calls for multi-position labels. opts.position can be 'start', 'mid' (default), or 'end'. |
| .richLabel(cb, opts?) | Add a rich / mixed-format label (nested SVG <tspan>s). Use .newline() in the callback to control line breaks. |
| .arrow([enabled]) | Shorthand for arrow markers. true/no-arg → markerEnd arrow. 'both' → both ends. 'start'/'end' → specific end. false → none. |
| .markerEnd(type) | Set marker type at the target end. See EdgeMarkerType. |
| .markerStart(type) | Set marker type at the source end. See EdgeMarkerType. |
| .fromPort(portId) | Connect from a specific named port on the source node. |
| .toPort(portId) | Connect to a specific named port on the target node. |
| .fromAngle(deg) | Set a fixed perimeter angle (degrees, 0 = right, 90 = down) on the source node. |
| .toAngle(deg) | Set a fixed perimeter angle (degrees, 0 = right, 90 = down) on the target node. |
| .from(nodeId) | Attach the source end to an existing node (useful with danglingEdge()). |
| .to(nodeId) | Attach the target end to an existing node (useful with danglingEdge()). |
| .fromAt(pos) | Set the free-endpoint coordinate for the source end ({ x, y }). |
| .toAt(pos) | Set the free-endpoint coordinate for the target end ({ x, y }). |
| .stroke(color, width?) | Set stroke color and optional width. |
| .fill(color) | Set fill color. |
| .opacity(value) | Set opacity (0–1). |
| .dashed() | Dashed stroke (8, 4). |
| .dotted() | Dotted stroke (2, 4). |
| .dash(pattern) | Custom SVG dasharray or preset ('dashed', 'dotted', 'dash-dot', 'solid'). |
EdgeMarkerType values: 'none', 'arrow', 'arrowOpen', 'diamond', 'diamondOpen', 'circle', 'circleOpen', 'square', 'bar', 'halfArrow'.
Animations
See the full Animations guide docs here.
VizCraft supports two complementary animation approaches:
- Registry/CSS animations (simple, reusable effects)
Attach an animation by name to a node/edge. The default core registry includes:
flow(edge)
import { viz } from 'vizcraft';
const b = viz().view(520, 160);
b.node('a')
.at(70, 80)
.circle(18)
.label('A')
.node('b')
.at(450, 80)
.rect(70, 44, 10)
.label('B')
.edge('a', 'b')
.arrow()
.animate('flow', { duration: '1s' })
.done();- Data-only timeline animations (
AnimationSpec) (sequenced tweens)
- Author with
builder.animate((aBuilder) => ...). - VizCraft stores compiled specs on the scene as
scene.animationSpecs. - Play them with
builder.play().
import { viz } from 'vizcraft';
const b = viz().view(520, 240);
b.node('a')
.at(120, 120)
.circle(20)
.label('A')
.node('b')
.at(400, 120)
.rect(70, 44, 10)
.label('B')
.edge('a', 'b')
.arrow()
.done();
b.animate((aBuilder) =>
aBuilder
.node('a')
.to({ x: 200, opacity: 0.35 }, { duration: 600 })
.node('b')
.to({ x: 440, y: 170 }, { duration: 700 })
.edge('a->b')
.to({ strokeDashoffset: -120 }, { duration: 900 })
);
const container = document.getElementById('viz-basic');
if (container) {
b.mount(container);
b.play();
}Animating edges with custom ids
If you create an edge with a custom id (third arg), target it explicitly in animations:
const b = viz().view(520, 240);
b.node('a')
.at(120, 120)
.circle(20)
.node('b')
.at(400, 120)
.rect(70, 44, 10)
.edge('a', 'b', 'e1')
.done();
b.animate((aBuilder) =>
aBuilder
.edge('a', 'b', 'e1')
.to({ strokeDashoffset: -120 }, { duration: 900 })
);Custom animatable properties (advanced)
Specs can carry adapter extensions so you can animate your own numeric properties:
b.animate((aBuilder) =>
aBuilder
.extendAdapter((adapter) => {
adapter.register?.('node', 'r', {
get: (target) => adapter.get(target, 'r'),
set: (target, v) => adapter.set(target, 'r', v),
});
})
.node('a')
.to({ r: 42 }, { duration: 500 })
);Playback controls
builder.play() returns a controller with pause(), play() (resume), and stop().
const controller = b.play();
controller?.pause();
controller?.play();
controller?.stop();Supported properties (core adapter)
Out of the box, timeline playback supports these numeric properties:
- Node:
x,y,opacity,scale,rotation - Edge:
opacity,strokeDashoffset
🎨 Styling
VizCraft generates standard SVG elements with predictable classes, making it easy to style with CSS.
/* Custom node style */
.viz-node-shape {
fill: #fff;
stroke: #333;
stroke-width: 2px;
}
/* Specific node class */
.my-node .viz-node-shape {
fill: #ff6b6b;
}
/* Edge styling (CSS defaults) */
.viz-edge {
stroke: #ccc;
stroke-width: 2;
}Edges can also be styled per-edge via the builder (inline SVG attributes override CSS):
b.edge('a', 'b').stroke('#e74c3c', 3).fill('none').opacity(0.8);🤝 Contributing
Contributions are welcome! This is a monorepo managed with Turbo.
- Clone the repo
- Install dependencies:
pnpm install - Run dev server:
pnpm dev
📄 License
MIT License
