Overlays
Overlays are an optional SVG layer that sits above nodes and edges. Use them for signals, annotations, selection indicators, grid labels, and other transient visual elements.
Mental model
An overlay has two halves:
- Authoring: use
builder.overlay((o) => ...)to describe what overlays exist and with what params - Rendering: the overlay registry turns each spec into SVG (built-ins ship pre-registered; custom visuals need a registered renderer)
Built-in primitive overlays
Three primitives work out of the box without a custom renderer: rect, circle, and text.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(520, 220);
builder
.node('a').at(160, 110).circle(18).label('A')
.node('b').at(360, 110).circle(18).label('B')
.overlay((o) =>
o
.text({ x: 24, y: 28, text: 'Overlay layer: on', fontSize: 14 }, { key: 'label' })
.rect({ x: 110, y: 70, w: 300, h: 80, rx: 10 }, { key: 'sel', className: 'viz-selection' })
);
builder.mount(document.getElementById('container'), {
css: '.viz-selection { fill: #3b82f6; fill-opacity: 0.12; stroke: #3b82f6; stroke-width: 2; }'
});
Primitive overlays can also anchor themselves to a node center with nodeId. circle and text use the resolved node center directly, rect centers itself on that anchor, and group uses it as the group origin. When nodeId is omitted, VizCraft keeps the existing absolute x / y behavior.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(640, 260);
builder
.node('producer').at(120, 120).rect(110, 72, 16).label('Producer', { dy: -50, fontWeight: 700 })
.node('broker').at(320, 120).rect(120, 72, 16).label('Broker', { dy: -50, fontWeight: 700 })
.node('store').at(520, 120).cylinder(120, 64).label('Store', { dy: -48, fontWeight: 700 })
.overlay((o) =>
o
.circle({ nodeId: 'producer', offsetX: 38, offsetY: -18, r: 6, fill: '#f59e0b', stroke: '#b45309', strokeWidth: 2 }, { key: 'producer-marker' })
.rect({ nodeId: 'broker', offsetY: 4, w: 74, h: 30, rx: 15, fill: '#dcfce7', stroke: '#16a34a', strokeWidth: 2 }, { key: 'broker-slot' })
.text({ nodeId: 'store', offsetY: 44, text: '12 persisted', fontSize: 14, fontWeight: 700, textAnchor: 'middle', fill: '#0f172a' }, { key: 'store-label' })
.group({ nodeId: 'broker', offsetX: -42, offsetY: -10 }, (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-group' })
);
builder.mount(document.getElementById('container'));
Signal overlay
The built-in signal overlay draws a moving marker between two nodes. By default it interpolates between node centers:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(520, 200);
builder
.node('a').at(120, 100).circle(18).label('A')
.node('b').at(400, 100).circle(18).label('B')
.overlay('signal', { from: 'a', to: 'b', progress: 0.5, magnitude: 0.7 }, 'sig');
builder.mount(document.getElementById('container'));
Follow the rendered edge path
Add followEdge: true when a single edge connects from -> to, or pass edgeId to follow a specific routed edge path. If the edge is missing or ambiguous, VizCraft falls back to the existing center-to-center interpolation.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(620, 280);
builder
.node('broker').at(150, 180).rect(120, 72, 16).label('Broker', { dy: -48, fontWeight: 700 })
.node('p2').at(470, 80).rect(110, 60, 14).label('Partition 2', { dy: -40, fontWeight: 700 })
.edge('broker', 'p2', 'broker-p2').routing('curved').via(250, 250).via(380, 40).stroke('#334155', 3)
.overlay((o) =>
o
.text({ x: 28, y: 30, text: 'Grey = center interpolation, orange = edge path', fontSize: 14 }, { key: 'legend' })
.add('signal', { from: 'broker', to: 'p2', progress: 0.56, magnitude: 0.35 }, { key: 'centerline', className: 'viz-signal-centerline' })
.add('signal', { from: 'broker', to: 'p2', edgeId: 'broker-p2', progress: 0.56, magnitude: 0.9 }, { key: 'followed', className: 'viz-signal-follow' })
);
builder.mount(document.getElementById('container'), {
css: '.viz-signal-centerline .viz-signal-shape { fill: #94a3b8; stroke: #475569; } ' +
'.viz-signal-follow .viz-signal-shape { fill: #f97316; stroke: #c2410c; }',
});
Keep a signal parked after arrival
Set resting: true to keep the same signal overlay visible at to once progress >= 1. Use parkAt to override the parked node and parkOffsetX / parkOffsetY to stack multiple arrived signals without switching to a different overlay kind. See SignalOverlayParams for the exported type.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(620, 260);
builder
.node('broker').at(140, 150).rect(120, 72, 16).label('Broker', { dy: -48, fontWeight: 700 })
.node('partition').at(470, 110).rect(140, 78, 16).label('Partition', { dy: -52, fontWeight: 700 })
.edge('broker', 'partition', 'broker-partition').routing('curved').via(250, 225).via(365, 55).stroke('#475569', 3)
.overlay((o) =>
o
.add('signal', {
from: 'broker', to: 'partition', edgeId: 'broker-partition', progress: 0.48, magnitude: 0.55,
}, { key: 'moving', className: 'viz-signal-transit' })
.add('signal', {
from: 'broker', to: 'partition', edgeId: 'broker-partition', progress: 1, resting: true,
parkOffsetX: -14, parkOffsetY: 10, magnitude: 0.8,
}, { key: 'rest-a', className: 'viz-signal-resting' })
.add('signal', {
from: 'broker', to: 'partition', edgeId: 'broker-partition', progress: 1, parkAt: 'partition',
parkOffsetX: 14, parkOffsetY: -10, magnitude: 0.8,
}, { key: 'rest-b', className: 'viz-signal-resting' })
);
builder.mount(document.getElementById('container'), {
css: '.viz-signal-transit .viz-signal-shape { fill: #38bdf8; stroke: #0369a1; } ' +
'.viz-signal-resting .viz-signal-shape { fill: #fbbf24; stroke: #b45309; }',
});
Animate a declarative multi-hop signal chain
Use chain to describe multiple hops inside one signal overlay. floor(progress) selects the active hop and the fractional part is that hop's local progress. Once progress >= chain.length, VizCraft parks the signal at the final hop's to node automatically. Each hop can still opt into routed edge following with followEdge or edgeId.
- Preview
- Code
import { viz } from 'vizcraft';
const 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: 'partition', edgeId: 'broker-partition' },
];
const builder = viz().view(860, 320);
builder
.node('producer').at(100, 220).rect(120, 72, 16).label('Producer', { dy: -50, fontWeight: 700 })
.node('dispatcher').at(280, 100).rect(130, 72, 16).label('Dispatcher', { dy: -50, fontWeight: 700 })
.node('adapter').at(470, 220).rect(120, 72, 16).label('Adapter', { dy: -50, fontWeight: 700 })
.node('broker').at(650, 100).rect(120, 72, 16).label('Broker', { dy: -50, fontWeight: 700 })
.node('partition').at(810, 220).rect(120, 72, 16).label('Partition 0', { dy: -50, fontWeight: 700 })
.edge('producer', 'dispatcher', 'producer-dispatcher').routing('curved').via(170, 280).via(220, 130).stroke('#334155', 3)
.edge('dispatcher', 'adapter', 'dispatcher-adapter').routing('curved').via(360, 20).via(390, 260).stroke('#334155', 3)
.edge('adapter', 'broker', 'adapter-broker').routing('curved').via(560, 120).via(590, 20).stroke('#334155', 3)
.edge('broker', 'partition', 'broker-partition').routing('curved').via(720, 150).via(760, 280).stroke('#334155', 3)
.overlay('signal', { chain, progress: 0, magnitude: 0.85 }, 'chain');
builder.animate((aBuilder) => {
aBuilder.overlay('chain');
aBuilder.to({ progress: 4.15 }, { duration: 3000, easing: 'easeInOut' });
});
builder.mount(document.getElementById('container'), {
css: '.viz-signal-chain .viz-signal-shape { fill: #22c55e; stroke: #166534; }',
});
builder.play();
Color individual signal balls
Set color to override the default blue fill on a per-signal basis. An optional glowColor adds a drop-shadow halo around the ball (defaults to color when omitted). When neither field is set, the signal uses the CSS class default (#3b82f6).
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(520, 200);
builder
.node('primary').at(120, 100).circle(18).label('Primary')
.node('reader').at(400, 100).circle(18).label('Reader')
.edge('primary', 'reader', 'primary-reader').routing('curved').via(260, 180).stroke('#334155', 3)
.overlay((o) =>
o
.add('signal', {
from: 'primary', to: 'reader', edgeId: 'primary-reader',
progress: 0.35, magnitude: 0.85, color: '#22c55e',
}, { key: 'committed' })
.add('signal', {
from: 'primary', to: 'reader', edgeId: 'primary-reader',
progress: 0.7, magnitude: 0.85, color: '#f59e0b',
}, { key: 'stale' })
);
builder.mount(document.getElementById('container'));
Combining multiple overlays
Use the callback form to compose several overlays in one call:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(520, 240);
builder
.grid(5, 3, { x: 30, y: 30 })
.node('a').at(120, 140).circle(18).label('A')
.node('b').at(400, 140).circle(18).label('B')
.overlay((o) =>
o
.add('grid-labels', {
colLabels: { 0: '0', 1: '1', 2: '2', 3: '3', 4: '4' },
rowLabels: { 0: 'top', 1: 'mid', 2: 'bot' },
yOffset: 18, xOffset: 18,
}, { key: 'labels' })
.add('signal', { from: 'a', to: 'b', progress: 0.25, magnitude: 0.5 }, { key: 'sig' })
);
builder.mount(document.getElementById('container'));
Built-in overlay kinds
These ship in the default registry and can be used immediately:
| Kind | Description |
|---|---|
signal | Moving marker between nodes, across hop chains, along edge paths, or parked in a node |
grid-labels | Axis/grid labels for the grid system |
data-points | Points attached to nodes |
rect | Primitive rectangle (via .rect() helper) |
circle | Primitive circle (via .circle() helper) |
text | Primitive text label (via .text() helper) |
group | Primitive overlay container with local child coordinates (via .group() helper) |
Keys
Overlays reconcile by unique key:
- If
spec.keyis present, that's used - Otherwise,
spec.idis used
If you render multiple overlays with the same id, always provide a key. The callback builder auto-generates stable keys for unkeyed overlays of the same id.
Animating overlays
Overlay params can be targeted by the data-only timeline animation system. Give the overlay instance a stable key and target it in animations with aBuilder.overlay('key'):
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(520, 200);
builder
.node('a').at(120, 100).circle(18).label('A')
.node('b').at(400, 100).circle(18).label('B')
.overlay('signal', { from: 'a', to: 'b', progress: 0, magnitude: 0.2 }, 'sig');
builder.animate((aBuilder) => {
aBuilder.overlay('sig');
aBuilder.to({ progress: 1 }, { duration: 1200, easing: 'easeInOut' });
aBuilder.wait(250);
aBuilder.to({ progress: 0 }, { duration: 1200, easing: 'easeInOut' });
});
builder.mount(document.getElementById('container'));
builder.play();
Group overlays
Move multiple overlay elements as one unit using a group overlay:
Set nodeId with optional offsetX / offsetY when the group's local origin should stay attached to a node center instead of an absolute scene position.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(520, 200);
builder
.node('a').at(120, 100).rect(110, 78, 14).label('A', { dy: -52, fontWeight: 700 })
.node('b').at(400, 100).rect(110, 78, 14).label('B', { dy: -52, fontWeight: 700 })
.overlay((o) => {
const r = 5, gap = 4, step = r _ 2 + gap;
const yOffsets = [-2 _ step, -step, 0, step, 2 * step];
o.group(
{ from: 'a', to: 'b', progress: 0, magnitude: 0.2 },
(g) => { yOffsets.forEach((dy) => g.circle({ x: 0, y: dy, r, fill: '#93c5fd', stroke: '#1d4ed8', strokeWidth: 2 })); },
{ key: 'swarm' }
);
});
builder.animate((aBuilder) => {
aBuilder.overlay('swarm');
aBuilder.to({ progress: 1 }, { duration: 1200, easing: 'easeInOut' });
aBuilder.wait(250);
aBuilder.to({ progress: 0 }, { duration: 1200, easing: 'easeInOut' });
});
builder.mount(document.getElementById('container'));
builder.play();
Registering custom overlay kinds
When built-in kinds aren't enough, register a new renderer:
import { defaultCoreOverlayRegistry } from 'vizcraft';
defaultCoreOverlayRegistry.register('selection', {
render: ({ spec }) => {
const { x, y, w, h } = spec.params;
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" class="viz-selection" />`;
},
update: ({ spec }, g) => {
let rect = g.querySelector('rect');
if (!rect) {
rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
g.appendChild(rect);
}
rect.setAttribute('x', String(spec.params.x));
rect.setAttribute('y', String(spec.params.y));
rect.setAttribute('width', String(spec.params.w));
rect.setAttribute('height', String(spec.params.h));
rect.setAttribute('rx', '8');
rect.setAttribute('class', spec.className ?? 'viz-selection');
},
});
// Use it
builder.overlay('selection', { x: 10, y: 10, w: 200, h: 120 }, 'sel');
The default registry is a singleton. Register custom overlays once (idempotently).
For the complete overlay builder API, see the Overlay API Reference.
Found a problem? Open an issue on GitHub.