Skip to main content

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():

info

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

Status: Move mouse over items or click and drag to select.

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:

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

tip

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:

Edge tooltips

Edges support the same API:

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.