Skip to content

Interactions

insomni renders to a single <canvas>, so there are no DOM nodes to attach listeners to. The interaction layer fills that gap: an interaction manager owns the canvas’s pointer/wheel/keyboard events and routes them to registered interaction nodes by z-order and hit-test. On top of the manager sit spatial-index helpers (point / region clouds, hit fan-out) and pure draw-into-a-Layer UI primitives (tooltip, crosshair, brush, menu).

Preview of the Hover & select demo
Hover & select Pointer hit-testing with createInteractionManager: hover to highlight, click to select.

Everything here is independent of the renderer — the primitives push shapes onto a Layer you own (typically a space: "ui" layer) and read positions you feed in. They never register their own DOM listeners.

createInteractionManager(element) returns the manager for a DOM element. It is cached per element — calling it twice with the same element returns the same manager. The manager sets touch-action: none on the element while active so touch pan/zoom belongs to your nodes rather than the browser.

import { createInteractionManager } from "insomni";
const manager = createInteractionManager(canvas);
const node = manager.add({
zIndex: 10,
space: "world",
viewport,
bounds: () => ({ x: 0, y: 0, width: 200, height: 120 }),
cursor: "pointer",
onPress: (e) => select(e.localX, e.localY),
onHoverEnter: () => highlight(true),
onHoverLeave: () => highlight(false),
});
// later
node.destroy();
manager.destroy();

| Member | Description | | -------------------------------------- | ------------------------------------------------------------------ | | element | The owned HTMLElement. | | add(spec) | Register a node; returns an InteractionNode. | | addPointCloud(spec) | Register a point cloud (see Point clouds). | | onBackgroundTap(handler) | Press-without-drag on empty space (the “click to deselect” event). | | onContextMenuRequest(handler, opts?) | Right-click, touch long-press, or Shift+F10 / ContextMenu key. | | onDoubleTap(handler, opts?) | Two taps within withinMs / slopPx. | | onChange(cb) | Fires whenever the manager invokes any user callback. | | hitTest(cssX, cssY) | true if any active node is hit at the CSS-px point. | | destroy() | Drop all nodes and listeners. |

onContextMenuRequest takes ContextMenuOpts (holdMs default 500, slopPx default 5); onDoubleTap takes DoubleTapOpts (withinMs default 300, slopPx default 5). onChange pairs naturally with an Invalidator to drive on-demand rendering only when something actually responded to input.

A node declares where it is, what gestures it handles, and how it looks.

| Field | Type | Default | Description | | ------------------------- | ------------------------------ | -------- | -------------------------------------------------------------------- | | zIndex | number | 0 | Higher wins hit-test; ties break by insertion order (later on top). | | space | "world" \| "ui" | "ui" | "world" transforms by the viewport camera; "ui" is CSS-px local. | | viewport | Viewport | — | Required when space === "world". | | bounds | () => FrameRect | — | Cheap CSS-px rect test, run first. | | contains | (x, y) => boolean | — | Fine-grained test in node-space coords. | | passthrough | boolean | false | Skip during hit-testing entirely (events fall through). | | cursor | string | — | Cursor while hovering. | | dragCursor | string | cursor | Cursor while dragging. | | enabled | boolean | true | Whether the node responds. | | onHoverEnter/Move/Leave | (e: PointerInfo) => void | — | Hover lifecycle. | | onPress | (e: PointerInfo) => void | — | Release within the movement threshold (a click/tap). | | onDragStart/End | (e: PointerInfo) => void | — | Drag lifecycle. | | onDragMove | (e: DragPointerInfo) => void | — | Per-move during a drag (carries dx / dy). | | onScroll | (e: ScrollInfo) => void | — | Mouse wheel. | | onPinch | (e: PinchInfo) => void | — | Two-finger pinch. |

Each gesture class (press / drag / hover / scroll / pinch) hit-tests independently: a press-only node (e.g. a selectable shape) can sit above a drag-only node (e.g. a panning camera) and neither swallows the other’s gesture. That’s how a click selects a shape while a drag on the same spot pans underneath.

InteractionNode exposes id, update(patch) (patch the spec in place), and destroy() (fires any pending onDragEnd / onHoverLeave).

All coordinates are element-local CSS pixels unless noted. Mods is { shift, ctrl, meta, alt }.

| Type | Key fields | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | PointerInfo | pointerId, type ("mouse" \| "touch" \| "pen"), x / y, localX / localY (node-space), buttons, mods, stopPropagation() | | DragPointerInfo | PointerInfo plus dx / dy (delta from the previous move). | | ScrollInfo | pointerId, x / y, dx / dy (wheel deltas), mods, stopPropagation() | | PinchInfo | phase ("start" \| "move" \| "end"), scaleFactor (cumulative), deltaScale (since last move), focalX / focalY, deltaX / deltaY (focal delta), mods |

localX / localY are world units for space: "world" on a camera viewport, and identical to x / y for space: "ui". BackgroundTapInfo and GestureEventInfo carry the same shape for the manager-level subscriptions (GestureEventInfo adds source including "keyboard" and the originalEvent).

