Skip to content
D
Documentation

Lazy

reference
7 min readUpdated

Import these from @grafloria/renderer.

Classes

HtmlHostCuller

The per-frame cull decision for HTML-layer node hosts.

Stateless with respect to the hosts themselves: admits() is told whether the host is currently attached rather than remembering it. That is on purpose — a culler holding its own attached-set is a second copy of a fact the DOM already owns, and the two desync the first time a node is removed from the model mid-gesture. The DOM is the record; this is only the policy.

culler.beginFrame(viewport.getViewBox(), viewport.getZoom(), heldByGesture); for (const node of customNodes) { if (culler.admits(node.id, bounds(node), host?.isConnected ?? false)) … }

ts
class HtmlHostCuller

Methods

  • constructor(options: HostCullOptions = {}, freeze: FreezeQuery | null = null)
  • getMode(): HostCullMode
  • beginFrame(visible: Rectangle, zoom: number, exempt: ReadonlySet<string>): void — Fix this frame's two rects and the set of nodes a live gesture owns.

visible is the WORLD rect actually on screen — the viewBox, not the raw camera rect. They diverge the moment zoom != 1, and culling against the camera rect drops hosts that are on screen whenever the board is zoomed out (which fit-to-content always does). The SVG side learned this the hard way; see svg-renderer.ts visibleRect.

  • admits(id: string, bounds: Rectangle, attached: boolean): boolean — Should this node's host be in the document on this frame?

ProgressiveMounter

ts
class ProgressiveMounter

Methods

  • constructor( engine: DiagramEngine, lifecycle: ViewLifecycle, frame: MountFrame, deferred: DeferredQuery )
  • isRunning(): boolean
  • mount(viewport: Rectangle, zoom: number, options: ProgressiveMountOptions = {}): Promise<MountStats> — Bring the scene up in rAF-yielded slices. Resolves when everything culling admits has a view (or when the mount is cancelled).
  • cancel(): void — Stop mounting. Whatever is not yet mounted is mounted by the next normal render — the gate is handed back here, so a cancelled mount can never strand half a diagram on screen.
  • dispose(): void

ViewLifecycle

ts
class ViewLifecycle implements MountGate

Methods

  • constructor(options: ViewLifecycleOptions = {})
  • freeze(kind: EntityKind, id: string): void — Give up this entity's view. It keeps its model and its spatial-index entry; it stops being drawn and stops costing anything per frame.
  • unfreeze(kind: EntityKind, id: string): void — Give it a view again. It is rebuilt on the next frame that can see it.
  • isFrozen(kind: EntityKind, id: string): boolean
  • isExplicitlyFrozen(kind: EntityKind, id: string): boolean — Explicitly frozen only — NOT the ones autoFreeze is holding off-screen.
  • unfreezeAll(): void
  • setAutoFreeze(on: boolean): void
  • isAutoFreeze(): boolean
  • retainedCount(): number — The views currently retained — i.e. what the renderer is paying for.

This is the number autoFreeze exists to bound, so it is measurable rather than asserted.

  • frozenCount(): number
  • retainVisible(visible: ReadonlyArray<readonly [EntityKind, string]>): void — Called by the renderer each frame with what culling admitted, BEFORE the gate is applied. Anything that was on screen and no longer is gets its view dropped.

Not called at all when autoFreeze is off — the whole feature is one Set diff per frame, and a host that has not asked for it pays nothing.

  • beginDeferred(): void — Defer EVERYTHING. Nothing has a view until admit() says so.
  • admit(kind: EntityKind, id: string): void — Let this entity's view be built from now on.
  • admitAll(kind: EntityKind): void — Admit a whole KIND without naming its members. Slice 0 uses this for nodes: a node's view is cheap (no routing), and enumerating 10k ids to admit them one at a time would cost more than building the ~56 views culling actually keeps.
  • endDeferred(): void — The mount is over (finished, cancelled, or pre-empted). Gate opens fully.
  • admits(kind: EntityKind, id: string): boolean — May the renderer build (or refresh) this entity's view on this frame?
  • isDeferring(): boolean — True while a progressive mount is running — the renderer's cue that the scene is being brought up in slices and is not yet whole.
  • setChangeHook(hook: (() => void) | null): void — Tell the renderer that what it would draw has changed, even though the model has not. Set by SVGRenderer.setViewLifecycle; see changeHook.

Interfaces

FreezeQuery

The sliver of ViewLifecycle that host culling honours.

isExplicitlyFrozen, NOT admits, and the distinction is load-bearing. admits is also false for anything autoFreeze is holding off-screen, and autoFreeze decides that against the bare viewport with no margin — routing custom hosts through it would silently override the hysteresis band with a zero-width one. isExplicitlyFrozen is the signal that a HOST deliberately said "this node has no view", which is a decision custom nodes should obey.

ts
interface FreezeQuery

Members

  • isExplicitlyFrozen(kind: EntityKind, id: string): boolean

HostCullOptions

ts
interface HostCullOptions

