API reference
Every public export of @dirtytalk/spatial with exact TypeScript signatures, parameters, return values, errors and examples.
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;
}
| Option | Default | Meaning |
|---|---|---|
bounds | { x: 0, y: 0, w: 0, h: 0 } | Initial bounds. |
clipsOverflow | false | Clip 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.SceneRootpassesundefinedaslayer.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;rectdefaults tothis.boundsand is clamped byclipsOverflowancestors. No-op when the node has noSceneRootancestor.batch(fn): protected. Runsfn, 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 iffnthrows, and the error propagates.setBounds(next): no-op if equal to current bounds. Otherwise emitspaintdamage for the old and new rects, andlayoutdamage for the new rect targeting the parent (if any).adoptChild(child): detacheschildfrom its previous parent, attaches it, emitspaintdamage forchild.bounds. ThrowsError('SceneNode.adoptChild: a node cannot adopt itself or an ancestor')ifchildis this node or an ancestor.removeChild(child): no-op ifchildis not a child. Otherwise removes it, emitspaintdamage for its bounds and setschild.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 underlyingDirtyChannel.renderer: the renderer passed to the constructor.fullFrame: defaultfalse. Whentrue, every frame repaints the whole bounds and visits all children without culling. A cost-comparison baseline; leavefalsein production.dispose(): cancels any pending frame; later damage is ignored.hitTest(x, y): returns the topmost, deepest node containing the point, ornull. See Pointer events.paint(layer): callspaint(layer)on every child in adoption order. The render pipeline does not use it; it paints throughpaintFrame.paintFrame(regions): protected stage-3 hook. Default:beginFrame(regions), paint children overlapping the regions,endFrame(); returns the number of nodes painted.regionsis never empty._emitDamage(damage): internal, called bySceneNode. 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;
}
| Option | Default | Meaning |
|---|---|---|
scheduler | new RAFScheduler() | Decides when pending damage becomes a frame. |
bounds | zero rect | Root bounds; the default interest region and clamp for frame regions. |
onFrameTiming | none | Called 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 whosepaint()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 fore.pointerIdand callonPointerDown.'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,
});