@zakkster/lite-scene
v1.0.0
Published
Reactive retained-mode Canvas2D scene graph for @zakkster/lite-signal. Nodes (group/rect/circle/line/text/image/path) accept signals as props; the renderer redraws only when something dirty changed. Hit testing, clip groups, pointerEvents, nested transfor
Maintainers
Readme
@zakkster/lite-scene
A retained-mode Canvas2D scene graph driven by @zakkster/lite-signal. Build a tree of shapes (group, rect, circle, line, text, image, path), pass signals where you want values to be reactive, and the renderer redraws only when something dirty changed. Nested transforms, opacity, hit testing, clip groups, pointerEvents: 'none' — and the draw path is allocation-free.
import { signal } from '@zakkster/lite-signal';
import { createScene, circle, rect, group } from '@zakkster/lite-scene';
const scene = createScene(canvas, { background: '#0a0e12' });
const mx = signal(0), my = signal(0);
scene.add(circle({ x: mx, y: my, radius: 24, fill: '#3fd0bf' }));
canvas.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mx.set(e.clientX - r.left);
my.set(e.clientY - r.top);
});That's it — the circle tracks the cursor. No requestAnimationFrame loop in your code, no manual redraws, no diff. The renderer wakes on a signal change, draws once, sleeps.
Contents
- Why · What it is / is not · Install · Quick start
- The model · Reactivity & coalescing · Transforms & nesting
- Hit testing · Clip groups · Pointer events
- API reference · Recipes
- Testing (for clients & QA) · Running the demo
- Edge cases & guarantees · Ecosystem · FAQ · License
Why
Canvas is the right primitive for visual UIs that can't be DOMed cheaply — dashboards, data viz, generative art, game HUDs, design tools. The annoying part has always been the glue: a requestAnimationFrame loop you write by hand, a dirty bit you flip in twelve places, an object model you invent from scratch, hit testing you wire up four times for four shape types, and a sinking realisation when a colour leaks from one shape into the next because someone forgot a ctx.save().
lite-scene is the small library that gives you all of that on top of signals. Shapes are reactive nodes; props are values or signal accessors; the renderer coalesces every change in a tick into a single draw on the next animation frame; state never leaks between siblings because save/restore is unconditional; and a hit test composes the transforms back the way they came. It's the thing you would write yourself the third time you needed it.
flowchart LR
S["signal change"] --> E["binding effect<br/>(per reactive prop)"]
E -->|"update cached field"| N[node]
E -->|"mark dirty"| Q["request draw<br/>(coalesced)"]
Q --> R["next animation frame"]
R --> D["full traversal: save → transform → drawSelf → children → restore"]
D --> C[canvas]What it is / is not
- It is a tiny retained-mode renderer (~500 lines) for Canvas2D. Hierarchical nodes; reactive props; cheap dirty-tracked redraw; hit testing; clip groups;
pointerEvents: 'none'. - It is not a game engine, a physics engine, an SVG renderer, or a virtual DOM. No keyed list reconciliation (children are mutated imperatively — that's its own primitive). No WebGL backend. No off-screen layer cache (yet). No
zIndex(use child order or nest groups). No dirty-region partial redraw — the renderer paints the whole scene per dirty frame; for most cases that's the right call, and when it isn't, that's a future iteration that deserves its own design pass.
Install
npm i @zakkster/lite-scene @zakkster/lite-signal@zakkster/lite-signal is a peer dependency (^1.1.0). ESM-only, ships TypeScript types.
import {
createScene,
group, rect, circle, line, text, image, path,
} from '@zakkster/lite-scene';Quick start
import { signal, effect } from '@zakkster/lite-signal';
import { createScene, rect, circle, text } from '@zakkster/lite-scene';
const scene = createScene(canvas, { background: '#0a0e12' });
// A counter that updates the title text reactively.
const n = signal(0);
scene.add(text({ x: 20, y: 40, text: () => `n = ${n()}`, font: '28px monospace', fill: '#3fd0bf' }));
// A rect whose size scales with the counter.
scene.add(rect({ x: 20, y: 80, width: () => 40 + n() * 8, height: 40, fill: '#ffb454' }));
// Click anywhere on the canvas to bump the counter.
canvas.addEventListener('click', () => n.set(n.peek() + 1));Every click increments n; both the text and the rect update on the next frame, in one redraw, because both bindings flip into the same coalesced flush.
The model
A scene is a tree of nodes. Each node has a kind (group | rect | circle | line | text | image | path), a transform (x, y, rotation, scaleX, scaleY), an opacity, a visible flag, and per-shape props (fill, stroke, radius, text, source, ...). Any prop may be a static value or a signal accessor — pass a function and it becomes reactive automatically. The convention matches Solid's () => value.
| Shape | Local origin draws at | Required props |
|---|---|---|
| group | — (transform container) | — |
| rect | top-left | width, height |
| circle | centre | radius |
| line | first endpoint at (0,0) | dx, dy (end relative to origin) |
| text | baseline at align point | text |
| image | top-left | source (any drawImage-compatible) |
| path | user-defined | draw(ctx, node) |
Two props on path are intentionally raw (never auto-bound as signals): draw and hitTest. They are the escape hatch — when the built-ins aren't enough, you write the canvas calls yourself, still inside the managed lifecycle.
Reactivity & coalescing
A signal accessor on a prop installs one effect when the node is attached. The effect reads the accessor (tracking dependencies), writes the cached field on the node, and requests a redraw. A burst of signal changes in one tick fires only one draw — requestDraw is idempotent via a queue flag, so 500 sliders moving in the same microtask collapse to a single frame. The draw itself is allocation-free; it just walks the tree and reads cached fields.
import { batch } from '@zakkster/lite-signal';
// 1000 signal writes → 1 redraw on the next frame
batch(() => {
for (const cell of cells) cell.value.set(Math.random());
});The renderer schedules at most one flush per frame via the schedule option (default requestAnimationFrame). Pass frameSchedule() from @zakkster/lite-raf to share its single loop with every other frame consumer:
import { frameSchedule, startFrames } from '@zakkster/lite-raf';
startFrames();
const scene = createScene(canvas, { schedule: frameSchedule() });Transforms & nesting
Transforms compose. A child's x/y/rotation/scale is applied in the parent's local space, exactly as you'd expect from ctx.translate/rotate/scale nesting. Opacity multiplies: a group at 0.5 containing a group at 0.5 containing a rect renders the rect at effective alpha 0.25. State (fill, stroke, font, alpha) is unconditionally isolated per node with save/restore, so a shape's colour can never leak to a sibling — modern canvas backends optimise save/restore heavily, and the safety is worth more than the saved cycles.
const dial = group({ x: 200, y: 200 }); // origin of the dial
dial.add(circle({ radius: 80, stroke: '#3fd0bf', strokeWidth: 2 }));
dial.add(group({ rotation: () => angle() }) // rotating needle
.add(rect({ y: -2, width: 70, height: 4, fill: '#ffb454' }))
);
scene.add(dial);Hit testing
scene.hitTest(x, y) returns the top-most node at the given scene coordinate, or null. Transforms are inverted on the way down — translate, rotate, scale — and the test compares against the node's local geometry. Coverage:
| Shape | Hit-test rule |
|---|---|
| rect | local bbox 0..width × 0..height |
| circle | x² + y² ≤ radius² |
| image | bbox only if width/height set (no auto-measure) |
| text | bbox only if width/height set; matches the top-left of the box. See note below. |
| path | only if hitTest(x, y, node) => boolean is provided |
| line, group | not hit-testable (use path with a custom hitTest for clickable lines) |
Text hit-test is geometric, not font-metric. The built-in test treats a
textnode as the top-left rectangle0..width × 0..height. That matchestextBaseline: "top"+textAlign: "left". Under the canvas defaults (alphabeticbaseline,start/leftalign) the rendered ink sits abovey=0and the rectangle won't cover it. For pixel-correct clickable text, either settextBaseline: "top"andtextAlign: "left"when you want hit-testing, wrap the text in a transparentrectsized to the box you want clickable, or usepathwith a customhitTest. This is an intentional v1 limit — full font-metric hit-testing is a wide rabbit hole (ascent/descent vary per glyph) and not worth the engine bloat.
The DOMMatrix path was deliberately avoided — every hit test on a pointermove would allocate, and matrix-free trig in lite-scene's hitTest does the same job allocation-free.
canvas.addEventListener('pointerdown', (e) => {
const r = canvas.getBoundingClientRect();
const hit = scene.hitTest(e.clientX - r.left, e.clientY - r.top);
if (hit) startDragging(hit);
});Clip groups
Wrap a subtree in a clipping region with group({ clip, width, height }):
const window = group({ x: 100, y: 100, width: 200, height: 120, clip: true });
window.add(rect({ width: 200, height: 120, fill: '#111820' }));
window.add(circle({ x: () => scroll(), y: 60, radius: 40, fill: '#3fd0bf' }));
scene.add(window);
// The circle scrolls horizontally; anything outside the 200×120 box is clipped.clip: true defaults to a rectangle at (0, 0, width, height). For an arbitrary shape, pass a function — it receives the context and node and builds the path you want:
group({ clip: (ctx, n) => { ctx.arc(50, 50, 40, 0, Math.PI * 2); } });Hit testing currently does not respect the clip — a click outside the clip region but inside a child's geometry will still hit that child. Easy to layer on later if it becomes a real problem.
Pointer events
Every node accepts pointerEvents: 'auto' | 'none'. Setting it to 'none' makes hitTest skip that node and its entire subtree — the SVG semantic. It's the right knob for overlays, indicators, and any cosmetic shape that shouldn't intercept clicks.
const tooltip = group({ pointerEvents: 'none' });
tooltip.add(rect({ width: 120, height: 40, fill: 'rgba(0,0,0,0.7)' }));
tooltip.add(text({ x: 10, y: 25, text: 'hover me', fill: '#fff' }));
// Clicks pass through to whatever is underneath.API reference
createScene(canvas, options?) → Scene
| Option | Type | Default | Meaning |
|---|---|---|---|
| dpr | number \| false | window.devicePixelRatio | Device pixel ratio. false disables DPR scaling. |
| background | string | (clearRect) | Fill colour painted before each frame instead of clearing. |
| autoResize | boolean | true | A ResizeObserver keeps canvas pixel size in sync with CSS size. |
| schedule | (flush) => void | requestAnimationFrame | One-shot scheduler for the next draw. |
Returns Scene: { canvas, ctx, root, dpr, add, remove, hitTest, markDirty, dispose }.
Node factories
group(props) // a transform container; supports `clip`, `width`, `height`
rect(props) // width, height, radius (rounded corners), fill, stroke, strokeWidth
circle(props) // radius, fill, stroke, strokeWidth
line(props) // dx, dy (endpoint relative to origin), stroke, strokeWidth
text(props) // text, font, fill, stroke, align, baseline; optional width/height for hit testing
image(props) // source, sx/sy/sw/sh (sub-rect), width, height
path(props) // draw(ctx, node), hitTest(x, y, node) — raw escape hatchAll factories accept the common props (x, y, rotation, scaleX, scaleY, opacity, visible, pointerEvents). Any value may be a signal accessor.
Node methods
| Method | Effect |
|---|---|
| node.add(child) → child | Append a child (re-parents if it was elsewhere). |
| node.remove() → node | Detach from parent; disposes effects on the subtree. |
| node.set(props) → node | Imperatively update props. Function values install/replace bindings; static values clear the binding for that prop. Marks dirty. |
| node.dispose() | Detach + recursively dispose children + drop bindings. |
Recipes
Animation via frameTime — animate purely with signals; the binding refires every frame the loop ticks:
import { frameTime, startFrames } from '@zakkster/lite-raf';
startFrames();
scene.add(rect({ x: 200, y: 200, width: 40, height: 40, fill: '#ffb454',
rotation: () => frameTime() * 0.001 }));Drag with hit-test + mutate — no diff, no virtual DOM, just signals and set():
let dragging = null;
canvas.addEventListener('pointerdown', (e) => { dragging = scene.hitTest(e.offsetX, e.offsetY); });
canvas.addEventListener('pointermove', (e) => { if (dragging) dragging.set({ x: e.offsetX, y: e.offsetY }); });
canvas.addEventListener('pointerup', () => { dragging = null; });Reactive list of shapes — derive the children from data; just rebuild the group when data changes (for small lists this is fine and avoids a keyed-reconciler dependency):
const items = signal([{ id: 1, x: 50 }, { id: 2, x: 150 }]);
const container = group();
scene.add(container);
effect(() => {
// remove old; recreate. For larger N, key by id and reuse — that's lite-keyed's job.
container.children.slice().forEach((c) => c.dispose());
for (const it of items()) container.add(circle({ x: it.x, y: 100, radius: 20, fill: '#3fd0bf' }));
});Testing (for clients & QA)
npm test # node --test test/*.test.js26 deterministic tests, no real canvas required. A recording mock context (test/harness.js) captures every ctx.* call so assertions run against the sequence of draws — what was painted, in what order, with what state. A synchronous flush scheduler keeps every assertion synchronous.
| Group | What's pinned down |
|---|---|
| Basic draw | rect/circle/line/text emit the right primitive calls |
| State-leak regression | a sibling without a fill cannot inherit a previous sibling's fillStyle (the unconditional save/restore guarantee) |
| Reactivity | a signal change fires one draw; multi-write bursts coalesce; attach with N bindings produces one initial draw |
| Transforms | nested opacity multiplies; children draw in declaration order; visible: false skips |
| Hit testing | rect/circle exact; nested transforms compose; top-most wins; pointerEvents: 'none' skips subtree |
| Clip | clip: true builds rect path + clip; clip: fn delegates path construction |
| Lifecycle | .set() with new signal replaces binding; with static drops binding; dispose stops all redraws; 500-child dispose cascades without leaking; re-parenting preserves bindings |
| Escape hatch | path runs user draw and hitTest |
A clean run prints # pass 26 / # fail 0.
Running the demo
example/demo.htmlOpen it directly — no build, no server. A live constellation of ~150 nodes reacts to the cursor; the stats panel shows redraw count and node count in real time, and you can pause the reactivity to confirm draws stop when bindings stop firing.
Edge cases & guarantees
- State isolation. Every node is wrapped in unconditional
save/restore. A shape'sfillStyle/strokeStyle/lineWidth/font/textAlign/textBaseline/globalAlphacannot leak to its siblings. - One draw per dirty tick. Multiple signal changes in the same microtask coalesce; the renderer schedules at most one flush per frame.
- Allocation-free draw. No closures or object literals on the draw path — only cached-field reads and
ctxcalls. - Re-parenting preserves bindings.
node.remove()disposes the subtree's effects;parent.add(node)recreates them. Move a subtree around without losing its reactivity. O(N)dispose. Disposing a group with N children does not splice N times — children's parent links are severed before recursion, so eachremove()early-returns.- DPR-correct. Internal pixel size is
cssSize × dpr; the draw loop appliessetTransform(dpr,…)so your coordinates are CSS pixels. - Same-frame composition. All effects in a tick flip cached fields synchronously; the draw reads a consistent snapshot.
Ecosystem
Zero-GC reactive toolkit; each package independent and MIT-licensed:
- @zakkster/lite-signal — the reactive core (peer dependency).
- @zakkster/lite-raf — frame scheduler; pair with
schedule: frameSchedule()to share one loop. - @zakkster/lite-resource — async state as a signal (race-safe data fetching).
- @zakkster/lite-channel — cross-tab signal sync.
- @zakkster/lite-persist — persist signals to storage.
- @zakkster/lite-router — the URL as a signal.
- @zakkster/lite-scene (this package) — reactive Canvas2D scene graph.
FAQ
Is the draw path really allocation-free?
After the scene is built, yes — drawNode reads fields and calls ctx.* methods. No object literals, no closures, no boxing. The one allocation per scheduled draw (the scheduler callback) is created once at createScene time, not per frame.
Can I drive animations off frameTime?
Yes — bind any prop to () => frameTime() * …. Every frame the loop ticks, the binding refires, the field updates, the scene marks dirty, and the next frame draws. Use schedule: frameSchedule() from lite-raf to share the loop.
Why is line not hit-testable?
Because the right hit threshold (line-width? × pixels of slop?) depends on the design, and I'd rather not invent it for you. Wrap a line in a path with a custom hitTest when you need a clickable wire.
How do I do filters or blending?
Reach for path and set ctx.filter / ctx.globalCompositeOperation yourself, then draw. These are out of v1's core prop surface deliberately — they bloat the API for an uncommon need.
Will this scale to thousands of nodes?
For static scenes, yes — the draw is a flat traversal with no allocations. The pressure point is the number of active bindings, since each is one effect node in lite-signal's registry. For very large dynamic scenes you'll want setDefaultRegistry(createRegistry({ maxNodes: 16384 })). The real perf ceiling beyond that is dirty-region rendering, which is a future iteration.
Why no zIndex?
Because child order is draw order, and that's clean. For layering, use nested groups. A first-class zIndex with reactive re-sort is a real feature, but the design has enough open questions that v1 leaves it out.
License
MIT © Zahary Shinikchiev
