# Core

Import these from `@grafloria/renderer`.

## Functions

### `cancelFrame`

Cancel a handle from {@link requestFrame}, whichever mechanism produced it.

```ts
function cancelFrame(handle: number): void
```

### `getDocument`

The ambient `Document`, or `undefined` on the server. Never throws.

```ts
function getDocument(): Document | undefined
```

### `hasDocument`

True when a real DOM `document` is reachable (browser, jsdom, happy-dom).

```ts
function hasDocument(): boolean
```

### `isBrowser`

True in a browser-like environment: a `window` AND a `document`.

```ts
function isBrowser(): boolean
```

### `now`

Monotonic-ish clock that also works where `performance` is absent.

```ts
function now(): number
```

### `renderToStaticSVG`

```ts
function renderToStaticSVG(options: StaticRenderOptions = {}): StaticRenderResult
```

### `requestFrame`

`requestAnimationFrame`, or a `setTimeout(…, 16)` shim where it is missing
(Node, older jsdom). Returns an opaque handle usable with {@link cancelFrame}.

```ts
function requestFrame(callback: (time: number) => void): number
```

## Classes

### `ViewportController`

ViewportController — the framework-agnostic camera.

This is the piece every framework wrapper otherwise re-implements (and gets
subtly wrong): screen↔world conversion, zoom clamping, pan accumulation, and
the `viewBox` convention that the SVG renderer and the hit-tester MUST agree
on. Owning it here means a React/Vue/web-component host inherits pixel-exact
hit-testing for free.

Like {@link InteractionController } it answers **"what is the camera now?"** and
never **"who should re-render?"** — hosts subscribe via {@link onChange} and
translate that into their own render trigger (`markForCheck`, `setState`, …). It has zero framework imports, zero engine imports, and no DOM dependency:
callers hand it a plain {@link CanvasRect}, not an element.

## The coordinate contract

`viewport.x/y` are WORLD coordinates. `viewport.width/height` are the canvas's
**CSS-pixel** size — NOT a world-space span. The world span actually shown is
derived by dividing by `zoom`, which is exactly what {@link getViewBox} does:

```text
  center      = (viewport.x + w/2, viewport.y + h/2)      // zoom is centre-preserving
  viewBox.w/h = (w / zoom, h / zoom)                      // higher zoom ⇒ less world visible
  viewBox.x/y = center − viewBox.w/h / 2
```

This is the identical formula `SVGRenderer.render()` applies to the viewport
it is handed (`libs/renderer/src/svg/svg-renderer.ts`, "Apply zoom to viewBox"),
and the one {@link clientToWorld} inverts. Because both sides derive from the
same {@link getViewBox}, screen→world round-trips exactly at any zoom — see
the round-trip tests in `viewport-controller.spec.ts`.

⚠️ Feed {@link getRenderViewport} — not a pre-scaled rectangle — to
`IRenderer.render(viewport, zoom)`. Dividing width/height by `zoom` *before*
calling `render()` makes the renderer divide by `zoom` a second time, applying
zoom quadratically and desynchronising the picture from the hit-tester at any
zoom ≠ 1. (`DiagramCanvasComponent.calculateActualViewport()` currently does
exactly that; the fix belongs to the zoom card and is why this convention now
lives in one place.)

```ts
class ViewportController
```

**Methods**

- `constructor(options: ViewportControllerOptions = {})`
- `getViewport(): Rectangle`
- `getZoom(): number`
- `getState(): ViewportState`
- `setViewport(viewport: Rectangle): void` — Replace the camera rectangle wholesale.
- `setCanvasSize(width: number, height: number): void` — Track the canvas element's pixel size. Call on mount and on resize: the
width/height of the camera rect must stay equal to the canvas's CSS-pixel
size for {@link clientToWorld} to be the true inverse of the rendered
`viewBox` (see the coordinate contract).
- `syncCanvasSize(rect: CanvasRect): void` — Convenience form of {@link setCanvasSize} taking a `getBoundingClientRect()`.
- `clampZoom(zoom: number): number` — Clamp to `[minZoom, maxZoom]`. Non-finite input falls back to the current zoom.
- `setZoom(zoom: number): number` — Set zoom (centre-preserving), clamped. Returns the zoom actually applied.
- `zoomBy(delta: number): number` — Additive zoom step, clamped — the convention the Angular canvas's wheel
handler uses (`zoom + delta`, not `zoom * factor`). Returns the applied zoom.
- `zoomByWheel(deltaY: number): number` — Apply one wheel notch. Mirrors `DiagramCanvasComponent.onWheel`: scrolling
DOWN (`deltaY > 0`) zooms OUT by `zoomSensitivity`, scrolling up zooms in.
- `zoomAtPoint(zoom: number, clientX: number, clientY: number, rect: CanvasRect): number` — Cursor-anchored zoom: change zoom while keeping the world point currently
under `(clientX, clientY)` pinned to that same screen pixel. This is the
standard "zoom towards the pointer" behaviour; the plain {@link setZoom} /
{@link zoomByWheel} pair is centre-anchored instead.

