# Lazy

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**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `margin?` | `number` |  | How far beyond the viewport edge a host is still kept mounted, in CSS pixels. |
| `hysteresis?` | `number` |  | The 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?` | `HostCullMode` |  | What 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**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `firstPaintMs` | `number` |  | ms from `mount()` to the first frame that reached the screen. |
| `completeMs` | `number` |  | Wall clock from `mount()` to the last entity mounted — INCLUDING the rAF waits. |
| `cpuMs` | `number` |  | CPU actually spent, summed over the slices — i.e. `completeMs` minus the time spent yielded to the browser. |
| `slices` | `number` |  | rAF slices used (1 = it all fitted in the first frame). |
| `nodesMounted` | `number` |  | Entities whose views were built. |
| `linksMounted` | `number` |  |  |
| `worstSliceMs` | `number` |  | The worst single slice — the jank a user would actually feel. |
| `aborted` | `boolean` |  | True if the mount was cancelled or pre-empted by a model change. |

### `ProgressiveMountOptions`

```ts
interface ProgressiveMountOptions
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `sliceMs?` | `number` |  | Target ms per slice. The chunk size adapts to hit it. Default 8 (half a 60fps frame). |
| `initialChunk?` | `number` |  | Links 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?` | `number` |  | Hard 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**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `autoFreeze?` | `boolean` |  | Drop 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;
```
