Skip to content
D
Documentation

Overview

reference
10 min readUpdated

Instance

Import these from @grafloria/renderer.

On their own pages

Functions

applyEdges

Reconcile the diagram's links against specs. See {@link applyNodes}.

ts
function applyEdges(diagram: DiagramModel, specs: Array<EdgeSpec | LinkModel>): boolean

applyEdgeSpec

Apply the mutable parts of an edge spec onto an existing link.

ts
function applyEdgeSpec(link: LinkModel, spec: EdgeSpec): void

applyGroups

Reconcile the diagram's groups against specs — add, update, remove — the way {@link applyNodes} does for nodes. A live GroupModel passes through (a Mermaid subgraph arrives that way). Removing a group never removes its boxes.

ts
function applyGroups(diagram: DiagramModel, specs: Array<GroupSpec | GroupModel>): boolean

Returns whether anything changed.

applyNodes

Reconcile the diagram's nodes against specs: add what is new, update what moved, remove what disappeared. Live NodeModels pass through untouched, so a host can mix "give me the data" with "here is my own model".

ts
function applyNodes(diagram: DiagramModel, specs: Array<NodeSpec | NodeModel>): boolean

Returns whether anything changed (i.e. whether a repaint is warranted).

applyNodeSpec

Apply the mutable parts of a spec onto an existing node (the update path).

ts
function applyNodeSpec(node: NodeModel, spec: NodeSpec): void

buildEdge

Build a fresh LinkModel from a spec. Returns null when an endpoint is unresolvable.

ts
function buildEdge(
  diagram: DiagramModel,
  spec: EdgeSpec,
  index: number
): LinkModel | null

buildNode

Build a fresh NodeModel from a spec, with deterministic ports.

ts
function buildNode(spec: NodeSpec, index: number): NodeModel

buildPort

Spec → PortModel, carrying the WHOLE port vocabulary through.

The gating spec is flattened onto the model's individual fields because that is the shape resolvePortConfig() reads; PortSpec.gating is only the ergonomic grouping of them.

A port with no explicit side and no group still lands on right (the PortModel default) — but a port that names a group and no side is built WITHOUT side, so explicitSide stays false and the group's side is inherited, which is the entire reason that flag exists.

ts
function buildPort(nodeId: string, spec: PortSpec, index: number): PortModel

contentBounds

World bounding box of what the canvas draws — every visible node, every routed link waypoint, every group frame with its caption — or null when there is nothing to fit.

ts
function contentBounds(model: DiagramModel): Rectangle | null

createDiagram

ts
function createDiagram(
  container: HTMLElement,
  options: CreateDiagramOptions = {}
): DiagramInstance

defaultPortId

The deterministic id of a node's default port on side.

ts
function defaultPortId(nodeId: string, side: (typeof PORT_SIDES)[number]): string

edgeSpecId

Stable id for the nth edge of a spec list.

ts
function edgeSpecId(spec: EdgeSpec, index: number): string

htmlLayerStyle

The HTML layer's style for a given camera transform (see ViewportController).

ts
function htmlLayerStyle(transform: string): string

isGroupModel

True for a live GroupModel.

ts
function isGroupModel(value: unknown): value is GroupModel

isLinkModel

True for a live LinkModel.

ts
function isLinkModel(value: unknown): value is LinkModel

isNodeModel

True for a live NodeModel (vs a plain spec object).

ts
function isNodeModel(value: unknown): value is NodeModel

nodeHostStyle

The style of one custom node's host element inside the HTML layer.

ts
function nodeHostStyle(
  x: number,
  y: number,
  width: number,
  height: number
): string

nodeSpecId

Stable id for the nth node of a spec list.

ts
function nodeSpecId(spec: NodeSpec, index: number): string

resolvePortId

Resolve an edge endpoint to a PORT id. Accepts (in order): an explicit port id, a side name, or the node's default port for fallbackSide. Returns undefined when the node does not exist.

ts
function resolvePortId(
  diagram: DiagramModel,
  nodeOrPortId: string,
  handle: string | undefined,
  fallbackSide: (typeof PORT_SIDES)[number]
): string | undefined

