API reference

Every public export of @dirtytalk/spatial with exact TypeScript signatures, parameters, return values, errors and examples.

View .md Edit on GitHub

All exports come from @dirtytalk/spatial. For guided explanations see Scene graph, Rendering and Pointer events.

Types

Rect

interface Rect {
  x: number;
  y: number;
  w: number;
  h: number;
}

A 2D axis-aligned rectangle in CSS pixels.

import type { Rect } from '@dirtytalk/spatial';

const r: Rect = { x: 10, y: 10, w: 120, h: 40 };

DamageKind

type DamageKind = 'paint' | 'layout' | 'data';

Classifies damage and determines which pipeline stages run: 'data' runs rebuildData, doLayout and paint; 'layout' runs doLayout and paint; 'paint' runs paint only.

import type { DamageKind } from '@dirtytalk/spatial';

const kind: DamageKind = 'layout';

Damage

interface Damage {
  rect: Rect;
  kind: DamageKind;
  node?: unknown;
}

A single damage entry. node is the SceneNode that raised it; root-level damage may omit it.

import type { Damage } from '@dirtytalk/spatial';

const d: Damage = { rect: { x: 0, y: 0, w: 10, h: 10 }, kind: 'paint' };

DirtyRegion

type DirtyRegion = readonly Damage[];

The Region type of the spatial DirtyChannel.

import type { DirtyRegion } from '@dirtytalk/spatial';

const region: DirtyRegion = [
  { rect: { x: 0, y: 0, w: 10, h: 10 }, kind: 'paint' },
];

Rect helpers

rectOverlaps

function rectOverlaps(a: Rect, b: Rect): boolean;

Returns true if the rects share area. Rects that only touch, and rects with zero or negative w or h, do not overlap.

import { rectOverlaps } from '@dirtytalk/spatial';

rectOverlaps({ x: 0, y: 0, w: 10, h: 10 }, { x: 10, y: 0, w: 10, h: 10 }); // false

rectEquals

function rectEquals(a: Rect, b: Rect): boolean;

Returns true if x, y, w and h are all strictly equal.

import { rectEquals } from '@dirtytalk/spatial';

rectEquals({ x: 0, y: 0, w: 1, h: 1 }, { x: 0, y: 0, w: 1, h: 1 }); // true

unionRects

function unionRects(rects: readonly Rect[]): Rect;

Returns the bounding rect of rects, or { x: 0, y: 0, w: 0, h: 0 } for an empty array.

import { unionRects } from '@dirtytalk/spatial';

unionRects([
  { x: 0, y: 0, w: 10, h: 10 },
  { x: 20, y: 20, w: 10, h: 10 },
]); // { x: 0, y: 0, w: 30, h: 30 }

rectClamp

function rectClamp(inner: Rect, outer: Rect): Rect;

Returns the intersection of inner and outer. When they do not overlap, the result has w and h of 0 (its x and y are not meaningful).

import { rectClamp } from '@dirtytalk/spatial';

rectClamp({ x: -5, y: 0, w: 20, h: 10 }, { x: 0, y: 0, w: 10, h: 10 });
// { x: 0, y: 0, w: 10, h: 10 }

pointInRect

function pointInRect(x: number, y: number, r: Rect): boolean;

Half-open containment test over [x, x + w) x [y, y + h): the top-left edge is inside, the bottom-right edge is outside.

import { pointInRect } from '@dirtytalk/spatial';

pointInRect(10, 10, { x: 10, y: 10, w: 5, h: 5 }); // true
pointInRect(15, 15, { x: 10, y: 10, w: 5, h: 5 }); // false

Scene

RectSpace

const RectSpace: Space<DirtyRegion>;

The Space (from @dirtytalk/engine) over DirtyRegion. intersects(interest, dirty) is true for any 'data' or 'layout' entry when both sides are non-empty, and for 'paint' entries only when their rect overlaps an interest rect. Also implements the optional in-place unionInto. See Rendering.

import { RectSpace } from '@dirtytalk/spatial';
import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine';
import type { DirtyRegion } from '@dirtytalk/spatial';

const channel = new DirtyChannel<DirtyRegion>(RectSpace, new SyncScheduler());

SceneNodeOptions

