API Reference
The complete public surface of @dirtytalk/spatial, organised by export. Every signature here matches the source. This page is a lookup reference; for the model behind these APIs read Concepts, and for a guided build see Getting Started.
All exports come from the package root — there are no subpaths:
import { // values rectOverlaps, rectEquals, unionRects, rectClamp, pointInRect, RectSpace, SceneNode, SceneRoot, PointerRouter,} from '@dirtytalk/spatial';
import type { // types Rect, DamageKind, Damage, DirtyRegion, SceneNodeOptions, Renderer2D, SceneRootOptions, FrameTiming, SpatialPointerEvent, PointerHandler,} from '@dirtytalk/spatial';Scheduler classes (SyncScheduler, ManualScheduler, RAFScheduler, MicrotaskScheduler) and the Space/DirtyChannel primitives come from @dirtytalk/engine.
A 2D axis-aligned rectangle in CSS pixels, top-left origin. Note the field names are w/h, not width/height.
interface Rect { x: number; y: number; w: number; h: number;}DamageKind
Section titled “DamageKind”Classifies a damage entry and thereby selects which pipeline stages run.
type DamageKind = 'paint' | 'layout' | 'data';| Value | Stages run |
|---|---|
'paint' | Paint only. |
'layout' | Layout → paint. |
'data' | Data → layout → paint. |
Damage
Section titled “Damage”A single damage entry: a rectangle, a kind, and (at runtime) the emitting node.
interface Damage { rect: Rect; kind: DamageKind; node?: unknown;}node is typed unknown to avoid an import cycle between types.ts and scene-node.ts. At runtime it holds the emitting SceneNode; SceneRoot casts it to SceneNode | undefined when running the pipeline. It is optional — damage marked directly on the channel may have no node.
DirtyRegion
Section titled “DirtyRegion”The region type carried by the spatial DirtyChannel: a read-only list of damage entries.
type DirtyRegion = readonly Damage[];Rect helpers
Section titled “Rect helpers”All five are pure, side-effect-free const arrow functions. Only unionRects and rectClamp allocate a new Rect.
rectOverlaps(a, b)
Section titled “rectOverlaps(a, b)”const rectOverlaps: (a: Rect, b: Rect) => boolean;Returns true iff the two rects share positive area. Uses half-open semantics: touching edges do not overlap. Returns false if either rect has w <= 0 or h <= 0.
| Inputs | Result |
|---|---|
| Identical non-empty rects | true |
| One fully contained in the other | true |
| Right edge of A == left edge of B (touching) | false |
Either rect has w = 0 or h = 0 | false |
rectOverlaps({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }); // truerectOverlaps({ x: 0, y: 0, w: 10, h: 10 }, { x: 10, y: 0, w: 10, h: 10 }); // false (touching)rectEquals(a, b)
Section titled “rectEquals(a, b)”const rectEquals: (a: Rect, b: Rect) => boolean;Field-by-field structural equality using === (no epsilon/tolerance): a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h. Any single differing field yields false.
rectEquals({ x: 1, y: 2, w: 3, h: 4 }, { x: 1, y: 2, w: 3, h: 4 }); // truerectEquals({ x: 1, y: 2, w: 3, h: 4 }, { x: 1, y: 2, w: 3, h: 5 }); // falseunionRects(rects)
Section titled “unionRects(rects)”const unionRects: (rects: readonly Rect[]) => Rect;Returns the bounding box (axis-aligned hull) of all input rects. Does not mutate inputs.
- Empty array → the origin sentinel
{ x: 0, y: 0, w: 0, h: 0 }. - Single-element array → a rect equal to that element.
- Each rect’s extent is treated as
[x, x + w]; zero-area members are not special-cased and can still pull the hull toward their corner.
unionRects([ { x: 0, y: 0, w: 30, h: 30 }, { x: 50, y: 50, w: 20, h: 20 },]); // { x: 0, y: 0, w: 70, h: 70 }
unionRects([]); // { x: 0, y: 0, w: 0, h: 0 }rectClamp(inner, outer)
Section titled “rectClamp(inner, outer)”const rectClamp: (inner: Rect, outer: Rect) => Rect;Returns the geometric intersection of inner and outer, with width and height floored at 0 via Math.max(0, …). This is the function used to clip damage to clipping ancestors.
- Non-overlapping inputs → a zero-area rect.
innerfully insideouter→ a rect value-equal toinner.innerequal toouter→ an equal rect.
rectClamp({ x: 5, y: 5, w: 100, h: 100 }, { x: 0, y: 0, w: 50, h: 50 });// { x: 5, y: 5, w: 45, h: 45 } (intersection)
rectClamp({ x: 200, y: 200, w: 10, h: 10 }, { x: 0, y: 0, w: 50, h: 50 });// { x: 200, y: 200, w: 0, h: 0 } (no overlap -> zero area)pointInRect(x, y, r)
Section titled “pointInRect(x, y, r)”const pointInRect: (x: number, y: number, r: Rect) => boolean;Half-open point test over [x, x + w) × [y, y + h): the top-left corner is inclusive, the bottom-right edge is exclusive (CSS pixel-grid convention). A zero-area rect (w = 0 or h = 0) contains no points.
pointInRect(0, 0, { x: 0, y: 0, w: 10, h: 10 }); // true (top-left inclusive)pointInRect(10, 5, { x: 0, y: 0, w: 10, h: 10 }); // false (right edge exclusive)RectSpace
Section titled “RectSpace”const RectSpace: Space<DirtyRegion>;The Space<DirtyRegion> implementation that wires the rect algebra into the engine’s DirtyChannel. Space<Region> (from @dirtytalk/engine) requires four pure methods, which RectSpace provides:
| Method | Behaviour |
|---|---|
empty(): DirtyRegion | Returns []. A fresh array on every call (empty() !== empty()). |
isEmpty(r): boolean | r.length === 0. |
union(a, b): DirtyRegion | Identity short-circuit: if a is empty returns b by reference; if b is empty returns a by reference; otherwise returns [...a, ...b]. Never mutates. Concatenation only — no dedup, no geometric merge. |
intersects(interest, dirty): boolean | false if either side is empty. Otherwise a nested loop returning true as soon as any interest rect overlaps any dirty rect (via rectOverlaps). O(N×M). DamageKind is ignored — only rects matter. |
You normally never call these directly; SceneRoot passes RectSpace to its DirtyChannel. They are public for advanced uses (e.g. building your own channel over the spatial region).
import { DirtyChannel } from '@dirtytalk/engine';import { RectSpace } from '@dirtytalk/spatial';import type { DirtyRegion } from '@dirtytalk/spatial';
const channel = new DirtyChannel<DirtyRegion>(RectSpace, scheduler);SceneNode
Section titled “SceneNode”abstract class SceneNode { constructor(options?: SceneNodeOptions);}The abstract base class for every node in the scene tree. Subclass it and implement paint. Owns its bounds and contributes damage to the root channel via the parent chain.
SceneNodeOptions
Section titled “SceneNodeOptions”interface SceneNodeOptions { bounds?: Rect; clipsOverflow?: boolean;}| Option | Default | Meaning |
|---|---|---|
bounds | { x: 0, y: 0, w: 0, h: 0 } | The node’s initial axis-aligned rectangle. |
clipsOverflow | false | When true, descendants’ damage rects are clamped to this node’s bounds. |
Public properties
Section titled “Public properties”| Property | Type | Default | Meaning |
|---|---|---|---|
bounds | Rect | from options or {0,0,0,0} | The node’s rectangle. Mutable, but prefer setBounds for damage tracking. |
parent | SceneNode | null | null | Parent in the tree; set by adoptChild / removeChild. |
children | SceneNode[] | [] | Direct children in adoption order (= z-order; later = topmost). |
clipsOverflow | boolean | false | When true, descendants’ damage is clamped to this node’s bounds. |
abstract paint(layer)
Section titled “abstract paint(layer)”abstract paint(layer: unknown): void;Called during the paint stage. Subclasses must implement it. layer is the renderer-provided draw surface; the spatial package never inspects it (during the culled walk SceneRoot calls child.paint(undefined)). Define layer to whatever your renderer needs.
rebuildData?() (optional)
Section titled “rebuildData?() (optional)”rebuildData?(): void;Optional hook for nodes that own a data pipeline (e.g. plot mark layers). Called in Stage 1 only for data-kind damage entries whose node defines it. Use it to re-bin or recompute derived geometry.
doLayout?() (optional)
Section titled “doLayout?() (optional)”doLayout?(): void;Optional hook for nodes that own layout. Called in Stage 2 for any non-paint damage entry (layout or data) whose node defines it. Use it to position content within this.bounds.
protected markDamaged(kind, rect?)
Section titled “protected markDamaged(kind, rect?)”protected markDamaged(kind: DamageKind, rect?: Rect): void;The core damage emitter — protected, so only subclasses call it. Behaviour:
rectdefaults tothis.boundsif omitted.- The rect is clipped by walking up the parent chain: every ancestor with
clipsOverflow === trueclamps it (rectClamp) to that ancestor’s bounds. Cumulative across multiple clipping ancestors. (A node’s ownclipsOverflowdoes not clip its own damage — only ancestors clip.) - Builds
{ rect: clipped, kind, node: this }. - If a
batchis active, pushes into the buffer and returns (no emit yet). - Otherwise resolves the root. If there is no connected root, it is a silent no-op. Otherwise calls
root._emitDamage(damage).
class Badge extends SceneNode { paint(_layer: unknown): void {} flash(): void { this.markDamaged('paint'); // defaults to this.bounds }}protected batch(fn)
Section titled “protected batch(fn)”protected batch(fn: () => void): void;Coalesces all markDamaged calls made inside fn before emitting.
- Nested batch: if a batch is already in flight, just runs
fn()— the outer batch absorbs everything; the inner one does not emit separately. - Runs
fn()insidetry/finally; the buffer is always cleared even iffnthrows. - Empty buffer → no emit (
batch(() => {})emits nothing). - On flush, buffered damages are grouped by kind. For each kind: one entry → emitted as-is; multiple → emitted as a single entry whose rect is
unionRectsof that kind’s rects. Each grouped entry is emitted via the root. - Kind-grouping order follows first-seen-kind order.
class Panel extends SceneNode { paint(_layer: unknown): void {} redrawTwoRegions(): void { this.batch(() => { this.markDamaged('paint', { x: 0, y: 0, w: 30, h: 30 }); this.markDamaged('paint', { x: 50, y: 50, w: 20, h: 20 }); }); // Same-kind rects union -> beginFrame gets ONE region: // [{ x: 0, y: 0, w: 70, h: 70 }] }}setBounds(next)
Section titled “setBounds(next)”setBounds(next: Rect): void;The damage-tracked way to move or resize a node.
- No-op if
rectEquals(this.bounds, next)— no damage, no mutation. - Otherwise: emits
markDamaged('paint', prev)(erase old footprint), assignsthis.bounds = next, emitsmarkDamaged('paint', next)(fill new footprint), and if the node has a parent, emitsmarkDamaged('layout')(re-layout the parent). Net for a connected node with a parent: 2 paint + 1 layout. - On a root-less node it still mutates
this.boundsbut emits nothing.
node.setBounds({ x: 100, y: 40, w: 120, h: 40 });// erase old rect (paint) + fill new rect (paint) + parent layoutadoptChild(child)
Section titled “adoptChild(child)”adoptChild(child: SceneNode): void;Attaches child as the topmost child of this node.
- If
childalready has a parent, detaches it first viachild.parent.removeChild(child). - Sets
child.parent = thisand appends tothis.children(so it becomes topmost in z-order). - If this node is connected to a root, emits a single full-bounds
paintfor the child (child.markDamaged('paint', child.bounds)) so the newly-visible region paints. If not connected, no damage is emitted.
removeChild(child)
Section titled “removeChild(child)”removeChild(child: SceneNode): void;Detaches child from this node.
- No-op if
childis not inthis.children. - Splices the child out, then emits
child.markDamaged('paint', child.bounds)to damage the vacated area, then setschild.parent = null. (Order matters: damage is emitted while the child is still parented so root resolution still works.)
Root resolution and clipping internals
SceneNode resolves its owning root by walking the parent chain, starting at itself — a node that is itself a root resolves to itself (so SceneRoot.adoptChild can emit adopt-time paint for its direct children). Root detection is structural/duck-typed: any ancestor exposing a function-valued _emitDamage member counts as a root. A node not connected to such a root silently swallows all damage. Damage rects are clipped by cumulative rectClamp against each ancestor whose clipsOverflow is true.
SceneRoot
Section titled “SceneRoot”class SceneRoot extends SceneNode { constructor(renderer: Renderer2D, options?: SceneRootOptions);}The concrete root node. Owns the DirtyChannel<DirtyRegion>, the scheduler, and the Renderer2D, and drives the three-stage pipeline. Inherits all of SceneNode (bounds, parent, children, clipsOverflow, setBounds, adoptChild, removeChild, markDamaged, batch).
Constructor
Section titled “Constructor”constructor(renderer: Renderer2D, options: SceneRootOptions = {})| Parameter | Notes |
|---|---|
renderer | Required. Stored on this.renderer. |
options.scheduler | Default new RAFScheduler(). Pass SyncScheduler or ManualScheduler in tests. |
options.bounds | Passed to super; defaults to {0,0,0,0}. Used as the default interest region. |
options.onFrameTiming | Optional. When set, every rendered frame is timed and reported. |
On construction it creates this.channel = new DirtyChannel(RectSpace, scheduler ?? new RAFScheduler()) and subscribes once with interest = () => [{ rect: this.bounds, kind: 'paint' }] (a thunk, re-evaluated per flush so resizing is respected) and callback (dirty) => this._renderFrame(dirty).
SceneRootOptions
Section titled “SceneRootOptions”interface SceneRootOptions { scheduler?: Scheduler; // default: new RAFScheduler() bounds?: Rect; // default interest region onFrameTiming?: (timing: FrameTiming) => void; // opt-in timing hook}Public properties
Section titled “Public properties”| Property | Type | Notes |
|---|---|---|
channel | readonly DirtyChannel<DirtyRegion> | The owned dirty channel. You can push marks directly via channel.mark([...]). |
renderer | readonly Renderer2D | The renderer contract instance. |
fullFrame | boolean (default false) | When true, damage is ignored: every frame repaints [this.bounds] and the paint walk visits all children (no culling). The “damage tracking off” baseline for cost comparison — leave false in production. |
paint(_layer)
Section titled “paint(_layer)”paint(_layer: unknown): void;The root does not paint itself. It walks children in adoption order and calls each child.paint(_layer). This is the un-culled walk, distinct from the culled walk used during rendering.
hitTest(x, y)
Section titled “hitTest(x, y)”hitTest(x: number, y: number): SceneNode | null;Returns the topmost, deepest node containing the point, or null.
- Walks children in reverse adoption order (topmost = last adopted wins).
- For the first child whose bounds contain
(x, y)(pointInRect, half-open), recurses and returns the deepest descendant hit, else that child itself. - Returns
nullif no child contains the point. The root is never a valid hit target.
const node = root.hitTest(120, 35); // SceneNode | nullchannel-direct marking
Section titled “channel-direct marking”Because channel is public, you can mark damage that has no owning node — e.g. a manual full-region invalidation:
root.channel.mark([{ rect: { x: 0, y: 0, w: 800, h: 600 }, kind: 'paint' }]);The render pipeline (behaviour)
Section titled “The render pipeline (behaviour)”When the channel flushes, the root runs three ordered stages over the flushed DirtyRegion:
- Stage 1 — data. For each entry: if
kind === 'data'and the node definesrebuildData, call it. - Stage 2 — layout. For each entry: if
kind !== 'paint'(solayoutordata) and the node definesdoLayout, call it. - Stage 3 — paint.
regions = fullFrame ? [this.bounds] : dirty.map(d => d.rect)(the individual, disjoint rects — not their union). Callrenderer.beginFrame(regions); paint each top-level child whose bounds overlap a region, in adoption order (this is the cull); callrenderer.endFrame().
Strict per-frame call order for a data damage: rebuildData → doLayout → beginFrame → paint → endFrame. The cull is one level deep — only direct children of the root are culled; a painted child draws its own subtree.
Renderer2D
Section titled “Renderer2D”interface Renderer2D { beginFrame(regions: readonly Rect[]): void; endFrame(): void;}The renderer contract the package ships (no implementation). beginFrame(regions) begins a frame clipped/scissored to the given damage regions; the array is never empty when called, and regions is the list of individual damage rects, not their union. A v1 renderer may unionRects(regions) and clip to the bounding box, or ignore regions and clear the whole canvas; a multi-rect renderer scissors to each. endFrame() takes no arguments (submit/flush).
const renderer: Renderer2D = { beginFrame(regions) { // v1: clip to the bounding box of all damage const box = unionRects([...regions]); ctx.save(); ctx.beginPath(); ctx.rect(box.x, box.y, box.w, box.h); ctx.clip(); ctx.clearRect(box.x, box.y, box.w, box.h); }, endFrame() { ctx.restore(); },};FrameTiming
Section titled “FrameTiming”interface FrameTiming { layoutMs: number; // ms in data + layout stages (rebuildData + doLayout) paintMs: number; // ms in paint stage (beginFrame -> paint walk -> endFrame) paintedNodes: number; // top-level nodes whose paint() actually ran this frame}Reported via onFrameTiming exactly once per rendered frame. paintedNodes is the headline cost signal — it scales with the damaged area (every child in full-frame mode), not the scene size. When onFrameTiming is omitted there is zero overhead: performance.now() is never called.
const root = new SceneRoot(renderer, { bounds: { x: 0, y: 0, w: 800, h: 600 }, onFrameTiming: ({ layoutMs, paintMs, paintedNodes }) => { console.log( `layout ${layoutMs}ms paint ${paintMs}ms nodes ${paintedNodes}`, ); },});PointerRouter
Section titled “PointerRouter”class PointerRouter { constructor(root: SceneRoot); dispatch(e: SpatialPointerEvent): SceneNode | null;}Routes pointer events into the scene with capture semantics. Framework-agnostic: you translate DOM PointerEvents into SpatialPointerEvents at the boundary. Maintains a private capture map keyed by pointerId.
SpatialPointerEvent
Section titled “SpatialPointerEvent”interface SpatialPointerEvent { type: 'down' | 'move' | 'up' | 'cancel'; x: number; y: number; buttons: number; pointerId: number;}A minimal, surface-agnostic pointer event. Build it from a DOM event: { type, x: e.offsetX, y: e.offsetY, buttons: e.buttons, pointerId: e.pointerId }.
PointerHandler
Section titled “PointerHandler”interface PointerHandler { onPointerDown?(e: SpatialPointerEvent): void; onPointerMove?(e: SpatialPointerEvent): void; onPointerUp?(e: SpatialPointerEvent): void; onPointerCancel?(e: SpatialPointerEvent): void;}The optional handler interface a SceneNode may implement to receive pointer callbacks. The router treats nodes as Partial<PointerHandler> and optional-chains the matching method — a node with no handler is still a valid hit target, receives no callback, and never throws.
constructor(root)
Section titled “constructor(root)”constructor(root: SceneRoot)Holds the root for hit-testing.
dispatch(e)
Section titled “dispatch(e)”dispatch(e: SpatialPointerEvent): SceneNode | null;Dispatches an event and returns the receiving node (or null). Capture-based semantics:
| Event | Behaviour |
|---|---|
down | Hit-tests via root.hitTest. On a hit: captures the node for e.pointerId, invokes onPointerDown, returns it. On a miss: captures nothing, returns null. |
move/up/cancel with capture | Delivers to the captured node (even if the pointer drifted outside its bounds). On up/cancel, releases the capture. Returns the captured node. |
move uncaptured | Hit-tests by current position; invokes onPointerMove on the hit (if any); returns it or null. |
up/cancel uncaptured | Dropped — returns null, no handler invoked. |
Multiple pointerIds are tracked independently. Hover is just move dispatch — a node repaints on hover by calling markDamaged('paint') inside onPointerMove.
const router = new PointerRouter(root);
const hit = router.dispatch({ type: 'down', x: 120, y: 35, buttons: 1, pointerId: 1,});// `hit` is the captured node; subsequent move/up for pointerId 1 follow it.See also
Section titled “See also”- Getting Started — install and build a working scene.
- Concepts — the damage model, pipeline, and pointer routing explained.
- Engine: API Reference —
Space,DirtyChannel, and the scheduler classes. - Engine: Concepts — coalescing, interest, and flush semantics.