toEdgeSpec

Model → spec for a link. See {@link toNodeSpec} — no selected either, for the same reason.

ts
function toEdgeSpec(link: LinkModel): EdgeSpec

toNodeSpec

Model → spec: the projection a host needs to write model changes BACK into its own state (a React useState, a Vue ref, a web-component property). Without it a wrapper would have to reach into engine models, which is exactly the coupling these specs exist to avoid.

It carries no selected. Selection is viewer state that changes on every click, and nodes:change — when hosts store this projection — fires for the document (a node added, removed, dropped), not for a click. A projected selected therefore went stale the moment the user clicked elsewhere, and the host's next write (a rename) selected the node again. Absent, writing the projection back never touches the selection. A host that wants to DRIVE the selection still sets selected in its own specs (setNodes applies it) and reads it from selection:change.

ts
function toNodeSpec(node: NodeModel): NodeSpec

Classes

DomEventBinder

ts
class DomEventBinder

Methods

  • getDraggingNodeIds(): string[] — . The nodes currently being DRAGGED (past the movement threshold — an armed-but-uncommitted press is a click, not a drag).
  • hasActiveGesture(): boolean — True while a resize / rotate / vertex gesture owns the pointer.
  • constructor( private readonly container: HTMLElement, private readonly host: DomEventBinderHost, options: DomEventBinderOptions = {} )
  • attach(): void — Bind DOM listeners. No-op on the server and no-op if already attached.
  • detach(): void — Remove EXACTLY the listeners we added, and drop all gesture state.
  • get isAttached(): boolean
  • onWheel(event: WheelEvent): void
  • onMouseDown(event: MouseEvent): void
  • onMouseMove(event: MouseEvent): void
  • onMouseUp(event: MouseEvent): void
  • onMouseLeave(): void — Pointer left the canvas — abort every in-flight gesture so nothing sticks.
  • onDoubleClick(event: MouseEvent): void — Double-click: node → in-place rename; link label → rename; link body → waypoint.
  • onKeyDown(event: KeyboardEvent): void
  • onKeyUp(event: KeyboardEvent): void
  • beginLabelEdit(target: TextEditTarget, options?: { seed?: string }): boolean — Open the in-place label editor programmatically — the seam behind F2 and a host's context-menu Rename. Unlike the double-click path this is NOT gated on enableInPlaceTextEdit: an explicit call IS the host's opt-in. Returns false when the target does not exist / is not editable / readonly.

RenderScheduler

ts
class RenderScheduler

Methods

  • constructor(options: RenderSchedulerOptions)
  • get stats(): Readonly<RenderSchedulerStats>
  • get pending(): boolean — True while a frame is queued but has not run yet.
  • schedule(): void — Mark dirty and queue a frame. Idempotent within a tick — the second and later calls before the frame runs are counted as coalesced, not queued.
  • flush(): void — Paint NOW, bypassing rAF and the idle-skip check, and cancel any queued frame. This is the mount paint (and the "give me a correct DOM before I measure it" escape hatch).
  • cancel(): void — Drop a queued frame without painting.
  • dispose(): void

Constants

HTML_LAYER_CLASS

ts
const HTML_LAYER_CLASS: "grafloria-html-layer"

INSTANCE_ATTR

data-grafloria-instance — the renderer's CSS scope, mirrored onto the root.

ts
const INSTANCE_ATTR: "data-grafloria-instance"

PORT_SIDES

ts
const PORT_SIDES: readonly ["top", "right", "bottom", "left"]

ROOT_CLASS

The DOM skeleton of a mounted diagram — ONE definition, used by both halves.

The server (renderToStaticSVG) emits this markup as a string; the client (createDiagram) builds the identical structure with createElement, or ADOPTS the server's when hydrating. Any divergence between the two — a class name, a style declaration, an attribute — is a hydration mismatch, so both paths read the constants from here rather than each spelling them out.

← the deterministic part
…custom nodes…
← client-only

The HTML layer holds nodes that render as framework components (React portals, slotted templates). It carries the camera as a CSS transform so it stays registered with the SVG layer, and is pointer-events: none so it does not eat clicks meant for the SVG underneath — each mounted node host turns pointer events back on for itself.

ts
const ROOT_CLASS: "grafloria-diagram-root"

ROOT_STYLE

ts
const ROOT_STYLE: "position:relative;width:100%;height:100%;overflow:hidden"

SVG_LAYER_CLASS

ts
const SVG_LAYER_CLASS: "grafloria-svg-layer"

SVG_LAYER_STYLE

ts
const SVG_LAYER_STYLE: "position:absolute;top:0;left:0;width:100%;height:100%"

Interfaces

DiagramEventMap

ts
interface DiagramEventMap

Properties

NameTypeDefaultDescription
'nodes:change'{ nodes: NodeModel[] }
'edges:change'{ edges: LinkModel[] }
'selection:change'{ nodes: NodeModel[]; edges: LinkModel[] }
connect{ link: LinkModel }
reconnect{ link: LinkModel; endpoint: 'source' | 'target' }
'node:click'{ node: NodeModel; world: { x: number; y: number } }
'node:doubleclick'{ node: NodeModel; world: { x: number; y: number } }
'edge:click'{ edge: LinkModel; world: { x: number; y: number } }
'viewport:change'{ viewport: Rectangle; zoom: number }
readyvoid
'nodes:change'{ nodes: NodeModel[] }
'edges:change'{ edges: LinkModel[] }
'selection:change'{ nodes: NodeModel[]; edges: LinkModel[] }
'node:click'{ node: NodeModel; world: { x: number; y: number } }
'node:doubleclick'{ node: NodeModel; world: { x: number; y: number } }
'edge:click'{ edge: LinkModel; world: { x: number; y: number } }
'viewport:change'{ viewport: Rectangle; zoom: number }

DomEventBinderHost

Everything the binder needs from its host. Keeps this class DI-free.

ts
interface DomEventBinderHost

Properties

NameTypeDefaultDescription
viewportViewportController
interactionInteractionController

Members

  • getEngine(): DiagramEngine | null — The engine, or null before a diagram is attached.
  • getRect(): CanvasRect — The canvas' client rect (for screen→world).
  • requestRender(): void — "Something visible changed" — the host coalesces this into a frame.
  • emit(event: string, payload: unknown): void — Emit a public diagram event (node:click, connect, …).
  • beginSelectionBatch?(): void — Optional: bracket a USER GESTURE so the host can coalesce its selection events. The binder opens a batch around every DOM event it handles, and holds one from a press to its release (a marquee clears on the press and selects on the release). Batches nest; a host that implements these emits ONE selection:change, with the final selection, when the outermost one closes — instead of one per model mutation plus the binder's own (which fired stale, mid-gesture events: {n:1,e:1} then {n:1,e:0} for one click).
  • endSelectionBatch?(): void

DomEventBinderOptions

ts
interface DomEventBinderOptions

Properties

NameTypeDefaultDescription
enablePan?booleanMiddle-drag / space-drag / wheel-scroll panning. Default true.
enableZoom?booleanCtrl/⌘ + wheel zoom. Default true.
zoomSensitivity?numberRelative zoom step per wheel notch. Default 0.1 (a notch is ×1.1).
dragThreshold?numberCSS px the pointer must travel before a node drag commits. Default 4.
readonly?booleanIgnore every mutation-causing gesture (still pans/zooms). Default false.

GroupFrameStyle

A zone's own frame — what makes a group look like the tinted, captioned regions of the diagrams AI tools draw instead of the theme's titled box. Declaring any of it replaces the theme frame (no title band).

ts
interface GroupFrameStyle

Properties

NameTypeDefaultDescription
fill?string
stroke?string
strokeWidth?number
strokeDasharray?string
borderRadius?number
color?stringCaption colour.
fontSize?numberCaption size in px. Default 11.
fontWeight?string | number
fontFamily?string
letterSpacing?numberCaption letter spacing in px.
textTransform?'none' | 'uppercase' | 'lowercase' | 'capitalize'