interface SceneNodeOptions {
  bounds?: Rect;
  clipsOverflow?: boolean;
}
OptionDefaultMeaning
bounds{ x: 0, y: 0, w: 0, h: 0 }Initial bounds.
clipsOverflowfalseClip descendants’ damage to this node’s bounds.
import type { SceneNodeOptions } from '@dirtytalk/spatial';

const options: SceneNodeOptions = { bounds: { x: 0, y: 0, w: 50, h: 50 } };

SceneNode

abstract class SceneNode {
  bounds: Rect;
  parent: SceneNode | null;
  children: SceneNode[];
  clipsOverflow: boolean;

  constructor(options?: SceneNodeOptions);

  abstract paint(layer: unknown): void;
  rebuildData?(): void;
  doLayout?(): void;

  protected markDamaged(kind: DamageKind, rect?: Rect): void;
  protected batch(fn: () => void): void;

  setBounds(next: Rect): void;
  adoptChild(child: SceneNode): void;
  removeChild(child: SceneNode): void;
}

Abstract base for painted objects. It owns its bounds and parent/children, and reports damage up the parent chain to the SceneRoot.

Members

  • paint(layer): required. Draw the node. SceneRoot passes undefined as layer.
  • rebuildData(): optional. Called once per frame for 'data' damage raised by this node.
  • doLayout(): optional. Called once per frame for 'data' or 'layout' damage targeting this node.
  • markDamaged(kind, rect?): protected. Reports damage; rect defaults to this.bounds and is clamped by clipsOverflow ancestors. No-op when the node has no SceneRoot ancestor.
  • batch(fn): protected. Runs fn, buffering this node’s damage, then emits one entry per kind and target node with the union rect. Nested calls are absorbed by the outer batch. Damage is emitted even if fn throws, and the error propagates.
  • setBounds(next): no-op if equal to current bounds. Otherwise emits paint damage for the old and new rects, and layout damage for the new rect targeting the parent (if any).
  • adoptChild(child): detaches child from its previous parent, attaches it, emits paint damage for child.bounds. Throws Error('SceneNode.adoptChild: a node cannot adopt itself or an ancestor') if child is this node or an ancestor.
  • removeChild(child): no-op if child is not a child. Otherwise removes it, emits paint damage for its bounds and sets child.parent = null.
import { SceneNode } from '@dirtytalk/spatial';

class Dot extends SceneNode {
  color = 'black';

  setColor(color: string) {
    this.color = color;
    this.markDamaged('paint');
  }

  paint() {}
}

const parent = new Dot({ bounds: { x: 0, y: 0, w: 100, h: 100 } });
parent.adoptChild(new Dot({ bounds: { x: 10, y: 10, w: 5, h: 5 } }));

SceneRoot

class SceneRoot extends SceneNode {
  readonly channel: DirtyChannel<DirtyRegion>;
  readonly renderer: Renderer2D;
  fullFrame: boolean;

  constructor(renderer: Renderer2D, options?: SceneRootOptions);

  dispose(): void;
  hitTest(x: number, y: number): SceneNode | null;
  paint(layer: unknown): void;
  protected paintFrame(regions: readonly Rect[]): number;
  _emitDamage(damage: Damage): void;
}

Root of a scene graph. Owns the DirtyChannel, the scheduler and the renderer, and runs the data, layout, paint pipeline on each flush.

Parameters

  • renderer: Renderer2D: receives frames.
  • options?: SceneRootOptions: see SceneRootOptions.

Members

  • channel: the underlying DirtyChannel. renderer: the renderer passed to the constructor.
  • fullFrame: default false. When true, every frame repaints the whole bounds and visits all children without culling. A cost-comparison baseline; leave false in production.
  • dispose(): cancels any pending frame; later damage is ignored.
  • hitTest(x, y): returns the topmost, deepest node containing the point, or null. See Pointer events.
  • paint(layer): calls paint(layer) on every child in adoption order. The render pipeline does not use it; it paints through paintFrame.
  • paintFrame(regions): protected stage-3 hook. Default: beginFrame(regions), paint children overlapping the regions, endFrame(); returns the number of nodes painted. regions is never empty.
  • _emitDamage(damage): internal, called by SceneNode. Do not call it directly.
import { SceneRoot } from '@dirtytalk/spatial';
import type { Renderer2D } from '@dirtytalk/spatial';
import { ManualScheduler } from '@dirtytalk/engine';

