Pointer events

Hit-test a scene graph and route pointer events to nodes with PointerRouter, including capture semantics and DOM wiring.

View .md Edit on GitHub

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 clipsOverflow does 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

FieldTypeMeaning
type'down' | 'move' | 'up' | 'cancel'Event phase.
x, ynumberPosition in the root’s coordinate space.
buttonsnumberButton bitmask (as in DOM PointerEvent.buttons).
pointerIdnumberIdentifies 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 for pointerId and onPointerDown is called on it. If nothing is hit, nothing is captured and dispatch returns null.
  • move, up, cancel with a captured node: delivered to the captured node even if the pointer has left its bounds. up and cancel then release the capture.
  • move with no captured node (hover): hit-tested at the current position and delivered to the hit node, if any.
  • up or cancel with no captured node: ignored; returns null.
  • A second down for the same pointerId replaces 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 setPointerCapture on the DOM element. If drags can leave the canvas, call canvas.setPointerCapture(e.pointerId) on pointerdown so pointermove and pointerup keep arriving.

Hover works through the same path: route pointermove and have nodes call markDamaged('paint') in onPointerMove when their appearance changes. See Scene graph.