Skip to content

ChipiKaf/vizcraft

Repository files navigation

VizCraft

npm version npm downloads CI Release Snapshot license

πŸ“– 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 getNodeBoundingBox utility 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);

Declarative spec API (fromSpec)

If you prefer to describe a scene as a plain JSON object β€” or you're generating scenes programmatically / from an LLM β€” use fromSpec. It returns an ordinary VizBuilder you can chain or mount normally:

import { fromSpec } from 'vizcraft';

const builder = fromSpec({
  view: { width: 900, height: 360 },
  nodes: [
    { id: 'client', label: 'Client',        x: 80,  y: 180, fill: '#0e7490' },
    { id: 'lb',     label: 'Load Balancer', x: 420, y: 180, fill: '#7c3aed' },
    { id: 's1',     label: 'Server 1',  shape: 'cylinder', x: 760, y: 100 },
  ],
  edges: [
    { from: 'client', to: 'lb' },
    { from: 'lb',     to: 's1' },
  ],
});

builder.mount(document.getElementById('canvas')!);

For more examples and best practices, see docs here.

πŸ“š Documentation (Topics)

Full documentation site: docs here

Docs topics (same as the sidebar):

Run the docs locally:

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.

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 edge

Common lifecycle:

  • builder.build() creates a serializable VizScene.
  • builder.mount(container) renders into an SVG inside your container.
  • builder.destroy() tears down a mounted scene securely, cleaning up DOM and event listeners.
  • builder.play() plays any compiled timeline specs.
  • builder.patchRuntime(container) applies runtime-only updates (useful for per-frame updates without remounting).
  • builder.svg({ includeRuntime: true }) exports an SVG snapshot including the current animated pose (useful for GIF/video pipelines).

Plugins

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. Set height: 0 on the shape and let compartments compute the total.

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 upward

You 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:

  1. 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();
  1. 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();

// Create + store a data-only AnimationSpec
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(); // Warns + no-ops if mount wasn't called
}

Animating edges with custom ids

Edges can have any id (you can pass it as the optional third argument to builder.edge(from, to, id)):

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', 'e1').arrow()
 .done();

b.animate((aBuilder) =>
  aBuilder.edge('a', 'b', 'e1').to({ strokeDashoffset: -120 }, { duration: 900 })
);

When you don’t provide an explicit edge id, the default convention is "from->to".

Custom animatable properties (advanced)

You can animate properties that aren’t in the core set by extending the adapter for a specific spec:

b.animate((aBuilder) =>
  aBuilder
    .extendAdapter((adapter) => {
      // adapter may support register(kind, prop, { get, set })
      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 })
);

See the docs for the recommended get/set implementations for SVG attributes.

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)

🧭 Advanced Topics

For deeper guides and API references, see docs here.

  • Interactivity: attach onClick handlers and .tooltip() hover info to nodes/edges.
  • Text badges: pin corner indicators to nodes with .badge(text, opts?).
  • Overlays: add non-node/edge visuals using .overlay(id, params, key?).
  • React integration: see the workspace package packages/react-vizcraft (monorepo).

Node-relative primitive overlays

rect, circle, text, and group overlays can anchor themselves to a node with nodeId. circle and text use the resolved node center directly, rect centers itself on that point, and group uses it as the group origin. offsetX / offsetY let you stack anchored overlays without maintaining a separate scene-coordinate lookup table. Omitting nodeId preserves the existing absolute x / y behavior.

