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).

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

FieldTypeDefaultDescription
zIndexnumber0Higher 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.
viewportViewportRequired when space === "world".
bounds() => FrameRectCheap CSS-px rect test, run first.
contains(x, y) => booleanFine-grained test in node-space coords.
passthroughbooleanfalseSkip during hit-testing entirely (events fall through).
cursorstringCursor while hovering.
dragCursorstringcursorCursor while dragging.
enabledbooleantrueWhether the node responds.
onHoverEnter/Move/Leave(e: PointerInfo) => voidHover lifecycle.
onPress(e: PointerInfo) => voidRelease within the movement threshold (a click/tap).
onDragStart/End(e: PointerInfo) => voidDrag lifecycle.
onDragMove(e: DragPointerInfo) => voidPer-move during a drag (carries dx / dy).
onScroll(e: ScrollInfo) => voidMouse wheel.
onPinch(e: PinchInfo) => voidTwo-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 }.

TypeKey fields
PointerInfopointerId, type ("mouse" | "touch" | "pen"), x / y, localX / localY (node-space), buttons, mods, stopPropagation()
DragPointerInfoPointerInfo plus dx / dy (delta from the previous move).
ScrollInfopointerId, x / y, dx / dy (wheel deltas), mods, stopPropagation()
PinchInfophase ("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:

ConstantValueUse
USER_DEFAULT_Z0Default zIndex for user nodes.
VIEWPORT_Z-100Data-viewport-scoped interactions.
CAMERA_Z-200Camera viewports (what bindViewport uses).
PRESS_MOVEMENT_THRESHOLD_PX4Travel 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 fieldTypeDescription
pointsnumber[] | Float32ArrayFlat coords in the declared space.
space"world" | "ui""world" needs a viewport. Default "ui".
viewportViewportRequired for space: "world".
nearestWithinnumberCSS-px radius; nearest point within it is hovered.
hitRadiusnumberExact per-point radius when nearestWithin is unset.
pickAxis"x" | "y" | "xy"Distance metric. "x" ignores the off-axis (nearest-x hover). Default "xy".
zIndex / enabledAs for a node.
onHoverEnter/Leave/Press(index, evt) => voidReceive 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 memberDescription
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).