Package name: @graphora/interactions
Planned responsibility: pan, zoom, hover, click, drag, selection, keyboard handling, and future editing behaviors.
attachGraphInput(container, engine, renderer) adds primary-pointer hover,
node/edge selection, node dragging, background panning and wheel zoom to a
mounted vanilla graph. Call its returned disposer before destroying the engine.
It uses a structural host interface and has no engine or React dependency.
Optional onClick and onDragEnd callbacks support app-owned status UI.
See interaction concepts and the vanilla interaction example. Keyboard controls, menus and tooltips remain app-owned.
Highlight is immutable visual emphasis independent from hover, focus,
selection, and drag. createHighlightState, replaceHighlight, and
clearHighlight manage explicit node and edge IDs.
highlightNeighborhood(snapshot, { nodeId, mode }) derives a depth-one
neighborhood from a public graph snapshot. direct and all include inbound
and outbound relations; inbound and outbound respect directed edges.
highlightToInteractionRenderState copies only highlight IDs into an existing
renderer interaction state, preserving its other fields.
The first interaction primitive is framework-agnostic hover state. It converts renderer pick results into immutable hover state without owning selection, focus, drag, DOM listeners, or React behavior.
import { createHoverState, updateHoverFromPick } from '@graphora/interactions'
let hover = createHoverState()
const update = updateHoverFromPick(hover, renderer.pick(point, snapshot))
if (update.changed) {
hover = update.state
}
Hover state tracks node and edge targets separately:
hoveredNodeIdhoveredEdgeIdCanvas and miss picks clear hover. Selection, focus, click handling, dragging, and pointer listener lifecycle are intentionally left to later interaction tasks.
Selection state is also framework-agnostic and stores node and edge IDs separately. It does not require edge picking to be implemented, and it does not own click handling, keyboard shortcuts, selected styling, hover, focus, or drag state.
import {
createSelectionState,
selectItem,
toggleSelection
} from '@graphora/interactions'
let selection = createSelectionState()
selection = selectItem(selection, { type: 'node', nodeId: 'a' }).state
selection = toggleSelection(selection, { type: 'edge', edgeId: 'ab' }).state
Selection helpers return { state, previousState, changed }, so integrations
can skip redraws for idempotent operations. Selected IDs are duplicate-free,
in deterministic insertion order, and frozen.
Use selectionToInteractionRenderState(selection) when a renderer snapshot
needs the selected node and edge arrays. Visual selected styling is still owned
by later styling work.
Node dragging is represented as a pure drag session. It tracks one active node, an optional pointer ID, start/current screen points, and the current node position in world coordinates.
import {
applyNodeDragPosition,
startNodeDrag,
updateNodeDrag
} from '@graphora/interactions'
let drag = startNodeDrag({
nodeId: 'a',
pointerId: 1,
screenPoint: { x: 100, y: 100 },
nodePosition: { x: 0, y: 0 },
viewport
}).state
drag = updateNodeDrag(drag, {
pointerId: 1,
screenPoint: { x: 120, y: 100 },
viewport
}).state
applyNodeDragPosition(store, drag)
Screen-space movement is divided by the viewport zoom, so a 20px pointer move
at zoom 2 moves the node by 10 world units. Applying a drag uses
GraphStore.updateNodePosition, preserving core position-update and
graph:changed events.
This package does not yet attach DOM pointer listeners or call
setPointerCapture; integrations can use the stored pointer ID as the
framework-agnostic capture token until pointer lifecycle work lands.
Pan and zoom controls wrap the renderer viewport utilities with small interaction-state helpers. They do not attach DOM event listeners.
import {
startPan,
updatePan,
zoomViewportByWheel
} from '@graphora/interactions'
let pan = startPan({
pointerId: 1,
screenPoint: { x: 120, y: 80 },
viewport
}).state
const nextPan = updatePan(pan, {
pointerId: 1,
screenPoint: { x: 140, y: 80 }
})
const nextZoom = zoomViewportByWheel(nextPan.viewport!, {
screenPoint: { x: 200, y: 120 },
deltaY: -100
})
updatePan uses the viewport from pan start plus the current screen delta, so
the visible graph follows the pointer according to the established viewport
panBy convention. zoomViewportByWheel delegates to zoomAt, preserving the
world point under the cursor while clamping to the viewport zoom range.
Use fitViewportToBounds and fitViewportToNode for graph and item fitting.
Lasso selection uses the same pure-state style as pan, zoom, hover, drag, and click helpers. It tracks one active pointer, rectangle or polygon geometry in screen space, and the matching world-space bounds/points used for node hit testing.
import {
applyLassoSelection,
nodeIdsInLasso,
startLasso,
updateLasso
} from '@graphora/interactions'
let lasso = startLasso({
pointerId: 1,
screenPoint: { x: 120, y: 80 },
viewport
}).state
lasso = updateLasso(lasso, {
pointerId: 1,
screenPoint: { x: 220, y: 160 },
viewport
}).state
const selectedNodeIds = nodeIdsInLasso(lasso, renderNodes)
selection = applyLassoSelection(selection, selectedNodeIds).state
Integrations can draw active feedback from screenBounds for rectangle mode or
screenPoints for polygon mode. The helpers intentionally avoid DOM listeners
and renderer overlay ownership, so lasso can compose with app-specific pointer
capture and canvas/SVG/HTML feedback layers.
Lasso applies only to nodes for now and preserves selected edge IDs. Edge selection behavior is explicitly deferred until edge hit-testing and selection semantics are expanded.
Click helpers convert renderer pick results into framework-agnostic payloads for node, edge, and canvas clicks. They also keep click-vs-drag threshold logic out of DOM-specific code.
import {
clickEventFromPick,
focusViewportOnNode,
isClickWithinDragThreshold
} from '@graphora/interactions'
if (isClickWithinDragThreshold(pointerDownPoint, pointerUpPoint)) {
const event = clickEventFromPick(renderer.pick(pointerUpPoint), viewport)
}
const focused = focusViewportOnNode(viewport, {
position: { x: 20, y: -10 },
radius: 16
})
clickEventFromPick returns node:clicked, edge:clicked, or
canvas:clicked payloads with screen point, world point, and viewport context.
Miss or null picks return null. DOM listener wiring and React callbacks are
left to later layers.
Context-menu helpers reuse renderer pick results so vanilla, React, and other integrations can render menus without duplicating graph hit testing. The interactions package only creates the intent payload; the app owns the menu DOM, commands, focus management, and whether to suppress the browser's default context menu.
import { contextMenuEventFromPick } from '@graphora/interactions'
container.addEventListener('contextmenu', (event) => {
event.preventDefault()
const point = screenPointFromEvent(event)
const menu = contextMenuEventFromPick(
renderer.pick(point),
engine.getViewport(),
{ trigger: 'pointer' }
)
if (menu?.type === 'node:context-menu') {
showNodeMenu(menu.nodeId, menu.screenPoint)
}
})
contextMenuEventFromPick returns node:context-menu,
edge:context-menu, or canvas:context-menu payloads with screen point, world
point, viewport context, and a trigger of pointer, keyboard, or
programmatic. Miss or null picks return null.
Keyboard integrations should provide the screen point where the menu should
open, such as the focused item center or the viewport center, then pass
{ trigger: 'keyboard' }. Keep preventDefault() in the integration layer so
apps can choose where native browser context menus remain available.
Tooltip helpers reuse renderer pick results to create framework-agnostic tooltip intent payloads. The package does not render tooltip DOM, manage async content, or own hover/focus/click listener timing; integrations decide those details.
import { tooltipEventFromPick } from '@graphora/interactions'
container.addEventListener('pointermove', (event) => {
const point = screenPointFromEvent(event)
const tooltip = tooltipEventFromPick(
renderer.pick(point),
engine.getViewport(),
{ trigger: 'hover', showDelayMs: 120, hideDelayMs: 80 }
)
if (tooltip?.type === 'node:tooltip') {
showTooltip(tooltip.nodeId, tooltip.screenPoint)
}
})
tooltipEventFromPick returns node:tooltip, edge:tooltip, or
canvas:tooltip payloads with screen point, world point, viewport context,
trigger, and optional show/hide delay metadata. Miss or null picks return
null.
Supported triggers are hover, focus, click, and programmatic. Async
content remains application-owned: keep pending requests, loading states, and
DOM or framework rendering outside @graphora/interactions.
createFocusState, moveFocus, reconcileFocus, clearFocus and
focusToInteractionRenderState manage one node/edge target independently of
selection, hover, highlight and DOM focus. moveFocus wraps in the supplied
visible order; reconcileFocus clears removed/filtered targets. IDs compare by
String(id) within separate node/edge namespaces. Always reconcile after changing
visible data, even when the next key has not arrived yet.
let focus = createFocusState()
focus = moveFocus(focus, [{ type: 'node', nodeId: 'a' }])
engine.setInteractionState(
focusToInteractionRenderState(focus, engine.getInteractionState())
)
const shortcut = matchKeyboardShortcut(
{ key: 'ArrowRight', editable: false },
DEFAULT_GRAPH_SHORTCUTS
)
// Application executes shortcut?.command and decides whether to preventDefault.
Import these helpers from @graphora/interactions. The matcher accepts normalized
input, never a required DOM event. It rejects editable, composing and already
handled events, and requires exact key/modifier matches. Apps inspect composed
paths/ancestors for form controls, contenteditable regions and custom editors;
pass the result as editable. Matching is disabled by enabled: false or an
empty binding list. Only allowEditable: true overrides editability suppression.
Defaults are optional; Tab is unbound and shortcuts never install global listeners.
The vanilla interaction and React callbacks examples share app-owned wiring in
examples/shared/keyboard-demo.ts: node/edge navigation, candidate-ID filtering,
selection toggling, fit focused/selected items, and independent focus clearing.
They remap node-navigation defaults to item-navigation commands. Edge fitting uses
positioned endpoints. Canvas remains a visual surface; applications own semantic
companion UI and screen-reader validation.