Skip to content
D
Documentation

Core

reference
8 min readUpdated

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

NameTypeDefaultDescription
leftnumber
topnumber
widthnumber
heightnumber

HydrationSnapshot

Everything the client needs to reproduce this render exactly.

ts
interface HydrationSnapshot

Properties

NameTypeDefaultDescription
instanceIdstring
widthnumber
heightnumber
zoomnumber
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

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

StaticRenderResult

ts
interface StaticRenderResult

Properties

NameTypeDefaultDescription
htmlstringThe full layer skeleton — drop this straight into your container.
svgstringOnly the <svg> element.
cssstringThe 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.
snapshotHydrationSnapshot

ViewportControllerOptions

ts
interface ViewportControllerOptions

Properties

NameTypeDefaultDescription
viewport?RectangleCamera rectangle. x/y are WORLD coordinates; width/height are the canvas's CSS-PIXEL dimensions (see the coordinate contract below).
zoom?number
minZoom?numberDefault 0.1 — matches DiagramCanvasComponent.minZoom.
maxZoom?numberDefault 3.0 — matches DiagramCanvasComponent.maxZoom.
zoomSensitivity?numberAdditive 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

NameTypeDefaultDescription
xnumber
ynumber

ViewportState

Immutable snapshot of the camera.

ts
interface ViewportState

Properties

NameTypeDefaultDescription
viewportRectangle
zoomnumber

Types

Unsubscribe

Remove a previously registered listener.

ts
type Unsubscribe = () => void;

ViewportChangeListener

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

Was this page helpful?