const renderer: Renderer2D = {
  beginFrame(regions) {
    console.log(regions);
  },
  endFrame() {},
};
const scheduler = new ManualScheduler();
const root = new SceneRoot(renderer, {
  scheduler,
  bounds: { x: 0, y: 0, w: 800, h: 600 },
});

scheduler.pump(); // flush pending damage into a frame
root.dispose();

Rendering

Renderer2D

interface Renderer2D {
  beginFrame(regions: readonly Rect[]): void;
  endFrame(): void;
}

The contract a renderer implements. beginFrame receives the frame’s individual damage rects (never empty), not their union. endFrame closes the frame. Frames with no region left after clamping are skipped entirely.

import type { Renderer2D } from '@dirtytalk/spatial';

const renderer: Renderer2D = {
  beginFrame(regions) {
    console.log('scissor to', regions.length, 'rects');
  },
  endFrame() {},
};

SceneRootOptions

interface SceneRootOptions {
  scheduler?: Scheduler;
  bounds?: Rect;
  onFrameTiming?: (timing: FrameTiming) => void;
}
OptionDefaultMeaning
schedulernew RAFScheduler()Decides when pending damage becomes a frame.
boundszero rectRoot bounds; the default interest region and clamp for frame regions.
onFrameTimingnoneCalled after each painted frame with a FrameTiming.
import type { SceneRootOptions } from '@dirtytalk/spatial';
import { MicrotaskScheduler } from '@dirtytalk/engine';

const options: SceneRootOptions = {
  scheduler: new MicrotaskScheduler(),
  bounds: { x: 0, y: 0, w: 640, h: 480 },
};

FrameTiming

interface FrameTiming {
  layoutMs: number;
  paintMs: number;
  paintedNodes: number;
}
  • layoutMs: ms in the data and layout stages.
  • paintMs: ms in the paint stage (beginFrame, paint walk, endFrame).
  • paintedNodes: number of top-level nodes whose paint() ran.
import type { FrameTiming } from '@dirtytalk/spatial';

const log = (t: FrameTiming) => console.log(`${t.paintedNodes} nodes`);

Pointer

SpatialPointerEvent

interface SpatialPointerEvent {
  type: 'down' | 'move' | 'up' | 'cancel';
  x: number;
  y: number;
  buttons: number;
  pointerId: number;
}

A minimal, surface-agnostic pointer event. Compatible with values taken from a DOM PointerEvent.

import type { SpatialPointerEvent } from '@dirtytalk/spatial';

const e: SpatialPointerEvent = {
  type: 'down',
  x: 20,
  y: 20,
  buttons: 1,
  pointerId: 1,
};

PointerHandler

interface PointerHandler {
  onPointerDown?(e: SpatialPointerEvent): void;
  onPointerMove?(e: SpatialPointerEvent): void;
  onPointerUp?(e: SpatialPointerEvent): void;
  onPointerCancel?(e: SpatialPointerEvent): void;
}

Optional methods a SceneNode subclass defines to receive events from PointerRouter. Missing methods are skipped.

import { SceneNode } from '@dirtytalk/spatial';
import type { PointerHandler, SpatialPointerEvent } from '@dirtytalk/spatial';

class Handle extends SceneNode implements PointerHandler {
  onPointerUp(e: SpatialPointerEvent) {
    console.log('released at', e.x, e.y);
  }

  paint() {}
}

PointerRouter

class PointerRouter {
  constructor(root: SceneRoot);
  dispatch(e: SpatialPointerEvent): SceneNode | null;
}

Hit-tests the scene and dispatches events to a single node, with per-pointer capture. There is no bubbling.

Parameters

  • root: SceneRoot: the scene to hit-test.
  • dispatch(e): the event to route.

Returns the node that received the event, or null if none did.

Behaviour

  • 'down': hit-test; if hit, capture the node for e.pointerId and call onPointerDown.
  • 'move', 'up', 'cancel': delivered to the captured node for that pointer ('up' and 'cancel' release it). Without a captured node, 'move' is delivered to the hit node and 'up' and 'cancel' are ignored.

See Pointer events for details and DOM wiring.

import { PointerRouter } from '@dirtytalk/spatial';
import type { SceneRoot } from '@dirtytalk/spatial';

declare const root: SceneRoot;

const router = new PointerRouter(root);
const target = router.dispatch({
  type: 'down',
  x: 20,
  y: 20,
  buttons: 1,
  pointerId: 1,
});