Built-in nodes register below user nodes so your interactions sit on top by default:

| Constant | Value | Use | | ----------------------------- | ------ | ------------------------------------------------------------------ | | USER_DEFAULT_Z | 0 | Default zIndex for user nodes. | | VIEWPORT_Z | -100 | Data-viewport-scoped interactions. | | CAMERA_Z | -200 | Camera viewports (what bindViewport uses). | | PRESS_MOVEMENT_THRESHOLD_PX | 4 | Travel allowed between press and release before it becomes a drag. |

For many similar targets (data points, bars, tiles) you don’t register one node each — you register a single cloud node backed by a uniform spatial grid.

registerPointCloud(manager, element, spec) (or manager.addPointCloud(spec)) hit-tests a flat [x0, y0, x1, y1, ...] array.

| PointCloudSpec field | Type | Description | | -------------------------- | -------------------------- | ------------------------------------------------------------------------------ | | points | number[] \| Float32Array | Flat coords in the declared space. | | space | "world" \| "ui" | "world" needs a viewport. Default "ui". | | viewport | Viewport | Required for space: "world". | | nearestWithin | number | CSS-px radius; nearest point within it is hovered. | | hitRadius | number | Exact per-point radius when nearestWithin is unset. | | pickAxis | "x" \| "y" \| "xy" | Distance metric. "x" ignores the off-axis (nearest-x hover). Default "xy". | | zIndex / enabled | — | As for a node. | | onHoverEnter/Leave/Press | (index, evt) => void | Receive the point index plus the PointerInfo. |

PointCloudNode adds setPoints(points) (rebuilds the grid) and setEnabled(enabled) to the standard node members.

const cloud = manager.addPointCloud({
points: positions, // [x, y, x, y, ...] in world units
space: "world",
viewport,
nearestWithin: 12,
onHoverEnter: (i) => showTooltipFor(i),
onHoverLeave: () => hideTooltip(),
onPress: (i) => selection.add(i),
});
cloud.setPoints(nextPositions); // on data change

registerRegionCloud(manager, element, spec) is the rect sibling for “whole shape” hover (bars, tiles, boxplot frames). Slots are [x, y, w, h, ...] in element-local CSS px (space: "ui" only). The front-most rect containing the pointer wins. RegionCloudSpec mirrors PointCloudSpec minus the radius/axis fields, using rects instead of points; RegionCloudNode adds setRects and setEnabled.

The manager’s hover dispatch is exclusive — one node wins per pointer. When several consumers (tooltip, selection, crosshair) all need the same hit, createHitFanOut(spec) owns N clouds and fans one hit out to many subscribers, keeping a “primary” active hit that survives in-place data re-syncs.

import { createHitFanOut } from "insomni";
const fanOut = createHitFanOut({ manager, element: canvas, baseZIndex: 10 });
fanOut.sync([
{ id: "points", positions, pickRadius: 12, pickAxis: "xy" },
{ id: "bars", rects, pickRadius: 0 },
]);
const off = fanOut.subscribe({
key: "tooltip",
onHoverEnter: (ctx) => tooltip.show(ctx.position, contentFor(ctx)),
onHoverLeave: () => tooltip.hide(),
});
const hit = fanOut.pickAt(cssX, cssY); // one-shot lookup, highest-z wins

| HitFanOut member | Description | | --------------------- | ------------------------------------------------------------------- | | sync(slots) | Replace the active slot set (once per pipeline run). | | subscribe(sub) | Register a HitFanOutSubscriber; returns unsubscribe. | | state() | Live HitFanOutState view of the current hit. | | subscribeState(fn) | Subscribe to enter/leave/sync-induced state transitions. | | pickAt(x, y, opts?) | One-shot lookup at CSS px; returns HitFanOutPickAtResult \| null. | | dispose() | Tear down all owned clouds. |

A HitSlot carries a stable id (fast-path matching across syncs), either positions or rects, a pickRadius, and optional pickAxis. Subscribers receive a HitFanOutEventContext (position, slotIdx, slot, hitIndex, pointer). HitFanOutPickAtOptions selects which slot kinds participate ("point" | "region" | "any", default "any").

trackHover<Id>() collapses the onHoverEnter / onHoverLeave bookkeeping every node repeats into one HoverTracker:

import { trackHover } from "insomni";
const hover = trackHover<string>();
manager.add({ /* ... */, ...hover.bind("node-a") });
manager.add({ /* ... */, ...hover.bind("node-b") });
if (hover.isHovered("node-a")) { /* emphasize */ }
hover.set(indexFromPointCloud); // drive from a cloud's index instead

createSelection<T>(initial?) is a notifying Set with editor-style modifier semantics. pressFromEvent(id, mods) applies the convention: plain click replaces, shift toggles, meta/ctrl removes (SelectionMods is Pick<Mods, "shift" | "meta" | "ctrl">).

import { createSelection } from "insomni";
const selection = createSelection<number>();
selection.onChange(() => redraw());
manager.addPointCloud({
points,
onPress: (i, e) => selection.pressFromEvent(i, e.mods),
});
manager.onBackgroundTap(() => selection.clear());