Properties

NameTypeDefaultDescription
margin?numberHow far beyond the viewport edge a host is still kept mounted, in CSS pixels.
hysteresis?numberThe extra distance, in CSS pixels, a host must travel BEYOND margin before it is culled. This is the hysteresis band, and it is the difference between culling and thrashing.
mode?HostCullModeWhat a cull does to the element. Default 'detach' — see {@link HostCullMode}.

MountGate

The gate the renderer consults before instantiating an entity's VIEW.

A gate can only ever SUBTRACT from what culling already admitted — it never adds an off-screen entity back in. That asymmetry is deliberate: a gate bug can make something arrive late, never wrong.

ts
interface MountGate

Members

  • admits(kind: EntityKind, id: string): boolean — May the renderer build (or refresh) this entity's view on this frame?
  • isDeferring?(): boolean — True while a progressive mount is running — the renderer's cue that the scene is being brought up in slices and is not yet whole.

MountStats

What one mount() actually did — the numbers the claim lives on.

ts
interface MountStats

Properties

NameTypeDefaultDescription
firstPaintMsnumberms from mount() to the first frame that reached the screen.
completeMsnumberWall clock from mount() to the last entity mounted — INCLUDING the rAF waits.
cpuMsnumberCPU actually spent, summed over the slices — i.e. completeMs minus the time spent yielded to the browser.
slicesnumberrAF slices used (1 = it all fitted in the first frame).
nodesMountednumberEntities whose views were built.
linksMountednumber
worstSliceMsnumberThe worst single slice — the jank a user would actually feel.
abortedbooleanTrue if the mount was cancelled or pre-empted by a model change.

ProgressiveMountOptions

ts
interface ProgressiveMountOptions

Properties

NameTypeDefaultDescription
sliceMs?numberTarget ms per slice. The chunk size adapts to hit it. Default 8 (half a 60fps frame).
initialChunk?numberLinks admitted by the FIRST link slice — the one slice with no measurement to adapt from. Default 4, deliberately timid: link costs are wildly skewed (a typical link routes in 3ms, a long one against 10k obstacles in 850ms), so a big opening chunk is a coin-flip on a multi-second stall. It ramps up fast from here when links are cheap.
maxSlices?numberHard cap on slices, so a pathological scene still terminates. Default 500.
onFirstPaint?(stats: Readonly<MountStats>) => void
onSlice?(stats: Readonly<MountStats>) => void

ViewLifecycleOptions

ts
interface ViewLifecycleOptions

Properties

NameTypeDefaultDescription
autoFreeze?booleanDrop the view of any entity that leaves the viewport. Default false — the historical behaviour (views linger in the LRU until evicted by pressure).

Types

DeferredQuery

What the renderer deferred on the last frame — culling admitted it, the gate did not.

ts
type DeferredQuery = () => ReadonlyArray<readonly [EntityKind, string]>;

EntityKind

Also has every member of String, listed on its own entry.

The two things that have views.

ts
type EntityKind = 'node' | 'link';

HostCullMode

Also has every member of String, listed on its own entry.

What a cull does to the host element.

'detach' — remove the element from the document and keep the reference. Re-entry re-appends the SAME element: renderCustomNode is NOT called again, removeCustomNode is NOT called on the cull, and everything inside the widget survives byte for byte.

'destroy' — fire removeCustomNode and drop the element. Re-entry re-creates the host and re-runs renderCustomNode. Frees the widget's memory; costs a full re-init and every piece of state the widget was holding.

'detach' is the default, and the reasoning is that the cost this feature exists to remove is a cost of being ATTACHED. Layout, style recalc, paint, compositing and hit testing are all charged per element IN THE DOCUMENT; a detached subtree is inert heap. Detaching therefore captures essentially the whole win while keeping the mount-once guarantee that makes custom nodes usable at all. Measured on a 300-widget board with ~16 tiles on screen: 2720 DOM nodes under the canvas fall to 164, a 94% reduction, and a pan away and back re-runs the painter exactly zero times.

What 'detach' does NOT bound is the RETAINED set. A host mounted once is kept for the life of the instance, so panning a 10,000-widget board end to end eventually holds 10,000 detached elements — the same "cache versus leak" distinction ViewLifecycle's header draws about autoFreeze, and the honest reason 'destroy' exists. (Same board, after a sweep across and back: 20 attached, 78 retained off-screen.) Choose 'destroy' when the widgets are individually huge — a WebGL scene, a 50k-row grid — and heap rather than frame time is the binding constraint. It is opt-in inside an opt-in, because a host that asks for it is accepting that its painter re-runs and its widget state is lost.

ts
type HostCullMode = 'detach' | 'destroy';

MountFrame

Produce and PRESENT one frame. Deliberately not "the SVG renderer": the SVG patcher, the canvas backend and the tier-switching backend all satisfy this, so a progressive mount works on any of them.

ts
type MountFrame = (viewport: Rectangle, zoom: number) => void;

Was this page helpful?

Lazy — Grafloria