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).
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.
Interaction manager
Section titled “Interaction manager”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),});
// laternode.destroy();manager.destroy();InteractionManager
Section titled “InteractionManager”| 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.
InteractionNodeSpec
Section titled “InteractionNodeSpec”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).
Event info types
Section titled “Event info types”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).
Z-order constants
Section titled “Z-order constants”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. |
Hit-testing
Section titled “Hit-testing”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.
Point clouds
Section titled “Point clouds”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 changeRegion clouds
Section titled “Region clouds”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.
Hit fan-out
Section titled “Hit fan-out”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").
Hover & selection
Section titled “Hover & selection”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 insteadcreateSelection<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.
UI primitives
Section titled “UI primitives”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.
Tooltip
Section titled “Tooltip”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 frametooltip.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.
Crosshair
Section titled “Crosshair”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 (CrosshairStyle — color, 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).
See also
Section titled “See also”- Cameras & viewport —
bindViewportis built on the manager and registers aCAMERA_Znode. - Layers & groups — the
Layerthe UI primitives draw into. - Math, color & animation —
Invalidator,transition, and theColortype these primitives use.