Returns the zoom actually applied (clamped).
- `pan(dx: number, dy: number): void` — Translate the camera by a WORLD-space delta.
- `panByScreenDelta(dxPx: number, dyPx: number): void` — Translate the camera by a SCREEN-space (pixel) drag delta, converting to
world units by dividing by zoom.

Sign convention matches the canvas's middle-drag handler: pass
`(lastClientX - clientX, lastClientY - clientY)`, i.e. dragging the pointer
RIGHT moves the camera LEFT, so the content appears to follow the cursor.
- `getViewBox(): Rectangle` — The world-space rectangle actually visible — centre-preserving zoom applied
to the camera rect. Identical to the `viewBox` `SVGRenderer` emits, and the
basis of {@link clientToWorld}.
- `getViewBoxString(): string` — The `viewBox` attribute string: `"x y width height"`.
- `getRenderViewport(): Rectangle` — The rectangle to hand to `IRenderer.render(viewport, zoom)` alongside
{@link getZoom}. The renderer applies the zoom itself, so this is the raw
camera rect — do NOT pre-divide it by zoom (see the class docs).
- `getHtmlLayerTransform(): string` — CSS transform that keeps an HTML overlay layer registered with the SVG
layer in the hybrid renderer: `translate(...) scale(zoom)`.

MUST be driven off the same {@link getViewBox} the SVG viewBox and
{@link worldToClient} use — NOT the raw `viewport.x/y`. Since the camera
rect's width/height became CANVAS PIXELS (see setCanvasSize), the visible
world box is the pixel rect expanded around its centre by 1/zoom; the SVG
renderer applies exactly that expansion (svg-renderer.ts `viewBoxX =
centerX - width/zoom/2`). Using the raw `viewport.x` here omitted the
`width*(1-zoom)/2` centring term, so the HTML custom-node layer drifted
from the SVG at any zoom != 1 — invisible until a custom-node dashboard was
framed with fitToBounds. Routing through getViewBox() makes a host at world
W land at the identical pixel worldToClient(W) reports. Identical at zoom 1.
- `clientToWorld(clientX: number, clientY: number, rect: CanvasRect): ViewportPoint` — Convert a client/screen point (e.g. `event.clientX/Y`) into world space. Exact inverse of {@link worldToClient} at any zoom.
- `worldToClient(worldX: number, worldY: number, rect: CanvasRect): ViewportPoint` — Convert a world point into client/screen coordinates — for positioning
overlays, toolbars and tooltips over the canvas. Exact inverse of
{@link clientToWorld}.
- `fitToBounds(bounds: Rectangle, padding = 40, options?: { maxZoom?: number }): number` — Frame `bounds` (a world-space content rectangle): pick the largest clamped
zoom at which it fits inside the canvas with `padding` CSS pixels of margin
on every side, and centre it. A zero-area canvas or bounds is a no-op.

Returns the zoom actually applied.
- `onChange(listener: ViewportChangeListener): Unsubscribe` — Subscribe to camera changes. Returns an unsubscribe function.
- `dispose(): void` — Drop all subscribers.

## Interfaces

### `CanvasRect`

The subset of `DOMRect` the camera actually needs. Any `getBoundingClientRect()`
result satisfies it; tests can pass a plain object. Keeping it structural is
what lets this class run in Node with no DOM.

```ts
interface CanvasRect
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `left` | `number` |  |  |
| `top` | `number` |  |  |
| `width` | `number` |  |  |
| `height` | `number` |  |  |

### `HydrationSnapshot`

Everything the client needs to reproduce this render exactly.

```ts
interface HydrationSnapshot
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `instanceId` | `string` |  |  |
| `width` | `number` |  |  |
| `height` | `number` |  |  |
| `zoom` | `number` |  |  |
| `viewport` | `{ x: number; y: number }` |  |  |

### `StaticRenderOptions`

The deterministic SERVER path.

`renderToStaticSVG()` runs the real `DiagramEngine` + the real `SVGRenderer`
in Node, with no DOM anywhere, and returns:

- `html`     — the exact markup `createDiagram()` would have mounted,
  - `svg`      — just the `<svg>` (for an `<img>`, an email, a README),
  - `snapshot` — the four values the client must reuse to reproduce the same
                 VNode tree byte-for-byte: instance scope, canvas size, camera
                 origin and zoom.

Hand the snapshot back to `createDiagram(el, { hydrate: snapshot })` and the
client rebuilds the same model, renders the same VNodes, and ADOPTS the DOM
that is already on the page — no re-creation, no flash, no re-layout. The
competitors either punt on SSR entirely (React Flow is `'use client'`-only) or
server-render something that can never become interactive (Mermaid).

## What makes it deterministic

- ids: `node-<i>` / `edge-<i>` when the spec omits them (never a nanoid);
- ports: rewritten to `<nodeId>__<side>` (the engine's auto-ports are nanoids
  and the renderer emits them as VNode keys) — see `instance/model-input.ts`;
- instance scope: `instanceId` is fixed here and echoed in the snapshot,
  because the renderer's fallback counter restarts in every process;
- camera: the snapshot carries width/height/zoom/origin, so the client's
  `viewBox` is identical even before it has measured the container.

## Scope (stated plainly)

Custom / HTML-layer nodes are NOT server-rendered: they are framework
components, and the server has no framework. They mount on hydration, inside
the (empty, correctly-transformed) HTML layer this emits. Everything the SVG
renderer draws — nodes, ports, edges, labels, arrows, routing — IS in the
snapshot, which is the part that would otherwise re-layout.

```ts
interface StaticRenderOptions
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `nodes?` | `NodeSpec[]` |  |  |
| `edges?` | `EdgeSpec[]` |  |  |
| `theme?` | `Theme` |  |  |
| `width?` | `number` |  | Canvas width in CSS px. Default 800. |
| `height?` | `number` |  | Canvas height in CSS px. Default 600. |
| `zoom?` | `number` |  |  |
| `viewport?` | `{ x: number; y: number }` |  | Camera origin in world coordinates. Default (0, 0). |
| `instanceId?` | `string` |  | CSS scope for this diagram. Default `'grafloria-ssr'`. Give each diagram on a page its own id if you server-render more than one. |
| `fitView?` | `boolean` |  | Frame the content instead of using `viewport`/`zoom`. Default false. |
| `fitPadding?` | `number` |  | Padding (CSS px) for `fitView`. Default 40. |
| `standalone?` | `boolean` |  | Add `xmlns` to the `<svg>` so it stands alone as a file. Default false. |

### `StaticRenderResult`

```ts
interface StaticRenderResult
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `html` | `string` |  | The full layer skeleton — drop this straight into your container. |
| `svg` | `string` |  | Only the `<svg>` element. |
| `css` | `string` |  | The stylesheet the diagram needs. In CSS mode the theme is expressed purely as `--grafloria-*` variables, so the SVG above is theme-INDEPENDENT (which is what makes hydration a no-op) — but it is also unstyled until this CSS is on the page. Ship it in a `<style>` tag; the client re-injects identical content under the same ids, so nothing repaints. |
| `snapshot` | `HydrationSnapshot` |  |  |

### `ViewportControllerOptions`

```ts
interface ViewportControllerOptions
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `viewport?` | `Rectangle` |  | Camera rectangle. `x`/`y` are WORLD coordinates; `width`/`height` are the canvas's CSS-PIXEL dimensions (see the coordinate contract below). |
| `zoom?` | `number` |  |  |
| `minZoom?` | `number` |  | Default 0.1 — matches `DiagramCanvasComponent.minZoom`. |
| `maxZoom?` | `number` |  | Default 3.0 — matches `DiagramCanvasComponent.maxZoom`. |
| `zoomSensitivity?` | `number` |  | Additive step applied per wheel notch. Default 0.1. |

### `ViewportPoint`

A point in world space. Declared structurally (rather than imported from
`@grafloria/engine`) so the viewport module stays dependency-free: camera math
needs no diagram model. Structurally identical to `Point` from `@grafloria/engine`,
so the two interoperate without conversion.

```ts
interface ViewportPoint
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `x` | `number` |  |  |
| `y` | `number` |  |  |

### `ViewportState`

Immutable snapshot of the camera.

```ts
interface ViewportState
```

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `viewport` | `Rectangle` |  |  |
| `zoom` | `number` |  |  |

## Types

### `Unsubscribe`

Remove a previously registered listener.

```ts
type Unsubscribe = () => void;
```

### `ViewportChangeListener`

```ts
type ViewportChangeListener = (state: ViewportState) => void;
```