builder.overlay((o) =>
  o
    .circle(
      {
        nodeId: 'producer',
        offsetX: 36,
        offsetY: -18,
        r: 6,
        fill: '#f59e0b',
        stroke: '#b45309',
        strokeWidth: 2,
      },
      { key: 'producer-marker' }
    )
    .rect(
      {
        nodeId: 'broker',
        w: 72,
        h: 28,
        rx: 14,
        fill: '#dcfce7',
        stroke: '#16a34a',
        strokeWidth: 2,
      },
      { key: 'broker-slot' }
    )
    .text(
      {
        nodeId: 'store',
        offsetY: 40,
        text: '12 persisted',
        textAnchor: 'middle',
        fontWeight: 700,
      },
      { key: 'store-label' }
    )
    .group(
      { nodeId: 'broker', offsetX: -40, offsetY: -8 },
      (g) => {
        g.circle({
          x: 0,
          y: -10,
          r: 4,
          fill: '#93c5fd',
          stroke: '#1d4ed8',
          strokeWidth: 2,
        });
        g.circle({
          x: 0,
          y: 0,
          r: 4,
          fill: '#93c5fd',
          stroke: '#1d4ed8',
          strokeWidth: 2,
        });
        g.circle({
          x: 0,
          y: 10,
          r: 4,
          fill: '#93c5fd',
          stroke: '#1d4ed8',
          strokeWidth: 2,
        });
      },
      { key: 'broker-batch' }
    )
);

Routed signal overlays

The built-in signal overlay can follow the actual rendered edge path instead of moving center-to-center:

const builder = viz()
  .view(620, 280)
  .node('broker', { at: { x: 150, y: 180 }, rect: { w: 120, h: 72, rx: 16 } })
  .node('p2', { at: { x: 470, y: 80 }, rect: { w: 110, h: 60, rx: 14 } })
  .edge('broker', 'p2', 'broker-p2')
  .routing('curved')
  .via(250, 250)
  .via(380, 40)
  .done()
  .overlay(
    'signal',
    {
      from: 'broker',
      to: 'p2',
      edgeId: 'broker-p2',
      progress: 0.6,
      magnitude: 0.8,
    },
    'sig'
  );

Use followEdge: true when there is only one from -> to edge. If the edge is missing or ambiguous, VizCraft falls back to the existing straight center-to-center interpolation.

To keep a signal visible after arrival without switching overlay kinds, set resting: true. Use parkAt to override the parked node and parkOffsetX / parkOffsetY to stack multiple arrived signals inside the same node:

builder.overlay(
  'signal',
  {
    from: 'broker',
    to: 'p2',
    edgeId: 'broker-p2',
    progress: 1,
    resting: true,
    parkOffsetX: 12,
    parkOffsetY: -8,
  },
  'sig-resting'
);

Multi-hop signal chains

Use chain to move one signal overlay across multiple hops declaratively. floor(progress) selects the active hop and the fractional part drives that hop locally. Once progress >= chain.length, the signal parks at the final hop's to node automatically. Each hop can still set followEdge or edgeId.

builder.overlay(
  'signal',
  {
    chain: [
      { from: 'producer', to: 'dispatcher', edgeId: 'producer-dispatcher' },
      { from: 'dispatcher', to: 'adapter', edgeId: 'dispatcher-adapter' },
      { from: 'adapter', to: 'broker', edgeId: 'adapter-broker' },
      { from: 'broker', to: 'p0', edgeId: 'broker-p0' },
    ],
    progress: 2.4,
    magnitude: 0.9,
  },
  'sig-chain'
);

Per-signal color override

Set color to override the default blue fill on individual signals. An optional glowColor adds a drop-shadow halo (defaults to color).

// Green ball β€” majority committed
builder.overlay(
  'signal',
  {
    from: 'primary',
    to: 'reader',
    edgeId: 'primary-reader',
    progress: 0.6,
    magnitude: 0.85,
    color: '#22c55e',
  },
  'committed'
);

// Amber ball β€” stale snapshot
builder.overlay(
  'signal',
  {
    from: 'primary',
    to: 'reader',
    edgeId: 'primary-reader',
    progress: 0.6,
    magnitude: 0.85,
    color: '#f59e0b',
  },
  'stale'
);

🀝 Contributing

Contributions are welcome! This is a monorepo managed with Turbo.

  1. Clone the repo
  2. Install dependencies: pnpm install
  3. Run dev server: pnpm dev

πŸ“„ License

MIT License

About

Build beautiful, animated node-link diagrams and algorithm visualizations with ease. Features a fluent API, grid layouts, custom overlays and more.

Resources

License

MIT, MIT licenses found

Licenses found

MIT
LICENSE
MIT
LICENSE.txt

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages