Import these from @grafloria/renderer.
Functions
cancelFrame
Cancel a handle from {@link requestFrame}, whichever mechanism produced it.
tsfunction cancelFrame(handle: number): void
getDocument
The ambient Document, or undefined on the server. Never throws.
tsfunction getDocument(): Document | undefined
hasDocument
True when a real DOM document is reachable (browser, jsdom, happy-dom).
tsfunction hasDocument(): boolean
isBrowser
True in a browser-like environment: a window AND a document.
tsfunction isBrowser(): boolean
now
Monotonic-ish clock that also works where performance is absent.
tsfunction now(): number
renderToStaticSVG
tsfunction 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}.
tsfunction 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:
textcenter = (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.)
tsclass ViewportController
Methods
constructor(options: ViewportControllerOptions = {})getViewport(): RectanglegetZoom(): numbergetState(): ViewportStatesetViewport(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 renderedviewBox(see the coordinate contract).syncCanvasSize(rect: CanvasRect): void— Convenience form of {@link setCanvasSize} taking agetBoundingClientRect().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, notzoom * factor). Returns the applied zoom.zoomByWheel(deltaY: number): number— Apply one wheel notch. MirrorsDiagramCanvasComponent.onWheel: scrolling DOWN (deltaY > 0) zooms OUT byzoomSensitivity, 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 theviewBoxSVGRendereremits, and the basis of {@link clientToWorld}.getViewBoxString(): string— TheviewBoxattribute string:"x y width height".getRenderViewport(): Rectangle— The rectangle to hand toIRenderer.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— Framebounds(a world-space content rectangle): pick the largest clamped zoom at which it fits inside the canvas withpaddingCSS 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.
tsinterface CanvasRect
Properties
| Name | Type | Default | Description |
|---|---|---|---|
left | number | ||
top | number | ||
width | number | ||
height | number |
HydrationSnapshot
Everything the client needs to reproduce this render exactly.
tsinterface 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 markupcreateDiagram()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) — seeinstance/model-input.ts; - instance scope:
instanceIdis 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
viewBoxis 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.
tsinterface 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
tsinterface 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
tsinterface 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.
tsinterface ViewportPoint
Properties
| Name | Type | Default | Description |
|---|---|---|---|
x | number | ||
y | number |
ViewportState
Immutable snapshot of the camera.
tsinterface ViewportState
Properties
| Name | Type | Default | Description |
|---|---|---|---|
viewport | Rectangle | ||
zoom | number |
Types
Unsubscribe
Remove a previously registered listener.
tstype Unsubscribe = () => void;
ViewportChangeListener
tstype ViewportChangeListener = (state: ViewportState) => void;
Was this page helpful?