GroupSpec

A GROUP in the spec — a zone around some boxes. bounds pins its frame; without it the frame is fitted around children with padding. The children become the group's members (they travel with it). Stored as a GroupModel whose metadata.frameStyle carries style + labelPlacement, so it serializes.

ts
interface GroupSpec

Properties

NameTypeDefaultDescription
idstring
label?string
children?string[]
bounds?{ x: number; y: number; width: number; height: number }
padding?numberSpace between the children and the fitted frame. Default 20.
style?GroupFrameStyle
labelPlacement?GroupLabelPlacementDefault 'top-left'.
direction?'LR' | 'RL' | 'TB' | 'TD' | 'BT'How the zone lays its boxes out under a composing layout: 'LR' a row, 'TB' a column.

NodeSublabel

A node's second line, when it needs its own font or colour. See NodeSpec.sublabel.

ts
interface NodeSublabel

Properties

NameTypeDefaultDescription
textstring
fontFamily?stringA CSS font stack, or 'mono' for a monospace one.
fontSize?numberpx. Default: 0.85 of the label's size.
color?stringDefault: the theme's secondary text colour.
fontWeight?string | number

RenderSchedulerOptions

RenderScheduler — framework-agnostic rAF coalescing + idle-skip.

Blocker #3 of the headless-instance contract (see ./diagram-instance.ts): the only render loop in the codebase was DiagramCanvasComponent.scheduleRender(), a private Angular method. This is that logic, lifted verbatim in behaviour and with no framework or DOM imports, so React / the web component / a plain <script> host all inherit the same frame discipline:

  • Coalescing. Any number of schedule() calls in one tick collapse into exactly ONE painted frame. A burst of engine events (node:changed ×N, a drag's mousemoves, several prop changes in one React commit) paints once.
    • Idle-skip. A queued frame is DROPPED when shouldSkip() says nothing visible can have changed — cheaper than a no-op render of a big diagram.
    • Synchronous escape. flush() paints right now and cancels the queued frame; used for the mount paint (so the first frame is not one rAF late) and by tests.

requestFrame/cancelFrame are injectable: pass fakes in tests, and note the default falls back to setTimeout where rAF is missing (Node), so a scheduler constructed during SSR never throws — it simply never gets a chance to fire because nothing calls schedule() on the server.

ts
interface RenderSchedulerOptions

Properties

NameTypeDefaultDescription
onFrame() => voidThe paint. Called at most once per frame.
shouldSkip?() => booleanIdle-skip predicate, evaluated INSIDE the frame (not at schedule time, so it sees the final state of the tick). Return true to drop the frame.
requestFrame?(cb: (time: number) => void) => numberInjectable rAF (defaults to the platform one, with a setTimeout fallback).
cancelFrame?(handle: number) => voidInjectable cancel, must pair with requestFrame.

RenderSchedulerStats

Cheap counters — a steady-state idle canvas should paint 0 frames.

ts
interface RenderSchedulerStats

Properties

NameTypeDefaultDescription
schedulednumberschedule() calls.
paintednumberFrames actually painted (onFrame ran).
skippednumberQueued frames dropped by shouldSkip().
coalescednumberschedule() calls that folded into an already-queued frame.
lastFrameMsnumberDuration (ms) of the most recent paint.

Types

CreateDiagram

The factory's own signature, for hosts that store it.

ts
type CreateDiagram = typeof import('./create-diagram').createDiagram;

DiagramEventHandler

ts
type DiagramEventHandler<K extends DiagramEventName> = (
  payload: DiagramEventMap[K]
) => void;

DiagramEventName

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

ts
type DiagramEventName = keyof DiagramEventMap;

EdgeInput

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

ts
type EdgeInput = EdgeSpec | LinkModel;

GroupLabelPlacement

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

Where a zone's caption sits.

ts
type GroupLabelPlacement = 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right';

NodeInput

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

Nodes/edges may be handed in as plain specs or as live engine models.

ts
type NodeInput = NodeSpec | NodeModel;

Was this page helpful?

Overview — Grafloria