Pointer events
Hit-test a scene graph and route pointer events to nodes with PointerRouter, including capture semantics and DOM wiring.
PointerRouter turns surface-agnostic pointer events into calls on the SceneNode under the pointer. It uses SceneRoot.hitTest to find the target and captures the pointer on down.
Hit-testing
root.hitTest(x, y) returns the node under the point, or null.
import { SceneNode, SceneRoot } from '@dirtytalk/spatial';
import type { Renderer2D } from '@dirtytalk/spatial';
import { SyncScheduler } from '@dirtytalk/engine';
class Box extends SceneNode {
paint() {}
}
const renderer: Renderer2D = { beginFrame() {}, endFrame() {} };
const root = new SceneRoot(renderer, {
scheduler: new SyncScheduler(),
bounds: { x: 0, y: 0, w: 400, h: 300 },
});
const back = new Box({ bounds: { x: 0, y: 0, w: 100, h: 100 } });
const front = new Box({ bounds: { x: 50, y: 50, w: 100, h: 100 } });
root.adoptChild(back);
root.adoptChild(front);
root.hitTest(60, 60); // front (adopted last, so on top)
root.hitTest(10, 10); // back
root.hitTest(300, 200); // null
Rules, as implemented:
- Children are checked in reverse adoption order: the last adopted child is topmost.
- The first child whose bounds contain the point is chosen and the search descends into it. The result is the deepest descendant that contains the point, or that child itself if none of its children do.
- Once a child contains the point, siblings below it are not tried, even if the child has no deeper hit.
- A node is only reachable through its ancestors: a descendant outside its parent’s bounds is never hit. Bounds share one coordinate space, and
clipsOverflowdoes not affect hit-testing. - Containment is half-open (
pointInRect): the left and top edges hit, the right and bottom edges do not. - The root is never a hit target.
PointerRouter
import { PointerRouter } from '@dirtytalk/spatial';
import type { SceneRoot } from '@dirtytalk/spatial';
declare const root: SceneRoot;
const router = new PointerRouter(root);
router.dispatch(event) takes a SpatialPointerEvent and returns the SceneNode that received it, or null.
SpatialPointerEvent
| Field | Type | Meaning |
|---|---|---|
type | 'down' | 'move' | 'up' | 'cancel' | Event phase. |
x, y | number | Position in the root’s coordinate space. |
buttons | number | Button bitmask (as in DOM PointerEvent.buttons). |
pointerId | number | Identifies the pointer; capture is tracked per id. |
PointerHandler
A node opts in by defining any of these methods. Each is optional, and nodes without a method silently ignore that event. SceneNode does not declare them; add them to your subclass (optionally with implements PointerHandler).
import { SceneNode } from '@dirtytalk/spatial';
import type { PointerHandler, SpatialPointerEvent } from '@dirtytalk/spatial';
class Button extends SceneNode implements PointerHandler {
hovered = false;
onPointerMove() {
if (!this.hovered) {
this.hovered = true;
this.markDamaged('paint');
}
}
onPointerDown(e: SpatialPointerEvent) {
console.log('down at', e.x, e.y);
}
paint() {}
}
Dispatch semantics
- No bubbling. An event is delivered to exactly one node, never to its ancestors or siblings.
down: hit-tests at(x, y). If a node is hit, it is captured forpointerIdandonPointerDownis called on it. If nothing is hit, nothing is captured anddispatchreturnsnull.move,up,cancelwith a captured node: delivered to the captured node even if the pointer has left its bounds.upandcancelthen release the capture.movewith no captured node (hover): hit-tested at the current position and delivered to the hit node, if any.uporcancelwith no captured node: ignored; returnsnull.- A second
downfor the samepointerIdreplaces the previous capture.
Wiring DOM events
Translate each DOM PointerEvent into a SpatialPointerEvent in the canvas’s coordinate space, then dispatch. offsetX and offsetY work when the canvas is not CSS-scaled; otherwise scale them to the root’s coordinate space.
import { PointerRouter } from '@dirtytalk/spatial';
import type { SceneRoot, SpatialPointerEvent } from '@dirtytalk/spatial';
declare const root: SceneRoot;
declare const canvas: HTMLCanvasElement;
const router = new PointerRouter(root);
const forward = (type: SpatialPointerEvent['type']) => (e: PointerEvent) => {
router.dispatch({
type,
x: e.offsetX,
y: e.offsetY,
buttons: e.buttons,
pointerId: e.pointerId,
});
};
canvas.addEventListener('pointerdown', forward('down'));
canvas.addEventListener('pointermove', forward('move'));
canvas.addEventListener('pointerup', forward('up'));
canvas.addEventListener('pointercancel', forward('cancel'));
Note: The router does not call
setPointerCaptureon the DOM element. If drags can leave the canvas, callcanvas.setPointerCapture(e.pointerId)onpointerdownsopointermoveandpointerupkeep arriving.
Hover works through the same path: route pointermove and have nodes call markDamaged('paint') in onPointerMove when their appearance changes. See Scene graph.