Interactivity
VizCraft provides click handlers for basic interactions and a mathematical hit testing API for building full editors.
Click events
Attach click handlers to nodes and edges with .onClick():
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(400, 200);
builder
.node('btn').at(200, 100).rect(120, 50, 10)
.label('Click Me')
.class('interactive-node')
.onClick((id) => alert(`Clicked node ${id}`))
.done();
builder.mount(document.getElementById('container'));
Interactive elements automatically receive cursor: pointer when an onClick handler is attached.
Subscribing from outside the builder
Fluent .onClick() only works when you own the builder call site. When the
scene comes from fromSpec (JSON can't carry
function handlers) or when the diagram is embedded — an iframe, a
downstream component, a page that only receives the mounted SVG — the
MountController exposes
onNodeClick(cb) and onEdgeClick(cb). Both fire for every node/edge,
even ones with no fluent onClick, and return an unsubscribe function.
import { fromSpec } from 'vizcraft';
const controller = fromSpec({
view: { width: 400, height: 200 },
nodes: [
{ id: 'a', label: 'A', x: 80, y: 100 },
{ id: 'b', label: 'B', x: 300, y: 100 },
],
edges: [{ from: 'a', to: 'b' }],
}).mount(document.getElementById('canvas')!);
const off = controller.onNodeClick(({ id, node, originalEvent }) => {
console.log('clicked', id, node.label?.text);
if (originalEvent.shiftKey) selectAdditional(id);
});
controller.onEdgeClick(({ id, edge }) => {
console.log('clicked edge', id, edge.from, '→', edge.to);
});
// later: off();
The same event is also dispatched as a bubbling CustomEvent named
vizcraft:node-click (and vizcraft:edge-click) on the SVG root. That
matters for cross-frame embeds: a parent page can listen for clicks
without touching the child's runtime.
// The consumer only has the SVG root — e.g. an iframe or a web component:
svgRoot.addEventListener('vizcraft:node-click', (e) => {
const { id, node, originalEvent } = e.detail;
parent.postMessage({ type: 'node-click', id }, '*');
});
Every edge automatically gets a transparent 10px hit path so clicks are
easy to land — override with edge.hitArea for a bigger or smaller target.
Hit testing
VizCraft provides a DOM-independent hit testing API for building advanced interactions (drag-and-drop, connection snapping, marquee selection). All queries run against the VizScene data structure — no DOM required.
Interactive demo
Point hit testing
hitTest returns the topmost element at a given coordinate, checking in Z-index order:
import { hitTest } from 'vizcraft';
const result = hitTest(scene, { x: 250, y: 150 });
if (result?.type === 'node') {
console.log('Hit node:', result.id);
if (result.compartmentId) {
console.log('Compartment:', result.compartmentId);
}
if (result.entryId) {
console.log('Entry:', result.entryId);
}
} else if (result?.type === 'port') {
console.log('Hit port:', result.portId, 'on node:', result.nodeId);
} else if (result?.type === 'edge') {
console.log('Hit edge:', result.id);
} else {
console.log('Hit empty space');
}
Customize tolerances for edges and ports:
const easyHit = hitTest(scene, point, {
edgeTolerance: 15, // easier to click thin lines
portTolerance: 20, // easier to snap to ports
});
Rectangle selection (marquee)
hitTestRect returns all nodes and edges intersecting a rectangle:
import { hitTestRect } from 'vizcraft';
const rect = { x: 100, y: 100, w: 300, h: 200 };
const selected = hitTestRect(scene, rect);
selected.forEach((item) => {
if (item.type === 'node') {
console.log('Selected node:', item.id);
}
});
Nearest port snapping
When drawing a new connection, snap to the closest port:
import { nearestPort } from 'vizcraft';
const port = nearestPort(scene, { x: 260, y: 150 }, { maxDistance: 50 });
if (port) {
console.log(
'Snap to:',
port.nodeId,
port.portId,
'at distance:',
port.distance
);
}
Edge proximity
Check distance from a point to a specific edge:
import { edgeDistance } from 'vizcraft';
const dist = edgeDistance(scene, 'a->b', { x: 200, y: 150 });
Pan & zoom
Enable interactive viewport controls for large diagrams:
- Preview
- Code
import { viz } from 'vizcraft';
const scene = viz()
.view(800, 600)
.node('a', { circle: { r: 40 }, at: { x: 200, y: 200 }, fill: '#f6c177' })
.node('b', {
diamond: { w: 100, h: 80 },
at: { x: 600, y: 200 },
fill: '#cba6f7',
})
.edge('a', 'b', { arrow: true });
const controller = scene.mount(document.getElementById('viz'), {
panZoom: true,
minZoom: 0.1, // default
maxZoom: 5, // default
initialZoom: 'fit', // default
});
Interactions:
- Scroll wheel / trackpad to zoom
- Click and drag background to pan
- Double click background to auto-fit
Programmatic viewport control
// Fit all elements into view
controller.fitToContent(20); // 20px padding
// Zoom and center on a specific node
controller.zoomToNode('user-node');
// Manual control
controller.setZoom(2.5);
controller.setPan({ x: -100, y: -50 });
controller.reset();
// Snapshot current viewport state
const state = controller.getState(); // { zoom: number, pan: Vec2 }
// Subscribe to viewport changes
const unsubscribe = controller.onChange(({ zoom, pan }) => {
console.log('Scale:', zoom, 'Translation:', pan);
});
Persisting viewport across scene rebuilds
When a scene is rebuilt (e.g. in a React useLayoutEffect), the
PanZoomController is destroyed and the viewport resets. Use getState() to
capture the viewport before destroying, then pass the values back via
initialZoom and initialPan:
// 1. Read from the outgoing controller
const prev = oldController.getState();
oldScene.destroy();
// 2. Mount the new scene with the saved viewport
const newController = newScene.mount(container, {
panZoom: true,
initialZoom: prev.zoom,
initialPan: prev.pan,
});
initialPan is only used when initialZoom is a number (not 'fit').
Tooltips
Add hover tooltips to nodes and edges. Tooltips appear after a short delay (300 ms by default), follow the cursor, and are keyboard-accessible.
Plain text tooltip
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz()
.view(400, 160)
.node('server')
.at(200, 80)
.rect(140, 50, 10)
.label('API Server')
.fill('#f6c177')
.tooltip('Handles REST requests')
.done();
builder.mount(document.getElementById('container'));
Hover over the node to see the tooltip.
Structured tooltip
Pass an object with an optional title and an array of sections (label/value
pairs) for richer content:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz()
.view(400, 160)
.node('db')
.at(200, 80)
.cylinder(100, 60)
.label('PostgreSQL')
.fill('#9ccfd8')
.tooltip({
title: 'Database',
sections: [
{ label: 'Engine', value: 'PostgreSQL 16' },
{ label: 'Connections', value: '42 / 100' },
],
})
.done();
builder.mount(document.getElementById('container'));
Edge tooltips
Edges support the same API:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz()
.view(400, 160)
.node('a').at(100, 80).circle(30).label('A').fill('#cba6f7').done()
.node('b').at(300, 80).circle(30).label('B').fill('#eb6f92').done()
.edge('a', 'b')
.tooltip('Latency: 12 ms')
.stroke('#666', 2)
.arrow()
.done();
builder.mount(document.getElementById('container'));
Declarative form
Tooltips can also be set through the options object:
const scene = viz()
.node('n', { tooltip: 'Quick info' })
.edge('a', 'b', {
tooltip: { title: 'Link', sections: [{ label: 'Weight', value: '3' }] },
})
.done();
Theming
The tooltip element uses CSS custom properties so you can match your application's palette:
.viz-container {
--viz-tooltip-bg: #1e1e2e;
--viz-tooltip-fg: #cdd6f4;
--viz-tooltip-label: #a6adc8;
}
Keyboard accessibility
Nodes and edges that have tooltips receive tabindex="0" automatically, so
users can tab to them and trigger the tooltip via focus. Pressing Escape
dismisses any visible tooltip.
Found a problem? Open an issue on GitHub.