Selection<T> exposes size, has, values, toArray, single (the id when exactly one is selected), set, add, delete, clear, pressFromEvent, and onChange.

These are pure state + render objects. You drive them from interaction events, tick step(dt) per frame, and call draw(layer) to push shapes onto a layer you own. Tooltip and menu are font-agnostic: they take a measure(text, fontSize) => { width, height } callback (typically an adapter over a glyph atlas) so the module never imports font code. Many accept an optional Invalidator so on-demand loops wake during fades.

createTooltip(opts) is a fading, auto-flipping tooltip with title + rows. Show it at an anchor, tick it, and draw it.

import { createTooltip } from "insomni";
const tooltip = createTooltip({
measure: (t, fs) => atlas.measureText(t, fs),
placement: "auto",
bounds: () => ({ x: 0, y: 0, width: w, height: h }),
});
tooltip.show(
{ x, y },
{
title: "Sample A",
rows: [
{ label: "value", value: "42", swatch: rgba(0.2, 0.6, 1) },
{ label: "rank", value: "3" },
],
},
);
// per frame
tooltip.step(dt);
tooltip.draw(uiLayer);

TooltipOptions covers placement ("top" | "bottom" | "left" | "right" | "auto"), bounds for clamp/flip, the required measure, a style (TooltipStyle), showDelay (default 80ms), hideDelay (100ms), fadeMs (120ms), and invalidator. The Tooltip object has show, move (anchor only), hide, step, draw, visible, opacity, and dispose. layoutTooltip is exported separately as the pure layout function (returns a TooltipLayout of box + per-row positions) if you want to compute geometry without drawing.

createCrosshair(opts) draws guide line(s) tracking a position you feed in. It owns no listeners — call setPosition from an onHoverMove.

import { createCrosshair } from "insomni";
const crosshair = createCrosshair({
axis: "x",
bounds: () => plotRect,
snap: (p) => ({ x: snapToNearestX(p.x), y: p.y }),
});
manager.add({
bounds: () => plotRect,
onHoverMove: (e) => crosshair.setPosition({ x: e.x, y: e.y }),
onHoverLeave: () => crosshair.setPosition(null),
});
crosshair.draw(uiLayer);

CrosshairOptions: axis (CrosshairAxis = "x" | "y" | "xy", default "x"), required bounds, style (CrosshairStylecolor, width, opacity), an optional snap (return null to suppress drawing for a frame), and invalidator. The Crosshair exposes position (snapped), rawPosition (pre-snap), setPosition, draw, and dispose.

createBrush(opts) is a drag-to-rect selection. Route an interaction node’s drag lifecycle into begin / update / end; it normalizes, axis-locks, clamps to bounds, and emits callbacks.

import { createBrush } from "insomni";
const brush = createBrush({
axis: "x",
bounds: () => plotRect,
onEnd: (rect) => rect && applyRangeSelection(rect),
});
manager.add({
bounds: () => plotRect,
onDragStart: (e) => brush.begin(e.x, e.y),
onDragMove: (e) => brush.update(e.x, e.y),
onDragEnd: () => brush.end(),
});
brush.draw(uiLayer);

BrushOptions: axis (BrushAxis, default "xy"), required bounds, style (BrushStyle), the onStart / onChange / onEnd callbacks (onEnd gets the committed BrushRect or null when collapsed), an optional snap, and invalidator. The Brush adds beginResize(edge, x, y) (drag an existing edge — BrushEdge is "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"), beginTranslate(x, y) (move without resizing), cancel, setRect, plus rect, active, draw, and dispose.

createMenu(opts) is an interactive popup menu — sibling to the tooltip, but it tracks a hovered item and resolves clicks. The caller feeds hover positions and routes clicks out.

import { createMenu } from "insomni";
const menu = createMenu({ measure: (t, fs) => atlas.measureText(t, fs) });
menu.show(
{ x, y },
{
items: [
{ id: "copy", label: "Copy", kbd: "⌘C" },
{ separator: true, id: "", label: "" },
{ id: "delete", label: "Delete", danger: true },
],
},
);
manager.add({
bounds: () => menu.getBounds() ?? { x: 0, y: 0, width: 0, height: 0 },
onHoverMove: (e) => menu.setHoverPosition({ x: e.x, y: e.y }),
onHoverLeave: () => menu.setHoverPosition(null),
onPress: (e) => {
const id = menu.pickItemAt(e.x, e.y);
if (id) {
runAction(id);
menu.hide();
}
},
});
menu.step(dt);
menu.draw(uiLayer);

MenuOptions: placement (MenuPlacement = "bottom-right" | "bottom-left" | "top-right" | "top-left" | "auto", default "bottom-right"), bounds, the required measure, a style (MenuStyle), fadeMs (default 80ms), and invalidator. A MenuItem carries id, label, and optional separator, disabled, danger, swatch, and kbd. The Menu exposes show, hide, step, draw, setHoverPosition, pickItemAt (returns the item id or null), getBounds, visible, opacity, and dispose. layoutMenu is the exported pure layout function (returns a MenuLayout).