# Grafloria · GPT-6 Luna # JavaScript quick start Mount a sized, interactive diagram in plain JavaScript with either [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) or the `` custom element. Grafloria has one headless model underneath its framework bindings; the element and `render()` give you two ways to mount that same diagram in a browser. ## Prerequisites - A browser project that runs JavaScript modules and can resolve npm packages (the steps below use Vite). - `@grafloria/element` 0.4.83, with peer dependencies `@grafloria/engine` `^0.3.16` and `@grafloria/renderer` `^0.4.15`. ## Install ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer npm install --save-dev vite ``` Add a `dev` script that runs Vite, or start the local server with `npx vite` from your project directory. ## 1. Mount with `render()` Use `render()` when you want the live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) returned to your JavaScript code. It takes the diagram data first and then a sized target element. Create `index.html` with a real height for the canvas: ```html Grafloria flow
``` In `src/main.js`, pass a spec containing two nodes and an edge: ```js import { render } from '@grafloria/element'; const canvas = document.getElementById('canvas'); if (!(canvas instanceof HTMLElement)) { throw new Error('Canvas element not found'); } canvas.style.height = '80vh'; const diagram = render({ nodes: [ { id: 'ingest', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Ingest' } }, { id: 'publish', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Publish' } }, ], edges: [{ id: 'ingest-to-publish', source: 'ingest', target: 'publish' }], }, canvas); ``` ![Look at the canvas immediately after `render()` mounts the diagram.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/7f794eb07613b0168e61389e4b23a2f0.png) The canvas shows **Ingest** connected to **Publish**. You can drag nodes, connect them, pan, and zoom; `diagram` is the returned instance for further operations. The spec is diagram data, not Mermaid text. ## 2. Or mount the custom element Choose `` when HTML attributes are the natural place to provide the diagram. Import the package from your JavaScript module to register the element, then put JSON arrays in its `nodes` and `edges` attributes. Use this `index.html` instead of the `render()` version above. The element fills its own box, so give the element a resolved height: ```html Grafloria flow ``` Use the exported [`GrafloriaFlowElement`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#grafloriaflowelement) class to narrow the queried element to the custom-element API. Importing the package registers the default tag. ```js import { GrafloriaFlowElement } from '@grafloria/element'; const element = document.querySelector('grafloria-flow'); const flow = element instanceof GrafloriaFlowElement ? element : new GrafloriaFlowElement(); if (!(element instanceof GrafloriaFlowElement)) { flow.style.width = '100%'; flow.style.height = '80vh'; flow.nodes = [ { id: 'extract', position: { x: 60, y: 80 }, label: 'Extract' }, { id: 'load', position: { x: 300, y: 80 }, label: 'Load' }, ]; flow.edges = [{ id: 'extract-to-load', source: 'extract', target: 'load' }]; document.body.append(flow); } flow.fitView(); ``` ![Look at the element's canvas immediately after it mounts the diagram.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/42b3472cdef8fbf1c92b4def8af7528a.png) The element shows **Extract** connected to **Load**, and `flow.fitView()` frames the diagram in the element. Its `nodes` and `edges` values are JSON strings in HTML; for rich JavaScript objects, set the element's `nodes` and `edges` properties instead. The element exposes its instance through `diagram` when you need the instance API. ## What you have Both entry points mount an interactive diagram with a visible edge in a container with a real height. `render()` returns the instance directly; the custom element gives you HTML attributes and DOM events, and disposes its diagram when disconnected. ## Where next - The [`` reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#grafloriaflowelement) documents the element class and its API. - [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) for the model, document, and command concepts behind the bindings. - [Open the JavaScript starter in StackBlitz](https://stackblitz.com/github/grafloria/grafloria/tree/main/starters/javascript?file=src/main.js) to run the `render()` sample, or browse the [live demo gallery](https://grafloria.com/demos/). # React quick start Build a working diagram in React, connect sibling UI to its live instance, and render kit diagrams and comment threads. One headless model drives every framework binding: the React components are framework-specific skins over the same diagram engine. ## Prerequisites Use a browser-based React app with React and React DOM 17, 18, or 19. The `@grafloria/react` 0.10.6 package declares peers `@grafloria/engine` `^0.3.0`, `@grafloria/renderer` `^0.4.16`, and `@grafloria/element` `^0.4.3`. Install the binding and its peers: ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` ## 1. Mount a flow Use [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriaflow) for an interactive node-and-edge canvas. The starter data uses [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec); `defaultNodes` and `defaultEdges` seed an uncontrolled diagram, which the instance owns after mount. ```tsx import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'ingest', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Ingest' } }, { id: 'publish', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Publish' } }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'ingest', target: 'publish' }]; export default function App() { return (
); } ``` ![The Ingest and Publish nodes appear connected in the mounted canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/34a9b527931125821d2696da3475dd6f.png) The mounted canvas shows Ingest connected to Publish, with the minimap, zoom and fit controls, and background supplied by `plugins`. Give the wrapper a resolved height; the canvas fills its parent. ## 2. Keep controlled data in React state When application UI owns the graph, [`useNodesState`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#usenodesstate) and [`useEdgesState`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#useedgesstate) provide state plus a separate callback for changes coming back from the canvas. Keep both callback connections: without `onNodesChange`, for example, a node drag can be overwritten by stale controlled state on the next render. ```tsx import { GrafloriaFlow, useEdgesState, useNodesState } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const initialNodes: NodeSpec[] = [ { id: 'ingest', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, label: 'Ingest' }, { id: 'publish', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, label: 'Publish' }, ]; const initialEdges: EdgeSpec[] = [{ id: 'e1', source: 'ingest', target: 'publish' }]; export default function ControlledFlow() { const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, , onEdgesChange] = useEdgesState(initialEdges); return (
); } ``` ![The controlled canvas shows its Add node button and the connected Ingest and Publish nodes.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/5a92ce8cf362520f0a721000cc676145.png) The button adds a node to React state, and dragging or deleting nodes and edges sends the resulting models back through the third tuple values. The second tuple value updates React's specs; it is not the canvas change callback. ## 3. Reach the canvas from sibling UI Wrap a toolbar and canvas in [`GrafloriaProvider`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriaprovider) when the toolbar is outside the flow subtree. [`useGrafloria`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#usegrafloria) returns the live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance), or `null` until the flow mounts. The toolbar below calls `fitView()` on that instance. For live readouts, [`useSelection`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#useselection) returns the current selection as state, [`useOnSelectionChange`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#useonselectionchange) runs a callback when it changes, and [`useViewport`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#useviewport) returns the camera state. [`useGrafloriaStore`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#usegrafloriastore) exposes the nearest store itself; use it when you need the store rather than only its instance. ```tsx import { useState } from 'react'; import { GrafloriaFlow, GrafloriaProvider, useGrafloria, useGrafloriaStore, useOnSelectionChange, useSelection, useViewport, } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'draft', position: { x: 40, y: 80 }, label: 'Draft' }, { id: 'review', position: { x: 280, y: 80 }, label: 'Review' }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'draft', target: 'review' }]; function Toolbar() { const instance = useGrafloria(); return ( ); } function CanvasReadout() { const store = useGrafloriaStore(); const instance = useGrafloria(); const { nodes: selectedNodes } = useSelection(); const { zoom } = useViewport(); const [selectionEvents, setSelectionEvents] = useState(0); useOnSelectionChange(() => setSelectionEvents((count) => count + 1)); const storeConnected = instance !== null && store?.get() === instance; return ( {storeConnected ? 'Canvas connected' : 'Waiting for canvas'} ·{' '} {selectedNodes.length} selected · {Math.round(zoom * 100)}% zoom ·{' '} {selectionEvents} selection changes ); } export default function App() { return (
); } ``` ![Fit diagram and the selection and zoom readout sit above the connected canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/54c417ee83972c441008ab984cf63d95.png) The toolbar is a sibling of the canvas, and Fit diagram frames its nodes when clicked. The readout shows the selected-node count and zoom; selecting a node updates the count and selection-change total. A single flow can also publish its instance to its own children without a provider; use the provider for components outside that subtree. ## 4. Render a kit diagram Use [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriadiagram) when you have a diagram spec rather than node and edge props. This example passes a JSON-encoded spec string; the mounted component renders a connected Ingest-to-Publish diagram. ```tsx import { GrafloriaDiagram } from '@grafloria/react'; const spec = JSON.stringify({ nodes: [ { id: 'ingest', position: { x: 40, y: 80 }, data: { label: 'Ingest' } }, { id: 'publish', position: { x: 280, y: 80 }, data: { label: 'Publish' } }, ], edges: [{ id: 'e1', source: 'ingest', target: 'publish' }], }); export default function KitDiagram() { return (
); } ``` ![The diagram shows Ingest connected to Publish.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/bbde5f4755399111d28401894d2b8718.png) ## 5. Add a comment panel Set `comments` on the flow to enable its comment store, then pass that store to [`GrafloriaCommentPanel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriacommentpanel). [`CommentStore`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-comments-commentstore#commentstore) is available from the instance after mount; this example anchors a starter thread to the Review node. ```tsx import { useState } from 'react'; import { GrafloriaCommentPanel, GrafloriaFlow } from '@grafloria/react'; import type { CommentStore } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'design', position: { x: 40, y: 100 }, size: { width: 150, height: 66 }, label: 'Design' }, { id: 'review', position: { x: 260, y: 100 }, size: { width: 150, height: 66 }, label: 'Review' }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'design', target: 'review' }]; export default function FlowWithComments() { const [store, setStore] = useState(null); return (
{ const commentStore = instance.getCommentStore(); if (commentStore) { commentStore.createThread( { kind: 'node', id: 'review' }, 'Can we tighten the review step?' ); setStore(commentStore); } }} /> {store && (
)}
); } ``` ![A comment thread about the Review step appears beside the connected Design and Review nodes.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/68cebc7a53efbfdc4e74440f54bfb270.png) The canvas renders Design and Review with their edge, and the panel appears beside it with a thread attached to Review. The panel follows the store passed at mount; this example keeps that store in React state until the component unmounts. ## What you have You now have an interactive React canvas, controlled state that stays synchronized with edits, sibling UI that reaches the canvas instance, a generic kit-diagram host, and a comment panel bound to the canvas store. ## Where next - [Every demo as a React component](https://grafloria.com/demos-react/) — see the live React examples. - [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) — the model and engine ideas behind the bindings. - [The instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) — follow data between React and the live diagram. - [Custom nodes in React](https://grafloria.com/learn/react-custom-nodes/) — render React components for node types. # Vue quick start Render a flow, connect sibling UI to its live instance, and mount the diagram and comments components in a Vue 3 app. One headless model drives every framework binding, so you can learn the model once and use the same diagram behavior across frameworks. ## Prerequisites Use Vue 3.4 or later; for the shared engine, renderer, and element package setup, see the [JavaScript quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/javascript-quick-start). Install the binding and its peers: ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue ``` ## 1. Render a flow In Vue, bind typed initial arrays to [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaflow) with `:default-nodes` and `:default-edges`; for the shared flow and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec) overview, see the [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). Give the flow's parent a height because the canvas fills that parent. Create `src/App.vue`: ```vue ``` The mounted canvas shows Ingest connected to Publish, with the minimap, zoom/fit controls, and background grid enabled by `plugins`. For application-owned state, bind `v-model:nodes` and `v-model:edges` instead of using the `default…` props. ## 2. Reach the instance from a sibling Place the sibling panel under [`GrafloriaProvider`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaprovider), and call [`useGrafloria`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#usegrafloria), [`useSelection`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#useselection), [`useViewport`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#useviewport), and [`useOnSelectionChange`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#useonselectionchange) in its Vue setup scope; the callback subscription ends with that scope. For the shared sibling-instance pattern, see the [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). Create `src/App.vue` with a sibling inspector component declared in the same file. The child component calls the composables from its own setup scope, under the provider: ```vue ``` ## 3. Render a diagram spec Use [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriadiagram) when you have a diagram spec instead of flow props. Its `spec` accepts a diagram spec object or a JSON string; this example uses a JSON string with nodes and edges. Create `src/SpecDiagram.vue`: ```vue ``` The component mounts a diagram with Draft connected to Review. Changing the spec's value replaces the mounted diagram; an equal value does not trigger a replacement. ## 4. Add a comment panel In Vue, capture the store in a nullable `shallowRef` from the flow's `@init` handler, then use `v-if` to mount [`GrafloriaCommentPanel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriacommentpanel) only when the store exists. For the shared flow comments and [`CommentStore`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-comments-commentstore#commentstore) pattern, see the [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). Create `src/CommentsExample.vue`: ```vue ``` The initial view contains the Design → Review → Ship flow and a comment thread attached to Review. `getCommentStore()` returns `null` when comments are not enabled, so the panel renders only after the enabled flow supplies its store. ## What you have You can mount a typed flow, read live instance state from a sibling component, render a JSON diagram spec, and attach the comments UI to a flow's comment store. ## Where to go next - [State and data flow in Vue](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) explains controlled and uncontrolled flow data and saving diagrams. - [Lay out diagrams automatically](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams) covers layout choices. - See the [live drag-and-undo demo](https://grafloria.com/demos-vue/#/interaction/drag-undo) and [live comments demo](https://grafloria.com/demos-vue/#/collab/comments). # Angular quick start Mount a working diagram in Angular, then choose the right host and integration components for diagrams, kits, toolbars, and property editing. The same headless model drives every framework binding; Angular components provide the Angular-shaped way to mount and work with it. ## Prerequisites Use Angular 18.1 through 22. The Angular package's peer dependencies include Angular common, core, forms, and platform-browser, Grafloria engine and renderer, RxJS, and Grafloria element. Install the Angular binding and its peer packages in your Angular project: ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element ``` ## 1. Mount a canvas Use [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) for a flow editor backed by node and edge data. Its `nodes` and `edges` model bindings round-trip edits to the arrays. [`GrafloriaNodeDefDirective`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-core#graflorianodedefdirective) marks a template for nodes of a matching type. The sample includes real data, gives the canvas a height, and turns on the shipped minimap, zoom and fit controls, and background grid. ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective], template: `
{{ data['title'] }} {{ data['owner'] }}
`, styles: [ `.job-card { box-sizing: border-box; height: 100%; padding: 12px; border: 1px solid #94a3b8; border-radius: 8px; background: white; display: grid; align-content: center; gap: 6px; }`, ], }) export class AppComponent { nodes: NodeSpec[] = [ { id: 'extract', type: 'job', position: { x: 60, y: 90 }, size: { width: 180, height: 84 }, data: { title: 'Extract', owner: 'Data team' }, }, { id: 'publish', type: 'job', position: { x: 340, y: 90 }, size: { width: 180, height: 84 }, data: { title: 'Publish', owner: 'Platform team' }, }, ]; edges: EdgeSpec[] = [ { id: 'extract-publish', source: 'extract', target: 'publish' }, ]; } ``` ![The mounted canvas shows its graph and plugin controls.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/4f8855216a97fbe6bf06adb21cf630d2.png) The canvas shows two Angular-rendered job cards connected by an edge, plus its plugin controls and grid. A matching `grafloriaNode` template is the integration: the canvas routes nodes of that type through Angular's template rendering without a separate node registry. ## 2. Choose a kit host for a data-first diagram Use [`GrafloriaDiagramComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-classes#grafloriadiagramcomponent) when a kit builds a complete diagram from a spec. Here `erDiagram` supplies the entity and relationship data, and `` mounts the resulting kit spec. The host also accepts UML and other kit specs through the same `spec` input. ```ts import { Component } from '@angular/core'; import { GrafloriaDiagramComponent } from '@grafloria/angular'; import { erDiagram } from '@grafloria/element'; @Component({ selector: 'app-entity-diagram', standalone: true, imports: [GrafloriaDiagramComponent], template: ` `, }) export class EntityDiagramComponent { spec = erDiagram({ entities: [ { id: 'CUSTOMER', name: 'Customer', position: { x: 60, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'name', type: 'varchar' }, ], }, { id: 'ORDER', name: 'Order', position: { x: 360, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'customer_id', type: 'int', fk: true }, ], }, ], relationships: [{ from: 'CUSTOMER', to: 'ORDER', label: 'places' }], }); } ``` ![The kit host displays the entity-relationship diagram.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/b64d6e8109a966411112d436594df578.png) The mounted kit renders two entity tables joined by a relationship. For a dashboard kit, [`GrafloriaDashboardComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-grafloriadashboardcomponent#grafloriadashboardcomponent) is the data-first host; its widget templates use [`GrafloriaWidgetDefDirective`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-classes#grafloriawidgetdefdirective). ## 3. Find the Angular integrations These components connect the canvas to custom Angular UI or add editing controls around it. The canvas already mounts its link toolbar when a link is hovered or selected; use the component directly when you need to place that toolbar in your own host. | Need | Component | What it provides | |---|---|---| | Render a node with Angular markup | `GrafloriaNodeDefDirective` | An `ng-template[grafloriaNode]` matched by node type. Its context exposes the live node and its `data` payload. | | Make an HTML node element a connection endpoint | [`GrafloriaHandleDirective`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-core#grafloriahandledirective) | The `grafloriaHandle` attribute marks a source or target inside an HTML node; the parent node element needs a `data-node-id`. | | Add contextual actions for a node | [`NodeToolbarComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-nodetoolbarcomponent#nodetoolbarcomponent) | A floating node toolbar positioned relative to the supplied node. Bind its node and engine. | | Edit selected node properties | [`PropertyPanelComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-propertypanelcomponent#propertypanelcomponent) | A schema-driven property editor. Set its `selectedNodes` property on the component instance; the panel needs a property schema for the node type. Its default update mode applies valid changes immediately; deferred mode provides Save and Cancel actions. | These are integrations around the canvas, not alternate diagram hosts. The canvas owns drawing and interaction; custom-node templates add Angular-rendered node content, while toolbars and the property panel provide companion UI. The following standalone examples show each companion component in a mounted Angular view. ### Add a node toolbar Pass the selected live node and the canvas engine to the node toolbar. Set [`NodeToolbarConfig`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-interfaces#nodetoolbarconfig) to keep the toolbar visible without requiring a selection. This sample uses a [`ToolbarAction`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-interfaces#toolbaraction) to place an Inspect action beside the first node; selecting it writes that node's ID to the browser console. ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent, NodeToolbarComponent } from '@grafloria/angular'; import type { NodeToolbarConfig, ToolbarAction } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-node-actions', standalone: true, imports: [DiagramCanvasComponent, NodeToolbarComponent], template: `
@if (canvas.activeEngine(); as engine) { @if (engine.getDiagram()?.getNode('job'); as node) { } }
`, }) export class NodeActionsComponent { nodes: NodeSpec[] = [ { id: 'job', type: 'task', position: { x: 100, y: 110 }, size: { width: 180, height: 80 }, label: 'Review' }, { id: 'next', position: { x: 360, y: 110 }, size: { width: 160, height: 72 }, label: 'Publish' }, ]; edges: EdgeSpec[] = [{ id: 'job-next', source: 'job', target: 'next' }]; toolbarConfig: NodeToolbarConfig = { behavior: { hideOnMultiSelect: false } }; actions: ToolbarAction[] = [ { id: 'inspect', label: 'Inspect', onClick: node => console.info(node.id) }, ]; } ``` ![The Inspect toolbar is positioned beside the canvas node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/9564d1e8d1f100054516841b1161cb13.png) ### Add a link toolbar The canvas normally supplies link actions itself. Mount [`LinkToolbarComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-linktoolbarcomponent#linktoolbarcomponent) directly when you want a separate toolbar host; this example attaches an Inspect action described by [`LinkToolbarAction`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-interfaces#linktoolbaraction) to the live link in a mounted canvas. ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent, LinkToolbarComponent } from '@grafloria/angular'; import type { LinkToolbarAction } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-link-actions', standalone: true, imports: [DiagramCanvasComponent, LinkToolbarComponent], template: `
@if (canvas.activeEngine(); as engine) { @if (engine.getDiagram()?.getLink('flow'); as link) { } }
`, }) export class LinkActionsComponent { nodes: NodeSpec[] = [ { id: 'start', position: { x: 70, y: 120 }, size: { width: 150, height: 70 }, label: 'Start' }, { id: 'finish', position: { x: 350, y: 120 }, size: { width: 150, height: 70 }, label: 'Finish' }, ]; edges: EdgeSpec[] = [{ id: 'flow', source: 'start', target: 'finish' }]; actions: LinkToolbarAction[] = [ { id: 'inspect', label: 'Inspect link', onClick: context => console.info(context.link.id) }, ]; } ``` ![The Inspect link toolbar is attached to the rendered edge.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/416c74784a5214b786473dcc7d393caa.png) The live link supplies the toolbar's position and its action context. The Inspect button logs the connected link's ID when clicked. ### Add a property panel The property panel needs a schema for the selected node type. Register one with [`PropertyPanelService`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-services-propertypanelservice#propertypanelservice) before displaying the panel; assign a [`PropertyDiagramNode`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-services#propertydiagramnode) to the component instance's `selectedNodes` property and define its fields with [`PropertySchema`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-types-propertyschema#propertyschema). Its string editor changes the selected node's data immediately. ```ts import { AfterViewInit, Component, viewChild } from '@angular/core'; import { PropertyPanelComponent, PropertyPanelService } from '@grafloria/angular'; import type { PropertyDiagramNode } from '@grafloria/angular'; import type { PropertySchema } from '@grafloria/renderer'; @Component({ selector: 'app-properties', standalone: true, imports: [PropertyPanelComponent], template: `

Current title: {{ selectedNodes.data['title'] }}

`, }) export class PropertiesComponent implements AfterViewInit { panel = viewChild.required(PropertyPanelComponent); selectedNodes: PropertyDiagramNode = { id: 'job', type: 'quick-start-task', label: 'Review', data: { title: 'Review' }, }; constructor(propertyPanel: PropertyPanelService) { if (!propertyPanel.getSchema('quick-start-task')) { const schema: PropertySchema = { properties: [{ key: 'title', label: 'Title', editor: 'string' }], }; propertyPanel.registerSchema('quick-start-task', schema); } } ngAfterViewInit(): void { this.panel().selectedNodes = this.selectedNodes; } onPropertyChanged(property: string, value: unknown): void { console.info(`${property}: ${String(value)}`); } } ``` ![The property panel displays the Title editor for the selected node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/f0b6de9bbb9f4d02d96379303fa31d96.png) The panel renders a Title editor for the selected node. Editing the field changes its `data.title` value, which the line below the panel reflects. ### Add an interaction settings panel [`InteractionConfigPanelComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-interactionconfigpanelcomponent#interactionconfigpanelcomponent) needs the live canvas engine. Guard the panel with Angular's `@if` so it mounts after the canvas provides that engine. ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent, InteractionConfigPanelComponent } from '@grafloria/angular'; import type { NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-interaction-settings', standalone: true, imports: [DiagramCanvasComponent, InteractionConfigPanelComponent], template: ` @if (canvas.activeEngine(); as engine) { } `, }) export class InteractionSettingsComponent { nodes: NodeSpec[] = [ { id: 'task', position: { x: 80, y: 90 }, size: { width: 160, height: 72 }, label: 'Task' }, ]; onConfigChanged(config: object): void { console.info('Interaction settings changed', config); } } ``` ![The expanded interaction settings panel appears with the task canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/7ec7324527d593d9d6ea80ef2e9b8a3e.png) The expanded settings panel reads and updates interaction configuration on the same engine that drives the visible node. ### Show comment threads [`GrafloriaCommentPanelComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-classes#grafloriacommentpanelcomponent) uses the comment store created by the canvas. This sample mounts the conversation panel beside a canvas with comments enabled. ```ts import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent, GrafloriaCommentPanelComponent } from '@grafloria/angular'; import type { NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-comments', standalone: true, imports: [DiagramCanvasComponent, GrafloriaCommentPanelComponent], template: ` @if (canvas.getCommentStore(); as store) { } `, }) export class CommentsComponent implements AfterViewInit { canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = [ { id: 'note', position: { x: 80, y: 90 }, size: { width: 160, height: 72 }, label: 'Discuss this step' }, ]; ngAfterViewInit(): void { this.canvas().getCommentStore()?.createThread( { kind: 'node', id: 'note' }, 'Should this step include an approval?' ); } } ``` ![The comment panel lists the seeded thread beside the canvas node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/304a75914d88098827db6af459ef00f6.png) The comment panel receives the canvas's live store and shows the seeded thread anchored to the node. ### Identify the lower-level canvas package [`CanvasNgCanvasNgComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-canvas-ng#canvasngcanvasngcomponent) belongs to the separate `@grafloria/canvas-ng` package. It has no inputs or outputs and is not the diagram canvas above; this sample mounts that package's component as-is. Install that optional package before using this sample: ```bash npm install @grafloria/canvas-ng ``` ```ts import { Component } from '@angular/core'; import { CanvasNgCanvasNgComponent } from '@grafloria/canvas-ng'; @Component({ selector: 'app-canvas-ng', standalone: true, imports: [CanvasNgCanvasNgComponent], template: ``, }) export class CanvasNgExampleComponent {} ``` ![The lower-level component displays its package placeholder text.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/77e55f992b60d4946070386c0692f80e.png) This component renders its own package placeholder; use `DiagramCanvasComponent` to mount a Grafloria diagram. ## What you have You can mount a controlled canvas with typed node and edge data, render custom nodes as Angular templates, and choose the generic kit host when a kit provides the diagram spec. The component map shows which separate integrations add node connections, contextual actions, and property editing. ## Where to go next - [Create custom nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/create-custom-nodes) to build out template-rendered nodes. - [Build ER and UML diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-er-and-uml-diagrams) for the data-first kit hosts. - [Build dashboards](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-dashboards) for dashboard views and widget templates. - Explore the [Angular tutorial](https://grafloria.com/learn/angular/) and the [Angular demo gallery](https://grafloria.com/demos-angular/) for live examples. # Qwik quick start The Qwik binding combines a flow component with sibling hooks, a generic diagram host, and a comment panel. One headless model drives every framework binding; the Qwik components expose that model in Qwik's resumable app structure. ## Prerequisites Use Qwik 1.x; [@grafloria/qwik](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik) 0.10.6 also declares `@grafloria/element` `^0.4.3` as a peer. For the shared engine and renderer peer ranges, see [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start). Install the binding and its peer packages: ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element ``` ## 1. Render a flow For what the flow, node and edge specs, and default-data props do, see [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). Here, [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow) uses `fitView` to frame the mounted flow. ## 2. Connect a sibling toolbar Wrap the flow and its toolbar in [`GrafloriaProvider`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaprovider). The sibling calls [`useGrafloria`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#usegrafloria) for the live instance, and [`useSelection`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#useselection) and [`useViewport`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#useviewport) expose selection and camera state as reactive signals. Use [`useOnSelectionChange$`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#useonselectionchange) when a QRL handler needs each selection update; it receives a [`SelectionChange`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#selectionchange). The instance signal is `undefined` until the flow mounts. This route combines the flow, sibling toolbar, comment panel, and generic spec host. The toolbar reads selection and viewport state; selecting a node also logs the selected node and edge counts. The comment panel waits for the flow's live instance, then receives its non-serializable comment store. ```tsx import { $, component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize, } from '@builder.io/qwik'; import type { CommentStore } from '@grafloria/engine'; import { GrafloriaCommentPanel, GrafloriaDiagram, GrafloriaFlow, GrafloriaProvider, useGrafloria, useOnSelectionChange$, useSelection, useViewport, type EdgeSpec, type NodeSpec, type SelectionChange, } from '@grafloria/qwik'; const nodes: NodeSpec[] = [ { id: 'plan', position: { x: 60, y: 60 }, size: { width: 160, height: 64 }, label: 'Plan' }, { id: 'build', position: { x: 300, y: 60 }, size: { width: 160, height: 64 }, label: 'Build' }, { id: 'test', position: { x: 540, y: 60 }, size: { width: 160, height: 64 }, label: 'Test' }, ]; const edges: EdgeSpec[] = [ { id: 'e1', source: 'plan', target: 'build', sourceHandle: 'right', targetHandle: 'left' }, { id: 'e2', source: 'build', target: 'test', sourceHandle: 'right', targetHandle: 'left' }, ]; const spec = { nodes, edges }; const Toolbar = component$(() => { const instance = useGrafloria(); const selection = useSelection(); const viewport = useViewport(); useOnSelectionChange$($((change: SelectionChange) => { console.info('Selection changed', change.nodes.length, change.edges.length); })); return ( ); }); const CommentPanelHost = component$(() => { const instance = useGrafloria(); const store = useSignal>(); useVisibleTask$(({ track }) => { const diagram = track(() => instance.value); if (!diagram) return; const comments = diagram.getCommentStore(); if (!comments) return; comments.createThread({ kind: 'node', id: 'plan' }, 'Confirm the handoff criteria.'); store.value = noSerialize(comments); }); return store.value ? : null; }); export default component$(() => (
)); ``` ## 3. Add an anchored comment panel For the comment-store setup, see [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). In Qwik, [`GrafloriaCommentPanel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriacommentpanel) appears after `useVisibleTask$` reads the live instance and keeps its store in a signal wrapped with `noSerialize()`. ## 4. Mount a spec with the generic diagram component Use [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriadiagram) when your input is a diagram spec rather than the flow component's node and edge props. It accepts an object spec. The object spec renders as a diagram in the component's container. A changed spec or options value replaces the mounted diagram and calls `onReady$` again; an equal spec value does not trigger a replacement. ## Where to go next See the [live Qwik demos](https://grafloria.com/demos-qwik/) to run the binding's examples, including the provider-and-hooks and generic spec examples. Continue with [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) for the shared model and instance concepts, or [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows) to make diagrams interactive for end users. # How Grafloria works Grafloria is one headless diagram model with framework bindings around it: the binding mounts the diagram, the instance connects its rendered canvas to its data and engine, and the renderer turns model intent into pixels. ## One headless model, every framework Use the binding that fits your application. Each example mounts the same two-node flow; drag a node or connect nodes in the resulting canvas, regardless of framework. ### React For a runnable React flow and its initial-data props, see the [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). The framework component mounts the shared model rather than a React-specific diagram model. ### Vue For a runnable Vue flow and its initial-data props, see the [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start). Vue also mounts the shared model; the binding changes how you express the component, not the diagram's underlying model. ### Qwik Qwik renders the component on the server and resumes it in the browser; follow the [Qwik quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/qwik-quick-start) for a runnable component setup. ### Angular Angular uses [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) with two-way node and edge bindings. The canvas displays the same flow, and user edits update the bound arrays. ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-root', imports: [DiagramCanvasComponent], template: ` `, }) export class AppComponent { nodes: NodeSpec[] = [ { id: 'ingest', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, label: 'Ingest' }, { id: 'publish', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, label: 'Publish' }, ]; edges: EdgeSpec[] = [{ id: 'flow', source: 'ingest', target: 'publish' }]; } ``` Each binding translates its convenient spec inputs into the same live model. The model is headless: it describes the diagram without depending on React, Vue, Qwik, Angular, or a DOM. That is why the same data and engine behavior make sense in each binding. ## Two layers, one truth The diagram owns data; the engine owns behavior. A [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) is the renderer-level facade between them and the canvas. ```mermaid flowchart LR Specs["Framework specs"] --> Model["DiagramModel: nodes, links, groups, viewport"] Engine["DiagramEngine: commands, history, layout, validation"] --> Model Model --> Instance["DiagramInstance: canvas and model facade"] Instance --> Pixels["Renderer: visible diagram"] ``` For plain JavaScript, [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) mounts a spec into a real, sized host and returns that same kind of instance. This complete browser example renders two boxes, fits them into view, and reads the live model's document: ```ts import { render } from '@grafloria/element'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'ingest', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, label: 'Ingest' }, { id: 'publish', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, label: 'Publish' }, ]; const edges: EdgeSpec[] = [{ id: 'flow', source: 'ingest', target: 'publish' }]; const container = document.createElement('div'); container.style.height = '400px'; document.body.append(container); const instance = render({ nodes, edges }, container); instance.fitView(); const diagram = instance.getModel(); const engine = instance.getEngine(); const activeDiagram = engine.getDiagram(); if (activeDiagram === null) { throw new Error('The engine has no active diagram.'); } const savedDocument = activeDiagram.serialize(); const savedJson = JSON.stringify(savedDocument); ``` The canvas shows the two labeled boxes and their edge. `getModel()` returns the live [`DiagramModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-diagrammodel#diagrammodel), where diagram data lives; `getEngine()` returns the [`DiagramEngine`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-engine#diagramengine), where behavior lives. `serialize()` produces the document object, and `JSON.stringify()` turns it into JSON text. Use the instance for canvas-facing work, the model for data queries, and the engine for behavior. ## The document is the API The model serializes the diagram as one JSON document: it includes the schema version, diagram metadata and name, nodes, links, groups, and viewport. The same document shape is used for persistence and restoration, so data does not need a separate format for each framework. `savedDocument` in the example above is that document object; `savedJson` is its JSON representation. ## User edits are commands on one history stack When a user drags, connects, deletes, or groups an item in the mounted canvas, Grafloria turns the gesture into a command on the engine's shared history stack. Try dragging either node in one of the mounted examples and pressing Ctrl+Z (or ⌘Z on macOS): the move is undone. The framework binding reflects the changed model back into application state. By contrast, adding initial specs or loading a document is setup, not a user edit to undo. See [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) for commands and programmatic edits. ## The model stores intent, not pixels Node specs provide identity, label, and geometry; an edge spec identifies its source and target. The renderer decides how to draw that intent. For example, an edge without pinned handles can use the side that faces its partner, so the connection adapts as nodes move instead of preserving a pixel path. In the examples, `position` locates each node and `source`/`target` describe the edge. Grafloria computes and draws the connection on the canvas; the spec does not contain the rendered pixels. See [Diagram intent and rendering](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/diagram-intent-and-rendering) for geometry and routing. ## Where next - [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document) — nodes, links, groups, and serialized data. - [Instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) — how specs and model changes move through the instance. - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) — how gestures and programmatic edits enter history. - [Diagram intent and rendering](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/diagram-intent-and-rendering) — how the renderer turns model geometry and connections into a visible diagram. - [Live interaction demo](https://grafloria.com/demos/#interaction) — drag a node and try undo. # The graph model and document A diagram is a model of nodes, ports, links, and groups; save that model as a document, then reconstruct it when you load the document. ## One diagram, two layers The [`DiagramModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-diagrammodel#diagrammodel) holds diagram data: nodes, links, groups, and viewport state. The engine owns behavior such as commands, history, validation, and layout. Framework bindings accept convenient specs and turn them into live models; a rendered instance gives you access to the model behind its canvas. The [`NodeModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-nodemodel#nodemodel) represents an item with an identity, type, geometry, data, and ports. A node starts with four bidirectional ports, one on each side. Add a [`PortModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-portmodel#portmodel) when a connection needs a specific direction, side, or other port-level configuration. Ports describe where a connection may attach and what constraints apply; they are not separate diagram nodes. A [`LinkModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-linkmodel#linkmodel) connects source and target ports. Its path type records the geometry intent; the renderer draws and routes the line. A [`GroupModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-groupmodel#groupmodel) collects member entities into a container and can itself be nested in another group. ```mermaid flowchart LR Diagram["DiagramModel"] --> Nodes["NodeModel"] Nodes --> Ports["PortModel"] Links["LinkModel"] --> Ports Diagram --> Links Diagram --> Groups["GroupModel"] Groups --> Nodes ``` ## Mount a diagram and round-trip its document Use [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) to mount the data spec and get a live instance. Read the model from that instance, serialize it with [`DiagramSerializer`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-serialization#diagramserializer), then pass the saved JSON to [`fromDocument`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#fromdocument) to reconstruct the model. This browser example renders an order flow, serializes it, and reconstructs its model. Give the canvas a height so it has room to draw. ```html
``` ```ts import { DiagramSerializer } from '@grafloria/engine'; import { fromDocument, render } from '@grafloria/element'; const originalHost = document.getElementById('original')!; originalHost.style.height = '360px'; const original = render( { nodes: [ { id: 'intake', type: 'rect', position: { x: 40, y: 70 }, size: { width: 130, height: 56 }, label: 'Intake', data: { owner: 'Operations' }, }, { id: 'review', type: 'rect', position: { x: 250, y: 70 }, size: { width: 130, height: 56 }, label: 'Review', data: { owner: 'Finance' }, }, ], edges: [{ id: 'intake-review', source: 'intake', target: 'review' }], groups: [ { id: 'approval', label: 'Approval', children: ['intake', 'review'], bounds: { x: 20, y: 30, width: 380, height: 150 }, }, ], }, originalHost ); const diagramModel = original.getModel(); const serializer = new DiagramSerializer(); const savedDocument = JSON.stringify(serializer.serializeEnvelope(diagramModel)); const restoredModel = fromDocument(savedDocument).model; console.log('Restored node IDs:', restoredModel.getNodes().map((node) => node.id)); ``` The canvas shows two labelled nodes joined by an edge inside the Approval group. The saved envelope contains the diagram model data, including nodes, links, groups, and viewport. `fromDocument()` accepts the JSON string and returns a loaded spec containing the restored model; the console lists its node IDs. ## What the document contains Serialization gives persistence one diagram-level format rather than separate node, edge, and group files. The flat model serialization includes a schema version, identity and metadata, the diagram name, nodes, links, groups, and viewport; each node entry includes its serialized ports. Strokes and comments are included when present. `serializeEnvelope()` wraps the diagram data with portable document metadata. `deserialize()` accepts the envelope as well as the flat serializer form, while `fromDocument()` turns a saved document into a renderable spec. The document preserves model data and connection intent, not functions or an application's runtime. A custom-node painter is code and must be supplied again when loading a diagram that needs it. The renderer remains responsible for drawing and routing from the restored model. ## Where to go next - [Configure ports](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/configure-ports) for port direction, positioning, and connection options. - [Route and edit edges](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/route-and-edit-edges) for link geometry and routing. - [Group nodes and containers](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/group-nodes-and-containers) for group membership and nesting. - [Import and round-trip diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/import-and-round-trip-diagrams) for persistence and text formats. # The instance and data flow The live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) connects your diagram specs to the rendered canvas: it reconciles application data into live models, coordinates model changes with the renderer, and exposes the engine for diagram behavior. ## One model, three responsibilities The framework bindings are thin wrappers around one headless model. They accept [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec) data, create a live instance, and reflect model changes through their framework's data contract. The instance is the facade between the canvas, the data model, and the engine: ```mermaid flowchart LR A["Application specs"] -->|"props or setNodes() / setEdges()"| I["DiagramInstance"] I -->|reconcile| M["DiagramModel: live nodes, links, groups"] M -->|invalidations and scheduled paint| R["Renderer: SVG and HTML layers"] M -->|model events| I I -->|callbacks, events, or two-way updates| A I -->|"getEngine()"| E["DiagramEngine: behavior"] ``` Think of the layers this way: specs are convenient application input; the live model is the diagram's data; the engine owns behavior such as commands and validation; and the renderer turns model intent into pixels. The instance joins those layers so the same model drives each framework binding. ## Mounting hands you the live instance In browser JavaScript, [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) mounts a data spec and returns the instance for that canvas. This runnable example starts with one node. Clicking **Add node** reconciles a second node into the mounted diagram; the instance event listener reports the live model's node count. ```ts import { render } from '@grafloria/element'; import type { NodeSpec } from '@grafloria/renderer'; const host = document.createElement('div'); host.style.cssText = 'width: 100%; height: 400px'; document.body.append(host); const button = document.createElement('button'); button.textContent = 'Add node'; document.body.prepend(button); const specs: NodeSpec[] = [ { id: 'first', position: { x: 40, y: 60 }, label: 'First' }, ]; let currentSpecs = specs; const instance = render({ nodes: specs, edges: [] }, host); instance.on('nodes:change', ({ nodes }) => { console.log(`Live nodes: ${nodes.length}`); }); button.addEventListener('click', () => { const next: NodeSpec = { id: `node-${currentSpecs.length + 1}`, position: { x: 220, y: 60 }, label: 'Second', }; currentSpecs = [...currentSpecs, next]; instance.setNodes(currentSpecs); }); ``` The canvas displays both labeled nodes after the click. `setNodes()` returns `void`; the event listener receives the updated live node models when the model's membership changes. Give the host a resolved height so the canvas has space to draw. ## Use the same flow through a framework The wrappers mount that same model through their own component conventions. Their instance callback gives you the same `DiagramInstance`, while controlled inputs let application state own the specs. In the React, Vue, and Angular examples below, the **Add node** button shows the inbound update path. ### React [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriaflow) accepts `defaultNodes` for an instance-owned graph. `onInit` captures the instance, and `onNodesChange` receives live node models when membership changes. ```tsx import { useRef } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance, NodeSpec } from '@grafloria/renderer'; const initialNodes: NodeSpec[] = [ { id: 'first', position: { x: 40, y: 60 }, label: 'First' }, ]; export function ReactFlowExample() { const instance = useRef(null); const specs = useRef(initialNodes); function addNode() { const next: NodeSpec = { id: `node-${specs.current.length + 1}`, position: { x: 220, y: 60 }, label: 'Second', }; specs.current = [...specs.current, next]; instance.current?.setNodes(specs.current); } return ( <>
{ instance.current = live; }} onNodesChange={(nodes) => console.log(`Live nodes: ${nodes.length}`)} />
); } ``` The click adds a node to the live model, and `onNodesChange` observes the membership update. In controlled React mode, pass `nodes` with `onNodesChange`; otherwise a later render can reconcile stale application state back into the canvas. See the [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start) for the controlled ownership pattern. ### Vue [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaflow) emits `init` with the instance. This example uses the instance to update its live model; Vue's component does not declare an `onNodesChange` callback, so the code subscribes to the instance's `nodes:change` event. ```vue ``` The click adds the second node, and the `nodes:change` listener reports the live model's node count. For controlled Vue data, use `v-model:nodes`; the wrapper reconciles that prop into the model and emits updated specs when the controlled nodes change. See the [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start). ### Angular Angular's [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) follows the same model flow through signal-based two-way bindings. Here the component instance remains behind its Angular component boundary: the host owns a typed spec array, and the binding reconciles it into the rendered diagram. ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-flow-example', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class FlowExampleComponent { nodes: NodeSpec[] = [ { id: 'first', position: { x: 40, y: 60 }, label: 'First' }, ]; edges: EdgeSpec[] = []; addNode(): void { this.nodes = [ ...this.nodes, { id: `node-${this.nodes.length + 1}`, position: { x: 220, y: 60 }, label: 'Second' }, ]; } } ``` Clicking **Add node** updates the host array; `[(nodes)]` feeds it into the live model and writes canvas-side node changes back to the host. Angular also exposes `modelChange` for incremental model patches. See the [Angular quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/angular-quick-start). ## Reconciliation preserves the live diagram `setNodes()` and `setEdges()` reconcile incoming specs rather than rebuilding the diagram. New ids add models, missing ids remove them, and ids that remain update their existing live objects. The renderer, selection, listeners, and mounted plugins stay attached to the same instance; the next scheduled paint reflects the model change. That identity preservation matters when your app passes a changed array for a graph whose ids remain stable. It also means a same-id spec updates the live model rather than replacing the entire document. When importing a different document that reuses ids and must replace all current state, clear the existing specs before applying the new ones. The reconciliation details and import pattern are covered in [Add nodes from palettes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/add-nodes-from-palettes). Model change events are not a frame-by-frame drag feed. Structural changes such as adding or removing nodes emit `nodes:change`; moving or editing a node schedules a repaint, while changes from an interaction are reflected at its commit points. The wrappers project data according to their contracts: React's callback receives live models, while Vue's controlled output provides specs. Treat those values as application synchronization, not as a full-document persistence format. ## Choose the layer for the task Start with the instance for mounted-canvas work: `getModel()` reads live diagram data, `getEngine()` reaches engine behavior, and `setNodes()` / `setEdges()` reconcile input. Use the framework component's controlled binding when application state owns the graph. The instance itself does not provide undo; history lives on the engine. The [DiagramInstance reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) maps the full surface, while [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) explains the engine's undoable edits. See the live [React save-and-restore demo](https://grafloria.com/demos-react/#/interaction/save-and-restore), [Vue save-and-restore demo](https://grafloria.com/demos-vue/#/interaction/save-and-restore), and [Angular snapshot-and-restore demo](https://grafloria.com/demos-angular/#/interaction/save-and-restore) to watch model data travel through a mounted canvas. # Command history and edits An undoable [Command](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-commands-classes-a-r#command) packages a diagram edit so the engine can execute it, undo it, and put it on the shared history stack. The [DiagramInstance](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) exposes the mounted diagram's model and its engine. The [DiagramModel](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-diagrammodel#diagrammodel) owns the diagram data; the [DiagramEngine](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-engine#diagramengine) owns behavior, including command history. A user gesture goes through the command manager, while initial setup and loading can write to the model directly: ```mermaid flowchart LR G["User gesture or user-directed edit"] --> C["CommandManager.execute(command)"] C --> H["Undo history"] H --> U["Undo / redo"] S["Setup or load"] --> M["DiagramModel operations"] M --> V["Diagram data, not an undo step"] ``` ## Choose by intent Use model operations to build or load the diagram: adding nodes directly to the model does not create a history entry. Use a command when your feature edits on the user's behalf, such as applying a toolbar action or suggestion; executing it records an undoable change. This helper expects the instance from an already mounted diagram. It adds a starting node as setup, then adds a suggested node through the shipped [`AddNodeCommand`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-commands-classes-a-r#addnodecommand). After it completes, the mounted diagram contains both boxes; the setup node is not an undo step, while the suggestion is. ```ts import { AddNodeCommand, NodeModel } from '@grafloria/engine'; import type { DiagramInstance } from '@grafloria/renderer'; export async function prepareAndSuggest(instance: DiagramInstance): Promise { const diagram = instance.getModel(); const start = new NodeModel({ id: 'start', type: 'basic', position: { x: 80, y: 80 }, size: { width: 160, height: 80 }, }); diagram.addNode(start); const suggestion = new NodeModel({ id: 'suggestion', type: 'basic', position: { x: 300, y: 80 }, size: { width: 160, height: 80 }, }); await instance.getEngine().commandManager.execute(new AddNodeCommand(suggestion)); } ``` The command manager's `execute()` returns a promise, so await it before checking the diagram or updating controls. To undo that edit later, call `undo()` on `instance.getEngine()`; `DiagramInstance` itself has no `undo()` method. The starting node remains because `diagram.addNode()` did not enter history. To combine several related edits into one undo step, use the shipped [`BatchCommand`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-commands-classes-a-r#batchcommand). ## Gestures use the same history Dragging, connecting, deleting, pasting, and grouping are user edits, so their completed actions are represented as commands on the same history stack as a programmatic feature edit. A completed drag is one history step rather than one step per pointer-position update. Undo and redo apply those commands to the model, so the rendered diagram reflects the reverted or replayed state. Try the [live interaction demo](https://grafloria.com/demos/#interaction): drag an item, then use the platform's undo shortcut to see the gesture revert. ## Related - [Undo and redo edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/undo-and-redo) — wire history controls into your app. - [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document) — learn what the commands change. - [The instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) — follow data between the mounted canvas and your application. # Diagram intent and rendering A Grafloria spec records what nodes represent, where they belong, and how links relate them; the renderer computes link routes and paints the visible diagram. ## One model, two layers The spec is the input vocabulary shared by Grafloria's framework bindings. Bindings reconcile specs into the live diagram model; the renderer reads that model to choose geometry and draw it. The [diagram instance](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) is the facade to the mounted canvas, its data model, and its engine. ```mermaid flowchart LR S["Node and edge specs"] --> M["Live diagram model"] M --> R["Renderer: route and paint"] R --> V["Visible diagram"] U["User edit"] --> M ``` For nodes, the spec describes geometry such as position and size along with a label and application data. For edges, `source` and `target` identify nodes; optional handles express endpoint intent. Omit handles to let the renderer select the port-facing side as nodes move, or name a handle to pin an endpoint. A `router` chooses the route, while `connector` controls how a routed polyline is drawn. `type` is the edge shape shorthand; when router and connector are omitted, they are derived from it. The model stores intent rather than a drawing frozen into pixels. An obstacle-avoiding route, for example, is recalculated from the current node positions. A hand-edited route is different: explicit waypoints record the bends the renderer must preserve. ## Render a routed diagram Use [the `render` function](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) when you want to mount a spec directly in a browser host. The sample's nodes place a wall between two endpoints, and its edge requests an orthogonal route with obstacle avoidance. ```html
``` ```ts import { render } from '@grafloria/element'; import type { RenderSpec } from '@grafloria/element'; import type { DiagramInstance, EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'start', position: { x: 80, y: 150 }, size: { width: 130, height: 56 }, label: 'Start' }, { id: 'wall', position: { x: 350, y: 90 }, size: { width: 140, height: 170 }, label: 'Obstacle' }, { id: 'finish', position: { x: 650, y: 150 }, size: { width: 130, height: 56 }, label: 'Finish' }, ]; const edges: EdgeSpec[] = [ { id: 'path', source: 'start', target: 'finish', type: 'orthogonal', router: 'avoid' }, ]; const spec: RenderSpec = { nodes, edges }; const container = document.getElementById('app')!; container.style.width = '800px'; container.style.setProperty('height', '420px', 'important'); const instance: DiagramInstance = render(spec, container); instance.fitView(); ``` `fitView()` frames all three nodes in the mounted canvas. `router: 'avoid'` selects obstacle avoidance, and `type: 'orthogonal'` gives the routed path right-angle geometry. The endpoint handles are omitted, so the renderer uses the port-facing attachment behavior rather than pinning either end to a named side. ## The same intent in framework bindings The framework component changes how you mount and size the canvas; the `NodeSpec` and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec) data still describe the same graph. These examples use default data so the component owns the initial specs rather than receiving controlled state. ### Angular ```ts import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-diagram-intent', standalone: true, imports: [DiagramCanvasComponent], template: `
`, styles: [':host { display: block; } .canvas { height: 420px; }'], }) export class DiagramIntentExampleComponent { nodes: NodeSpec[] = [ { id: 'start', position: { x: 80, y: 150 }, size: { width: 130, height: 56 }, label: 'Start' }, { id: 'wall', position: { x: 350, y: 90 }, size: { width: 140, height: 170 }, label: 'Obstacle' }, { id: 'finish', position: { x: 650, y: 150 }, size: { width: 130, height: 56 }, label: 'Finish' }, ]; edges: EdgeSpec[] = [ { id: 'path', source: 'start', target: 'finish', type: 'orthogonal', router: 'avoid' }, ]; } ``` The Angular canvas starts from the same nodes and edge. Two-way bindings keep the component's edited node and edge arrays in the component fields. ## Keep the distinction clear - Use node positions and sizes to describe box geometry; use edge endpoints and handles to describe connection intent. - Choose a `router` for where a line travels and a `connector` for how its path is drawn. `type` is the shorthand shape setting, not a node position. - Use `waypoints` when the bends themselves are part of the saved intent. Without manual waypoints, the route follows the current endpoints and routing rules. - The spec passed to `render()` is an object (or its JSON), not a Mermaid-style text DSL. Use the text import API when your input is diagram text. For a running view of an edge routed around a movable obstacle, see the [edge-routing demo](https://grafloria.com/demos/edges/edge-routing.html). For the broader model vocabulary, continue to [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document), then see [Route and edit edges](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/route-and-edit-edges) for user-edited bends. # Build runnable workflows Build a branching workflow diagram and run a browser-side path through it, showing the current, completed, and unselected steps and links. Use this pattern when your application owns the workflow rules and execution. The canvas displays the workflow; your runner decides which branch to take and updates the visible data as each step runs. In this example the runner takes the approved branch. ## 1. Describe the steps and branches Use [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec#nodespec) for each step and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec) for each connection. The graph below branches from `Check approval` to either `Deploy` or `Hold`; the runner takes the approved route through `Deploy` to `Finish`. The bindings pass these specs to a live [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow) in Qwik, [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriaflow) in React, and [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaflow) in Vue. Plain JavaScript mounts the same graph with [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render), and Angular uses [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent). ## 2. Mount the graph and run its selected path Each example starts with the same five nodes and four links. Press **Run workflow**: the runner marks a node as running, waits, then marks it complete and advances along the selected links. The initial links leaving `Check approval` are labelled `approved` and `rejected`; the final view marks `Hold` and its link as not selected. Status is represented in the node labels and node/link styles, so the workflow rules and the status display remain application data. The JavaScript, React, and Vue samples reconcile these changes with `setNodes()` and `setEdges()` on the mounted [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance); Angular and Qwik bind updated data arrays. The canvas fills its parent. Give the host a resolved height; without one, the canvas is blank. Choose the install command for your framework; these are alternatives, not a single command sequence: ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer npm install @grafloria/react @grafloria/engine @grafloria/renderer react react-dom @grafloria/element npm install @grafloria/vue @grafloria/engine @grafloria/renderer vue @grafloria/element npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element ``` :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const nodes = [ { id: 'trigger', position: { x: 40, y: 180 }, size: { width: 140, height: 60 }, label: 'Trigger' }, { id: 'check', position: { x: 230, y: 180 }, size: { width: 160, height: 60 }, label: 'Check approval' }, { id: 'deploy', position: { x: 460, y: 90 }, size: { width: 140, height: 60 }, label: 'Deploy' }, { id: 'hold', position: { x: 460, y: 270 }, size: { width: 140, height: 60 }, label: 'Hold' }, { id: 'finish', position: { x: 650, y: 90 }, size: { width: 140, height: 60 }, label: 'Finish' }, ]; const edges = [ { id: 'start', source: 'trigger', target: 'check' }, { id: 'approved', source: 'check', target: 'deploy', label: 'approved' }, { id: 'rejected', source: 'check', target: 'hold', label: 'rejected' }, { id: 'finish-link', source: 'deploy', target: 'finish' }, ]; const host = document.createElement('div'); host.style.height = '440px'; document.body.append(host); const button = document.createElement('button'); button.textContent = 'Run workflow'; document.body.insertBefore(button, host); const readout = document.createElement('p'); readout.textContent = 'Ready'; document.body.insertBefore(readout, host); const instance = render({ nodes, edges }, host); instance.fitView(30); /** * @param {string | null} current * @param {string[]} completed * @param {string[]} skipped * @param {string | null} activeLink * @param {string[]} completedLinks * @param {string | null} skippedLink */ function paint(current, completed, skipped, activeLink, completedLinks, skippedLink) { instance.setNodes(nodes.map((node) => { const id = node.id ?? ''; const state = id === current ? 'running' : completed.includes(id) ? 'completed' : skipped.includes(id) ? 'not selected' : 'ready'; const fill = state === 'running' ? '#dbeafe' : state === 'completed' ? '#dcfce7' : state === 'not selected' ? '#f1f5f9' : '#ffffff'; const stroke = state === 'running' ? '#2563eb' : state === 'completed' ? '#16a34a' : state === 'not selected' ? '#94a3b8' : '#64748b'; return { ...node, label: `${node.label} · ${state}`, style: { fill, stroke, strokeWidth: 2 } }; })); instance.setEdges(edges.map((edge) => { const id = edge.id ?? ''; const state = id === activeLink ? 'running' : completedLinks.includes(id) ? 'completed' : id === skippedLink ? 'not selected' : ''; const stroke = state === 'running' ? '#2563eb' : state === 'completed' ? '#16a34a' : state === 'not selected' ? '#94a3b8' : '#64748b'; return { ...edge, label: state || edge.label, style: { stroke, strokeWidth: state === 'running' ? 4 : 2 } }; })); } /** @param {number} ms */ const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); async function run() { const approved = true; const path = approved ? ['trigger', 'check', 'deploy', 'finish'] : ['trigger', 'check', 'hold']; const pathLinks = approved ? ['start', 'approved', 'finish-link'] : ['start', 'rejected']; const allNodeIds = nodes.map((node) => node.id ?? ''); const allLinkIds = edges.map((edge) => edge.id ?? ''); const completed = []; const completedLinks = []; for (let index = 0; index < path.length; index += 1) { const nodeId = path[index]; if (nodeId === undefined) continue; const activeLink = pathLinks[index - 1] ?? null; paint(nodeId, completed, [], activeLink, completedLinks, null); readout.textContent = `Running: ${nodeId}`; await delay(500); completed.push(nodeId); if (activeLink) completedLinks.push(activeLink); } const skipped = allNodeIds.filter((id) => !path.includes(id)); const skippedLink = allLinkIds.find((id) => !pathLinks.includes(id) && id !== 'finish-link') ?? null; paint(null, completed, skipped, null, completedLinks, skippedLink); readout.textContent = `Completed the ${approved ? 'approved' : 'rejected'} branch`; } button.addEventListener('click', () => { void run(); }); paint(null, [], [], null, [], null); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const NODES: NodeSpec[] = [ { id: 'trigger', position: { x: 40, y: 180 }, size: { width: 140, height: 60 }, label: 'Trigger' }, { id: 'check', position: { x: 230, y: 180 }, size: { width: 160, height: 60 }, label: 'Check approval' }, { id: 'deploy', position: { x: 460, y: 90 }, size: { width: 140, height: 60 }, label: 'Deploy' }, { id: 'hold', position: { x: 460, y: 270 }, size: { width: 140, height: 60 }, label: 'Hold' }, { id: 'finish', position: { x: 650, y: 90 }, size: { width: 140, height: 60 }, label: 'Finish' }, ]; const EDGES: EdgeSpec[] = [ { id: 'start', source: 'trigger', target: 'check' }, { id: 'approved', source: 'check', target: 'deploy', label: 'approved' }, { id: 'rejected', source: 'check', target: 'hold', label: 'rejected' }, { id: 'finish-link', source: 'deploy', target: 'finish' }, ]; @Component({ selector: 'app-workflow', standalone: true, imports: [DiagramCanvasComponent], template: `

{{ readout }}

`, }) export class WorkflowComponent { nodes: NodeSpec[] = NODES; edges: EdgeSpec[] = EDGES; readout = 'Ready'; private paint(current: string | null, completed: string[], skipped: string[], activeLink: string | null, completedLinks: string[], skippedLink: string | null): void { this.nodes = NODES.map((node) => { const id = node.id ?? ''; const state = id === current ? 'running' : completed.includes(id) ? 'completed' : skipped.includes(id) ? 'not selected' : 'ready'; const fill = state === 'running' ? '#dbeafe' : state === 'completed' ? '#dcfce7' : state === 'not selected' ? '#f1f5f9' : '#ffffff'; const stroke = state === 'running' ? '#2563eb' : state === 'completed' ? '#16a34a' : state === 'not selected' ? '#94a3b8' : '#64748b'; return { ...node, label: `${node.label ?? id} · ${state}`, style: { fill, stroke, strokeWidth: 2 } }; }); this.edges = EDGES.map((edge) => { const id = edge.id ?? ''; const state = id === activeLink ? 'running' : completedLinks.includes(id) ? 'completed' : id === skippedLink ? 'not selected' : ''; const stroke = state === 'running' ? '#2563eb' : state === 'completed' ? '#16a34a' : state === 'not selected' ? '#94a3b8' : '#64748b'; return { ...edge, label: state || edge.label, style: { stroke, strokeWidth: state === 'running' ? 4 : 2 } }; }); } async run(): Promise { const approved = true; const path = approved ? ['trigger', 'check', 'deploy', 'finish'] : ['trigger', 'check', 'hold']; const pathLinks = approved ? ['start', 'approved', 'finish-link'] : ['start', 'rejected']; const completed: string[] = []; const completedLinks: string[] = []; for (let index = 0; index < path.length; index += 1) { const nodeId = path[index]; if (nodeId === undefined) continue; const activeLink = pathLinks[index - 1] ?? null; this.paint(nodeId, completed, [], activeLink, completedLinks, null); this.readout = `Running: ${nodeId}`; await new Promise((resolve) => window.setTimeout(resolve, 500)); completed.push(nodeId); if (activeLink) completedLinks.push(activeLink); } const skipped = NODES.map((node) => node.id ?? '').filter((id) => !path.includes(id)); const skippedLink = EDGES.map((edge) => edge.id ?? '').find((id) => !pathLinks.includes(id) && id !== 'finish-link') ?? null; this.paint(null, completed, skipped, null, completedLinks, skippedLink); this.readout = `Completed the ${approved ? 'approved' : 'rejected'} branch`; } } ``` ```tsx title="Qwik" import { component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const INITIAL_NODES: NodeSpec[] = [ { id: 'trigger', position: { x: 40, y: 180 }, size: { width: 140, height: 60 }, label: 'Trigger' }, { id: 'check', position: { x: 230, y: 180 }, size: { width: 160, height: 60 }, label: 'Check approval' }, { id: 'deploy', position: { x: 460, y: 90 }, size: { width: 140, height: 60 }, label: 'Deploy' }, { id: 'hold', position: { x: 460, y: 270 }, size: { width: 140, height: 60 }, label: 'Hold' }, { id: 'finish', position: { x: 650, y: 90 }, size: { width: 140, height: 60 }, label: 'Finish' }, ]; const INITIAL_EDGES: EdgeSpec[] = [ { id: 'start', source: 'trigger', target: 'check' }, { id: 'approved', source: 'check', target: 'deploy', label: 'approved' }, { id: 'rejected', source: 'check', target: 'hold', label: 'rejected' }, { id: 'finish-link', source: 'deploy', target: 'finish' }, ]; export default component$(() => { const nodes = useSignal(INITIAL_NODES); const edges = useSignal(INITIAL_EDGES); const readout = useSignal('Ready'); return (

{readout.value}

); }); ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance } from '@grafloria/renderer'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const NODES: NodeSpec[] = [ { id: 'trigger', position: { x: 40, y: 180 }, size: { width: 140, height: 60 }, label: 'Trigger' }, { id: 'check', position: { x: 230, y: 180 }, size: { width: 160, height: 60 }, label: 'Check approval' }, { id: 'deploy', position: { x: 460, y: 90 }, size: { width: 140, height: 60 }, label: 'Deploy' }, { id: 'hold', position: { x: 460, y: 270 }, size: { width: 140, height: 60 }, label: 'Hold' }, { id: 'finish', position: { x: 650, y: 90 }, size: { width: 140, height: 60 }, label: 'Finish' }, ]; const EDGES: EdgeSpec[] = [ { id: 'start', source: 'trigger', target: 'check' }, { id: 'approved', source: 'check', target: 'deploy', label: 'approved' }, { id: 'rejected', source: 'check', target: 'hold', label: 'rejected' }, { id: 'finish-link', source: 'deploy', target: 'finish' }, ]; export function Workflow() { const instance = useRef(null); const [readout, setReadout] = useState('Ready'); async function run(): Promise { const api = instance.current; if (!api) return; const approved = true; const path = approved ? ['trigger', 'check', 'deploy', 'finish'] : ['trigger', 'check', 'hold']; const pathLinks = approved ? ['start', 'approved', 'finish-link'] : ['start', 'rejected']; const completed: string[] = []; const completedLinks: string[] = []; const paint = (current: string | null, skipped: string[], activeLink: string | null, skippedLink: string | null): void => { api.setNodes(NODES.map((node) => { const id = node.id ?? ''; const state = id === current ? 'running' : completed.includes(id) ? 'completed' : skipped.includes(id) ? 'not selected' : 'ready'; const fill = state === 'running' ? '#dbeafe' : state === 'completed' ? '#dcfce7' : state === 'not selected' ? '#f1f5f9' : '#ffffff'; const stroke = state === 'running' ? '#2563eb' : state === 'completed' ? '#16a34a' : state === 'not selected' ? '#94a3b8' : '#64748b'; return { ...node, label: `${node.label ?? id} · ${state}`, style: { fill, stroke, strokeWidth: 2 } }; })); api.setEdges(EDGES.map((edge) => { const id = edge.id ?? ''; const state = id === activeLink ? 'running' : completedLinks.includes(id) ? 'completed' : id === skippedLink ? 'not selected' : ''; const stroke = state === 'running' ? '#2563eb' : state === 'completed' ? '#16a34a' : state === 'not selected' ? '#94a3b8' : '#64748b'; return { ...edge, label: state || edge.label, style: { stroke, strokeWidth: state === 'running' ? 4 : 2 } }; })); }; for (let index = 0; index < path.length; index += 1) { const nodeId = path[index]; if (nodeId === undefined) continue; const activeLink = pathLinks[index - 1] ?? null; paint(nodeId, [], activeLink, null); setReadout(`Running: ${nodeId}`); await new Promise((resolve) => window.setTimeout(resolve, 500)); completed.push(nodeId); if (activeLink) completedLinks.push(activeLink); paint(null, [], null, null); } const skipped = NODES.map((node) => node.id ?? '').filter((id) => !path.includes(id)); const skippedLink = EDGES.map((edge) => edge.id ?? '').find((id) => !pathLinks.includes(id) && id !== 'finish-link') ?? null; paint(null, skipped, null, skippedLink); setReadout(`Completed the ${approved ? 'approved' : 'rejected'} branch`); } return (

{readout}

{ api.fitView(30); instance.current = api; }} />
); } ``` ```vue title="Vue" ``` ::: ## What the run displays While a step is active, its label ends in `running` and its fill and border turn blue; the incoming selected link is also blue and labelled `running`. Completed steps and traversed links turn green. At the end, `Hold` and the rejected branch are grey and labelled `not selected`. The status text above the canvas shows the current step and the selected branch result. The instance is the facade to the mounted canvas and its data model. In these examples, the instance's [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) methods `setNodes()` and `setEdges()` reconcile new specs into that live diagram. The runner—not the graph renderer—owns the example's approval rule and the sequence of steps. ## Props used | Option | Type | Default | What it does | | --- | --- | --- | --- | | `defaultNodes` | `NodeSpec[]` | Not specified | Seeds the mounted flow with its step nodes. | | `defaultEdges` | `EdgeSpec[]` | Not specified | Seeds the mounted flow with its branch links. | | `onInit` | `(instance: DiagramInstance) => void` | Not specified | Gives React and Vue the mounted instance. | | `nodes` (Qwik) | `NodeSpec[]` | Not specified | Supplies controlled node data; the sample updates labels and styles as its runner advances. | | `edges` (Qwik) | `EdgeSpec[]` | Not specified | Supplies controlled link data; the sample updates link labels and styles during the run. | | `fitView` (Qwik) | `boolean` | Not specified | Fits the initial workflow in the canvas. | | `readonly` (Qwik) | `boolean` | Not specified | Disables canvas editing while the runner updates its controlled data. | | `nodes` (Angular) | `readonly (`NodeSpec` \| [`NodeModel`](doc:grafloria-engine-models-nodemodel#nodemodel))[] \| `undefined` | `undefined` | Controlled nodes; `[(nodes)]` writes canvas changes back to the host. | | `edges` (Angular) | `readonly (`EdgeSpec` \| [`LinkModel`](doc:grafloria-engine-models-linkmodel#linkmodel))[] \| `undefined` | `undefined` | Controlled links; `[(edges)]` writes canvas changes back to the host. | Plain JavaScript gets the instance directly from `render()`. Angular binds its node and edge arrays with the canvas's two-way `nodes` and `edges` inputs. ## Pitfalls - Give the canvas parent a real height. The canvas fills its parent; an unresolved parent height leaves a blank canvas. - Keep the workflow rule in your runner. The graph describes steps and connections; replace the example's fixed `approved` value with your application's branch condition. ## See it running The [n8n-style workflow demo](https://grafloria.com/demos/interaction/n8n-workflow.html) runs a graph from its trigger, shows running and completed node states, and routes a condition down one branch. Its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/n8n-workflow.html) shows the repository's fuller execution UI. The [workflow automation builder](https://grafloria.com/demos/interaction/workflow-builder.html) tests a Condition and health-check Switch against sample inputs; its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/workflow-builder.html) includes the step editor and run card. ## Related - [Execute and compute flows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/execute-and-compute-flows) — separate workflow execution from the graph that displays it. - [Add nodes from palettes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/add-nodes-from-palettes) — let users build the graph interactively. - [The instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) — understand how specs reconcile into the live model. # Execute and compute flows This page focuses on what happens after the graph is mounted: update execution status as a runner advances, or follow live links to recompute values. For the framework-specific mounting setup, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). Grafloria does not run your business logic for you. Set each live node's status as your runner advances, and calculate values by following the diagram's current links. Each framework example uses typed graph data; the JavaScript example shows the same host-side logic in the browser. The JavaScript example runs in a browser app whose build resolves npm package imports. ## Prepare the graph and its host-side logic This module declares two small graphs: a workflow whose nodes receive execution statuses, and a typed-port pipeline that calculates `input × 3 + 10`. `compute()` follows the live link models, while `execute()` updates the mounted model's status and active-wire style. ```ts title="flow-logic.ts" import type { DiagramModel } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; export const executionNodes: NodeSpec[] = [ { id: 'trigger', position: { x: 60, y: 120 }, size: { width: 130, height: 54 }, label: 'Trigger' }, { id: 'fetch', position: { x: 260, y: 120 }, size: { width: 130, height: 54 }, label: 'Fetch data' }, { id: 'transform', position: { x: 460, y: 120 }, size: { width: 130, height: 54 }, label: 'Transform' }, ]; export const executionEdges: EdgeSpec[] = [ { id: 'fetch-link', source: 'trigger', target: 'fetch' }, { id: 'transform-link', source: 'fetch', target: 'transform' }, ]; export const computeNodes: NodeSpec[] = [ { id: 'in', position: { x: 40, y: 120 }, size: { width: 120, height: 56 }, label: 'input', ports: [{ id: 'in.out', side: 'right', type: 'output', dataType: 'number' }], data: { value: 2 }, }, { id: 'mul', position: { x: 240, y: 120 }, size: { width: 120, height: 56 }, label: '× 3', ports: [ { id: 'mul.in', side: 'left', type: 'input', dataType: 'number' }, { id: 'mul.out', side: 'right', type: 'output', dataType: 'number' }, ], data: { operation: 'multiply', factor: 3, value: 0 }, }, { id: 'add', position: { x: 440, y: 120 }, size: { width: 120, height: 56 }, label: '+ 10', ports: [ { id: 'add.in', side: 'left', type: 'input', dataType: 'number' }, { id: 'add.out', side: 'right', type: 'output', dataType: 'number' }, ], data: { operation: 'add', amount: 10, value: 0 }, }, { id: 'out', position: { x: 640, y: 120 }, size: { width: 120, height: 56 }, label: 'sink', ports: [{ id: 'out.in', side: 'left', type: 'input', dataType: 'number' }], data: { operation: 'sink', value: 0 }, }, ]; export const computeEdges: EdgeSpec[] = [ { id: 'l1', source: 'in', target: 'mul', sourceHandle: 'in.out', targetHandle: 'mul.in' }, { id: 'l2', source: 'mul', target: 'add', sourceHandle: 'mul.out', targetHandle: 'add.in' }, { id: 'l3', source: 'add', target: 'out', sourceHandle: 'add.out', targetHandle: 'out.in' }, ]; export async function execute(model: DiagramModel, report: (message: string) => void): Promise { const order = ['trigger', 'fetch', 'transform']; for (const id of order) model.getNode(id)?.setState({ status: 'pending' }); for (const id of order) { const node = model.getNode(id); if (!node) continue; const incoming = model.getLinks().filter((link) => link.targetNodeId === id); for (const link of incoming) link.updateStyle({ animation: { type: 'flow' } }); node.setState({ status: 'running', animateStatus: true }); report(`running: ${id}`); await new Promise((resolve) => window.setTimeout(resolve, 500)); node.setState({ status: 'completed' }); for (const link of incoming) link.updateStyle({ animation: { type: 'none' } }); } report('flow completed'); } export function compute(model: DiagramModel, value: number): string { const source = model.getNode('in'); if (source) source.data.value = Number.isFinite(value) ? value : 0; for (const id of ['mul', 'add', 'out']) { const node = model.getNode(id); if (!node) continue; const incoming = model.getLinks().find((link) => link.targetNodeId === id); const fromId = incoming?.sourceNodeId; const from = fromId ? model.getNode(fromId) : undefined; if (!from || typeof from.data.value !== 'number') continue; const incomingValue = from.data.value; if (node.data.operation === 'multiply') { node.data.value = incomingValue * node.data.factor; } else if (node.data.operation === 'add') { node.data.value = incomingValue + node.data.amount; } else { node.data.value = incomingValue; } } const valueAt = (id: string): number => { const node = model.getNode(id); return typeof node?.data.value === 'number' ? node.data.value : 0; }; return `→ ×3=${valueAt('mul')} → +10=${valueAt('add')} → sink=${valueAt('out')}`; } export function watchCompute(model: DiagramModel, report: (message: string) => void): (value: number) => void { let currentValue = Number(model.getNode('in')?.data.value ?? 0); const refresh = (): void => report(compute(model, currentValue)); model.on('link:added', refresh); model.on('link:removed', refresh); refresh(); return (value: number): void => { currentValue = value; refresh(); }; } ``` The execution example sets `pending`, then marks one node `running` and `completed` at a time; the node status classes and link animation are rendered by Grafloria. The computing example stores each result in the live node data and prints the derived chain above the canvas. Its order is fixed for this pipeline; for a graph that branches or can be reordered, derive a topological order from your graph. ## Execute a flow Use execution statuses when your application already knows which work is ready, active, or complete. The runner remains host code; the mounted diagram displays the status changes and animated incoming link. For framework-specific mounting, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). :::code-group ```html title="JavaScript"
ready
``` ```ts title="Angular" import { AfterViewInit, Component, signal, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { DiagramModel } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; import { execute, executionEdges, executionNodes } from './flow-logic'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` {{ status() }} `, }) export class ExecuteFlowComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = executionNodes; edges: EdgeSpec[] = executionEdges; readonly status = signal('ready'); private model: DiagramModel | null = null; ngAfterViewInit(): void { this.model = this.canvas().activeEngine()?.getDiagram() ?? null; } async run(): Promise { if (this.model) await execute(this.model, (message) => { this.status.set(message); }); } } ``` ```tsx title="Qwik" import { component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; export default component$(() => { const nodes: NodeSpec[] = [ { id: 'trigger', position: { x: 60, y: 120 }, size: { width: 130, height: 54 }, label: 'Trigger' }, { id: 'fetch', position: { x: 260, y: 120 }, size: { width: 130, height: 54 }, label: 'Fetch data' }, { id: 'transform', position: { x: 460, y: 120 }, size: { width: 130, height: 54 }, label: 'Transform' }, ]; const runningNodes: NodeSpec[] = nodes.map((node) => ({ ...node, label: `${node.label} · running` })); const edges: EdgeSpec[] = [ { id: 'fetch-link', source: 'trigger', target: 'fetch' }, { id: 'transform-link', source: 'fetch', target: 'transform' }, ]; const running = useSignal(false); return (
{running.value ? 'running: all workflow stages' : 'ready'}
); }); ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import type { DiagramModel } from '@grafloria/engine'; import { execute, executionEdges, executionNodes } from './flow-logic'; export function ExecuteFlow() { const model = useRef(null); const [status, setStatus] = useState('ready'); return (
{status}
{ model.current = instance.getModel(); }} />
); } ``` ```vue title="Vue" ``` ::: In the JavaScript sample's initial render, look for the three connected nodes and the **Run flow** control above the canvas. ![The JavaScript sample mounts the connected three-node workflow and Run flow control.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/bd51678b7e84c6866749c039c264bbaa.png) Press **Run flow** in the JavaScript, Angular, React, or Vue sample to move each node through pending, running, and completed. While a node runs, its incoming link uses the `flow` animation; the status readout follows the same order. In Qwik, check **Run flow** to reconcile running annotations into the mounted nodes and update the readout; that example uses node labels rather than engine status animation. The other samples set node statuses and link styles on the mounted model. ![The canvas starts with Trigger, Fetch data, and Transform connected below the Run flow control.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/4ecd2f7c7860d37ba27796642efcc3e1.png) The status value is a [`NodeModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-nodemodel) state field. Grafloria renders statuses such as `running`, `completed`, `error`, and `warning` as status classes; `animateStatus: false` disables motion while preserving the status treatment. Link animation is set through the live link's `updateStyle()` method. | Option or field | Type | Default | What it does | |---|---|---|---| | `status` | `'idle' \| 'pending' \| 'running' \| 'completed' \| 'error' \| 'warning'` | Not specified | Supplies the node's execution state for rendering. | | `animateStatus` | `boolean` | Not specified | Set to `false` to keep the status appearance without its motion. | | `animation.type` | `'marching-ants' \| 'flow' \| 'pulse' \| 'dash-flow' \| 'none'` | Not specified | Chooses the link animation; this example uses `flow` while the link is active and `none` afterward. | The runner above is intentionally small. Add your own cancellation, retries, failure policy, or branching rules around the same model updates. If you expose a controlled React graph, wire its change callback so user edits do not get replaced by stale application state; see [the React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start). ## Compute values along the live links Use live topology when graph edits should change the computation path. The host reads the current links, calculates the downstream values, and updates a readout; typing a new source value recomputes the chain. The `ports` and `sourceHandle` / `targetHandle` fields declare numeric ports and the endpoints each edge names, but the arithmetic remains application code. :::code-group ```html title="JavaScript"
``` ```ts title="Angular" import { AfterViewInit, Component, signal, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { DiagramModel } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; import { computeEdges, computeNodes, watchCompute } from './flow-logic'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` {{ result() }} `, }) export class ComputingFlowsComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = computeNodes; edges: EdgeSpec[] = computeEdges; readonly result = signal(''); private model: DiagramModel | null = null; private setInput: ((value: number) => void) | null = null; ngAfterViewInit(): void { this.model = this.canvas().activeEngine()?.getDiagram() ?? null; if (this.model) this.setInput = watchCompute(this.model, (message) => { this.result.set(message); }); } update(event: Event): void { const input = event.target; if (input instanceof HTMLInputElement) this.setInput?.(Number(input.value)); } } ``` ```tsx title="Qwik" import { component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; export default component$(() => { const nodes: NodeSpec[] = [ { id: 'in', position: { x: 40, y: 120 }, size: { width: 120, height: 56 }, label: 'input', ports: [{ id: 'in.out', side: 'right', type: 'output', dataType: 'number' }], data: { value: 2 }, }, { id: 'mul', position: { x: 240, y: 120 }, size: { width: 120, height: 56 }, label: '× 3', ports: [ { id: 'mul.in', side: 'left', type: 'input', dataType: 'number' }, { id: 'mul.out', side: 'right', type: 'output', dataType: 'number' }, ], data: { operation: 'multiply', factor: 3, value: 0 }, }, { id: 'add', position: { x: 440, y: 120 }, size: { width: 120, height: 56 }, label: '+ 10', ports: [ { id: 'add.in', side: 'left', type: 'input', dataType: 'number' }, { id: 'add.out', side: 'right', type: 'output', dataType: 'number' }, ], data: { operation: 'add', amount: 10, value: 0 }, }, { id: 'out', position: { x: 640, y: 120 }, size: { width: 120, height: 56 }, label: 'sink', ports: [{ id: 'out.in', side: 'left', type: 'input', dataType: 'number' }], data: { operation: 'sink', value: 0 }, }, ]; const edges: EdgeSpec[] = [ { id: 'l1', source: 'in', target: 'mul', sourceHandle: 'in.out', targetHandle: 'mul.in' }, { id: 'l2', source: 'mul', target: 'add', sourceHandle: 'mul.out', targetHandle: 'add.in' }, { id: 'l3', source: 'add', target: 'out', sourceHandle: 'add.out', targetHandle: 'out.in' }, ]; const input = useSignal('2'); return (
→ ×3={Number(input.value) * 3} → +10={Number(input.value) * 3 + 10} → sink={Number(input.value) * 3 + 10}
); }); ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import type { DiagramModel } from '@grafloria/engine'; import { computeEdges, computeNodes, watchCompute } from './flow-logic'; export function ComputingFlows() { const setInput = useRef<((value: number) => void) | null>(null); const [result, setResult] = useState(''); return (
{result}
{ const model: DiagramModel = instance.getModel(); setInput.current = watchCompute(model, setResult); }} />
); } ``` ```vue title="Vue" ``` ::: In the JavaScript sample's initial render, look for the calculated readout above the canvas: the source value flows through ×3 and +10 to the sink. ![The JavaScript readout shows the input value propagated through multiplication, addition, and the sink.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/ad4875f8f76f7171baf6ec4890c849c8.png) The JavaScript, Angular, React, and Vue readouts start with the graph's initial value (`2`), then update after input events and link additions or removals by reading the current connections. If a node no longer has an incoming value, these samples leave its previous value in place; define a reset or missing-input policy that matches your application. The Qwik readout recalculates when its bound input changes for this fixed three-stage pipeline; it does not subscribe to rewiring. ![The input readout shows the calculated values through the multiply, add, and sink nodes.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/b47563b4883e0f88e8ac1253b8e77bb2.png) | Field | Type | Default | What it does | |---|---|---|---| | `source` / `target` | `string` | Required by `EdgeSpec` | Identifies the source and target nodes of a graph edge. | | `sourceHandle` / `targetHandle` | `string` | Optional | Pins an edge to the declared output and input port ids. | | Port `dataType` | `string` | Not specified | Labels each port's value type; the example uses `number` at both ends of every connection. | The links and typed ports describe the graph; they do not execute the arithmetic. Keep computation and missing-input rules in your application, and in the JavaScript, Angular, React, and Vue variants recalculate from the live model when the source value or topology changes. For port configuration and connection rules, see [Configure ports](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/configure-ports) and [Validate connections](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/validate-connections). ## Demos and related pages - [Execute flow demo](https://grafloria.com/demos/interaction/execute-flow.html) · [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/execute-flow.html) - [Computing flows demo](https://grafloria.com/demos/interaction/computing-flows.html) · [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/computing-flows.html) - [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows) - [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document) - [The instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) # Add nodes from palettes Build a small palette beside a mounted diagram; choosing a shape adds a labelled node to the live model in the next available grid position. Use this pattern when your app owns a short list of node types and wants readers to add them without editing diagram data directly. The framework bindings mount a canvas; its instance reaches the engine, which adds each selected node to the diagram. ## Build a palette Start with an empty diagram. In JavaScript, React and Vue, keep the live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) from the component's init callback and call `getEngine().addNode()` from each palette button. Angular gets its active engine from the canvas component when a button is clicked; Qwik updates controlled node data. The engine returns a promise for the node it adds; each sample places new nodes in a grid so their labels stay visible beside previous choices. Unlike a workflow that starts from a fixed graph, each palette choice adds one labelled `rect` at the next grid position; see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows) for the framework-specific mounting patterns. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const root = document.createElement('div'); root.style.cssText = 'display:grid;grid-template-columns:160px 1fr;height:420px'; document.body.append(root); const palette = document.createElement('div'); palette.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:12px'; const canvas = document.createElement('div'); canvas.style.height = '420px'; root.append(palette, canvas); const instance = render({ nodes: [], edges: [] }, canvas); const kinds = ['Source', 'Filter', 'Sink']; async function addNode(kind) { const count = instance.getModel().getNodes().length; await instance.getEngine().addNode({ type: 'rect', position: { x: 40 + (count % 4) * 170, y: 40 + Math.floor(count / 4) * 90 }, size: { width: 140, height: 64 }, data: { label: kind }, }); instance.renderNow(); } for (const kind of kinds) { const button = document.createElement('button'); button.textContent = kind; button.addEventListener('click', () => { void addNode(kind); }); palette.append(button); } ``` ```ts title="Angular" import { Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-node-palette', standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class NodePaletteComponent { readonly canvas = viewChild.required(DiagramCanvasComponent); readonly nodes: NodeSpec[] = []; readonly edges: EdgeSpec[] = []; async addNode(kind: string): Promise { const engine = this.canvas().activeEngine(); if (!engine) return; const diagram = engine.getDiagram() ?? engine.createDiagram(); const count = diagram.getNodes().length; await engine.addNode({ type: 'rect', position: { x: 40 + (count % 4) * 170, y: 40 + Math.floor(count / 4) * 90 }, size: { width: 140, height: 64 }, data: { label: kind }, }); } } ``` ```tsx title="Qwik" import { $, component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { NodeSpec } from '@grafloria/renderer'; export default component$(() => { const nodes = useSignal([]); const kinds = ['Source', 'Filter', 'Sink']; return (
{kinds.map((kind) => ( ))}
); }); ``` ```tsx title="React" import { useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; export function NodePalette() { const api = useRef(null); const kinds = ['Source', 'Filter', 'Sink']; async function addNode(kind: string): Promise { const instance = api.current; if (!instance) return; const count = instance.getModel().getNodes().length; await instance.getEngine().addNode({ type: 'rect', position: { x: 40 + (count % 4) * 170, y: 40 + Math.floor(count / 4) * 90 }, size: { width: 140, height: 64 }, data: { label: kind }, }); instance.renderNow(); } return (
{kinds.map((kind) => )}
{ api.current = instance; }} />
); } ``` ```vue title="Vue" ``` ::: Click a palette button to add a labelled `rect` node. JavaScript, React and Vue call `addNode()` on the mounted instance; Angular calls `addNode()` on its active engine, and Qwik updates its controlled node array. Each addition occupies the next grid position. ## Options that matter | Option | Type | Default | What it does | | --- | --- | --- | --- | | `defaultNodes` | `NodeSpec[]` | Not specified | Seeds the diagram once; the JavaScript, React and Vue samples pass an empty array. Angular and Qwik pass empty controlled node arrays. | | `defaultEdges` | `EdgeSpec[]` | Not specified | Seeds the diagram's edges; the samples pass an empty array. | | `onInit` / `onInit$` | `(instance: DiagramInstance) => void` | Not specified | Gives the mounted instance to the JavaScript, React and Vue palette code. Angular reads the active engine from its canvas component; Qwik uses controlled node data. | | `addNode` config `type` | `string` | Required | Selects the node type; the sample uses `rect`. | | `addNode` config `position` | `Point` | Required | Sets the node's top-left position in diagram coordinates. | | `addNode` config `size` | `Size` | Optional | Sets the node's width and height. | | `addNode` config `data` | `any` | Optional | Carries node data; `data.label` supplies the visible label in these samples. | ## Pitfalls - If you later replace externally edited node data with `setNodes()` or `loadText()`, those calls reconcile rather than rebuild: ids that remain keep their live objects. Clear the current edges and nodes first, then apply the edited data. See [Edit and copy nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/edit-and-copy-nodes) for node-data workflows. - This palette uses the built-in `rect` node and a data label. For custom-rendered nodes, follow [Create custom nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/create-custom-nodes). ## See it running The [Drag & drop demo](https://grafloria.com/demos/interaction/drag-and-drop.html) shows a drag-and-drop palette whose nodes land at the drop point. For categorized, searchable built-in shapes, see the [Stencil palette demo](https://grafloria.com/demos/diagrams/stencil-palette.html). ## Related - [JavaScript quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/javascript-quick-start) - [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start) - [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start) - [Angular quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/angular-quick-start) - [Qwik quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/qwik-quick-start) - [The instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow) - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) # Edit, copy, and remove nodes Use the canvas for the user's selection and the instance's engine for command-based edits. This lets you edit a node label in place, copy or duplicate selected nodes, paste clipboard contents, and remove selected nodes without rebuilding the diagram. ## Edit a label in place Enable in-place editing on the mounted instance. A double-click opens an editor on the node label; Enter or blur commits the new label as one undoable step, while Escape abandons it. The JavaScript, Angular, React, and Vue examples also add toolbar actions for copy, paste, duplicate, and delete; the Qwik example follows the mounted-canvas pattern and uses its keyboard shortcuts for clipboard actions. For the typed node data and mounting patterns across JavaScript, Angular, React, and Vue, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). This page adds the engine actions for editing, copying, pasting, duplicating, and deleting selected nodes; click a node before using Copy, Duplicate, or Delete. Paste uses the engine clipboard rather than the operating system clipboard. :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import type { NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'draft', position: { x: 80, y: 110 }, size: { width: 150, height: 60 }, label: 'Draft' }, { id: 'review', position: { x: 360, y: 110 }, size: { width: 150, height: 60 }, label: 'Review' }, ]; const root = document.createElement('main'); root.style.height = '620px'; document.body.append(root); const toolbar = document.createElement('div'); const canvas = document.createElement('div'); canvas.style.height = '570px'; root.append(toolbar, canvas); const instance = render({ nodes }, canvas); instance.getEngine().setInteractionConfig({ enableInPlaceTextEdit: true }); async function act(action: 'copy' | 'paste' | 'duplicate' | 'delete'): Promise { const engine = instance.getEngine(); const hasSelection = instance.getModel().getSelectedNodes().length > 0; if (action === 'copy' && hasSelection) await engine.copy(); if (action === 'paste' && engine.hasClipboardData()) await engine.paste(); if (action === 'duplicate' && hasSelection) await engine.duplicate(); if (action === 'delete' && hasSelection) await engine.deleteSelection(); instance.renderNow(); } for (const action of ['copy', 'paste', 'duplicate', 'delete'] as const) { const button = document.createElement('button'); button.textContent = action[0].toUpperCase() + action.slice(1); button.addEventListener('click', () => { void act(action); }); toolbar.append(button); } ``` ```ts title="Angular" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class NodeEditorComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = [ { id: 'draft', position: { x: 80, y: 110 }, size: { width: 150, height: 60 }, label: 'Draft' }, { id: 'review', position: { x: 360, y: 110 }, size: { width: 150, height: 60 }, label: 'Review' }, ]; ngAfterViewInit(): void { this.canvas().activeEngine()?.setInteractionConfig({ enableInPlaceTextEdit: true }); } async copy(): Promise { const engine = this.canvas().activeEngine(); if (engine && engine.getDiagram()?.getSelectedNodes().length) await engine.copy(); } async paste(): Promise { const engine = this.canvas().activeEngine(); if (engine?.hasClipboardData()) await engine.paste(); } async duplicate(): Promise { const engine = this.canvas().activeEngine(); if (engine && engine.getDiagram()?.getSelectedNodes().length) await engine.duplicate(); } async deleteSelection(): Promise { const engine = this.canvas().activeEngine(); if (engine && engine.getDiagram()?.getSelectedNodes().length) await engine.deleteSelection(); } } ``` ```tsx title="React" import { useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import type { NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'draft', position: { x: 80, y: 110 }, size: { width: 150, height: 60 }, label: 'Draft' }, { id: 'review', position: { x: 360, y: 110 }, size: { width: 150, height: 60 }, label: 'Review' }, ]; export default function NodeEditor() { const instance = useRef(null); async function act(action: 'copy' | 'paste' | 'duplicate' | 'delete'): Promise { const api = instance.current; if (!api) return; const engine = api.getEngine(); const hasSelection = api.getModel().getSelectedNodes().length > 0; if (action === 'copy' && hasSelection) await engine.copy(); if (action === 'paste' && engine.hasClipboardData()) await engine.paste(); if (action === 'duplicate' && hasSelection) await engine.duplicate(); if (action === 'delete' && hasSelection) await engine.deleteSelection(); api.renderNow(); } return (
{ instance.current = api; api.getEngine().setInteractionConfig({ enableInPlaceTextEdit: true }); }} />
); } ``` ```vue title="Vue" ``` ::: On the canvas, double-click Draft or Review to edit its label. Enter and blur commit the edit; Escape cancels it. Click a node to select it, then use Copy followed by Paste to create a separate node; repeat Paste to place further copies at cascading offsets. Duplicate creates a copy directly from the selection, and Delete removes the selected node. The JavaScript, Angular, React, and Vue examples expose these actions as toolbar buttons that run only when their required selection or clipboard content exists. Command-based changes participate in the engine's undo history; ⌘Z or Ctrl+Z undoes the last edit. ## Copy and paste versus duplicate `copy()` snapshots the current selection to the engine clipboard; `paste()` creates new entities from that clipboard. `duplicate()` creates copies directly from the current selection. Both operations create nodes with new IDs and positions, so editing one copy does not change its source. Paste can be repeated from the same clipboard contents. These are engine operations, not browser clipboard integration. | Operation | Option | Type | Default | Effect | | --- | --- | --- | --- | --- | | `copy()` | `includeGroups` | `boolean` | `false` | Includes groups containing selected nodes. | | `copy()` | `includeLinks` | `boolean` | `true` | Includes links between copied nodes. | | `paste()` | `offset` | [`Point`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-interaction-interfaces-a-t#point) | First paste: `{ x: 20, y: 20 }`; later pastes cascade | Shifts pasted entities to a new position. | | `paste()` | `selectPasted` | `boolean` | `true` | Selects the pasted entities. | | `duplicate()` | `offset` | [`Point`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-interaction-interfaces-a-t#point) | `{ x: 20, y: 20 }` | Shifts duplicated entities to a new position. | | `duplicate()` | `selectDuplicated` | `boolean` | `true` | Selects the duplicated entities. | | `deleteSelection()` | `deleteChildren` | `boolean` | `true` | Recursively deletes child nodes. | | `deleteSelection()` | `deleteLinks` | `boolean` | `true` | Removes links before deleting nodes; links attached to a removed node are removed either way. | The default paste offset starts at 20 pixels down and right and advances for subsequent pastes of the same clipboard contents. The copy and paste demo shows the rendered result: each paste has a different ID and position, and moving one copy leaves the others in place. Try [Copy / paste](https://grafloria.com/demos/interaction/copy-paste.html) while you click Original, copy it, and paste more than once. The demo source is [copy-paste.html](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/copy-paste.html). ## Remove selected nodes `deleteSelection()` acts on the current selection. When you remove a node, its connected links are removed too, so the diagram does not retain dangling edges. The deletion is undoable with the engine's command history. This is ordinary deletion; reconnecting neighbors across a removed middle node is an extra behavior implemented by the separate delete-middle-node demo, not an effect of `deleteSelection()` itself. See [Delete a middle node](https://grafloria.com/demos/nodes/delete-middle-node.html) for the custom variant that adds an undoable A-to-C bridge after removing B; its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/nodes/delete-middle-node.html) explicitly reads the neighbors before deleting. ## Add a context menu A context menu is host UI: the [context-menu demo](https://grafloria.com/demos/interaction/context-menu.html) builds a Rename / Duplicate / Delete menu, listens for a real `contextmenu` event, anchors its menu at the pointer, and calls model or engine operations for the targeted node. Its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/context-menu.html) is a starting point when you need that interaction rather than the toolbar used above. For plain JavaScript, the same task is also shown in the [edit-label demo](https://grafloria.com/demos/nodes/edit-label.html), where double-click opens the editor and Enter commits the label. See its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/nodes/edit-label.html). ## Options that affect the edit | Option | Type | Default | What it does | | --- | --- | --- | --- | | `enableInPlaceTextEdit` | `boolean` | `false` | Enables double-click editing of node labels. | | `offset` on `paste()` | `Point` | First paste: `{ x: 20, y: 20 }`; later pastes cascade | Sets the position shift applied to pasted entities. Pass an explicit point to control placement. | | `offset` on `duplicate()` | `Point` | `{ x: 20, y: 20 }` | Sets the position shift applied to duplicated entities. | | `selectPasted` | `boolean` | `true` | Selects the pasted entities. | | `selectDuplicated` | `boolean` | `true` | Selects the duplicated entities. | ## Pitfalls - The engine clipboard is separate from `navigator.clipboard`; connect it to the operating-system clipboard yourself if cross-tab or system paste is required. - In-place label editing is opt-in. Leave `enableInPlaceTextEdit` off if your app handles the node's double-click event with its own editor. - Do not put user-supplied node data into `innerHTML` in a custom renderer. Use `textContent` for untrusted values to avoid interpreting them as markup. See [Create custom nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/create-custom-nodes). - For a custom delete that reconnects neighboring nodes, use the delete-middle-node demo's separate read-neighbors, remove, and bridge steps; node deletion alone does not create that bridge. ## Related - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) explains how edits enter the undo stack. - [Undo and redo edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/undo-and-redo) covers the user-facing history controls. - [Resize and size nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/resize-and-size-nodes) covers custom node rendering and registration timing. # Undo and redo diagram edits Use the diagram's command history when users need to reverse a gesture or when your feature edits the graph on their behalf. A drag is one undoable step; your own toolbar action can use that same history. ## Add undo and redo controls The canvas already handles `Ctrl+Z` / `⌘Z` for undo and `Ctrl+Y` / `⌘⇧Z` for redo. To add buttons, keep the live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) and call `undo()` or `redo()` on its engine. In Angular, [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent) exposes those operations directly. Each sample starts with two nodes. Drag either node and undo to return it to the start of the drag; redo reapplies the move. In JavaScript, Angular, Vue, and React, **Add node** adds a rectangle through the engine; Undo removes it and Redo restores it. The Qwik sample demonstrates drag undo with the built-in keyboard shortcuts rather than a custom **Add node** control. [Live drag-and-undo demo](https://grafloria.com/demos/interaction/drag-undo.html). Its canvas starts with two nodes; drag one before trying undo. ### JavaScript, Angular, Qwik, Vue, and React Each example is a mounted canvas with a real height. In JavaScript, mount with [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core); use `getEngine().addNode()` for a programmatic addition. The typed sample data uses [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec); Angular controlled arrays can also carry live [`NodeModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-nodemodel) and [`LinkModel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-linkmodel) instances. For React use [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react); for Vue use [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue); for Qwik use [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik). React and Vue acquire the instance through their component callback. Angular obtains the canvas with `viewChild.required()` and calls `activeEngine().addNode()` for programmatic edits, while its undo and redo buttons call the canvas methods directly. The Qwik sample uses the default-data component and the built-in keyboard shortcuts. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; /** @type {import('@grafloria/renderer').NodeSpec[]} */ const nodes = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' }, { id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' }, ]; /** @type {import('@grafloria/renderer').EdgeSpec[]} */ const edges = [{ id: 'e1', source: 'a', target: 'b' }]; const wrapper = document.createElement('div'); wrapper.style.height = '460px'; document.body.append(wrapper); const toolbar = document.createElement('div'); wrapper.append(toolbar); const host = document.createElement('div'); host.style.height = '420px'; wrapper.append(host); const instance = render({ nodes, edges }, host); let nextSuggestion = 1; async function addSuggestion() { const number = nextSuggestion++; await instance.getEngine().addNode({ type: 'rect', position: { x: 220 + (number - 1) * 24, y: 300 }, size: { width: 160, height: 70 }, }); } /** @param {string} label @param {() => void} action */ function addButton(label, action) { const button = document.createElement('button'); button.textContent = label; button.addEventListener('click', action); toolbar.append(button); } addButton('Add node', () => { void addSuggestion(); }); addButton('Undo', () => { void instance.getEngine().undo(); }); addButton('Redo', () => { void instance.getEngine().redo(); }); ``` ```ts title="Angular" import { Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { LinkModel, NodeModel } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-undo-redo', standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class UndoRedoComponent { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: readonly (NodeSpec | NodeModel)[] | undefined = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' }, { id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' }, ]; edges: readonly (EdgeSpec | LinkModel)[] | undefined = [{ id: 'e1', source: 'a', target: 'b' }]; private nextSuggestion = 1; addSuggestion(): void { const number = this.nextSuggestion++; const engine = this.canvas().activeEngine(); if (engine) void engine.addNode({ type: 'rect', position: { x: 220 + (number - 1) * 24, y: 300 }, size: { width: 160, height: 70 }, }); } undo(): void { void this.canvas().undo(); } redo(): void { void this.canvas().redo(); } } ``` ```tsx title="Qwik" import { component$ } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' }, { id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' }, ]; const edges: EdgeSpec[] = []; export default component$(() => (
)); ``` ```vue title="Vue" ``` ```tsx title="React" import { useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' }, { id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; export default function UndoRedoExample() { const instance = useRef(null); const nextSuggestion = useRef(1); function addSuggestion(): void { const number = nextSuggestion.current++; const engine = instance.current?.getEngine(); if (engine) void engine.addNode({ type: 'rect', position: { x: 220 + (number - 1) * 24, y: 300 }, size: { width: 160, height: 70 }, }); } return (
{ instance.current = diagramInstance; }} />
); } ``` ::: ## What the calls change `undo()` and `redo()` return promises because a command can do asynchronous work. Await them when the next step depends on the history operation finishing. After an undo, the engine's model contains the reverted state. Controlled React, Angular, and Vue bindings reflect model changes back to application data; these React and Vue examples use initial `defaultNodes` and `defaultEdges` instead. For programmatic edits, use command-backed operations when users must be able to undo them; setup and restoration stay outside history. See [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) for the instance, engine, and model roles. | Input | Type | Default | Effect | | --- | --- | --- | --- | | `defaultNodes` | [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec) `[]` | Not stated | Initial nodes for React and Vue. | | `defaultEdges` | [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec) `[]` | Not stated | Initial edges for React and Vue. | | `nodes` | `readonly (NodeSpec \| NodeModel)[] \| undefined` | `undefined` | Angular's controlled node data; two-way binding reflects canvas edits. | | `edges` | `readonly (EdgeSpec \| LinkModel)[] \| undefined` | `undefined` | Angular's controlled edge data; two-way binding reflects canvas edits. | ## Pitfalls - Do not use model-level mutations for a feature action that users need to undo. Model operations used to set up, load, or synchronize a diagram stay out of the history. - Await engine operations before starting a dependent operation. The command methods are asynchronous. - A drag is one history step, not one step per pointer movement. Undo returns the node to the position where that drag started. ## Related - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) explains commands and the shared history in more depth. - [Synchronize diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/synchronize-diagrams) covers collaboration between diagrams. - [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start), [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start), [Angular quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/angular-quick-start), and [Qwik quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/qwik-quick-start) show the surrounding framework setup. - [Collaboration-aware undo demo](https://grafloria.com/demos/interaction/undo-redo.html) shows how a peer's undo applies to that peer's own edits. # Create custom nodes Use a custom node when a node needs content beyond Grafloria's built-in label and shape. Declare nodes as [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec#nodespec) values and connect them with [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec) edges. The framework bindings put your own component or template inside the node; the diagram still owns its geometry, hit-testing, selection and connections. The plain JavaScript example uses a structured HTML body inside an SVG `foreignObject`. ## 1. Render content inside a node Give each node a `type`, a real `size`, and data for its content. Then connect node IDs with edges. In React, register a component for the type and set `custom: true`; Vue and Angular use the matching type declaration to opt into framework templates. Plain JavaScript and Qwik can use the structured `metadata.html` body shown below. The sample renders two connected service cards. Each framework's node body stays inside its box as the diagram moves and zooms; the link remains attached to the node geometry. The HTML-node demo shows a card with a heading, progress line and badge inside its node. The examples use these node fields: | Field | Type | Default | What it does | | --- | --- | --- | --- | | `type` | `string` | `'rect'` | Selects the custom-node type rendered by a matching component, slot or template. | | `size` | `{ width: number; height: number }` | Not specified | Sets the node box that contains the custom content. | | `data` | `Record` | Not specified | Carries the node payload to custom components and templates. | | `custom` | `boolean` | Not specified | On React nodes, `true` routes the node through the HTML layer. Vue and Angular also opt in from a matching declared type. | ### Framework examples Use [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) in the browser to mount a data spec. For structured HTML, put a sanitized content tree in `metadata.html.content`; the body renders inside the node's `foreignObject`, so it moves with the node group. The code creates a host with a real height. Angular's [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) and exact `grafloriaNode` template, Qwik's [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow), React's [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriaflow), and Vue's [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaflow) and named slot are the framework front doors. Angular and Vue opt matching template types in automatically; React uses both a `nodeTypes` entry and `custom: true`. In Qwik, mount the flow with Qwik's renderer and pass nodes with structured HTML metadata. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const nodes = [ { id: 'api', position: { x: 80, y: 100 }, size: { width: 210, height: 100 }, metadata: { html: { content: { tag: 'div', className: 'service-card', children: [ { tag: 'strong', text: 'API gateway' }, { tag: 'span', className: 'status', text: 'Healthy' }, ], }, }, }, }, { id: 'worker', position: { x: 400, y: 100 }, size: { width: 210, height: 100 }, metadata: { html: { content: { tag: 'div', className: 'service-card', children: [ { tag: 'strong', text: 'Order worker' }, { tag: 'span', className: 'status', text: 'Running' }, ], }, }, }, }, ]; const edges = [{ id: 'api-worker', source: 'api', target: 'worker' }]; const target = document.createElement('div'); target.style.height = '420px'; target.style.width = '100%'; target.id = 'diagram'; document.body.append(target); const style = document.createElement('style'); style.textContent = '#diagram .service-card { font: 14px/1.4 system-ui, sans-serif; padding: 12px; } #diagram .service-card strong { display: block; } #diagram .service-card .status { color: #059669; font-size: 12px; }'; document.head.append(style); const instance = render({ nodes, edges }, target); instance.fitView(); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-board', standalone: true, imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective], template: `
{{ data['name'] }} {{ data['status'] }}
`, styles: [`.service-card { height: 100%; box-sizing: border-box; padding: 12px; border: 1px solid #94a5f0; border-radius: 10px; background: white; } .service-card strong, .service-card span { display: block; } .service-card span { color: #059669; font-size: 12px; }`], }) export class BoardComponent { nodes: NodeSpec[] = [ { id: 'api', type: 'service', position: { x: 80, y: 100 }, size: { width: 210, height: 100 }, data: { name: 'API gateway', status: 'Healthy' } }, { id: 'worker', type: 'service', position: { x: 400, y: 100 }, size: { width: 210, height: 100 }, data: { name: 'Order worker', status: 'Running' } }, ]; edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }]; } ``` ```tsx title="Qwik" import { render as renderQwik } from '@builder.io/qwik'; import { GrafloriaFlow, type EdgeSpec, type NodeSpec } from '@grafloria/qwik'; const nodes: NodeSpec[] = [ { id: 'api', position: { x: 80, y: 100 }, size: { width: 210, height: 100 }, metadata: { html: { content: { tag: 'div', children: [ { tag: 'strong', text: 'API gateway' }, { tag: 'span', text: 'Healthy' }, ], }, }, }, }, { id: 'worker', position: { x: 400, y: 100 }, size: { width: 210, height: 100 }, metadata: { html: { content: { tag: 'div', children: [ { tag: 'strong', text: 'Order worker' }, { tag: 'span', text: 'Running' }, ], }, }, }, }, ]; const edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }]; const target = typeof document === 'undefined' ? null : document.createElement('div'); if (target) { target.style.height = '420px'; document.body.append(target); void renderQwik(target, ); } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeProps, NodeSpec, NodeTypes } from '@grafloria/react'; function ServiceNode({ id, selected }: NodeProps) { return (
{id}
Service node
); } const nodeTypes: NodeTypes = { service: ServiceNode }; const nodes: NodeSpec[] = [ { id: 'api', type: 'service', custom: true, position: { x: 80, y: 100 }, size: { width: 210, height: 100 } }, { id: 'worker', type: 'service', custom: true, position: { x: 400, y: 100 }, size: { width: 210, height: 100 } }, ]; const edges: EdgeSpec[] = [{ id: 'api-worker', source: 'api', target: 'worker' }]; export function Board() { return (
); } ``` ```vue title="Vue" ``` ::: In plain JavaScript, `render()` returns the mounted instance and `fitView()` frames both cards and their connecting edge. Grafloria sanitizes the structured tree rather than inserting it as raw HTML. Angular renders the template with the node's `data` payload inside the sized canvas. Read payload keys with index syntax because the template context uses `Record`. The `GrafloriaNodeDefDirective` import is required for `grafloriaNode` to match. Qwik's renderer mounts the flow in the browser when `document` exists. The flow shows the two cards from their structured HTML trees and connects them with the edge. React renders a custom component for both nodes; each card shows its node ID and a selection-aware border, and each React spec sets `custom: true`. Vue renders the named slot for each matching node and exposes its data in the slot scope. Both service cards fill their node boxes while the edge connects them. ## 2. Keep the content within the node box Set `size` on each node; the diagram uses that geometry to size and position the custom-content host. Make the root element fill the box with `height: 100%` and `box-sizing: border-box`, so padding and borders fit within the node's hit area. Give the canvas parent a real height as well. In React, a missing or mismatched `custom: true` flag or `nodeTypes` key means the custom component does not render. In Vue and Angular, match the type exactly to the named slot or template directive. For Angular, include `GrafloriaNodeDefDirective` in the component's `imports`; without it the `grafloriaNode` template is ignored. The JavaScript `metadata.html` route is sanitized and structured. Supply element descriptions and text values rather than interpolating untrusted values into raw HTML. ## 3. See the nodes running - [Live HTML-node demo](https://grafloria.com/demos/nodes/html-nodes.html) — structured HTML rendered inside a node. - [Custom-node demo](https://grafloria.com/demos/nodes/custom-nodes.html) — built-in terminal, predefined-process and document shapes connected in one diagram. - [Custom-node demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/nodes/custom-nodes.html) If you need a different built-in silhouette or fill rather than custom content, use the per-node `shape` option; the custom-node demo shows terminal, predefined-process and document silhouettes. For node sizing and geometry, see [Resize and size nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/resize-and-size-nodes). # Resize and size nodes Set a node's starting dimensions, constrain a user's resize gesture, or let the renderer fit a node to its label. This page focuses on node sizing; for mounting patterns and framework-specific data bindings, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). These steps cover JavaScript, Angular, React, and Vue; the Qwik binding is outside this page's scope. ## Mount nodes with sizes and sizing rules The example renders four nodes: one with a declared starting size, one whose resize gesture has minimums and maximums, one with a locked aspect ratio, and one that opts into content-aware sizing. Give the canvas a real height so the rendered diagram has room to appear. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '700px'; document.body.append(host); const diagram = render({ nodes: [ { id: 'sized', position: { x: 120, y: 80 }, size: { width: 180, height: 90 }, label: 'Declared size: 180 × 90', }, { id: 'resizable', position: { x: 120, y: 220 }, size: { width: 160, height: 100 }, label: 'Resize me: 80–260 wide', metadata: { sizing: { minWidth: 80, minHeight: 60, maxWidth: 260, maxHeight: 200, }, }, }, { id: 'ratio', position: { x: 420, y: 220 }, size: { width: 160, height: 100 }, label: 'Aspect ratio stays at 1.6', metadata: { sizing: { aspectLock: true } }, }, { id: 'auto', position: { x: 120, y: 360 }, size: { width: 60, height: 36 }, label: 'This long label grows beyond the declared sixty-pixel width', metadata: { sizing: { auto: true, padding: 10 } }, }, ], edges: [], }, host); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; import type { LinkModel, NodeModel } from '@grafloria/engine'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class AppComponent { nodes: readonly (NodeSpec | NodeModel)[] | undefined = [ { id: 'sized', position: { x: 120, y: 80 }, size: { width: 180, height: 90 }, label: 'Declared size: 180 × 90', }, { id: 'resizable', position: { x: 120, y: 220 }, size: { width: 160, height: 100 }, label: 'Resize me: 80–260 wide', metadata: { sizing: { minWidth: 80, minHeight: 60, maxWidth: 260, maxHeight: 200, }, }, }, { id: 'ratio', position: { x: 420, y: 220 }, size: { width: 160, height: 100 }, label: 'Aspect ratio stays at 1.6', metadata: { sizing: { aspectLock: true } }, }, { id: 'auto', position: { x: 120, y: 360 }, size: { width: 60, height: 36 }, label: 'This long label grows beyond the declared sixty-pixel width', metadata: { sizing: { auto: true, padding: 10 } }, }, ]; edges: readonly (EdgeSpec | LinkModel)[] | undefined = []; } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'sized', position: { x: 120, y: 80 }, size: { width: 180, height: 90 }, label: 'Declared size: 180 × 90', }, { id: 'resizable', position: { x: 120, y: 220 }, size: { width: 160, height: 100 }, label: 'Resize me: 80–260 wide', metadata: { sizing: { minWidth: 80, minHeight: 60, maxWidth: 260, maxHeight: 200, }, }, }, { id: 'ratio', position: { x: 420, y: 220 }, size: { width: 160, height: 100 }, label: 'Aspect ratio stays at 1.6', metadata: { sizing: { aspectLock: true } }, }, { id: 'auto', position: { x: 120, y: 360 }, size: { width: 60, height: 36 }, label: 'This long label grows beyond the declared sixty-pixel width', metadata: { sizing: { auto: true, padding: 10 } }, }, ]; const edges: EdgeSpec[] = []; export function App() { return (
); } ``` ```vue title="Vue" ``` ::: The first box starts at its declared 180 × 90 size. Select the constrained box to show the renderer's corner and edge resize handles, then drag a handle: its dimensions stay within the declared limits. The adjacent node holds its initial 1.6 width-to-height ratio while you resize it. The bottom node opts into content-aware sizing, so its long label expands it beyond its declared 60-pixel width. The non-auto node remains at its declared dimensions until a user resizes it. ## Choose the sizing fields Put per-node constraints in `metadata.sizing`. The renderer applies the same constraints to the interactive resize and content-aware sizing paths. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `size` | `{ width: number; height: number }` | No default stated | Declares a node's width and height in its [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec). | | `metadata.sizing.auto` | `boolean` | Off unless `true` | Opts that node into content-aware sizing to fit its label. | | `metadata.sizing.minWidth`, `minHeight` | `number` | No per-node constraint; the resizer's global floor still applies | Sets the node's minimum width or height during resizing and auto-sizing. | | `metadata.sizing.maxWidth`, `maxHeight` | `number` | No per-node limit | Sets the node's maximum width or height during resizing and auto-sizing. | | `metadata.sizing.aspectLock` | `boolean \| number` | Unlocked | `true` locks to the node's current width-to-height ratio; a positive number locks to that explicit ratio. | | `metadata.sizing.padding` | `number` | `8` px | Reserves space around the label when auto-sizing. | The selected-node handles appear for one selected, resizable, unlocked node. A multi-node selection does not provide proportional resize handles. For automatic fitting, only nodes with `metadata.sizing.auto: true` grow; `padding` defaults to 8 pixels unless you set it, as the example does. The auto-sized node's measured dimensions update on the renderer's frame, and the sizing pass uses the same min/max limits and aspect lock as dragging. ## Watch the demos - [Auto-sizing nodes](https://grafloria.com/demos/nodes/auto-sizing.html) shows an opted-in node fitting its label and growing again when the label changes. - [Node resize gesture](https://grafloria.com/demos/nodes/node-resize-gesture.html) shows the built-in handles, per-handle cursors, and live min/max/aspect constraints. - [Node resizer](https://grafloria.com/demos/nodes/node-resizer.html) contrasts the selected-node resizer with a custom resize control. ## Pitfalls - Register a custom node renderer before mounting the diagram. If a node's type has no renderer when its host mounts, that host stays empty; registering later does not back-fill it. A custom renderer runs at mount, not as a data binding, so update the DOM you own or use a component with its own reactive source when its content must change. - Give the canvas a parent with a resolved height. A canvas inside a zero-height wrapper has no visible area to draw into. ## Related - [Lay out diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams) - [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows) # Configure ports Declare connection points on each node to control their glyphs, labels, shared layout, and data-type rules; the same node specs work across Grafloria's framework bindings. ## When to use declared ports Use declared ports when a node needs named inputs and outputs, more than the four default connection points, or visible distinctions between connection types. The graph data belongs to the diagram model; the framework binding converts your node specs into live models, and the instance connects the rendered canvas to engine behavior. Each node can declare a `ports` array on its [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec#nodespec). Give each port an id, then add a `shape`, a `label`, a `group`, or a `dataType` as needed. Nodes without `ports` keep their four deterministic default ports. ## Declare and mount the ports The samples below all render the same small data-flow diagram: a `Source` node has number and string outputs, and a `Convert` node has matching inputs. Both nodes arrange their ports in named side groups. The port glyphs have different shapes and labels; the initial number-to-number link is present in every sample. A registered type palette adds type colours and can declare compatibility beyond exact type-name matches. The JavaScript and framework mounting patterns are covered in [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows); this page adds port declarations and a registered type palette to the node data. :::code-group ```ts title="JavaScript" import { render, portTypeRegistry, type EdgeSpec, type NodeSpec, } from '@grafloria/element'; portTypeRegistry.registerAll([ { name: 'number', color: '#2563eb', compatibleWith: ['number'] }, { name: 'string', color: '#9333ea', compatibleWith: ['string'] }, ]); const nodes: NodeSpec[] = [ { id: 'source', position: { x: 80, y: 130 }, size: { width: 160, height: 120 }, label: 'Source', metadata: { portGroups: { outputs: { id: 'outputs', side: 'right', visibility: 'always', layout: { strategy: 'sideLinear', args: { padding: 18 } }, }, }, }, ports: [ { id: 'number-out', group: 'outputs', type: 'output', dataType: 'number', shape: { shape: 'circle', size: 14 }, label: { text: 'number', layout: 'outside' }, }, { id: 'string-out', group: 'outputs', type: 'output', dataType: 'string', shape: { shape: 'square', size: 14 }, label: { text: 'string', layout: 'outside' }, }, ], }, { id: 'convert', position: { x: 430, y: 130 }, size: { width: 160, height: 120 }, label: 'Convert', metadata: { portGroups: { inputs: { id: 'inputs', side: 'left', visibility: 'always', layout: { strategy: 'sideLinear', args: { padding: 18 } }, }, }, }, ports: [ { id: 'number-in', group: 'inputs', type: 'input', dataType: 'number', shape: { shape: 'diamond', size: 14 }, label: { text: 'number', layout: 'outside' }, }, { id: 'string-in', group: 'inputs', type: 'input', dataType: 'string', shape: { shape: 'triangle', size: 14 }, label: { text: 'string', layout: 'outside' }, }, ], }, ]; const edges: EdgeSpec[] = [ { id: 'number-link', source: 'source', sourceHandle: 'number-out', target: 'convert', targetHandle: 'number-in' }, ]; const host = document.createElement('div'); host.style.width = '100%'; host.style.height = '420px'; document.body.append(host); requestAnimationFrame(() => render({ nodes, edges }, host)); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { portTypeRegistry } from '@grafloria/element'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; portTypeRegistry.registerAll([ { name: 'number', color: '#2563eb', compatibleWith: ['number'] }, { name: 'string', color: '#9333ea', compatibleWith: ['string'] }, ]); @Component({ selector: 'app-port-example', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class PortExampleComponent { nodes: NodeSpec[] = [ { id: 'source', position: { x: 80, y: 130 }, size: { width: 160, height: 120 }, label: 'Source', metadata: { portGroups: { outputs: { id: 'outputs', side: 'right', visibility: 'always', layout: { strategy: 'sideLinear', args: { padding: 18 } }, }, }, }, ports: [ { id: 'number-out', group: 'outputs', type: 'output', dataType: 'number', shape: { shape: 'circle', size: 14 }, label: { text: 'number', layout: 'outside' }, }, { id: 'string-out', group: 'outputs', type: 'output', dataType: 'string', shape: { shape: 'square', size: 14 }, label: { text: 'string', layout: 'outside' }, }, ], }, { id: 'convert', position: { x: 430, y: 130 }, size: { width: 160, height: 120 }, label: 'Convert', metadata: { portGroups: { inputs: { id: 'inputs', side: 'left', visibility: 'always', layout: { strategy: 'sideLinear', args: { padding: 18 } }, }, }, }, ports: [ { id: 'number-in', group: 'inputs', type: 'input', dataType: 'number', shape: { shape: 'diamond', size: 14 }, label: { text: 'number', layout: 'outside' }, }, { id: 'string-in', group: 'inputs', type: 'input', dataType: 'string', shape: { shape: 'triangle', size: 14 }, label: { text: 'string', layout: 'outside' }, }, ], }, ]; edges: EdgeSpec[] = [ { id: 'number-link', source: 'source', sourceHandle: 'number-out', target: 'convert', targetHandle: 'number-in' }, ]; } ``` ```tsx title="Qwik" import { $, component$ } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance, type EdgeSpec, type NodeSpec } from '@grafloria/qwik'; import { portTypeRegistry } from '@grafloria/element'; const nodes: NodeSpec[] = [ { id: 'source', position: { x: 80, y: 130 }, size: { width: 160, height: 120 }, label: 'Source', metadata: { portGroups: { outputs: { id: 'outputs', side: 'right', visibility: 'always', layout: { strategy: 'sideLinear', args: { padding: 18 } }, } } }, ports: [ { id: 'number-out', group: 'outputs', type: 'output', dataType: 'number', shape: { shape: 'circle', size: 14 }, label: { text: 'number', layout: 'outside' } }, { id: 'string-out', group: 'outputs', type: 'output', dataType: 'string', shape: { shape: 'square', size: 14 }, label: { text: 'string', layout: 'outside' } }, ], }, { id: 'convert', position: { x: 430, y: 130 }, size: { width: 160, height: 120 }, label: 'Convert', metadata: { portGroups: { inputs: { id: 'inputs', side: 'left', visibility: 'always', layout: { strategy: 'sideLinear', args: { padding: 18 } }, } } }, ports: [ { id: 'number-in', group: 'inputs', type: 'input', dataType: 'number', shape: { shape: 'diamond', size: 14 }, label: { text: 'number', layout: 'outside' } }, { id: 'string-in', group: 'inputs', type: 'input', dataType: 'string', shape: { shape: 'triangle', size: 14 }, label: { text: 'string', layout: 'outside' } }, ], }, ]; const edges: EdgeSpec[] = [ { id: 'number-link', source: 'source', sourceHandle: 'number-out', target: 'convert', targetHandle: 'number-in' }, ]; export default component$(() => (
{ portTypeRegistry.registerAll([ { name: 'number', color: '#2563eb', compatibleWith: ['number'] }, { name: 'string', color: '#9333ea', compatibleWith: ['string'] }, ]); instance.renderNow(); })} />
)); ``` ```vue title="Vue" ``` ::: When the canvas mounts, you see two labelled nodes, four always-visible ports, and the initial number-to-number connection. The source and target groups inherit their side and `sideLinear` layout; each port supplies its own glyph, label, and type. In every sample, the palette colors number ports blue and string ports purple. Drag `number-out` to `string-in` to see the mismatch refused; both registrations allow only the same named type. ## Port fields that shape the result | Option | Type | Default | What it does | | --- | --- | --- | --- | | `ports` | [`PortSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-portspec#portspec)[] | Omitted: four deterministic default ports | Declares the node's own connection points. | | `shape` | Shape object | Circle | Chooses `circle`, `square`, `diamond`, `triangle`, or an SVG `path`; `size` sets the glyph box in pixels. | | `label` | Label object | No label; when present, `layout` defaults to `outside` | Adds text near the port. Choose `inside`, `outside`, `orthogonal`, or `radial` placement. | | `group` | `string` | No group | Inherits shared settings from the same id in the node's `metadata.portGroups`; a port's own settings override group values. | | `metadata.portGroups` | Node metadata object | No groups | Defines shared port settings such as side, visibility, shape, and layout. A `sideLinear` layout distributes group members along that side. | | `dataType` | `string` | Untyped | Associates the port with a registered type for glyph colour and connection compatibility. | The default visibility mode shows ports on hover. The examples set each group's visibility to `always` so the ports and their labels appear immediately. `shape: 'path'` accepts caller-provided SVG path data; use it when the built-in glyphs do not distinguish your port. ## See the port behaviors The [port shapes demo](https://grafloria.com/demos/ports/port-shapes.html) compares circle, square, diamond, triangle, and custom-path glyphs as distinct SVG primitives. The [port labels demo](https://grafloria.com/demos/ports/port-labels.html) shows labels placed inside, outside, and orthogonal to their glyphs. The [port groups and layouts demo](https://grafloria.com/demos/ports/port-groups-and-layouts.html) compares a side column, a line segment, and an ellipse spread. The [typed ports demo](https://grafloria.com/demos/ports/typed-ports.html) registers number and string types, then shows a matching connection and a mismatched target. ## Pitfalls - If the node spec omits `ports`, it keeps the four deterministic defaults; add a `ports` array to replace that set with your declared ports. - Put shared configuration under the node's `metadata.portGroups` and match each port's `group` value to that entry's id. A port-level value takes precedence over the inherited group value. - Register named data types to assign colours or declare compatibility beyond exact matches. Compatibility is directional when `compatibleWith` is used; an untyped endpoint remains unconstrained. - A `dataType` alone does not pick a colour. Register a color for the type, then the renderer uses it for that type's port glyph. ## Related - [Validate connections](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/validate-connections) for connection rules beyond a port's direction and data type. - [Handle connection interactions](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/handle-connection-interactions) for responding to connection gestures. - [The diagram model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document) for how diagram specs become the live model. # Validate connections Refuse connections that break an application rule, and give users a reason for each refusal. Use a registered validator for rules that depend on your diagram's data, such as preventing a Sink from acting as a connection source. The validator evaluates a proposed connection on the live canvas; framework bindings still use the same model and rule. ## Mount the diagram and register a rule The examples mount a small Source → Transform → Sink diagram. A registered rule rejects any proposal whose source node has `data.role === 'sink'`; returning a string supplies the refusal reason, while `true` allows the proposal. The callback can also update the small status line so the reason appears in the view. For typed graph data and mounting the canvas in JavaScript, Angular, Qwik, React, or Vue, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). This page adds the connection rule: register [`registerConnectionValidator`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-ext-functions#registerconnectionvalidator) when the view mounts and keep its returned disposer for teardown. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; import { registerConnectionValidator } from '@grafloria/renderer'; const host = document.createElement('div'); host.style.height = '500px'; host.style.width = '100%'; document.body.append(host); const status = document.createElement('p'); status.textContent = 'Try drawing a connection out of the Sink.'; document.body.insertBefore(status, host); const nodes = [ { id: 'source', position: { x: 80, y: 120 }, size: { width: 120, height: 46 }, label: 'Source', data: { role: 'source' } }, { id: 'transform', position: { x: 320, y: 120 }, size: { width: 120, height: 46 }, label: 'Transform', data: { role: 'transform' } }, { id: 'sink', position: { x: 560, y: 120 }, size: { width: 120, height: 46 }, label: 'Sink', data: { role: 'sink' } }, ]; const edges = []; const dispose = registerConnectionValidator(({ sourceNode }) => { if (sourceNode.data?.role === 'sink') { status.textContent = 'Refused: a Sink has no outputs'; return 'A Sink has no outputs'; } return true; }); const instance = render({ nodes, edges }, host); instance.fitView(); window.addEventListener('pagehide', dispose, { once: true }); ``` ```ts title="Angular" import { Component, OnDestroy, signal } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; import { registerConnectionValidator } from '@grafloria/renderer'; @Component({ selector: 'app-validated-flow', standalone: true, imports: [DiagramCanvasComponent], template: `

{{ reason() }}

`, }) export class ValidatedFlowComponent implements OnDestroy { readonly reason = signal('Try drawing a connection out of the Sink.'); nodes: NodeSpec[] = [ { id: 'source', position: { x: 80, y: 120 }, size: { width: 120, height: 46 }, label: 'Source', data: { role: 'source' } }, { id: 'transform', position: { x: 320, y: 120 }, size: { width: 120, height: 46 }, label: 'Transform', data: { role: 'transform' } }, { id: 'sink', position: { x: 560, y: 120 }, size: { width: 120, height: 46 }, label: 'Sink', data: { role: 'sink' } }, ]; edges: EdgeSpec[] = []; private readonly dispose = registerConnectionValidator(({ sourceNode }) => { if (sourceNode.data['role'] === 'sink') { this.reason.set('Refused: a Sink has no outputs'); return 'A Sink has no outputs'; } return true; }); ngOnDestroy(): void { this.dispose(); } } ``` ```tsx title="Qwik" import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'; import { render } from '@grafloria/element'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; import { registerConnectionValidator } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'source', position: { x: 80, y: 120 }, size: { width: 120, height: 46 }, label: 'Source', data: { role: 'source' } }, { id: 'transform', position: { x: 320, y: 120 }, size: { width: 120, height: 46 }, label: 'Transform', data: { role: 'transform' } }, { id: 'sink', position: { x: 560, y: 120 }, size: { width: 120, height: 46 }, label: 'Sink', data: { role: 'sink' } }, ]; const edges: EdgeSpec[] = []; export default component$(() => { const host = useSignal(); const reason = useSignal('Try drawing a connection out of the Sink.'); useVisibleTask$(({ cleanup }) => { const container = host.value; if (!container) return; const dispose = registerConnectionValidator(({ sourceNode }) => { if (sourceNode.data['role'] === 'sink') { reason.value = 'Refused: a Sink has no outputs'; return 'A Sink has no outputs'; } return true; }); const instance = render({ nodes, edges }, container); instance.fitView(); cleanup(() => { dispose(); instance.dispose(); }); }, { strategy: 'document-ready' }); return (

{reason.value}

); }); ``` ```tsx title="React" import { useEffect, useState } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; import { registerConnectionValidator } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'source', position: { x: 80, y: 120 }, size: { width: 120, height: 46 }, label: 'Source', data: { role: 'source' } }, { id: 'transform', position: { x: 320, y: 120 }, size: { width: 120, height: 46 }, label: 'Transform', data: { role: 'transform' } }, { id: 'sink', position: { x: 560, y: 120 }, size: { width: 120, height: 46 }, label: 'Sink', data: { role: 'sink' } }, ]; const edges: EdgeSpec[] = []; export default function ValidatedFlow() { const [reason, setReason] = useState('Try drawing a connection out of the Sink.'); useEffect(() => { const dispose = registerConnectionValidator(({ sourceNode }) => { if (sourceNode.data?.role === 'sink') { setReason('Refused: a Sink has no outputs'); return 'A Sink has no outputs'; } return true; }); return dispose; }, []); return (

{reason}

); } ``` ```vue title="Vue" ``` ::: When the canvas mounts, it shows Source, Transform, and Sink with no links. Drag from Source to Transform or Transform to Sink to make a legal connection; dragging out of Sink leaves no link and changes the status line to “Refused: a Sink has no outputs.” The validator's returned string is the rule's reason; the status line is application UI updated by the same callback. ## Validator contract Write the rule against the source and target nodes. Return `true` to allow the proposal, `false` to veto without a reason, or a string to veto with that reason. Every registered validator must pass, so a single veto refuses the connection. | Return value | Type | Effect | | --- | --- | --- | | Allow | `true` | Accepts this validator's candidate. | | Veto | `false` | Refuses without a reason string. | | Veto with reason | `string` | Refuses and supplies the returned text as the reason. | ## Pitfalls - The validator registry is process-global, not per canvas. Keep the disposer returned by `registerConnectionValidator` and call it when this view unmounts; otherwise its rule also applies to other diagrams in the same process. - [`clearConnectionValidators`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-ext-functions#clearconnectionvalidators) removes every registered validator, not only the current view's. Use it only when you intentionally want a clean registry; normal teardown calls the view's own disposer. - Port direction and type compatibility are separate built-in validation layers. Use [Configure ports](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/configure-ports) for rules expressed by port direction or data types; register a custom validator for application-specific rules. ## See it running Open the [live connection-validation demo](https://grafloria.com/demos/interaction/validation.html) to try a rejected Sink connection and accepted connections in the browser. Its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/validation.html) drives the connection pipeline with a registered validator. ## Related - [Configure ports](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/configure-ports) for direction and data-type constraints. - [Handle connection interactions](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/handle-connection-interactions) for responding to connection gestures. # Handle connection interactions Observe a connection drag from start through completion or cancellation, and let users create links by dragging from a node body to another node. Use body-to-body connection when users should not have to aim at a small port. The built-in mode starts from the nearest port on the first node and completes at the nearest port on the second. With the mode off, the usual interaction is port-to-port dragging, and dragging a node body moves the node. ## Mount the diagram and listen to its connection lifecycle These examples mount two typed nodes and an empty edge list, then enable body-to-body connection and write each lifecycle event to a visible log. Drag anywhere on the source node to the target node and release to create a link; the log records the gesture as it happens. Drag off the target or press Escape to cancel instead. For the JavaScript, Angular, and Vue mounting patterns and access to the live canvas, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). This page adds body-to-body connection and a log of its lifecycle events. The diagram data uses [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec). :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const log = document.createElement('pre'); log.style.height = '88px'; log.style.overflow = 'auto'; log.textContent = 'Drag from source to target to see the connection lifecycle.'; const canvas = document.createElement('div'); canvas.style.width = '700px'; canvas.style.height = '420px'; document.body.append(log, canvas); /** @type {import('@grafloria/element').NodeSpec[]} */ const nodes = [ { id: 'src', position: { x: 80, y: 70 }, size: { width: 150, height: 60 }, label: 'source', ports: [{ id: 'src.out', side: 'right', type: 'output' }], }, { id: 'dst', position: { x: 430, y: 70 }, size: { width: 150, height: 60 }, label: 'target', ports: [{ id: 'dst.in', side: 'left', type: 'input' }], }, ]; /** @type {import('@grafloria/element').EdgeSpec[]} */ const edges = []; const instance = render({ nodes, edges }, canvas); const engine = instance.getEngine(); engine.setInteractionConfig({ enableEasyConnect: true }); const connectionEvents = [ 'connection:start', 'connection:update', 'connection:port-enter', 'connection:port-leave', 'connection:complete', 'connection:cancel', ]; for (const name of connectionEvents) { engine.eventBus.on(name, () => { if (log.textContent?.startsWith('Drag from')) log.textContent = ''; log.prepend(`${name}\n`); }); } ``` ```ts title="Angular" import { AfterViewInit, Component, OnDestroy, signal, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `
@for (row of rows(); track $index) {
{{ row }}
}
`, }) export class ConnectionInteractionsComponent implements AfterViewInit, OnDestroy { readonly canvas = viewChild.required(DiagramCanvasComponent); readonly rows = signal(['Drag from source to target to see the connection lifecycle.']); nodes: NodeSpec[] = [ { id: 'src', position: { x: 80, y: 70 }, size: { width: 150, height: 60 }, label: 'source', ports: [{ id: 'src.out', side: 'right', type: 'output' }], }, { id: 'dst', position: { x: 430, y: 70 }, size: { width: 150, height: 60 }, label: 'target', ports: [{ id: 'dst.in', side: 'left', type: 'input' }], }, ]; edges: EdgeSpec[] = []; private disposers: Array<() => void> = []; ngAfterViewInit(): void { const engine = this.canvas().activeEngine(); if (!engine) return; engine.setInteractionConfig({ enableEasyConnect: true }); const connectionEvents = [ 'connection:start', 'connection:update', 'connection:port-enter', 'connection:port-leave', 'connection:complete', 'connection:cancel', ]; this.disposers = connectionEvents.map((name) => engine.eventBus.on(name, () => this.rows.update((rows) => [name, ...rows].slice(0, 12))), ); } ngOnDestroy(): void { this.disposers.forEach((dispose) => dispose()); } } ``` ```vue title="Vue" ``` ::: The log names the six engine events. `connection:start` carries the source port and valid target ports; `connection:update` reports the current target and whether it is valid, including a rejection reason; `connection:port-enter` and `connection:port-leave` mark target-port crossings; `connection:complete` identifies the connected ports; and `connection:cancel` marks an abandoned or refused drag. `update` can fire repeatedly while the pointer moves. A successful drop ends with `complete`; a refused drop or Escape ends with `cancel`. [Open the live connection-events demo](https://grafloria.com/demos/interaction/connection-events.html) or [read its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/connection-events.html). ## Choose the connection gesture | Option | Type | Default | What it does | | --- | --- | --- | --- | | `enableEasyConnect` | `boolean` | `false` | Starts a connection from the nearest port when the user presses a node body, instead of moving the node. | JavaScript and Vue turn on `enableEasyConnect` after mount with `instance.getEngine().setInteractionConfig(...)`. Angular accesses its engine through `activeEngine()` and calls `setInteractionConfig(...)` there. Leave the option off to keep body-drag-to-move and start connections from ports instead. With it on, press anywhere on the source node and release on the target node; the built-in interaction creates the link without a custom tool. [Open the live easy-connect-body demo](https://grafloria.com/demos/interaction/easy-connect-body.html) or [read its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/easy-connect-body.html). ## Watch for - Keep the event subscriptions for the lifetime of the mounted canvas and call each returned disposer when the host unmounts. Otherwise, repeated mounts can leave old listeners recording the same gesture. - `enableEasyConnect` changes what a node-body press does: it starts a connection rather than moving that node. Leave the option disabled when users need body dragging to move nodes. - This example logs event names. Read the event payload when the host needs port details or a validation reason. ## Related - [Configure ports](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/configure-ports) - [Validate connections](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/validate-connections) - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) - [Events and interaction reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) # Lay out diagrams automatically Use automatic layout when you have a populated graph and want Grafloria to place its nodes instead of positioning them by hand. Each example lays out a small tree whose nodes start at the origin. ## Arrange a tree when the canvas mounts Choose a layout algorithm for your populated graph. In plain JavaScript, pass node and edge specs to `render()`, then call the mounted instance's engine layout method. In Angular, React, and Vue, pass the specs and layout request to the rendered component. In Qwik, mount and lay out the canvas in a browser-only visible task. Each canvas starts with nodes stacked at `(0, 0)`; the selected layout arranges the nodes into a tree. For plain JavaScript, [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) mounts the specs into an element and returns a live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance). The sample calls the engine through that mounted instance. ```js title="JavaScript" import { render } from '@grafloria/element'; const ids = ['root', 'a', 'b', 'a1', 'a2', 'b1', 'b2']; const nodes = ids.map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 120, height: 48 }, label: id, })); const edges = [ { id: 'e1', source: 'root', target: 'a' }, { id: 'e2', source: 'root', target: 'b' }, { id: 'e3', source: 'a', target: 'a1' }, { id: 'e4', source: 'a', target: 'a2' }, { id: 'e5', source: 'b', target: 'b1' }, ]; const container = document.createElement('div'); container.style.height = '520px'; document.body.append(container); async function showTree() { const instance = render({ nodes, edges }, container); const engine = instance.getEngine(); await engine.layout('dagre', { nodeSpacing: 40, rankSpacing: 80, }); instance.renderNow(); instance.fitView(40); } void showTree(); ``` The Angular, React, and Vue samples reuse [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec) and the mounting patterns from [Execute and compute flows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/execute-and-compute-flows), adding a `layout` request to [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent) and [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react) / [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue) to arrange the nodes automatically. :::code-group ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const ids = ['root', 'a', 'b', 'a1', 'a2', 'b1', 'b2']; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class DagreTreeComponent { nodes: NodeSpec[] = ids.map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 120, height: 48 }, label: id, })); edges: EdgeSpec[] = [ { id: 'e1', source: 'root', target: 'a' }, { id: 'e2', source: 'root', target: 'b' }, { id: 'e3', source: 'a', target: 'a1' }, { id: 'e4', source: 'a', target: 'a2' }, { id: 'e5', source: 'b', target: 'b1' }, ]; } ``` ```tsx title="Qwik" import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'; import { render } from '@grafloria/element'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const ids = ['root', 'a', 'b', 'a1', 'a2', 'b1', 'b2']; const nodes: NodeSpec[] = ids.map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 120, height: 48 }, label: id, })); const edges: EdgeSpec[] = [ { id: 'e1', source: 'root', target: 'a' }, { id: 'e2', source: 'root', target: 'b' }, { id: 'e3', source: 'a', target: 'a1' }, { id: 'e4', source: 'a', target: 'a2' }, { id: 'e5', source: 'b', target: 'b1' }, ]; export default component$(() => { const host = useSignal(); useVisibleTask$(({ cleanup }) => { const container = host.value; if (!container) return; const instance = render({ nodes, edges }, container); void instance.getEngine().layout('dagre', { nodeSpacing: 40, rankSpacing: 80, }).then(() => { instance.renderNow(); instance.fitView(40); }); cleanup(() => instance.dispose()); }); return
; }); ``` ```tsx title="React" import { GrafloriaFlow, type EdgeSpec, type NodeSpec } from '@grafloria/react'; const ids = ['root', 'a', 'b', 'a1', 'a2', 'b1', 'b2']; const nodes: NodeSpec[] = ids.map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 120, height: 48 }, label: id, })); const edges: EdgeSpec[] = [ { id: 'e1', source: 'root', target: 'a' }, { id: 'e2', source: 'root', target: 'b' }, { id: 'e3', source: 'a', target: 'a1' }, { id: 'e4', source: 'a', target: 'a2' }, { id: 'e5', source: 'b', target: 'b1' }, ]; export function DagreTree() { return (
); } ``` ```vue title="Vue" ``` ::: ### What you see The nodes no longer overlap at their shared starting point: Dagre positions the tree, including the disconnected `b2` node, and the canvas shows the arranged graph. Try the [Dagre tree demo](https://grafloria.com/demos/layout/dagre-tree.html) and its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/layout/dagre-tree.html) to see the same graph laid out live. ## Choose a layout The registry names documented for whole-diagram layout are `auto`, `architecture`, `elk`, `dagre`, `layered`, `tree`, `grid`, `circular`, `radial`, `force`, `spectral`, and `community`. Use `auto` when you want Grafloria to classify the graph and select an algorithm. For a pipeline or DAG, choose `elk`, `layered`, or `dagre`; for a hierarchy, choose `tree`; for networks and clusters, consider `force`, `community`, or `spectral`; and for a catalog, use `grid`, `circular`, or `radial`. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `layout` | `string` or object request | Not set | Selects the algorithm on the component. An object request can include the algorithm's options. | | `name` | `string` | — | Names the algorithm in an object layout request. | | `nodeSpacing` | `number` | Algorithm default | Sets space between nodes in the same rank or row. | | `rankSpacing` | `number` | Algorithm default | Sets space between ranks or layers. | The engine also accepts the selected algorithm directly. Call `layout()` through the mounted instance and await it; the engine commits the new node positions to the diagram. The JavaScript sample calls the [`DiagramEngine`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-engine) through the instance. ## Re-run layout on demand The component layout binding runs when its value changes, not when node data changes. This keeps a data update caused by dragging from starting a layout that would fight the user's gesture. When you need an explicit rerun, call `instance.getEngine().layout('dagre', options)` through the mounted instance, or use `applyLayout()` on the Angular or Vue component. `applyLayout()` reruns the bound request or accepts a registry layout request. ## Related - [Preserve the layout mental map](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/preserve-the-layout-mental-map) when new nodes arrive in a diagram that users have already arranged. - [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) for the shared engine model behind its framework bindings. # Preserve the layout mental map Add a node to an arranged graph with an incremental pass while minimizing movement to existing diagram content. Use incremental layout after inserting or editing nodes in a graph people already know. A full layout can move the entire graph; the incremental pass confines disturbance around the changed nodes and reports movement. ## Add a node with incremental layout 1. Mount a connected graph and lay it out with `layered` so its starting positions come from the same layout engine. In JavaScript, [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) mounts the spec and returns the live instance; framework apps use their canvas component. 2. Add the new node and its edges to the live [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance). Mark the new node in `changed`, then call `layoutIncremental()` on the engine returned by `getEngine()`. 3. Repaint and frame the result. The new node appears in the chain, while nodes outside the affected neighborhood remain anchors. The result includes a movement report and a tween plan; the engine returns the plan rather than animating it, so a host can drive the animation if needed. The task-specific difference is inserting a node into an arranged chain and measuring movement among the existing nodes; see [Lay out diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams) for the shared mounting, framework-binding, and typed-spec patterns. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; async function main() { const nodes = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: id, })); const edges = [ { id: 'e0', source: 'n0', target: 'n1' }, { id: 'e1', source: 'n1', target: 'n2' }, { id: 'e2', source: 'n2', target: 'n3' }, { id: 'e3', source: 'n3', target: 'n4' }, { id: 'e4', source: 'n4', target: 'n5' }, ]; const host = document.getElementById('app'); if (!host) throw new Error('Missing #app element'); host.style.height = '400px'; const instance = render({ nodes, edges }, host); const engine = instance.getEngine(); await engine.layout('layered'); instance.renderNow(); instance.fitView(40); instance.setNodes([ ...nodes, { id: 'inserted', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: 'inserted' }, ]); instance.setEdges([ ...edges, { id: 'x0', source: 'n2', target: 'inserted' }, { id: 'x1', source: 'inserted', target: 'n4' }, ]); const result = await engine.layoutIncremental({ name: 'layered', changed: ['inserted'], radius: 1 }); instance.renderNow(); instance.fitView(40); console.log(result.movement.total, result.tween.movingIds); } void main(); ``` ```ts title="Angular" import { Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { LinkModel, NodeModel } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class AppComponent { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: readonly (NodeSpec | NodeModel)[] | undefined = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: id, })); edges: readonly (EdgeSpec | LinkModel)[] | undefined = [ { id: 'e0', source: 'n0', target: 'n1' }, { id: 'e1', source: 'n1', target: 'n2' }, { id: 'e2', source: 'n2', target: 'n3' }, { id: 'e3', source: 'n3', target: 'n4' }, { id: 'e4', source: 'n4', target: 'n5' }, ]; insertNode(): void { const engine = this.canvas().activeEngine(); if (!engine) return; this.nodes = [ ...(this.nodes ?? []), { id: 'inserted', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: 'inserted' }, ]; this.edges = [ ...(this.edges ?? []), { id: 'x0', source: 'n2', target: 'inserted' }, { id: 'x1', source: 'inserted', target: 'n4' }, ]; setTimeout(() => { const liveEngine = this.canvas().activeEngine(); if (liveEngine) void liveEngine.layoutIncremental({ name: 'layered', changed: ['inserted'], radius: 1 }); }, 0); } } ``` ```vue title="Vue" ``` ::: In Qwik, mount `GrafloriaFlow` with `defaultNodes={baseNodes}` and `defaultEdges={baseEdges}`, then wrap the handler below with `$()` and pass it as `onInit$`. It lays out the mounted chain, inserts a node between `n2` and `n4`, and incrementally lays out the changed neighborhood. ```ts title="Qwik" import type { DiagramInstance } from '@grafloria/qwik'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; export const baseNodes: NodeSpec[] = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: id, })); export const baseEdges: EdgeSpec[] = [ { id: 'e0', source: 'n0', target: 'n1' }, { id: 'e1', source: 'n1', target: 'n2' }, { id: 'e2', source: 'n2', target: 'n3' }, { id: 'e3', source: 'n3', target: 'n4' }, { id: 'e4', source: 'n4', target: 'n5' }, ]; export async function onInit(instance: DiagramInstance): Promise { const engine = instance.getEngine(); await engine.layout('layered'); instance.renderNow(); instance.fitView(40); instance.setNodes([ ...baseNodes, { id: 'inserted', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: 'inserted' }, ]); instance.setEdges([ ...baseEdges, { id: 'x0', source: 'n2', target: 'inserted' }, { id: 'x1', source: 'inserted', target: 'n4' }, ]); await engine.layoutIncremental({ name: 'layered', changed: ['inserted'], radius: 1 }); instance.renderNow(); instance.fitView(40); } ``` The repository's [Qwik dynamic-layouting component](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/apps/demos-qwik/gallery/demos/dynamic-layouting.tsx) uses this `onInit$` flow in a Qwik-optimized application. The visible result is a laid-out chain with `inserted` connected after `n2` and before `n4`. `movement.total` reports the total distance traveled by pre-existing nodes; `tween.movingIds` identifies which existing nodes move. The tween plan is data only: call `result.tween.at(t)` over normalized time values if your host wants to animate positions. See the [live incremental-layout demo](https://grafloria.com/demos/layout/dynamic-layouting.html) and its [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/layout/dynamic-layouting.html). ## Options that shape the incremental pass | Option | Type | Default | What it does | | --- | --- | --- | --- | | `changed` | `string[]` | `[]` | Identifies newly added or edited node IDs. All other nodes count as existing for the movement report. | | `radius` | `number` | `1` | Sets how many graph hops the affected region may spread from changed nodes. | | `budget` | `{ maxPerNode?: number; averagePerNode?: number }` | — | Sets a maximum permitted movement per existing node or average movement. The returned report includes `withinBudget`. | The incremental call's `name` option selects a registered layout. When prior positions came from another engine, an incremental pass can redraw the graph because the engines do not share a common layout geometry. ## Pitfalls - Include the new node ID in `changed`; otherwise its default position can distort the baseline used to align the result. - A declarative `layout` prop reruns when the prop value changes, not when node data changes. Call the engine method for an on-demand pass; Angular also exposes `applyLayout()` for its bound layout. - Give the canvas wrapper a resolved height. The canvas fills its parent, so a zero-height parent renders a blank area. - In plain JavaScript and React, custom renderers are not used unless the node spec opts into the HTML layer with `custom: true`; see [Lay out diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams). ## Related - [Lay out diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams) for layout choices and declarative layout. - [Add nodes from palettes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/add-nodes-from-palettes) for reconciliation behavior when node IDs remain in the data. - [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) for undoing user edits. # Route and edit edges Use an edge's router to choose the path, reconnect an endpoint by dragging it to another node, and enable waypoint editing when readers need to shape a route by hand. An [edge spec](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec) stores the connection and routing intent; the mounted diagram renders that intent as geometry. ## Choose a route `type` describes the line's shape; `router` selects the path-finding behavior. The examples below put three routers on the same A-to-B corridor, with a wall in each lane. Drag a wall and compare the route. | `router` | Behavior | | --- | --- | | `orthogonal` | Right-angled path from the port's exit direction. | | `manhattan` | Grid-based right-angle routing with turn minimization. | | `avoid` | Routes around nodes and recalculates as nodes move. | | `elk` | Uses ELK edge routes, consistent with an ELK-laid-out graph. | | `straight` | Direct line between endpoints. | If `router` is unset, it is derived from `type`. The `orthogonal`, `manhattan`, and `elk` lanes below follow the routing-algorithms demo; the single-obstacle demo also shows an orthogonal route bending around a wall. For the shared framework setup and typed node and edge specs, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows); the examples here add routing and edge-editing behavior. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '600px'; document.body.append(host); const lanes = ['orthogonal', 'manhattan', 'elk']; const nodes = lanes.flatMap((router, index) => { const y = 110 + index * 150; return [ { id: `a${index}`, position: { x: 70, y: y - 24 }, size: { width: 108, height: 48 }, label: 'A' }, { id: `b${index}`, position: { x: 760, y: y - 24 }, size: { width: 108, height: 48 }, label: 'B' }, { id: `wall${index}`, position: { x: 410, y: y - 42 }, size: { width: 100, height: 84 }, label: router }, ]; }); const edges = lanes.map((router, index) => ({ id: `edge${index}`, source: `a${index}`, target: `b${index}`, router, })); const instance = render({ nodes, edges }, host); instance.fitView(); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const lanes: NonNullable[] = ['orthogonal', 'manhattan', 'elk']; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class RoutingExampleComponent { nodes: NodeSpec[] = lanes.flatMap((router, index) => { const y = 110 + index * 150; return [ { id: `a${index}`, position: { x: 70, y: y - 24 }, size: { width: 108, height: 48 }, label: 'A' }, { id: `b${index}`, position: { x: 760, y: y - 24 }, size: { width: 108, height: 48 }, label: 'B' }, { id: `wall${index}`, position: { x: 410, y: y - 42 }, size: { width: 100, height: 84 }, label: router }, ]; }); edges: EdgeSpec[] = lanes.map((router, index) => ({ id: `edge${index}`, source: `a${index}`, target: `b${index}`, router, })); } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const lanes: NonNullable[] = ['orthogonal', 'manhattan', 'elk']; const nodes: NodeSpec[] = lanes.flatMap((router, index) => { const y = 110 + index * 150; return [ { id: `a${index}`, position: { x: 70, y: y - 24 }, size: { width: 108, height: 48 }, label: 'A' }, { id: `b${index}`, position: { x: 760, y: y - 24 }, size: { width: 108, height: 48 }, label: 'B' }, { id: `wall${index}`, position: { x: 410, y: y - 42 }, size: { width: 100, height: 84 }, label: router }, ]; }); const edges: EdgeSpec[] = lanes.map((router, index) => ({ id: `edge${index}`, source: `a${index}`, target: `b${index}`, router, })); export function RoutingExample() { return (
); } ``` ```vue title="Vue" ``` ::: The canvas shows three A-to-B lanes, with a labelled wall in each. Move a wall to see the route respond to the new geometry. [Open the live routing-algorithms demo](https://grafloria.com/demos/edges/routing-algorithms.html) or [view its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/routing-algorithms.html). ## Reconnect an endpoint Select the edge, then drag its endpoint handle onto a valid port on another node. The following examples pin the endpoints to named sides so the handles begin on the shown node edges. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '600px'; document.body.append(host); const nodes = [ { id: 'a', position: { x: 80, y: 260 }, size: { width: 120, height: 60 }, label: 'A' }, { id: 'b', position: { x: 660, y: 110 }, size: { width: 120, height: 60 }, label: 'B' }, { id: 'c', position: { x: 660, y: 430 }, size: { width: 120, height: 60 }, label: 'C' }, ]; const edges = [{ id: 'e1', source: 'a', target: 'b', sourceHandle: 'right', targetHandle: 'left', type: 'direct' }]; const instance = render({ nodes, edges }, host); instance.fitView(); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class ReconnectExampleComponent { nodes: NodeSpec[] = [ { id: 'a', position: { x: 80, y: 260 }, size: { width: 120, height: 60 }, label: 'A' }, { id: 'b', position: { x: 660, y: 110 }, size: { width: 120, height: 60 }, label: 'B' }, { id: 'c', position: { x: 660, y: 430 }, size: { width: 120, height: 60 }, label: 'C' }, ]; edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b', sourceHandle: 'right', targetHandle: 'left', type: 'direct', }]; } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', position: { x: 80, y: 260 }, size: { width: 120, height: 60 }, label: 'A' }, { id: 'b', position: { x: 660, y: 110 }, size: { width: 120, height: 60 }, label: 'B' }, { id: 'c', position: { x: 660, y: 430 }, size: { width: 120, height: 60 }, label: 'C' }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b', sourceHandle: 'right', targetHandle: 'left', type: 'direct', }]; export function ReconnectExample() { return (
); } ``` ```vue title="Vue" ``` ::: After you select the wire between A and B, its endpoint handles appear. Drag the handle at B to C: the link now ends at C. A drop without a valid target leaves the original connection in place. [Open the live reconnect-edge demo](https://grafloria.com/demos/edges/reconnect-edge.html) or [view its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/reconnect-edge.html). ## Add and move waypoints Waypoint editing is opt-in. Enable it on the diagram, select an edge, click its path to add a bend, and drag that waypoint. Its interior path changes while the connection remains attached to both nodes. The JavaScript sample mounts the same two-node direct edge using [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render). Its returned [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) is the live diagram instance. For the basic mounted two-node pattern, see [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works); the Vue sample below keeps its setup runnable while adding waypoint editing on a diagonal edge. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const nodes = [ { id: 'a', position: { x: 120, y: 180 }, size: { width: 150, height: 70 }, label: 'A' }, { id: 'b', position: { x: 620, y: 180 }, size: { width: 150, height: 70 }, label: 'B' }, ]; const edges = [{ id: 'e1', source: 'a', target: 'b', type: 'direct' }]; const instance = render({ nodes, edges }, host, { interaction: { enableWaypointEditing: true, showWaypointHandles: true }, }); instance.fitView(); ``` ```ts title="Angular" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class EditableEdgeExampleComponent implements AfterViewInit { canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = [ { id: 'a', position: { x: 120, y: 180 }, size: { width: 150, height: 70 }, label: 'A' }, { id: 'b', position: { x: 620, y: 180 }, size: { width: 150, height: 70 }, label: 'B' }, ]; edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b', type: 'direct' }]; ngAfterViewInit(): void { this.canvas().activeEngine()?.setInteractionConfig({ enableWaypointEditing: true, showWaypointHandles: true, }); } } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', position: { x: 120, y: 180 }, size: { width: 150, height: 70 }, label: 'A' }, { id: 'b', position: { x: 620, y: 180 }, size: { width: 150, height: 70 }, label: 'B' }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b', type: 'direct' }]; export function EditableEdgeExample() { return (
); } ``` ```vue title="Vue" ``` ::: The initial wire is straight. Once selected, clicking along its path adds a waypoint at that location; dragging the waypoint bends the line without moving either endpoint. A second path click adds another bend. Angular needs the interaction settings applied to the mounted canvas's engine; the other bindings accept them through their `interaction` prop. [Open the live editable-edge demo](https://grafloria.com/demos/edges/editable-edge.html) or [view its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/editable-edge.html). ## Options and pitfalls | Option | Type | Default | What it does | | --- | --- | --- | --- | | `router` | `'straight' \| 'orthogonal' \| 'manhattan' \| 'avoid' \| 'elk'` or a registered router name | Derived from `type` when omitted | Chooses the edge's route. | | `type` | `'direct' \| 'smooth' \| 'orthogonal' \| 'bezier'` | — | Chooses the edge's line shape. | | `sourceHandle`, `targetHandle` | `string` | Not specified | Pin an endpoint to a port or side; omit them for port-facing attachment. | | `interaction.enableWaypointEditing` | `boolean` | `false` | Allows adding, moving, or removing waypoints interactively. | | `interaction.showWaypointHandles` | `boolean` | `true` | Shows waypoint handles on selected edges. | - `type` and `router` answer different questions: line shape versus path selection. A connector controls corner drawing and is separate from both. - Waypoint editing is disabled by default. Without enabling it, clicking the path does not add a bend. - Endpoint reconnection drops onto a port. If the target is missing or invalid, the existing connection is restored. - An edge-handles route is pinned when you give it `sourceHandle` and `targetHandle`; a hand-bent route stores its waypoints in the edge's `points` after editing. For port compatibility and connection rules, see [Configure ports](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/configure-ports) and [Validate connections](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/validate-connections). For undoing edits, see [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history). # Style and label edges Use edge specifications to choose a route, draw labels and arrowheads, and make crossing edges visibly hop. A mounted diagram renders each of these settings as part of its live graph. This page focuses on the visual changes edge settings make in a mounted graph; for framework-specific setup and entry points, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). ## Set route geometry and appearance For the distinction among `type`, `router`, and `connector`, see [Diagram intent and rendering](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/diagram-intent-and-rendering); this mounted graph shows the visible differences: sharp versus rounded corners, distinct labels and markers, and hops at crossings. The `style` field accepts a partial [`LinkStyle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-types-linkstyle#linkstyle); it carries stroke properties, arrowheads, and crossing configuration. In all the framework samples, the canvas has a fixed height so the rendered diagram has space to appear. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const target = document.getElementById('app'); if (!target) throw new Error('Missing #app'); target.style.height = '900px'; const nodes = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 140, height: 56 }, label: 'Direct' }, { id: 'b', position: { x: 570, y: 150 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'c', position: { x: 80, y: 230 }, size: { width: 140, height: 56 }, label: 'Sharp step' }, { id: 'd', position: { x: 570, y: 300 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'e', position: { x: 80, y: 380 }, size: { width: 140, height: 56 }, label: 'Rounded step' }, { id: 'f', position: { x: 570, y: 450 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'g', position: { x: 80, y: 530 }, size: { width: 140, height: 56 }, label: 'Bezier' }, { id: 'h', position: { x: 570, y: 600 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'i', position: { x: 760, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross A' }, { id: 'j', position: { x: 1040, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross D' }, { id: 'k', position: { x: 760, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross B' }, { id: 'l', position: { x: 1040, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross C' }, ]; const edges = [ { id: 'direct', source: 'a', target: 'b', type: 'direct', label: 'direct', labelPlacement: 'above', labelStyle: { color: '#1d4ed8', fontSize: 12 }, style: { stroke: '#2563eb', strokeWidth: 2, arrowHead: { type: 'arrow', size: 14, filled: true } } }, { id: 'sharp', source: 'c', target: 'd', router: 'orthogonal', connector: 'straight', label: 'sharp corners', labelPlacement: 'above', style: { stroke: '#475569', strokeDasharray: '6 3', arrowHead: { type: 'diamond', size: 14, filled: false } } }, { id: 'rounded', source: 'e', target: 'f', router: 'orthogonal', connector: 'rounded', label: 'rounded corners', labelPlacement: 'below', labelStyle: { background: '#eff6ff', padding: 4, borderRadius: 4 }, style: { stroke: '#059669', strokeWidth: 2, arrowHead: { type: 'crow-foot', size: 14, filled: false } } }, { id: 'curve', source: 'g', target: 'h', type: 'bezier', label: 'no arrowhead', style: { stroke: '#9333ea', strokeWidth: 2, arrowHead: { type: 'none', size: 14, filled: false } } }, { id: 'cross-ad', source: 'i', target: 'l', type: 'direct', style: { stroke: '#0f766e', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, { id: 'cross-bc', source: 'k', target: 'j', type: 'direct', style: { stroke: '#b91c1c', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, ]; render({ nodes, edges }, target); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class EdgeStylesComponent { nodes = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 140, height: 56 }, label: 'Direct' }, { id: 'b', position: { x: 570, y: 150 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'c', position: { x: 80, y: 230 }, size: { width: 140, height: 56 }, label: 'Sharp step' }, { id: 'd', position: { x: 570, y: 300 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'e', position: { x: 80, y: 380 }, size: { width: 140, height: 56 }, label: 'Rounded step' }, { id: 'f', position: { x: 570, y: 450 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'g', position: { x: 80, y: 530 }, size: { width: 140, height: 56 }, label: 'Bezier' }, { id: 'h', position: { x: 570, y: 600 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'i', position: { x: 760, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross A' }, { id: 'j', position: { x: 1040, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross D' }, { id: 'k', position: { x: 760, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross B' }, { id: 'l', position: { x: 1040, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross C' }, ]; edges: EdgeSpec[] = [ { id: 'direct', source: 'a', target: 'b', type: 'direct', label: 'direct', labelPlacement: 'above', labelStyle: { color: '#1d4ed8', fontSize: 12 }, style: { stroke: '#2563eb', strokeWidth: 2, arrowHead: { type: 'arrow', size: 14, filled: true } } }, { id: 'sharp', source: 'c', target: 'd', router: 'orthogonal', connector: 'straight', label: 'sharp corners', labelPlacement: 'above', style: { stroke: '#475569', strokeDasharray: '6 3', arrowHead: { type: 'diamond', size: 14, filled: false } } }, { id: 'rounded', source: 'e', target: 'f', router: 'orthogonal', connector: 'rounded', label: 'rounded corners', labelPlacement: 'below', labelStyle: { background: '#eff6ff', padding: 4, borderRadius: 4 }, style: { stroke: '#059669', strokeWidth: 2, arrowHead: { type: 'crow-foot', size: 14, filled: false } } }, { id: 'curve', source: 'g', target: 'h', type: 'bezier', label: 'no arrowhead', style: { stroke: '#9333ea', strokeWidth: 2, arrowHead: { type: 'none', size: 14, filled: false } } }, { id: 'cross-ad', source: 'i', target: 'l', type: 'direct', style: { stroke: '#0f766e', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, { id: 'cross-bc', source: 'k', target: 'j', type: 'direct', style: { stroke: '#b91c1c', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, ]; } ``` ```tsx title="Qwik" import { render as renderQwik } from '@builder.io/qwik'; import { GrafloriaFlow, type EdgeSpec } from '@grafloria/qwik'; const nodes = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 140, height: 56 }, label: 'Direct' }, { id: 'b', position: { x: 570, y: 150 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'c', position: { x: 80, y: 230 }, size: { width: 140, height: 56 }, label: 'Sharp step' }, { id: 'd', position: { x: 570, y: 300 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'e', position: { x: 80, y: 380 }, size: { width: 140, height: 56 }, label: 'Rounded step' }, { id: 'f', position: { x: 570, y: 450 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'g', position: { x: 80, y: 530 }, size: { width: 140, height: 56 }, label: 'Bezier' }, { id: 'h', position: { x: 570, y: 600 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'i', position: { x: 760, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross A' }, { id: 'j', position: { x: 1040, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross D' }, { id: 'k', position: { x: 760, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross B' }, { id: 'l', position: { x: 1040, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross C' }, ]; const edges: EdgeSpec[] = [ { id: 'direct', source: 'a', target: 'b', type: 'direct', label: 'direct', labelPlacement: 'above', labelStyle: { color: '#1d4ed8', fontSize: 12 }, style: { stroke: '#2563eb', strokeWidth: 2, arrowHead: { type: 'arrow', size: 14, filled: true } } }, { id: 'sharp', source: 'c', target: 'd', router: 'orthogonal', connector: 'straight', label: 'sharp corners', labelPlacement: 'above', style: { stroke: '#475569', strokeDasharray: '6 3', arrowHead: { type: 'diamond', size: 14, filled: false } } }, { id: 'rounded', source: 'e', target: 'f', router: 'orthogonal', connector: 'rounded', label: 'rounded corners', labelPlacement: 'below', labelStyle: { background: '#eff6ff', padding: 4, borderRadius: 4 }, style: { stroke: '#059669', strokeWidth: 2, arrowHead: { type: 'crow-foot', size: 14, filled: false } } }, { id: 'curve', source: 'g', target: 'h', type: 'bezier', label: 'no arrowhead', style: { stroke: '#9333ea', strokeWidth: 2, arrowHead: { type: 'none', size: 14, filled: false } } }, { id: 'cross-ad', source: 'i', target: 'l', type: 'direct', style: { stroke: '#0f766e', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, { id: 'cross-bc', source: 'k', target: 'j', type: 'direct', style: { stroke: '#b91c1c', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, ]; const target = typeof document === 'undefined' ? null : document.createElement('div'); if (target) { target.style.height = '900px'; document.body.append(target); void renderQwik(target, ); } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec } from '@grafloria/renderer'; const nodes = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 140, height: 56 }, label: 'Direct' }, { id: 'b', position: { x: 570, y: 150 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'c', position: { x: 80, y: 230 }, size: { width: 140, height: 56 }, label: 'Sharp step' }, { id: 'd', position: { x: 570, y: 300 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'e', position: { x: 80, y: 380 }, size: { width: 140, height: 56 }, label: 'Rounded step' }, { id: 'f', position: { x: 570, y: 450 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'g', position: { x: 80, y: 530 }, size: { width: 140, height: 56 }, label: 'Bezier' }, { id: 'h', position: { x: 570, y: 600 }, size: { width: 140, height: 56 }, label: 'Target' }, { id: 'i', position: { x: 760, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross A' }, { id: 'j', position: { x: 1040, y: 680 }, size: { width: 120, height: 48 }, label: 'Cross D' }, { id: 'k', position: { x: 760, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross B' }, { id: 'l', position: { x: 1040, y: 840 }, size: { width: 120, height: 48 }, label: 'Cross C' }, ]; const edges: EdgeSpec[] = [ { id: 'direct', source: 'a', target: 'b', type: 'direct', label: 'direct', labelPlacement: 'above', labelStyle: { color: '#1d4ed8', fontSize: 12 }, style: { stroke: '#2563eb', strokeWidth: 2, arrowHead: { type: 'arrow', size: 14, filled: true } } }, { id: 'sharp', source: 'c', target: 'd', router: 'orthogonal', connector: 'straight', label: 'sharp corners', labelPlacement: 'above', style: { stroke: '#475569', strokeDasharray: '6 3', arrowHead: { type: 'diamond', size: 14, filled: false } } }, { id: 'rounded', source: 'e', target: 'f', router: 'orthogonal', connector: 'rounded', label: 'rounded corners', labelPlacement: 'below', labelStyle: { background: '#eff6ff', padding: 4, borderRadius: 4 }, style: { stroke: '#059669', strokeWidth: 2, arrowHead: { type: 'crow-foot', size: 14, filled: false } } }, { id: 'curve', source: 'g', target: 'h', type: 'bezier', label: 'no arrowhead', style: { stroke: '#9333ea', strokeWidth: 2, arrowHead: { type: 'none', size: 14, filled: false } } }, { id: 'cross-ad', source: 'i', target: 'l', type: 'direct', style: { stroke: '#0f766e', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, { id: 'cross-bc', source: 'k', target: 'j', type: 'direct', style: { stroke: '#b91c1c', jumpPoints: { enabled: true, size: 10, detectMode: 'all' } } }, ]; export default function EdgeStyles() { return (
); } ``` ```vue title="Vue" ``` ::: The first four rows render four distinct path families. `router: 'orthogonal'` keeps both step routes axis-aligned; changing `connector` from `'straight'` to `'rounded'` changes their corner treatment without changing that routing choice. Each row also renders its own label and marker style. The two lower edges cross, and one draws a small hop at the crossing. ## Place and style labels Set `label` to put text on an edge. `labelPlacement: 'on'` places it on the line in its own box; `'above'` and `'below'` place it beside the line without that box. `labelStyle` controls the label's color, size, weight, family, and background. In the example, the direct edge's label has blue text and sits above its line, while the rounded route's label has a pale background and sits below. Labels belong to the edge path: when its route changes, the label follows the route. In the live label demo, drag the label along the wire, then move a node to see the edge and label re-route together. ## Choose edge markers Set `style.arrowHead` to a built-in marker type. The sample uses `arrow`, `diamond`, and `crow-foot`; `none` explicitly renders no arrowhead. The head stays at the endpoint as the route changes. The live marker demo shows the full built-in set: `arrow`, `open-arrow`, `circle`, `square`, `diamond`, `crow-foot`, `hollow-diamond`, and `one-or-many`, as well as `none`. ## Show crossings Give crossing edges `style.jumpPoints` with `enabled: true` to draw an arc where a line crosses another. The lower pair in the sample starts in an X, so the owning edge renders a hop. Move a node until the lines no longer cross and the hop disappears; move it back and the hop returns. `size` controls the arc size in pixels, and `detectMode: 'all'` selects all crossings. ## Options used here | Option | Type | Default | What it does | | --- | --- | --- | --- | | `type` | `'direct' \| 'smooth' \| 'orthogonal' \| 'bezier'` | Not specified | Selects a path family. | | `router` | [`LinkRouterName`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-types#linkroutername) | Derived from `type` when unset | Chooses where the line goes. | | `connector` | [`LinkConnectorName`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-models-types#linkconnectorname) | Derived from `type` when unset | Chooses how the route is drawn. | | `label` | `string` | Not specified | Adds text to the edge. | | `labelPlacement` | `'on' \| 'above' \| 'below'` | `'on'` | Places the label on, above, or below the edge. | | `labelStyle` | [`LabelStyle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-types-interfaces-b-r#labelstyle) | Not specified | Sets label color, size, weight, family, and background. | | `style` | `Partial` | Not specified | Sets the link's visual style, including stroke and arrowheads. | | `style.jumpPoints` | `JumpPointConfig` | Disabled unless enabled | Controls edge hops at crossings. `size` defaults to 10 px and `detectMode` to `'all'`. | These values live on the edge spec; you do not need to reach into the model to style a link. The route and connector answer different questions, so changing the connector does not select a new router. For more routing options and saved bends, see [Route and edit edges](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/route-and-edit-edges). ## Live demos and related pages - [Edge types](https://grafloria.com/demos/edges/edge-types.html) — [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/edge-types.html) - [Edge labels](https://grafloria.com/demos/edges/edge-labels.html) — [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/edge-labels.html) - [Edge markers](https://grafloria.com/demos/edges/edge-markers.html) — [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/edge-markers.html) - [Jump-overs](https://grafloria.com/demos/edges/jump-overs.html) — [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/edges/jump-overs.html) - [Route and edit edges](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/route-and-edit-edges) - [Theme and style diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/theme-and-style-diagrams) # Group nodes and containers Create a visible container and add nodes to its membership. Use a group when a set of nodes needs a real container relationship, not only a decorative rectangle: the engine tracks membership. This page shows the task in JavaScript, Angular, Qwik, Vue, and React. Unlike the mounted workflow graph in [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows), this task adds explicit group membership: a node travels with a frame because its ID is a member, not because it overlaps the frame. See that page for framework mounting and instance setup. ## Create a frame and add members In Angular, Vue, and React, mount ordinary node data, then use the mounted instance's engine to create a group, set its frame, and add node IDs to it. The renderer shows the frame behind the nodes. Membership is explicit: a node does not become a member merely because it overlaps the frame. For groups declared with the diagram data, use [`GroupSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance#groupspec). The JavaScript and Qwik examples declare the group in their initial specifications; Angular, Vue, and React add it through the mounted engine. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const nodes = [ { id: 'ingest', position: { x: 320, y: 160 }, size: { width: 120, height: 60 }, label: 'ingest' }, { id: 'transform', position: { x: 520, y: 160 }, size: { width: 120, height: 60 }, label: 'transform' }, { id: 'retry', position: { x: 420, y: 280 }, size: { width: 120, height: 60 }, label: 'retry' }, ]; const edges = [ { id: 'e1', source: 'ingest', target: 'transform' }, { id: 'e2', source: 'ingest', target: 'retry' }, ]; const groups = [ { id: 'pipeline', label: 'Pipeline', children: ['ingest', 'transform', 'retry'], bounds: { x: 290, y: 120, width: 400, height: 240 }, }, ]; render({ nodes, edges, groups }, host); ``` ```ts title="Angular" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class PipelineComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = [ { id: 'ingest', position: { x: 320, y: 160 }, size: { width: 120, height: 60 }, label: 'ingest' }, { id: 'transform', position: { x: 520, y: 160 }, size: { width: 120, height: 60 }, label: 'transform' }, { id: 'retry', position: { x: 420, y: 280 }, size: { width: 120, height: 60 }, label: 'retry' }, ]; edges: EdgeSpec[] = [ { id: 'e1', source: 'ingest', target: 'transform' }, { id: 'e2', source: 'ingest', target: 'retry' }, ]; async ngAfterViewInit(): Promise { const engine = this.canvas().activeEngine(); if (!engine) return; const pipeline = await engine.addGroup({ name: 'Pipeline' }); pipeline.setFrame({ x: 290, y: 120, width: 400, height: 240 }); await engine.addToGroup(pipeline.id, 'ingest'); await engine.addToGroup(pipeline.id, 'transform'); await engine.addToGroup(pipeline.id, 'retry'); } } ``` ```tsx title="Qwik" import { component$ } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { EdgeSpec, GroupSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'ingest', position: { x: 320, y: 160 }, size: { width: 120, height: 60 }, label: 'ingest' }, { id: 'transform', position: { x: 520, y: 160 }, size: { width: 120, height: 60 }, label: 'transform' }, { id: 'retry', position: { x: 420, y: 280 }, size: { width: 120, height: 60 }, label: 'retry' }, ]; const edges: EdgeSpec[] = [ { id: 'e1', source: 'ingest', target: 'transform' }, { id: 'e2', source: 'ingest', target: 'retry' }, ]; const groups: GroupSpec[] = [ { id: 'pipeline', label: 'Pipeline', children: ['ingest', 'transform', 'retry'], bounds: { x: 290, y: 120, width: 400, height: 240 }, }, ]; export default component$(() => (
)); ``` ```vue title="Vue" ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'ingest', position: { x: 320, y: 160 }, size: { width: 120, height: 60 }, label: 'ingest' }, { id: 'transform', position: { x: 520, y: 160 }, size: { width: 120, height: 60 }, label: 'transform' }, { id: 'retry', position: { x: 420, y: 280 }, size: { width: 120, height: 60 }, label: 'retry' }, ]; const edges: EdgeSpec[] = [ { id: 'e1', source: 'ingest', target: 'transform' }, { id: 'e2', source: 'ingest', target: 'retry' }, ]; export function Pipeline() { const onInit = async (instance: DiagramInstance): Promise => { const engine = instance.getEngine(); const pipeline = await engine.addGroup({ name: 'Pipeline' }); pipeline.setFrame({ x: 290, y: 120, width: 400, height: 240 }); await engine.addToGroup(pipeline.id, 'ingest'); await engine.addToGroup(pipeline.id, 'transform'); await engine.addToGroup(pipeline.id, 'retry'); instance.renderNow(); }; return (
); } ``` ::: The examples render three ordinary nodes inside a labelled **Pipeline** frame, with two edges still connecting them. The frame's coordinates are in diagram world space. For a nested container, create another group, add its node members, and add that group's ID to the outer group. Call `fitToContents()` from the inner group outward when the frames need to wrap their contents. Membership remains a model relationship rather than a rule inferred from current node positions. ## Group definition options For groups declared in diagram data instead of added after mount, use [`GroupSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance#groupspec). A group spec defines the frame and its initial member node IDs. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `id` | `string` | — | Identifies the group. | | `label` | `string` | — | Supplies the frame's visible label. | | `children` | `string[]` | — | Names the node IDs that become members. | | `bounds` | `{ x: number; y: number; width: number; height: number }` | — | Pins the frame to a world-space rectangle; without it, the frame fits around its children using `padding`. | | `padding` | `number` | `20` | Sets the space between the children and a fitted frame. | | `labelPlacement` | `GroupLabelPlacement` | `'top-left'` | Places the frame label. | | `style` | `GroupFrameStyle` | — | Sets the frame's style. | ## What to watch for - Set a real height on the canvas wrapper. The canvas fills its parent; an unresolved height leaves no drawing area. - Use `addToGroup(groupId, entityId)` in that order. It returns a promise, so await it before treating membership as complete. - A node positioned inside a frame is not necessarily a member. Add it to the group explicitly. - Groups and membership are edits made through engine operations; use the engine's history APIs for undo and redo rather than looking for those methods on the instance. ## See it running - [Group frames](https://grafloria.com/demos/grouping/group-frames.html) — visible labelled frames, including a nested frame. - [Sub-flow](https://grafloria.com/demos/grouping/sub-flow.html) — nested group membership and fit-to-contents behavior. For collapsing and expanding a group, see [Collapse and expand groups](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/collapse-and-expand-groups). For the model behind groups and membership, see [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document). # Collapse and expand groups Collapse a group into a compact placeholder, then expand it to restore its members and connections. ## When to use this Use collapse when readers need to focus on the surrounding graph without losing a group's contents. The diagram engine treats the group as a container: collapsing hides its members, moves crossing links to a placeholder, and merges parallel crossings; expanding restores the saved state. ## Set up a group and add collapse controls 1. Render nodes and edges, then add a group around the member nodes. The instance gives you the [`DiagramEngine`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-engine#diagramengine) through `getEngine()`; `addGroup()` creates the group, and `addToGroup()` assigns its members. 2. Call `collapseGroup(groupId, options?)` or `expandGroup(groupId)` from your controls. Both calls return promises, so await them before treating the operation as complete. The example places three member nodes inside the Service group. On load, all five nodes and four links are visible. Click **collapse group**: the three members disappear, the group becomes a Service placeholder, and links crossing the group boundary attach to the placeholder. The two links from `ext 1` merge into one proxy link labelled `2×`; the internal member link disappears while collapsed. Click **expand group** to restore the three members, their original positions, and all four links. This page adds collapse and expand controls to the mounted diagram; for mounting the canvas and retrieving its instance in each framework, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const toolbar = document.createElement('div'); const collapseButton = document.createElement('button'); const expandButton = document.createElement('button'); const host = document.createElement('div'); collapseButton.textContent = 'collapse group'; expandButton.textContent = 'expand group'; host.style.height = '480px'; toolbar.append(collapseButton, expandButton); document.body.append(toolbar, host); const nodes = [ { id: 'ext1', position: { x: 60, y: 80 }, size: { width: 120, height: 60 }, label: 'ext 1' }, { id: 'ext2', position: { x: 60, y: 300 }, size: { width: 120, height: 60 }, label: 'ext 2' }, { id: 'm1', position: { x: 420, y: 80 }, size: { width: 120, height: 60 }, label: 'member 1' }, { id: 'm2', position: { x: 420, y: 200 }, size: { width: 120, height: 60 }, label: 'member 2' }, { id: 'm3', position: { x: 420, y: 320 }, size: { width: 120, height: 60 }, label: 'member 3' }, ]; const edges = [ { id: 'a', source: 'ext1', target: 'm1' }, { id: 'b', source: 'ext1', target: 'm2' }, { id: 'c', source: 'ext2', target: 'm3' }, { id: 'd', source: 'm1', target: 'm2' }, ]; async function mountDiagram() { const instance = render({ nodes, edges }, host); const engine = instance.getEngine(); const group = await engine.addGroup({ name: 'Service' }); group.setFrame({ x: 400, y: 60, width: 180, height: 340 }); for (const id of ['m1', 'm2', 'm3']) { await engine.addToGroup(group.id, id); } instance.fitView(40); instance.renderNow(); collapseButton.addEventListener('click', async () => { await engine.collapseGroup(group.id, { proxyLabel: (info) => `${info.count}×` }); instance.renderNow(); }); expandButton.addEventListener('click', async () => { await engine.expandGroup(group.id); instance.renderNow(); }); } void mountDiagram(); ``` ```tsx title="Angular" import { AfterViewInit, Component, ViewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class CollapseExpandComponent implements AfterViewInit { @ViewChild(DiagramCanvasComponent) private canvas!: DiagramCanvasComponent; nodes: NodeSpec[] = [ { id: 'ext1', position: { x: 60, y: 80 }, size: { width: 120, height: 60 }, label: 'ext 1' }, { id: 'ext2', position: { x: 60, y: 300 }, size: { width: 120, height: 60 }, label: 'ext 2' }, { id: 'm1', position: { x: 420, y: 80 }, size: { width: 120, height: 60 }, label: 'member 1' }, { id: 'm2', position: { x: 420, y: 200 }, size: { width: 120, height: 60 }, label: 'member 2' }, { id: 'm3', position: { x: 420, y: 320 }, size: { width: 120, height: 60 }, label: 'member 3' }, ]; edges: EdgeSpec[] = [ { id: 'a', source: 'ext1', target: 'm1' }, { id: 'b', source: 'ext1', target: 'm2' }, { id: 'c', source: 'ext2', target: 'm3' }, { id: 'd', source: 'm1', target: 'm2' }, ]; private groupId: string | undefined; async ngAfterViewInit(): Promise { const engine = this.canvas.activeEngine(); if (!engine) return; const group = await engine.addGroup({ name: 'Service' }); group.setFrame({ x: 400, y: 60, width: 180, height: 340 }); for (const id of ['m1', 'm2', 'm3']) { await engine.addToGroup(group.id, id); } this.groupId = group.id; } async collapse(): Promise { const engine = this.canvas.activeEngine(); if (engine && this.groupId) { await engine.collapseGroup(this.groupId, { proxyLabel: (info) => `${info.count}×` }); } } async expand(): Promise { const engine = this.canvas.activeEngine(); if (engine && this.groupId) await engine.expandGroup(this.groupId); } } ``` ```tsx title="Qwik" import { $, component$ } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'ext1', position: { x: 60, y: 80 }, size: { width: 120, height: 60 }, label: 'ext 1' }, { id: 'ext2', position: { x: 60, y: 300 }, size: { width: 120, height: 60 }, label: 'ext 2' }, { id: 'm1', position: { x: 420, y: 80 }, size: { width: 120, height: 60 }, label: 'member 1' }, { id: 'm2', position: { x: 420, y: 200 }, size: { width: 120, height: 60 }, label: 'member 2' }, { id: 'm3', position: { x: 420, y: 320 }, size: { width: 120, height: 60 }, label: 'member 3' }, ]; const edges: EdgeSpec[] = [ { id: 'a', source: 'ext1', target: 'm1' }, { id: 'b', source: 'ext1', target: 'm2' }, { id: 'c', source: 'ext2', target: 'm3' }, { id: 'd', source: 'm1', target: 'm2' }, ]; export default component$(() => (
{ const engine = api.getEngine(); const group = await engine.addGroup({ name: 'Service' }); group.setFrame({ x: 400, y: 60, width: 180, height: 340 }); for (const id of ['m1', 'm2', 'm3']) { await engine.addToGroup(group.id, id); } const toolbar = document.createElement('div'); const collapseButton = document.createElement('button'); const expandButton = document.createElement('button'); toolbar.style.display = 'flex'; toolbar.style.gap = '8px'; toolbar.style.padding = '10px 24px'; collapseButton.type = 'button'; collapseButton.textContent = 'collapse group'; expandButton.type = 'button'; expandButton.textContent = 'expand group'; toolbar.append(collapseButton, expandButton); const root = document.getElementById('qwik-collapse-demo'); root?.prepend(toolbar); collapseButton.addEventListener('click', async () => { await engine.collapseGroup(group.id, { proxyLabel: (info) => `${info.count}×` }); api.renderNow(); }); expandButton.addEventListener('click', async () => { await engine.expandGroup(group.id); api.renderNow(); }); api.renderNow(); })} />
)); ``` ```tsx title="React" import { useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'ext1', position: { x: 60, y: 80 }, size: { width: 120, height: 60 }, label: 'ext 1' }, { id: 'ext2', position: { x: 60, y: 300 }, size: { width: 120, height: 60 }, label: 'ext 2' }, { id: 'm1', position: { x: 420, y: 80 }, size: { width: 120, height: 60 }, label: 'member 1' }, { id: 'm2', position: { x: 420, y: 200 }, size: { width: 120, height: 60 }, label: 'member 2' }, { id: 'm3', position: { x: 420, y: 320 }, size: { width: 120, height: 60 }, label: 'member 3' }, ]; const edges: EdgeSpec[] = [ { id: 'a', source: 'ext1', target: 'm1' }, { id: 'b', source: 'ext1', target: 'm2' }, { id: 'c', source: 'ext2', target: 'm3' }, { id: 'd', source: 'm1', target: 'm2' }, ]; export default function CollapseExpandDemo() { const instance = useRef(null); const groupId = useRef(undefined); const collapse = async () => { const api = instance.current; const id = groupId.current; if (!api || !id) return; await api.getEngine().collapseGroup(id, { proxyLabel: (info) => `${info.count}×` }); api.renderNow(); }; const expand = async () => { const api = instance.current; const id = groupId.current; if (!api || !id) return; await api.getEngine().expandGroup(id); api.renderNow(); }; const onInit = async (api: DiagramInstance): Promise => { instance.current = api; const engine = api.getEngine(); const group = await engine.addGroup({ name: 'Service' }); group.setFrame({ x: 400, y: 60, width: 180, height: 340 }); for (const id of ['m1', 'm2', 'm3']) { await engine.addToGroup(group.id, id); } groupId.current = group.id; api.renderNow(); }; return (
); } ``` ```vue title="Vue" ``` ::: Each sample renders the same five nodes and four links. Its setup creates the Service group and assigns `m1`, `m2`, and `m3`; the buttons await the engine operation. The JavaScript, Qwik, React, and Vue samples call `renderNow()` after the change. The Angular sample gets the active engine from its canvas view. ## Options that affect the result | Option | Type | Default | What it does | | --- | --- | --- | --- | | `groupId` | `string` | Required | Identifies the group to collapse or expand. | | `options` | `CollapseOptions` | `undefined` | Optional collapse settings. | | `CollapseOptions.proxyLabel` | `(info: ProxyLabelInfo) => string` | The crossing count as a string when greater than one; no synthetic label for a single crossing | Supplies the label for each aggregated proxy link. Returning an empty string suppresses the label. | `collapseGroup()` resolves after the engine executes the collapse command; `expandGroup()` resolves after it executes the expand command. Both calls operate on the live diagram through its instance's engine. The collapse snapshot retains member positions and prior group geometry; expanding restores those positions and geometry, restores removed links, and removes the placeholder. Internal links are removed while collapsed. Boundary links are grouped by external node and direction, with one proxy link retained per group. An empty group has no member nodes to hide, so it collapses without creating a placeholder. For the compact visual representation shown here, add member nodes before collapsing. ## Try the live demo Open the [Collapse & expand demo](https://grafloria.com/demos/grouping/collapse-expand.html) to see the member nodes hide, boundary links re-home to the placeholder, and the parallel links merge into one labelled proxy. The demo source is [collapse-expand.html](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/grouping/collapse-expand.html). ## Related - [Group nodes and containers](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/group-nodes-and-containers) — create groups and manage membership. - [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) — understand the engine commands behind edits. # Navigate diagrams Add viewport controls, make visible detail respond to zoom, and support touch navigation in a mounted diagram. The canvas and camera share one headless model across framework bindings. ## Add a minimap, controls, and zoom-level feedback Use the shipped canvas plugins for the minimap, zoom/fit toolbar, and dotted background; the plugin-enabled canvas also supports the built-in pan, zoom, tap-selection, node-drag, and pinch gestures. Add your own zoom choices through the mounted instance when you want a predictable detail readout: the renderer chooses a level-of-detail tier from the current zoom. The JavaScript, Angular, React, and Vue samples disable the adaptive quality governor so the tier follows zoom directly, matching the contextual-zoom demo. Each sample mounts a small connected diagram and leaves room for touch navigation. JavaScript, Angular, Qwik, React, and Vue show the minimap and shipped zoom/fit controls; only React and Vue add buttons with a tier readout. Angular displays its chosen zoom. The renderer changes detail as the camera zooms, without application code rewriting node data. Build on the mounting and typed-data pattern in [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows): this page adds the minimap, zoom-level controls and rendered-tier feedback, plus touch navigation. The Angular example uses [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent). :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; import { attachCanvasPlugins } from '@grafloria/renderer'; const nodes = Array.from({ length: 9 }, (_, i) => ({ id: `n${i}`, position: { x: 90 + (i % 3) * 300, y: 70 + Math.floor(i / 3) * 190 }, size: { width: 170, height: 74 }, data: { label: `Step ${i + 1}` }, })); const edges = Array.from({ length: 8 }, (_, i) => ({ id: `e${i}`, source: `n${i}`, target: `n${i + 1}`, })); const app = document.createElement('main'); app.style.height = '100vh'; const host = document.createElement('div'); host.style.cssText = 'height:100%;touch-action:none'; document.body.append(app); app.append(host); const instance = render({ nodes, edges }, host, { renderer: { qualityGovernor: false } }); attachCanvasPlugins(instance, { background: { variant: 'dots' }, minimap: true, controls: true, }); instance.fitView(40); instance.renderNow(); ``` ```ts title="Angular" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `
zoom {{ zoom }}×
`, }) export class NavigateDiagramsComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); zoom = 1; nodes: NodeSpec[] = Array.from({ length: 9 }, (_, i) => ({ id: `n${i}`, position: { x: 90 + (i % 3) * 300, y: 70 + Math.floor(i / 3) * 190 }, size: { width: 170, height: 74 }, data: { label: `Step ${i + 1}` }, })); edges: EdgeSpec[] = Array.from({ length: 8 }, (_, i) => ({ id: `e${i}`, source: `n${i}`, target: `n${i + 1}`, })); ngAfterViewInit(): void { this.canvas().fitToContent(40); } setZoom(zoom: number): void { this.canvas().viewportController()?.setZoom(zoom); this.zoom = zoom; } } ``` ```tsx title="Qwik" import { component$, $, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow, type EdgeSpec, type NodeSpec } from '@grafloria/qwik'; const nodes: NodeSpec[] = Array.from({ length: 9 }, (_, i) => ({ id: `n${i}`, position: { x: 90 + (i % 3) * 300, y: 70 + Math.floor(i / 3) * 190 }, size: { width: 170, height: 74 }, data: { label: `Step ${i + 1}` }, })); const edges: EdgeSpec[] = Array.from({ length: 8 }, (_, i) => ({ id: `e${i}`, source: `n${i}`, target: `n${i + 1}`, })); export default component$(() => { const ready = useSignal(false); return (
{ready.value ? 'Diagram ready' : 'Loading diagram'}
{ ready.value = true; })} />
); }); ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance, EdgeSpec, NodeSpec } from '@grafloria/react'; const nodes: NodeSpec[] = Array.from({ length: 9 }, (_, i) => ({ id: `n${i}`, position: { x: 90 + (i % 3) * 300, y: 70 + Math.floor(i / 3) * 190 }, size: { width: 170, height: 74 }, data: { label: `Step ${i + 1}` }, })); const edges: EdgeSpec[] = Array.from({ length: 8 }, (_, i) => ({ id: `e${i}`, source: `n${i}`, target: `n${i + 1}`, })); export default function NavigateDiagrams() { const instance = useRef(null); const [tier, setTier] = useState(''); const zoomTo = (zoom: number) => { const api = instance.current; if (!api) return; api.viewport.setZoom(zoom); api.renderNow(); setTier(`Selected ${zoom}×; zoom ${api.viewport.getZoom()}× — ${api.getQualityState().tier}`); }; return (
{tier}
{ instance.current = api; api.fitView(40); api.renderNow(); setTier(`Current zoom ${api.viewport.getZoom()}× — ${api.getQualityState().tier}`); }} />
); } ``` ```vue title="Vue" ``` ::: In the React and Vue samples, the buttons set zoom to 1.5×, 0.7×, 0.3×, or 0.15× and the readout shows the tier reported after each selection. The tiers are high, medium, sketch, and low at those zoom levels in the contextual-zoom demo, and rendered detail falls as you zoom out. Angular displays its current zoom value. JavaScript and Qwik use the minimap and zoom/fit toolbar; Qwik's status output changes when its canvas initializes. The minimap mirrors the nodes and camera; its camera rectangle follows navigation. On a touch screen, drag empty canvas to pan, pinch to zoom, tap a node to select it, or drag a node with one finger. The canvas container uses `touch-action: none` so the browser does not consume those gestures for page scrolling or zooming. ## Options that affect navigation | Option | Type | Default | What it does | | --- | --- | --- | --- | | `plugins` | `boolean \| CanvasPluginOptions` | Off unless enabled | On a framework canvas, `true` mounts the minimap, zoom/fit controls, and background grid. In JavaScript, configure those pieces with `attachCanvasPlugins()`. | | `minZoom` | `number` | `0.1` | Sets the camera's lower zoom bound. | | `maxZoom` | `number` | `3.0` | Sets the camera's upper zoom bound. | | `zoomSensitivity` | `number` | `0.1` | Sets the relative step per wheel notch or keyboard zoom. | | `enablePan` | `boolean` | Enabled | Enables canvas panning. | | `enableZoom` | `boolean` | Enabled | Enables canvas zooming. Angular names the wheel option `enableMouseWheelZoom`; touch zoom uses the shared gesture pipeline. | | `rendererConfig.qualityGovernor` | `boolean \| GovernorOptions` | Enabled | The adaptive governor can adjust detail under load. Set it to `false` when you want detail tier to follow zoom alone, as in the sample. | `NodeSpec` and `EdgeSpec` are the framework data types used for the mounted graph. The instance-level camera methods and the Angular `viewportController()` provide the same zoom operation; the Angular example binds the canvas through its component rather than constructing a second camera. Use the `plugins` prop when its default minimap, controls, and grid suit the editor. Use `attachCanvasPlugins()` when mounting through `render()` and when you need to choose plugin options separately. The instance's `viewport` controller sets zoom and reads the current zoom; `getQualityState()` reports the tier actually rendered. ## Pitfalls - Give the canvas a resolved height. It fills its parent; a zero-height parent appears blank. - Keep touch handling on the canvas area: `touch-action: none` prevents native browser gestures from interrupting pointer movement. - The zoom values in the sample are choices for its buttons, not universal thresholds. `minZoom` and `maxZoom` clamp camera movement; use the tier readout and your own diagram to choose useful stops. ## See it running - [Minimap & controls](https://grafloria.com/demos/misc/minimap-and-controls.html) — see the live minimap, its moving camera rectangle, zoom controls, and fit-view behavior. [Demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/misc/minimap-and-controls.html) - [Contextual zoom (LOD)](https://grafloria.com/demos/interaction/contextual-zoom.html) — compare the high, medium, sketch, and low tiers as zoom changes. [Demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/contextual-zoom.html) - [Touch device](https://grafloria.com/demos/interaction/touch-device.html) — try pan, pinch, tap-to-select, and node dragging on a touch screen or in device emulation. [Demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/interaction/touch-device.html) ## Related - [DiagramInstance](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) - [Canvas plugins](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-ext-components) - [The element API](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) - [Angular canvas component](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent) # Theme and style diagrams Use a [theme](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-types-interfaces-t-v) for the diagram-wide palette, then use node specs for individual differences. A named style class lets you reuse a set of node styles; `strokeWidth` sets a node's border width. Start with [LIGHT_THEME](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-themes-constants) or [DARK_THEME](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-themes-constants) for the overall palette. A node's spec supplies local styling, and a mounted [DiagramInstance](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) can update the theme or reconcile node specs without replacing the diagram. For plain JavaScript, [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) mounts the spec and returns that instance. ## Register styles and mount a styled diagram Call [`defineStyle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-themes-functions) before mounting the diagram, and refer to each named style in a node's `style.styleClass`. The JavaScript sample uses [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec) values and an [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec) list. It mounts three nodes: the `priority` node combines `warn` and `bold`; the second node overrides the named class's fill with an inline green fill; the third keeps the theme's node styling. Click **Toggle theme** to switch the mounted diagram's palette. Click **Emphasize priority** to change only the node whose id is `priority` to a 9px stroke. The names are prefixed to avoid collisions with other named styles in the application. Named styles are applied in order, so the later `bold` definition overrides `warn`'s `strokeWidth`; an element's own inline style overrides a named class. The cascade is theme, type default, named class, element inline, then state. ```ts import type { DiagramInstance, EdgeSpec, NodeSpec } from '@grafloria/renderer'; async function mountStyledDiagram(): Promise { const { render, defineStyle } = await import('@grafloria/element'); const { DARK_THEME, LIGHT_THEME } = await import('@grafloria/renderer'); const WARN = 'guide-warn'; const BOLD = 'guide-bold'; defineStyle(WARN, { fill: '#f97316', stroke: '#9a3412', strokeWidth: 2 }); defineStyle(BOLD, { strokeWidth: 6 }); const nodes: NodeSpec[] = [ { id: 'priority', position: { x: 50, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + bold' }, style: { styleClass: `${WARN} ${BOLD}` }, }, { id: 'override', position: { x: 280, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + inline fill' }, style: { styleClass: WARN, fill: '#22c55e' }, }, { id: 'plain', position: { x: 510, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'theme default' }, }, ]; const edges: EdgeSpec[] = []; const themeButton = document.createElement('button'); themeButton.type = 'button'; themeButton.textContent = 'Toggle theme'; const emphasizeButton = document.createElement('button'); emphasizeButton.type = 'button'; emphasizeButton.textContent = 'Emphasize priority'; const canvas = document.createElement('div'); canvas.style.height = '220px'; document.body.append(themeButton, emphasizeButton, canvas); let themeIsDark = false; let emphasized = false; const instance: DiagramInstance = render( { nodes, edges }, canvas, { theme: LIGHT_THEME }, ); themeButton.onclick = () => { themeIsDark = !themeIsDark; instance.setTheme(themeIsDark ? DARK_THEME : LIGHT_THEME); }; emphasizeButton.onclick = () => { emphasized = !emphasized; instance.setNodes(nodes.map((node) => node.id === 'priority' ? { ...node, style: { ...node.style, strokeWidth: emphasized ? 9 : 6 } } : node, )); instance.renderNow(); }; } void mountStyledDiagram(); ``` The JavaScript example creates a sized canvas and its controls, then uses [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) to get the live instance. `setTheme()` changes that instance's theme, and `setNodes()` reconciles the full node list. Keep the other nodes in the array: `setNodes()` reconciles the diagram, including removing nodes that are missing from the next list. ## Use the diagram in Angular, React, Vue, and Qwik These versions render the same named-class and inline-style examples. Angular and React switch themes by changing the component's `theme` input; Vue mounts with `DARK_THEME` as its component input. The Qwik sample mounts through `render()` in a visible task and keeps the live instance out of serialized state. [GrafloriaFlow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik) provides the React and Vue canvas; Angular uses [DiagramCanvasComponent](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent). Each canvas has a resolved height. :::code-group ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { defineStyle } from '@grafloria/renderer'; import { DARK_THEME, LIGHT_THEME, type EdgeSpec, type NodeSpec, type Theme, } from '@grafloria/renderer'; const WARN = 'guide-angular-warn'; const BOLD = 'guide-angular-bold'; defineStyle(WARN, { fill: '#f97316', stroke: '#9a3412', strokeWidth: 2 }); defineStyle(BOLD, { strokeWidth: 6 }); @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class StyledDiagramComponent { theme: Theme = LIGHT_THEME; nodes: NodeSpec[] = [ { id: 'priority', position: { x: 50, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + bold' }, style: { styleClass: `${WARN} ${BOLD}` }, }, { id: 'override', position: { x: 280, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + inline fill' }, style: { styleClass: WARN, fill: '#22c55e' }, }, { id: 'plain', position: { x: 510, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'theme default' }, }, ]; edges: EdgeSpec[] = []; toggleTheme(): void { this.theme = this.theme === LIGHT_THEME ? DARK_THEME : LIGHT_THEME; } } ``` ```tsx title="Qwik" import { $, component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize, } from '@builder.io/qwik'; import { defineStyle, render } from '@grafloria/element'; import { DARK_THEME, LIGHT_THEME } from '@grafloria/renderer'; import type { DiagramInstance, EdgeSpec, NodeSpec } from '@grafloria/renderer'; const WARN = 'guide-qwik-warn'; const BOLD = 'guide-qwik-bold'; const nodes: NodeSpec[] = [ { id: 'priority', position: { x: 50, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + bold' }, style: { styleClass: `${WARN} ${BOLD}` }, }, { id: 'override', position: { x: 280, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + inline fill' }, style: { styleClass: WARN, fill: '#22c55e' }, }, { id: 'plain', position: { x: 510, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'theme default' }, }, ]; const edges: EdgeSpec[] = []; export default component$(() => { const host = useSignal(); const instance = useSignal>(); const dark = useSignal(true); const emphasized = useSignal(false); useVisibleTask$(({ cleanup }) => { const element = host.value; if (!element) return; defineStyle(WARN, { fill: '#f97316', stroke: '#9a3412', strokeWidth: 2 }); defineStyle(BOLD, { strokeWidth: 6 }); const api = render({ nodes, edges }, element, { theme: DARK_THEME }); instance.value = noSerialize(api); cleanup(() => api.dispose()); }); const toggleTheme = $(() => { const api = instance.value; if (!api) return; dark.value = !dark.value; api.setTheme(dark.value ? DARK_THEME : LIGHT_THEME); }); const emphasizePriority = $(() => { const api = instance.value; if (!api) return; emphasized.value = !emphasized.value; api.setNodes(nodes.map((node) => node.id === 'priority' ? { ...node, style: { ...node.style, strokeWidth: emphasized.value ? 9 : 6 } } : node, )); api.renderNow(); }); return (
); }); ``` ```tsx title="React" import { useState } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import { defineStyle } from '@grafloria/element'; import { DARK_THEME, LIGHT_THEME } from '@grafloria/renderer'; import type { EdgeSpec, NodeSpec, Theme } from '@grafloria/renderer'; const WARN = 'guide-react-warn'; const BOLD = 'guide-react-bold'; defineStyle(WARN, { fill: '#f97316', stroke: '#9a3412', strokeWidth: 2 }); defineStyle(BOLD, { strokeWidth: 6 }); const nodes: NodeSpec[] = [ { id: 'priority', position: { x: 50, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + bold' }, style: { styleClass: `${WARN} ${BOLD}` }, }, { id: 'override', position: { x: 280, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'warn + inline fill' }, style: { styleClass: WARN, fill: '#22c55e' }, }, { id: 'plain', position: { x: 510, y: 60 }, size: { width: 190, height: 78 }, data: { label: 'theme default' }, }, ]; const edges: EdgeSpec[] = []; export function StyledDiagram() { const [theme, setTheme] = useState(LIGHT_THEME); return (
); } ``` ```vue title="Vue" ``` ::: In each component, the `priority` node's id and style travel together in its spec. Change a mounted node by id through the instance's `setNodes()` method, as in the JavaScript sample; pass the full node list so reconciliation keeps the other nodes. The frameworks use the same theme and node-style data because each binding feeds the same diagram model. ## Options that affect the result | Option | Type | Default | What it does | | --- | --- | --- | --- | | `theme` | `Theme` | `LIGHT_THEME` | Supplies the canvas palette and default node and link styles. | | `style.styleClass` | `string` | None | Names one or more registered styles for a node; separate multiple names with spaces. | | `style.strokeWidth` | `number` | Theme's node default | Sets the node's border width; a later cascade layer can override it. | The cascade makes conflicts predictable: an inline node style overrides its named class, while selection and other state styling sit above both. An unknown style-class name contributes no style. Keep named-style names distinctive: named styles use an application-wide registry. ## See the demos The [Named style classes demo](https://grafloria.com/demos/styling/named-style-classes.html) shows class stacking, inline overrides, and selection styling. Its initial view has an orange `warn` node, a green inline-fill override, and a thicker `warn bold` node. The [Dark mode demo](https://grafloria.com/demos/styling/dark-mode.html) demonstrates the theme palette change on a rendered diagram. ## Related - [Lay out diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams) - [Resize and size nodes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/resize-and-size-nodes) # Import and round-trip diagrams Import Mermaid-compatible text—including a `.mmd` file—into a live, editable canvas, then export the same diagram back to text without flattening it into an image. Use the canvas instance: [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) exposes `loadText()` and `exportText()`. Loading reconciles the parsed diagram into the mounted model, so its renderer and listeners remain attached. Exported text includes a Grafloria document sidecar by default: an untouched import/export round-trip retains the full document, while the readable Mermaid body remains available to other Mermaid tools. ## Import and export in the browser Use this pattern when a user supplies Mermaid text or a text file. The file picker accepts `.mmd`, `.mermaid`, and `.txt`; the file contents—not the file wrapper—are passed to the canvas. The JavaScript, Angular, React, and Vue examples start with two real nodes and an edge, then import or export on demand. The Qwik example mounts its editable canvas from typed node and edge specs. Give each canvas a sized parent so it can draw. ### JavaScript ```js import { render } from '@grafloria/element'; const app = document.createElement('main'); app.style.cssText = 'font: 14px sans-serif; padding: 12px;'; const file = document.createElement('input'); file.type = 'file'; file.accept = '.mmd,.mermaid,.txt'; const loadButton = document.createElement('button'); loadButton.type = 'button'; loadButton.textContent = 'Load text'; const exportButton = document.createElement('button'); exportButton.type = 'button'; exportButton.textContent = 'Export text'; const editor = document.createElement('textarea'); editor.setAttribute('aria-label', 'Mermaid diagram text'); editor.style.cssText = 'display:block;box-sizing:border-box;width:100%;height:150px;margin:8px 0;'; const status = document.createElement('p'); const host = document.createElement('div'); host.style.cssText = 'height:420px;width:100%;'; app.append(file, loadButton, exportButton, editor, status, host); document.body.append(app); const nodes = [ { id: 'start', position: { x: 60, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Start' } }, { id: 'finish', position: { x: 300, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Finish' } }, ]; const edges = [{ id: 'next', source: 'start', target: 'finish' }]; const instance = render({ nodes, edges }, host); editor.value = instance.exportText(); function applyText(source) { const result = instance.loadText(source); status.textContent = result.unsupported ? `Unsupported diagram type: ${result.unsupported}` : `Loaded from ${result.source}${result.bodyEdited ? ' (text body edited)' : ''}.`; } loadButton.addEventListener('click', () => applyText(editor.value)); exportButton.addEventListener('click', () => { editor.value = instance.exportText(); status.textContent = 'Exported the live diagram to Mermaid-compatible text.'; }); file.addEventListener('change', async () => { const selected = file.files?.[0]; if (!selected) return; editor.value = await selected.text(); applyText(editor.value); }); ``` The page mounts a two-node diagram, and Export text fills the editor with its Mermaid-compatible representation and lossless sidecar. Load text or choose a text file to update the same editable canvas. This uses [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) to mount the spec and the returned instance to do the round-trip. ### Angular ```ts import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `

{{ status }}

`, }) export class ImportTextComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = [ { id: 'start', position: { x: 60, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Start' } }, { id: 'finish', position: { x: 300, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Finish' } }, ]; edges: EdgeSpec[] = [{ id: 'next', source: 'start', target: 'finish' }]; text = ''; status = 'Export text to begin.'; ngAfterViewInit(): void { this.text = this.canvas().exportText(); } editText(event: Event): void { this.text = (event.currentTarget as HTMLTextAreaElement).value; } async loadFile(event: Event): Promise { const input = event.currentTarget as HTMLInputElement; const selected = input.files?.[0]; if (!selected) return; this.text = await selected.text(); this.loadText(); } loadText(): void { this.canvas().loadText(this.text); this.status = 'Applied the Mermaid-compatible text to the canvas.'; } exportText(): void { this.text = this.canvas().exportText(); this.status = 'Exported the live diagram to Mermaid-compatible text.'; } } ``` Here the mounted [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) owns the live diagram. Choosing a text file or clicking Load text reconciles its text into the canvas; Export text writes the live diagram back to the textarea. The Angular canvas methods return text for export and apply text to the existing diagram for import. ### Qwik ```tsx import { component$ } from '@builder.io/qwik'; import { GrafloriaFlow, type EdgeSpec, type NodeSpec } from '@grafloria/qwik'; const nodes: NodeSpec[] = [ { id: 'start', position: { x: 60, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Start' } }, { id: 'finish', position: { x: 300, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Finish' } }, ]; const edges: EdgeSpec[] = [{ id: 'next', source: 'start', target: 'finish' }]; export default component$(() => (
)); ``` The Qwik [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow) mounts the typed specs as an editable canvas. Use `loadText()` and `exportText()` on the live instance for the text round-trip described above. ### React ```tsx import { useRef, useState, type ChangeEvent } from 'react'; import { GrafloriaFlow, type DiagramInstance, type EdgeSpec, type NodeSpec } from '@grafloria/react'; const nodes: NodeSpec[] = [ { id: 'start', position: { x: 60, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Start' } }, { id: 'finish', position: { x: 300, y: 70 }, size: { width: 140, height: 60 }, data: { label: 'Finish' } }, ]; const edges: EdgeSpec[] = [{ id: 'next', source: 'start', target: 'finish' }]; export function ImportTextDiagram() { const instance = useRef(null); const [text, setText] = useState(''); const [status, setStatus] = useState('The canvas is ready.'); function applyText(source: string): void { const live = instance.current; if (!live) return; const result = live.loadText(source); setStatus(result.unsupported ? `Unsupported diagram type: ${result.unsupported}` : `Loaded from ${result.source}${result.bodyEdited ? ' (text body edited)' : ''}.`); } async function loadFile(event: ChangeEvent): Promise { const selected = event.currentTarget.files?.[0]; if (!selected) return; const source = await selected.text(); setText(source); applyText(source); } function exportText(): void { const live = instance.current; if (!live) return; setText(live.exportText()); setStatus('Exported the live diagram to Mermaid-compatible text.'); } return (

{{ status }}

``` The Vue [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaflow) emits its init callback with the live instance. The shallow ref keeps that instance unproxied; loading changes the canvas, while export replaces the textarea value with a new text representation. ## Text format and options [`importDiagramText`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-serialization#importdiagramtext), which `loadText()` uses, returns an [`ImportTextResult`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-serialization#importtextresult) describing the parsed model, which source won, and whether a sidecar body was edited. If a Mermaid type is recognized but unsupported, `unsupported` names it instead of guessing at a flowchart. The example status displays that result; unsupported text does not render as a valid diagram. An exported sidecar keeps the full Grafloria document for an untouched round trip. If a reader edits the Mermaid body, the default `auto` preference applies that body over the sidecar document: text structure and labels reflect the edit while data Mermaid cannot express, such as positions, styles, ports, and groups, remains from the sidecar. Pure Mermaid text has no sidecar and parses on a best-effort basis. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `prefer` on [`ImportTextOptions`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-serialization#importtextoptions) | `'auto' \| 'sidecar' \| 'text'` | `'auto'` | Chooses which source wins when the text contains both a sidecar and a body. `auto` uses the sidecar unless the body hash shows a hand edit; `sidecar` ignores body edits; `text` parses the body and ignores the sidecar. | The import reconciles into the existing diagram rather than replacing the instance; listeners, plugins, and the renderer stay attached. It is the model—not a screenshot—that the text sidecar carries. See [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) for the document and model relationship. ## What the demos show The [Mermaid text demo](https://grafloria.com/demos/misc/mermaid-text.html) starts with a Mermaid-shaped text pane and a rendered three-node flow. Its sidecar supports an unchanged, lossless round-trip; edit `build[Build]` to `build[Verify]` and the canvas reflects the body edit while the other nodes remain. The [Mermaid viewer demo](https://grafloria.com/demos/misc/mermaid-viewer.html) provides a paste-and-apply workflow for supported Mermaid diagram types. It names unsupported types instead of rendering them as a misleading flowchart. For `.drawio` XML rather than Mermaid text, see the [draw.io import demo](https://grafloria.com/demos/misc/drawio-import.html). It demonstrates plain and compressed imports, multi-page files, and editable canvas models. For the separate workflow of embedding a model in an exported SVG or PNG and reopening it as an editable diagram, see the [editable round-trip demo](https://grafloria.com/demos/misc/editable-round-trip.html). ## Pitfalls - `loadText()` reconciles entities by id. If you need a genuinely fresh document and the incoming text reuses ids, clear the current data before applying it; see [Add nodes from palettes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/add-nodes-from-palettes) for the reconciliation warning. - Untouched sidecar text carries more than Mermaid structure. Removing its `%%grafloria` comments leaves pure Mermaid, which parses best-effort and cannot retain every Grafloria-only detail. - The canvas fills its parent. Keep a real height on the wrapper; otherwise the imported diagram has no visible drawing area. ## Related - [Mermaid & the text format](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-serialization) — import and export behavior, sidecars, and supported Mermaid syntax. - [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document) — the editable document represented by the sidecar. - [Export diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/export-diagrams) — image and document export options. # Export diagrams This page covers live diagram exports in JavaScript, Angular, React and Vue, plus server-side SVG output; Qwik is outside its scope. Use it when you need a downloadable image or PDF from a live diagram, or a static SVG from a server process with no DOM. The server-side path uses the framework-independent static renderer. For how to mount a diagram and obtain its live instance in JavaScript and the framework bindings, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows); this page adds image and PDF downloads plus DOM-free SVG rendering. ## Download an image from a live diagram Mount a real diagram first, then call the instance's async `export()` method from a user action. PNG returns an image data URL; SVG returns its source string, which you can wrap in an SVG data URL for download. The export includes the diagram's rendered labels, edges and styling; PNG at `scale: 2` requests a 2× raster. 1. Mount a diagram with at least one node and edge, then call `export()` from a user action. The examples use the same three-node flow; their buttons download `diagram.png` or `diagram.svg` from the mounted canvas. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '400px'; const pngButton = document.createElement('button'); pngButton.textContent = 'Download PNG'; const svgButton = document.createElement('button'); svgButton.textContent = 'Download SVG'; const status = document.createElement('span'); document.body.append(pngButton, svgButton, status, host); const instance = render({ nodes: [ { id: 'ingest', label: 'Ingest', position: { x: 40, y: 80 }, size: { width: 150, height: 66 } }, { id: 'transform', label: 'Transform', position: { x: 260, y: 80 }, size: { width: 150, height: 66 } }, { id: 'publish', label: 'Publish', position: { x: 480, y: 80 }, size: { width: 150, height: 66 } }, ], edges: [ { id: 'e1', source: 'ingest', target: 'transform', label: 'rows' }, { id: 'e2', source: 'transform', target: 'publish' }, ], }, host); instance.renderNow(); function download(href, name) { const anchor = document.createElement('a'); anchor.href = href; anchor.download = name; anchor.click(); } async function downloadPng() { const image = await instance.export('png', { scale: 2 }); download(image, 'diagram.png'); status.textContent = 'PNG download started.'; } async function downloadSvg() { const svg = await instance.export('svg'); download(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`, 'diagram.svg'); status.textContent = 'SVG download started.'; } pngButton.addEventListener('click', downloadPng); svgButton.addEventListener('click', downloadSvg); ``` ```ts title="Angular" import { Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` {{ note }} `, }) export class DiagramExportComponent { readonly canvas = viewChild.required(DiagramCanvasComponent); note = 'Choose an image format to download.'; nodes: NodeSpec[] = [ { id: 'ingest', label: 'Ingest', position: { x: 40, y: 80 }, size: { width: 150, height: 66 } }, { id: 'transform', label: 'Transform', position: { x: 260, y: 80 }, size: { width: 150, height: 66 } }, { id: 'publish', label: 'Publish', position: { x: 480, y: 80 }, size: { width: 150, height: 66 } }, ]; edges: EdgeSpec[] = [ { id: 'e1', source: 'ingest', target: 'transform', label: 'rows' }, { id: 'e2', source: 'transform', target: 'publish' }, ]; async downloadPng(): Promise { const href = await this.canvas().exportDiagram('png', { scale: 2 }); this.download(href, 'diagram.png'); this.note = 'PNG download started.'; } async downloadSvg(): Promise { const svg = await this.canvas().exportDiagram('svg'); this.download(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`, 'diagram.svg'); this.note = 'SVG download started.'; } private download(href: string, name: string): void { const anchor = document.createElement('a'); anchor.href = href; anchor.download = name; anchor.click(); } } ``` ```vue title="Vue" ``` ::: After either button, the page reports that the PNG or SVG download started, and the browser downloads the selected file. The PNG contains a rasterized export; the SVG is vector markup you can open in a text editor or scale without rasterizing. [Open the live image-export demo](https://grafloria.com/demos/misc/download-image.html) or [view its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/misc/download-image.html). ## Download a PDF 2. Add a PDF action to the mounted diagram and call `export('pdf')`. The result is a PDF data URL, so the browser can download it with the same anchor pattern. This output is a vector PDF: node paths remain paths and labels remain selectable text. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const host = document.createElement('div'); host.style.height = '400px'; const pdfButton = document.createElement('button'); pdfButton.textContent = 'Download PDF'; const status = document.createElement('span'); document.body.append(pdfButton, status, host); const instance = render({ nodes: [ { id: 'requirements', label: 'Requirements', position: { x: 40, y: 80 }, size: { width: 170, height: 66 } }, { id: 'design', label: 'Design', position: { x: 280, y: 80 }, size: { width: 170, height: 66 } }, { id: 'ship', label: 'Ship', position: { x: 520, y: 80 }, size: { width: 170, height: 66 } }, ], edges: [ { id: 'e1', source: 'requirements', target: 'design' }, { id: 'e2', source: 'design', target: 'ship' }, ], }, host); instance.renderNow(); async function downloadPdf() { const href = await instance.export('pdf'); const anchor = document.createElement('a'); anchor.href = href; anchor.download = 'diagram.pdf'; anchor.click(); status.textContent = 'PDF download started.'; } pdfButton.addEventListener('click', () => void downloadPdf()); ``` ```ts title="Angular" import { Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: ` {{ note }} `, }) export class PdfExportComponent { readonly canvas = viewChild.required(DiagramCanvasComponent); note = 'Choose PDF to download.'; nodes: NodeSpec[] = [ { id: 'requirements', label: 'Requirements', position: { x: 40, y: 80 }, size: { width: 170, height: 66 } }, { id: 'design', label: 'Design', position: { x: 280, y: 80 }, size: { width: 170, height: 66 } }, { id: 'ship', label: 'Ship', position: { x: 520, y: 80 }, size: { width: 170, height: 66 } }, ]; edges: EdgeSpec[] = [ { id: 'e1', source: 'requirements', target: 'design' }, { id: 'e2', source: 'design', target: 'ship' }, ]; async downloadPdf(): Promise { const href = await this.canvas().exportDiagram('pdf'); const anchor = document.createElement('a'); anchor.href = href; anchor.download = 'diagram.pdf'; anchor.click(); this.note = 'PDF download started.'; } } ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'requirements', label: 'Requirements', position: { x: 40, y: 80 }, size: { width: 170, height: 66 } }, { id: 'design', label: 'Design', position: { x: 280, y: 80 }, size: { width: 170, height: 66 } }, { id: 'ship', label: 'Ship', position: { x: 520, y: 80 }, size: { width: 170, height: 66 } }, ]; const edges: EdgeSpec[] = [ { id: 'e1', source: 'requirements', target: 'design' }, { id: 'e2', source: 'design', target: 'ship' }, ]; export default function PdfExport() { const instanceRef = useRef(null); const [note, setNote] = useState('Choose PDF to download.'); async function downloadPdf(): Promise { const instance = instanceRef.current; if (!instance) return; const href = await instance.export('pdf'); const anchor = document.createElement('a'); anchor.href = href; anchor.download = 'diagram.pdf'; anchor.click(); setNote('PDF download started.'); } return (
{note} { instanceRef.current = instance; }} />
); } ``` ```vue title="Vue" ``` ::: The page reports that the PDF download started, and the browser downloads `diagram.pdf`. Select and copy a node label in a PDF reader to confirm that the text is selectable rather than part of a screenshot. [Open the live PDF-export demo](https://grafloria.com/demos/misc/pdf-export.html) or [view its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/misc/pdf-export.html). ## Render an SVG on the server 3. For a server-rendered artifact, call [`renderStatic`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) with plain node and edge specs. It returns `svg` and the stylesheet `css` without mounting a canvas or needing a DOM. Inline the stylesheet into the SVG before writing the file so the artifact carries its styles. This Node.js ESM example writes a self-contained `diagram.svg`; render the same specs again to produce the same SVG and CSS. ```ts import { writeFile } from 'node:fs/promises'; import { renderStatic } from '@grafloria/element'; const result = renderStatic({ nodes: [ { id: 'extract', label: 'Extract', position: { x: 40, y: 40 }, size: { width: 150, height: 66 } }, { id: 'load', label: 'Load', position: { x: 260, y: 40 }, size: { width: 150, height: 66 } }, { id: 'model', label: 'Model', position: { x: 150, y: 170 }, size: { width: 150, height: 66 } }, ], edges: [ { id: 'e1', source: 'extract', target: 'load' }, { id: 'e2', source: 'extract', target: 'model' }, ], width: 520, height: 300, standalone: true, instanceId: 'pipeline-diagram', }); const artifact = result.svg.replace( /^(]*>)/, `$1`, ); async function writeArtifact(): Promise { await writeFile('diagram.svg', artifact, 'utf8'); } void writeArtifact(); ``` The saved SVG contains the Extract, Load and Model nodes with their edges; the call returns `{ html, svg, css, snapshot }`, and only `svg` plus its inlined CSS goes into this standalone file. `renderStatic()` supports SVG-rendered diagram content, not framework custom-node components. [Open the live server-side export demo](https://grafloria.com/demos/misc/server-side-export.html) or [view its source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/misc/server-side-export.html). ## Options and caveats The instance's `export()` returns a `Promise`: SVG source for `svg`, and a `data:` URL for PNG, JPEG, WebP and PDF. It waits for asynchronous custom-node painters before capturing them. Angular exposes the equivalent async path as `exportDiagram(format, options)` on the canvas component. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `scale` | `number` | `1` | Sets raster image scale. | | `quality` | `number` from `0` to `1` | `0.92` | Sets JPEG or WebP quality. | | `backgroundColor` | `string` | transparent | Sets the export background color. | | `viewport` | `Rectangle` | content bounds | Selects a world-space rectangle to export. | | `padding` | `number` | `20` | Adds margin in world units around content bounds; ignored when `viewport` is explicit. | | `embedModel` | `boolean` | not specified | Embeds the source model in SVG metadata or a PNG text chunk for editable round-trips; JPEG and WebP ignore it. | | `onWarnings` | `(warnings: string[]) => void` | not specified | Receives fidelity warnings for the export. | `export()` needs a painted live canvas; call it in response to an action after mounting, not before the first paint. For SVG downloads, wrap the returned markup in an SVG data URL as the examples do. External images can depend on browser fetch/CORS access; check `onWarnings` when an asset or custom-node capture does not appear. The synchronous `exportSvgString()` and `exportPdf()` instance methods are alternatives when you need immediate results; they return result objects with warnings and do not wait for asynchronous painters or fetch external assets. ## Related - [The `DiagramInstance` API](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance) - [The element package API](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) - [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start) # Build ER and UML diagrams Use the ER and UML kits when your schema or class model already exists as data and you want Grafloria to render its cards, relationship notation, and interactive canvas. The kits return diagram specs; [`render()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) mounts those specs in JavaScript, while framework bindings mount the same specs in their host components. ## Build an ER diagram Pass entities and relationships to [`erDiagram()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-diagram-kit-functions#erdiagram). Each entity becomes a table card with typed column rows and PK/FK badges. Relationships draw as orthogonal edges with cardinality markers; endpoints written as `ENTITY.column` attach to that column's row. The kit provides row selection by default. The JavaScript example connects `CUSTOMER.id` to `ORDER.customer_id`. It renders two table cards, their key badges, and a one-to-many relationship with its label. Give the mounted canvas a real height so it has space to draw. The Angular sample uses column-qualified ends too, so each relationship endpoint attaches to the matching row. The JavaScript samples create and append a 500-pixel host before mounting. React and Vue mount the same ER data through their framework hosts; Angular uses a separate AUTHOR-to-BOOK example to show column-qualified endpoints. The React [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriadiagram) and Vue [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriadiagram) bindings accept the kit spec directly. Angular uses [`GrafloriaDiagramComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-classes#grafloriadiagramcomponent). Qwik uses [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriadiagram) with a serializable [`RenderSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#renderspec) of nodes and edges; that Qwik variant draws generic nodes and labeled edges, not the kits' HTML cards, key badges, cardinality markers, UML relationship markers, or multiplicity labels. The ER builder accepts [`ErDiagramOptions`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-diagram-kit-interfaces#erdiagramoptions); the UML builder accepts [`UmlDiagramOptions`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-diagram-kit-interfaces#umldiagramoptions). :::code-group ```js title="JavaScript" import { erDiagram, render } from '@grafloria/element'; const spec = erDiagram({ entities: [ { id: 'CUSTOMER', name: 'Customer', position: { x: 60, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'email', type: 'varchar' }, ] }, { id: 'ORDER', name: 'Order', position: { x: 390, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'customer_id', type: 'int', fk: true }, { name: 'placed_at', type: 'date' }, ] }, ], relationships: [ { from: 'CUSTOMER.id', to: 'ORDER.customer_id', label: 'places', cardinality: 'one-to-many', }, ], }); const host = document.createElement('div'); host.style.cssText = 'display:block; width:100%; height:500px'; document.body.append(host); const instance = render(spec, host); instance.fitView(40); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { GrafloriaDiagramComponent } from '@grafloria/angular'; import { erDiagram } from '@grafloria/element'; @Component({ selector: 'app-er-diagram', standalone: true, imports: [GrafloriaDiagramComponent], template: '', }) export class ErDiagramComponent { spec = erDiagram({ entities: [ { id: 'AUTHOR', name: 'Author', position: { x: 60, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'name', type: 'varchar' }, ] }, { id: 'BOOK', name: 'Book', position: { x: 390, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'author_id', type: 'int', fk: true }, { name: 'title', type: 'varchar' }, ] }, ], relationships: [{ from: 'AUTHOR.id', to: 'BOOK.author_id', label: 'writes', cardinality: 'one-to-many' }], }); } ``` ```tsx title="Qwik" import { component$ } from '@builder.io/qwik'; import { GrafloriaDiagram } from '@grafloria/qwik'; import type { RenderSpec } from '@grafloria/element'; const spec: RenderSpec = { nodes: [ { id: 'CUSTOMER', position: { x: 60, y: 80 }, size: { width: 240, height: 90 }, label: 'Customer · PK id · email' }, { id: 'ORDER', position: { x: 390, y: 80 }, size: { width: 260, height: 90 }, label: 'Order · PK id · FK customer_id' }, ], edges: [{ id: 'places', source: 'CUSTOMER', target: 'ORDER', label: 'places' }], }; export default component$(() => (
)); ``` ```tsx title="React" import { GrafloriaDiagram } from '@grafloria/react'; import { erDiagram } from '@grafloria/element'; const spec = erDiagram({ entities: [ { id: 'CUSTOMER', name: 'Customer', position: { x: 60, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'email', type: 'varchar' }, ] }, { id: 'ORDER', name: 'Order', position: { x: 390, y: 80 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'customer_id', type: 'int', fk: true }, { name: 'placed_at', type: 'date' }, ] }, ], relationships: [{ from: 'CUSTOMER.id', to: 'ORDER.customer_id', label: 'places', cardinality: 'one-to-many' }], }); export function ErDiagramView() { return (
); } ``` ```vue title="Vue" ``` ::: To connect tables without pinning to a specific row, use the entity IDs as relationship ends, such as `{ from: 'CUSTOMER', to: 'ORDER', label: 'places' }`. Use column-qualified ends for FK-to-PK relationships: the column name must exist on the named entity or the kit throws an error when it builds the spec. The default cardinality is `one-to-many`; choose another supported cardinality when the model calls for it. [Open the live ER diagram demo](https://grafloria.com/demos/diagrams/table-er.html) to see the rendered table cards, key badges, and relationship markers. For multiple foreign keys between the same tables, self-references, junction tables, and optional or mandatory cardinalities, see the [advanced ER demo](https://grafloria.com/demos/diagrams/er-advanced.html). Its column-level ends and cardinality choices use the same entity and relationship data model. ## Build a UML class diagram Pass class definitions and relationships to [`umlDiagram()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-diagram-kit-functions#umldiagram). Classes render in compartments for the class name, attributes, and methods. An abstract class name is italicized; relationship kinds supply their UML marker and line style. Multiplicity values add positioned labels to the live links. This example renders an abstract `Animal`, its `Dog` subclass, and an `Owner` aggregation. The kit draws the three-compartment cards, inheritance triangle, and hollow diamond; `render()` runs the kit's finalization hook when it mounts a spec. The React and Vue samples pass the spec to their framework binding. Angular mounts it with [`GrafloriaDiagramComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-classes#grafloriadiagramcomponent). :::code-group ```js title="JavaScript" import { render, umlDiagram } from '@grafloria/element'; const spec = umlDiagram({ classes: [ { id: 'Animal', abstract: true, position: { x: 180, y: 50 }, attributes: ['# name: String'], methods: ['+ speak(): void'], }, { id: 'Dog', position: { x: 180, y: 300 }, attributes: ['+ breed: String'], methods: ['+ fetch(): void'], }, { id: 'Owner', position: { x: 500, y: 300 }, attributes: ['+ name: String'], methods: ['+ adopt(dog): void'], }, ], relationships: [ { from: 'Dog', to: 'Animal', kind: 'inheritance' }, { from: 'Owner', to: 'Dog', kind: 'aggregation', label: 'owns', multiplicity: ['1', '0..*'] }, ], }); const host = document.createElement('div'); host.style.cssText = 'display:block; width:100%; height:500px'; document.body.append(host); const instance = render(spec, host); instance.fitView(40); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { GrafloriaDiagramComponent } from '@grafloria/angular'; import { umlDiagram } from '@grafloria/element'; @Component({ selector: 'app-uml-diagram', standalone: true, imports: [GrafloriaDiagramComponent], template: '', }) export class UmlDiagramComponent { spec = umlDiagram({ classes: [ { id: 'Animal', abstract: true, position: { x: 180, y: 50 }, attributes: ['# name: String'], methods: ['+ speak(): void'] }, { id: 'Dog', position: { x: 180, y: 300 }, attributes: ['+ breed: String'], methods: ['+ fetch(): void'] }, { id: 'Owner', position: { x: 500, y: 300 }, attributes: ['+ name: String'], methods: ['+ adopt(dog): void'] }, ], relationships: [ { from: 'Dog', to: 'Animal', kind: 'inheritance' }, { from: 'Owner', to: 'Dog', kind: 'aggregation', label: 'owns', multiplicity: ['1', '0..*'] }, ], }); } ``` ```tsx title="Qwik" import { component$ } from '@builder.io/qwik'; import { GrafloriaDiagram } from '@grafloria/qwik'; import type { RenderSpec } from '@grafloria/element'; const spec: RenderSpec = { nodes: [ { id: 'Animal', position: { x: 180, y: 50 }, size: { width: 220, height: 90 }, label: 'Animal · abstract · # name: String' }, { id: 'Dog', position: { x: 180, y: 300 }, size: { width: 220, height: 90 }, label: 'Dog · + breed: String' }, { id: 'Owner', position: { x: 500, y: 300 }, size: { width: 220, height: 90 }, label: 'Owner · + name: String' }, ], edges: [ { id: 'inherits', source: 'Dog', target: 'Animal', label: 'inherits' }, { id: 'owns', source: 'Owner', target: 'Dog', label: 'owns' }, ], }; export default component$(() => (
)); ``` ```tsx title="React" import { GrafloriaDiagram } from '@grafloria/react'; import { umlDiagram } from '@grafloria/element'; const spec = umlDiagram({ classes: [ { id: 'Animal', abstract: true, position: { x: 180, y: 50 }, attributes: ['# name: String'], methods: ['+ speak(): void'] }, { id: 'Dog', position: { x: 180, y: 300 }, attributes: ['+ breed: String'], methods: ['+ fetch(): void'] }, { id: 'Owner', position: { x: 500, y: 300 }, attributes: ['+ name: String'], methods: ['+ adopt(dog): void'] }, ], relationships: [ { from: 'Dog', to: 'Animal', kind: 'inheritance' }, { from: 'Owner', to: 'Dog', kind: 'aggregation', label: 'owns', multiplicity: ['1', '0..*'] }, ], }); export function UmlDiagramView() { return (
); } ``` ```vue title="Vue" ``` ::: Relationship kinds supported by the UML kit are `inheritance`, `realization`, `association`, `directed-association`, `aggregation`, `composition`, and `dependency`. The kit draws each kind's notation, including dashed lines for realization and dependency, diamonds at the source end for aggregation and composition, and an open target arrow for directed association. Provide `multiplicity: ['from', 'to']` to add a chip at each endpoint. The [class diagram demo](https://grafloria.com/demos/diagrams/class-uml.html) shows the basic class-card and relationship pattern. The [UML relationships demo](https://grafloria.com/demos/diagrams/uml-relationships.html) shows all seven relationship kinds, multiplicities, stereotypes, and their rendered markers. ## Use node and edge data directly Use the kits when you want their table or class cards and relationship notation. If your ER or UML model is already expressed as graph data, mount [`NodeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-nodespec#nodespec) nodes and [`EdgeSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-edgespec#edgespec) edges directly. The resulting canvas uses regular diagram nodes and links; it does not add the kits' HTML cards, PK/FK badges, UML markers, or multiplicity chips. For the JavaScript and framework mounting patterns, see [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows). Here, ordinary graph nodes carry class members as label text, so the canvas shows the relationships without UML compartments, markers, or multiplicity chips. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; const nodes = [ { id: 'CUSTOMER', position: { x: 60, y: 80 }, size: { width: 240, height: 90 }, label: 'Customer · id: int (PK) · email: varchar' }, { id: 'ORDER', position: { x: 390, y: 80 }, size: { width: 260, height: 90 }, label: 'Order · id: int (PK) · customer_id: int (FK)' }, ]; const edges = [{ id: 'places', source: 'CUSTOMER', target: 'ORDER', label: 'places' }]; const host = document.getElementById('diagram'); if (!host) throw new Error('Missing #diagram'); host.style.height = '500px'; const instance = render({ nodes, edges }, host); instance.fitView(40); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-entity-graph', standalone: true, imports: [DiagramCanvasComponent], template: ``, }) export class EntityGraphComponent { nodes: NodeSpec[] = [ { id: 'CUSTOMER', position: { x: 60, y: 80 }, size: { width: 240, height: 90 }, label: 'Customer · id: int (PK) · email: varchar' }, { id: 'ORDER', position: { x: 390, y: 80 }, size: { width: 260, height: 90 }, label: 'Order · id: int (PK) · customer_id: int (FK)' }, ]; edges: EdgeSpec[] = [{ id: 'places', source: 'CUSTOMER', target: 'ORDER', label: 'places' }]; } ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'CUSTOMER', position: { x: 60, y: 80 }, size: { width: 240, height: 90 }, label: 'Customer · id: int (PK) · email: varchar' }, { id: 'ORDER', position: { x: 390, y: 80 }, size: { width: 260, height: 90 }, label: 'Order · id: int (PK) · customer_id: int (FK)' }, ]; const edges: EdgeSpec[] = [{ id: 'places', source: 'CUSTOMER', target: 'ORDER', label: 'places' }]; export function EntityGraphView() { return (
); } ``` ```vue title="Vue" ``` ::: ## Options that matter | Option | Type | Default | What it does | | --- | --- | --- | --- | | `relationships` | ER relationship records or UML relationship records | `[]` | Describes the connections between entity or class IDs. ER relationship ends can include a column name; UML relationships can set a kind and multiplicities. | | `cardinality` (ER) | Named cardinality or `{ tail: string; head: string }` | `'one-to-many'` | Chooses ER endpoint markers. Named values include one-to-one, many-to-many, one-to-zero-or-many, and one-to-one-or-many. | | `multiplicity` (UML) | `[string, string]` | None | Adds the from-end and to-end multiplicity labels. | | `rowSelection` | `boolean` | `true` | Enables member/column row selection. Set `false` to opt out. | | `editable` | `boolean` | `false` | Adds in-canvas rename, add, and delete controls for entity columns or class members. Edits are undoable. | | `height` (entity or class) | `number` | Content-sized | Fixes a card's height; content exceeding it scrolls inside the card. | ## Pitfalls - Column-qualified ER relationship ends must name an existing entity and column. A missing entity or field causes `erDiagram()` to throw while building its spec. - The UML multiplicity labels need a live diagram model. Mount the spec; `render()` runs its `finalize` hook, which adds the labels after the links exist. - Framework components need a parent with a nonzero height. The examples set a 500-pixel canvas height; adapt that value to your layout. - The kit's UML relationship vocabulary does not include self-association. For the ER kit's supported self-reference pattern, see the [advanced ER demo](https://grafloria.com/demos/diagrams/er-advanced.html); a custom UML self-loop needs edge-level modeling beyond this kit. ## Related - [Lay out diagrams automatically](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/lay-out-diagrams) — arrange diagram nodes after building their data. - [Edit data models visually](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/edit-data-models-visually) — edit tables and fields on the canvas. - [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) — understand undoable diagram edits. # Edit data models visually Use the ER diagram kit when you want readers to rename tables and columns in place, add or remove columns, and see relationships stay attached to their fields as the schema changes. The kit turns entity and relationship data into table cards with typed columns, key badges, and crow’s-foot relationships. Set `editable: true` to add inline editing controls; a `TABLE.column` relationship endpoint pins its edge to that column’s row. [`erDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-diagram-kit-functions#erdiagram) builds the spec, and [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) or a framework diagram component mounts it. ## Build an editable ER diagram Use this pattern when your data is a relational schema and the diagram itself should be the editor. The example includes a field-level foreign-key relationship from `ORDERS.customer_id` to `CUSTOMERS.id`, plus a table-level relationship from `PRODUCTS` to `ORDERS`. The mounted diagram shows three entity cards, their columns and key badges, and orthogonal crow’s-foot edges. Double-click a table header or column name to edit it; use the card’s add and delete controls to change columns. Each edit is an undoable step, and the field-level edge remains attached to its column when rows move. Create the same kit spec in JavaScript, Angular, React, or Vue, then mount it with the framework’s diagram component. The JavaScript example mounts the spec directly. Each sample gives the canvas a real height. React’s [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriadiagram) and Vue’s [`GrafloriaDiagram`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriadiagram) mount the kit spec directly. Angular’s diagram component takes the spec as an input. :::code-group ```js title="JavaScript" import { erDiagram, render } from '@grafloria/element'; const spec = erDiagram({ editable: true, entities: [ { id: 'PRODUCTS', name: 'Products', position: { x: 80, y: 96 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'sku', type: 'varchar' }, { name: 'price', type: 'decimal' }, ] }, { id: 'CUSTOMERS', name: 'Customers', position: { x: 80, y: 360 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'name', type: 'varchar' }, { name: 'email', type: 'varchar' }, ] }, { id: 'ORDERS', name: 'Orders', position: { x: 500, y: 150 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'status', type: 'varchar' }, { name: 'customer_id', type: 'int', fk: true }, { name: 'total', type: 'decimal' }, ] }, ], relationships: [ { from: 'ORDERS.customer_id', to: 'CUSTOMERS.id', id: 'fk_customer', fromSide: 'left', toSide: 'right' }, { from: 'PRODUCTS', to: 'ORDERS', label: 'ordered as', fromSide: 'right', toSide: 'left' }, ], }); const host = document.createElement('div'); host.style.height = '640px'; document.body.append(host); const instance = render(spec, host); instance.fitView(40); window.addEventListener('pagehide', () => instance.dispose(), { once: true }); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { GrafloriaDiagramComponent } from '@grafloria/angular'; import { erDiagram } from '@grafloria/element'; const spec = erDiagram({ editable: true, entities: [ { id: 'PRODUCTS', name: 'Products', position: { x: 80, y: 96 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'sku', type: 'varchar' }, { name: 'price', type: 'decimal' }, ] }, { id: 'CUSTOMERS', name: 'Customers', position: { x: 80, y: 360 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'name', type: 'varchar' }, { name: 'email', type: 'varchar' }, ] }, { id: 'ORDERS', name: 'Orders', position: { x: 500, y: 150 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'status', type: 'varchar' }, { name: 'customer_id', type: 'int', fk: true }, { name: 'total', type: 'decimal' }, ] }, ], relationships: [ { from: 'ORDERS.customer_id', to: 'CUSTOMERS.id', id: 'fk_customer', fromSide: 'left', toSide: 'right' }, { from: 'PRODUCTS', to: 'ORDERS', label: 'ordered as', fromSide: 'right', toSide: 'left' }, ], }); @Component({ selector: 'app-erd-editor', standalone: true, imports: [GrafloriaDiagramComponent], template: '', }) export class ErdEditorComponent { readonly spec = spec; } ``` ```tsx title="React" import { GrafloriaDiagram } from '@grafloria/react'; import { erDiagram } from '@grafloria/element'; const spec = erDiagram({ editable: true, entities: [ { id: 'PRODUCTS', name: 'Products', position: { x: 80, y: 96 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'sku', type: 'varchar' }, { name: 'price', type: 'decimal' }, ] }, { id: 'CUSTOMERS', name: 'Customers', position: { x: 80, y: 360 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'name', type: 'varchar' }, { name: 'email', type: 'varchar' }, ] }, { id: 'ORDERS', name: 'Orders', position: { x: 460, y: 200 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'status', type: 'varchar' }, { name: 'customer_id', type: 'int', fk: true }, { name: 'total', type: 'decimal' }, ] }, ], relationships: [ { from: 'ORDERS.customer_id', to: 'CUSTOMERS.id', id: 'fk_customer', fromSide: 'left', toSide: 'right' }, { from: 'PRODUCTS', to: 'ORDERS', label: 'ordered as', fromSide: 'right', toSide: 'left' }, ], }); export default function ErdEditor() { return (
); } ``` ```vue title="Vue" ``` ::: The kit spec is regular diagram data plus a finalization step that installs the row interactions and, when editing is enabled, the inline editing behavior. Framework bindings mount that same spec; the JavaScript call returns a live instance, which the sample uses to fit the diagram and dispose it when its host page leaves. For a model that is not a relational schema, use the generic canvas: React’s [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-react#grafloriaflow), Vue’s [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-vue#grafloriaflow), or Angular’s [`DiagramCanvasComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent). For a non-relational model, the generic canvas changes the task: readers move entity nodes and create or remove connections, rather than editing table and column names or rows. See the [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start) for typed flow data and framework binding patterns, and [How Grafloria works](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/how-grafloria-works) for the shared model and Angular controlled-binding example. ## Options that shape the schema | Option | Type | Default | What it does | | --- | --- | --- | --- | | `entities` | Array of entity specs | Required | Declares each table’s id, optional display name, columns, and optional position. Each column can specify a name, type, primary-key flag, and foreign-key flag. | | `relationships` | Array of relationship specs | Empty | Declares links between entity ids or between `ENTITY.column` endpoints. A column endpoint anchors the edge to that row. | | `editable` | `boolean` | `false` | Adds table rename, column rename, add-column, and delete-column controls. Every edit is one undoable step. | | `rowSelection` | `boolean` | `true` | Enables column-row selection and the kit’s row selection events. Set it to `false` to opt out. | ## Pitfalls - Use the exact entity id and column name in a field endpoint such as `ORDERS.customer_id`. The kit throws if an entity or referenced column does not exist. - `editable` is opt-in. Without it, the diagram remains the read-only ER card view. - Deleting a column removes a relationship attached to that column. Other field relationships stay connected to their columns as those rows shift. - Give the canvas host a resolved height. The renderer fills its parent; a zero-height parent leaves no visible diagram. ## See it running [Open the live ERD editor demo](https://grafloria.com/demos/diagrams/erd-editor.html) to try inline table and column edits, column insertion and deletion, row selection, and field-level relationships. ## Related - [Build ER and UML diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-er-and-uml-diagrams) for read-only ER and UML diagrams from data. - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) for how diagram edits enter the undo history. - [Validate connections](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/validate-connections) for rules that allow or veto connections. # Build dashboards Declare dashboard views and widgets as data, mount the board in your framework, then switch views or layout and save the live board as a snapshot. ## When to use the dashboard kit Use [`GrafloriaDashboard`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik) (React and Vue) or [`GrafloriaDashboardComponent`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-angular-components-grafloriadashboardcomponent) (Angular) when people arrange widgets in grid cells. A dashboard is data first: views contain widget specs, and the kit supplies the grid, drag and resize behavior, and undo. The same headless model drives every framework binding. Plain JavaScript builds a spec with [`dashboard()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-functions) and mounts it with [`render()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core); framework bindings expose a live [`DashboardHandle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardhandle), whose snapshot has the [`DashboardSnapshot`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-types) shape. Widget declarations use [`DashboardWidgetSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardwidgetspec). The built-in widget renderers draw `kpi`, `line`, `bar`, `donut`, `funnel`, and `table` widgets from their `data`; start with those before supplying custom rendering. The examples below use KPI, line, and donut data. See the [live dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) for a multi-view board with built-in widgets. The [demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/dashboard/dashboard-builder.html) shows its full builder UI. ## Declare and mount the board Each view has an `id` and a `widgets` array; each widget needs an `id` and can name a renderer `kind`, cell `span` and `rows`, and renderer data. With no explicit `x` and `y`, widgets flow in declaration order. `span` defaults to 3 columns and `rows` to 1. The framework components mount the board from these specs; give their host a height so the rendered dashboard has room. The following examples declare two real views and mount the same board in each supported framework. The initial board displays KPI cards and a donut in Overview; the Revenue view has its own line and KPI widgets. :::code-group ```js title="JavaScript" import { dashboard, render } from '@grafloria/element'; const root = document.createElement('main'); const nav = document.createElement('nav'); const overviewButton = document.createElement('button'); overviewButton.textContent = 'Overview'; const revenueButton = document.createElement('button'); revenueButton.textContent = 'Revenue'; const layoutButton = document.createElement('button'); layoutButton.textContent = 'Switch layout'; const saveButton = document.createElement('button'); saveButton.textContent = 'Save board'; nav.append(overviewButton, revenueButton, layoutButton, saveButton); const canvas = document.createElement('div'); canvas.style.height = '560px'; root.append(nav, canvas); document.body.append(root); const views = [ { id: 'overview', name: 'Overview', widgets: [ { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1, data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } }, { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1, data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } }, { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region', data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } }, ] }, { id: 'revenue', name: 'Revenue', widgets: [ { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend', data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } }, { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1, data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } }, ] }, ]; const saved = localStorage.getItem('sales-dashboard'); const spec = dashboard(saved ? JSON.parse(saved) : { columns: 12, views }); const instance = render(spec, canvas); const handle = spec.handle; overviewButton.addEventListener('click', () => handle.showView('overview')); revenueButton.addEventListener('click', () => handle.showView('revenue')); layoutButton.addEventListener('click', () => { const next = handle.getLayout() === 'grid' ? 'split' : 'grid'; handle.setLayout(next); }); saveButton.addEventListener('click', () => { localStorage.setItem('sales-dashboard', JSON.stringify(handle.toJSON())); }); window.addEventListener('pagehide', () => instance.dispose(), { once: true }); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { GrafloriaDashboardComponent } from '@grafloria/angular'; import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element'; @Component({ selector: 'app-sales-dashboard', standalone: true, imports: [GrafloriaDashboardComponent], template: ` `, }) export class SalesDashboardComponent { tab: string | undefined = 'overview'; handle?: DashboardHandle; options = { columns: 12, gap: 8 }; views: DashboardViewSpec[] = [ { id: 'overview', name: 'Overview', widgets: [ { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1, data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } }, { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1, data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } }, { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region', data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } }, ] }, { id: 'revenue', name: 'Revenue', widgets: [ { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend', data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } }, { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1, data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } }, ] }, ]; switchLayout(): void { const handle = this.handle; if (handle) handle.setLayout(handle.getLayout() === 'grid' ? 'split' : 'grid'); } save(): void { const snapshot = this.handle?.toJSON(); if (snapshot) localStorage.setItem('sales-dashboard', JSON.stringify(snapshot)); } } ``` ```tsx title="React" import { useState } from 'react'; import { GrafloriaDashboard } from '@grafloria/react'; import type { DashboardHandle, DashboardViewSpec } from '@grafloria/element'; const views: DashboardViewSpec[] = [ { id: 'overview', name: 'Overview', widgets: [ { id: 'sales-kpi', kind: 'kpi', span: 3, rows: 1, data: { label: 'Sales', value: '$6.8M', delta: 12.4, spark: [4.2, 4.5, 5.1, 6.8] } }, { id: 'customers-kpi', kind: 'kpi', span: 3, rows: 1, data: { label: 'Customers', value: '1,284', delta: 8.1, spark: [980, 1090, 1150, 1284] } }, { id: 'region-mix', kind: 'donut', span: 6, rows: 2, title: 'Sales by region', data: { slices: [{ label: 'EMEA', value: 2.9 }, { label: 'Americas', value: 2.4 }, { label: 'APAC', value: 1.5 }] } }, ] }, { id: 'revenue', name: 'Revenue', widgets: [ { id: 'revenue-line', kind: 'line', span: 8, rows: 2, title: 'Revenue trend', data: { series: [{ name: 'Revenue', values: [4.2, 4.5, 5.1, 6.8] }], labels: ['Jan', 'Feb', 'Mar', 'Apr'] } }, { id: 'revenue-kpi', kind: 'kpi', span: 4, rows: 1, data: { label: 'Quarter total', value: '$6.8M', delta: 12.4 } }, ] }, ]; export default function SalesDashboard() { const [handle, setHandle] = useState(); const [tab, setTab] = useState('overview'); const [layout, setLayout] = useState<'grid' | 'split'>('grid'); return (
); } ``` ```vue title="Vue" ``` ::: The JavaScript, Angular, React, and Vue examples mount with Overview visible. Their built-in painters render the KPI, donut, and line cards from their data, and the board fits them into grid cells. Choose Revenue to frame the other view; Switch layout toggles the board between the cell grid and split layout. Save board stores the live snapshot in `localStorage`. ## Switch views and layout The framework components keep view selection in their `activeView` prop or model. Their `layout` prop is a live switch: changing it applies a handle call without remounting. For JavaScript, get the live handle from the dashboard spec and call `showView(id)` or `setLayout('grid' | 'split')` directly. In every binding, choose a declared view id; `DashboardHandle` exposes `views` in declaration order and `activeView` as the current id. The JavaScript sample uses [`dashboard()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-functions) to create the render spec, then [`render()`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) to mount it. `spec.handle` is the mounted [`DashboardHandle`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardhandle): `setLayout()` switches the active view between grid and split, while `showView()` frames the requested view. ## Persist the live board Call `toJSON()` on the handle after edits to capture the live board as plain data, then serialize that snapshot with your application's storage. The snapshot includes the current view layouts and board options; it excludes function callbacks. Restore it by passing the saved data back into `dashboard()` in JavaScript. Supply any non-serializable rendering callbacks again when rebuilding. In Angular, `snapshot()` returns the same data as `toJSON()`. The JavaScript, Angular, React, and Vue examples demonstrate saving to browser `localStorage`. Use storage appropriate to your application when the board must survive a browser change or be shared between users. A saved snapshot has the [`DashboardSnapshot`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-types) shape. ## Options that shape the board Pass board geometry and behavior through `options`. A view can also override its column count. Widget `span` and `rows` describe its cell size, while optional `x` and `y` place it explicitly. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `columns` | `number` | `12` | Sets the column count for every view unless a view overrides it. | | `gap` | `number` | `8` | Sets the gap between widgets and the board padding, in pixels. | | `sizing` | `'fit' \| 'grow'` | `'grow'` on fluid boards; `'fit'` on fixed boards | `grow` keeps row height and extends the board; `fit` keeps the board height and squeezes rows. | | `layout` | `'grid' \| 'split'` | `'grid'` | Chooses a cell grid or a splitter tree. Switch it live with the handle or component prop. | | `rowHeight` | `number` | `130` | Sets row height in `grow` mode, in pixels. | | `float` | `boolean` | `false` | With `false`, gravity packs widgets upward; with `true`, gaps can remain where widgets are dropped. | | `width`, `height` | `number` | `1180 × 660` | Set a fixed board size. An explicit `width` selects fixed mode. | | `static` | `boolean` | `false` | Turns off pointer dragging, resizing, and handles for a viewer board. | On [`DashboardWidgetSpec`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardwidgetspec), `id` identifies a widget, `kind` selects its renderer, `data` carries its payload, and `title` supplies a title. `pinned: true` prevents reflow from moving that widget. The six built-in kinds need no custom renderer; unknown kinds use a titled placeholder frame. ## Pitfalls - Give the dashboard host a resolved height; otherwise its canvas has no space to draw into. - `views` and the single-view `widgets` shorthand are mutually exclusive. Use `views` when people switch between boards. - Framework wrappers mount the board once; changing data or `options` afterward does not rebuild it. Use live props for `activeView`, `layout`, and `sizing`, and use the handle for live board operations. - React custom widgets use `widgetTypes`, not `options.renderWidget`; Vue uses `widget-` slots and Angular uses `grafloriaWidget` templates. This page uses built-in renderers instead. ## Live demo Open the [dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) to see its tabs, built-in widget cards, and dashboard layout. Read the [demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/dashboard/dashboard-builder.html) for the full palette and persistence controls. ## Related - [Dashboard handle reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardhandle) - [Dashboard options reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardoptions) - [Dashboard widget spec reference](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-dashboardwidgetspec) - [Dashboard kit functions](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-dashboard-kit-functions) # Synchronize diagrams Connect each diagram instance to a transport and give each peer its own actor id. Connected peers share edits; after a transport reconnects, the session exchanges missed operations so the replicas converge. ## Connect two peers Use the component's `collab` prop when both diagrams run in a supported framework. Each peer gets its own transport connection and actor id, while both transports connect through the same room. The framework examples use [`MemoryHub`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-sync-classes#memoryhub) to put two visible peers on one page; edits cross between their canvases. The plain JavaScript [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) entry point mounts a diagram, but does not expose that component prop. For the framework bindings and typed graph data, see [Execute and compute flows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/execute-and-compute-flows); this page adds the per-peer `collab` configuration that connects mounted diagrams to the same sync room. :::code-group ```html title="JavaScript"
``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { MemoryHub } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const hub = new MemoryHub(); @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class AppComponent { nodesA: NodeSpec[] = [ { id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } }, { id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; edgesA: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; nodesB: NodeSpec[] = [ { id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } }, { id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; edgesB: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; collabA = { transport: hub.connect('ana'), actor: 'ana' }; collabB = { transport: hub.connect('bo'), actor: 'bo' }; } ``` ```tsx title="Qwik" import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik'; import { MemoryHub } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } }, { id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; export default component$(() => { const collabA = useSignal | undefined>(); const collabB = useSignal | undefined>(); useVisibleTask$(() => { const hub = new MemoryHub(); collabA.value = noSerialize({ transport: hub.connect('ana'), actor: 'ana' }); collabB.value = noSerialize({ transport: hub.connect('bo'), actor: 'bo' }); }); const peerA = collabA.value; const peerB = collabB.value; return (
{peerA && peerB && ( <> )}
); }); ``` ```tsx title="React" import { useMemo } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import { MemoryHub } from '@grafloria/engine'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', label: 'Alpha', position: { x: 80, y: 90 }, size: { width: 150, height: 66 } }, { id: 'b', label: 'Beta', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; export default function App() { const peers = useMemo(() => { const hub = new MemoryHub(); return { a: { transport: hub.connect('ana'), actor: 'ana' }, b: { transport: hub.connect('bo'), actor: 'bo' }, }; }, []); return (
); } ``` ```vue title="Vue" ``` ::: Each Angular, Qwik, React, and Vue version mounts two canvases with the same two nodes and connecting edge. Move a node in either pane: the other pane receives the operation and renders the edit. The framework canvas joins a CRDT sync session when it mounts and leaves when it unmounts; set up its collaboration options before mounting. The diagram data is the document peers converge on. CRDT operations merge per property, so concurrent changes to different properties of the same node—for example, one peer moving it while another renames it—both survive. This is not whole-node last-write-wins replacement. ## Options and boundaries | Option | Type | Default | What it does | | --- | --- | --- | --- | | `collab` | Object containing a transport and actor id | Unset | Joins the canvas to a sync session. Give each peer a distinct actor id and connect its transport to the same room. | The collaboration option is fixed for the lifetime of a mounted instance. If a peer needs a new transport or actor, mount an instance with that configuration rather than changing `collab` in place. In Qwik, create transports in `useVisibleTask$` and retain them with Qwik's non-serializable state handling; a live transport is not serializable application data. Grafloria starts an anti-entropy round when a transport reports that it has reconnected, exchanging the operations each peer missed. Grafloria supplies convergence and the operation log, not your product's rooms, authentication, or storage; implement those at the transport and application layers. ## See the synchronization states In the conflict-resolution demo, peer A moves a node while peer B renames it. The status readouts disagree before exchange and show both edits after they converge. The initial view shows both peers with the same `Draft` node. [Open the live conflict-resolution demo](https://grafloria.com/demos/collab/conflict-resolution.html). For disconnected edits, open the offline-and-reconnect demo. Disconnect both peers, edit each side, and reconnect; the missed operations are exchanged and both diagrams converge. Its initial view shows two matching diagrams with `Alpha` connected to `Beta`. [Open the live offline-and-reconnect demo](https://grafloria.com/demos/collab/offline-and-reconnect.html). For actual cross-tab collaboration without a server, open the two-tabs demo in two browser tabs. It uses `BroadcastChannel` for transport, so edits in either tab reach the other; each tab keeps its own viewport. Its initial view shows the same two-node diagram in both panes. [Open the live two-tabs demo](https://grafloria.com/demos/collab/two-tabs-live.html). ## Pitfalls - Use one hub/room for peers that share a document, but a distinct actor id for each peer. The sender does not receive its own echo from [`MemoryHub`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-sync-classes#memoryhub). - A shared transport has to report status changes if you rely on automatic reconnect catch-up. - The plain JavaScript [`render`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core#render) API mounts a diagram but does not accept the framework `collab` prop. Its sample above demonstrates mounting only; it does not connect peers. Use a framework binding's collaboration prop for peer synchronization. - Do not treat the collaboration channel as persistence or authorization. The app still owns rooms, identity, and storage. ## Related - [Add presence and comments](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/add-presence-and-comments) — add live cursors and anchored threads to a collaboration session. - [The graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document) — understand the document peers synchronize. - [Command history and edits](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) — understand how local edits become operations. # Add presence and comments Use presence when participants need to see each other’s cursors and selections, and use anchored comment threads when discussion belongs to a node or link. A presenter can also broadcast a viewport so followers see the same world region and zoom. ## Show participants’ presence Give each canvas a collaboration transport and a unique actor id. The flow joins its sync session when it mounts; setting `presence` adds live cursors and remote selection outlines. For a local, server-free example, connect two peers through [`MemoryHub`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-sync-classes#memoryhub). Move the pointer over either canvas: the other pane shows that participant’s labelled cursor. Cursor presence is separate from diagram edits; it does not add cursor updates to the operation log. ```js import { MemoryHub } from '@grafloria/engine'; import { render } from '@grafloria/element'; import { createSyncSession } from '@grafloria/engine'; import { bindPresence } from '@grafloria/renderer'; const hub = new MemoryHub(); const anaHost = document.createElement('div'); const boHost = document.createElement('div'); anaHost.style.cssText = 'height:400px; flex:1'; boHost.style.cssText = 'height:400px; flex:1'; document.body.append(anaHost, boHost); const nodes = [ { id: 'plan', label: 'Plan', position: { x: 70, y: 90 }, size: { width: 150, height: 66 } }, { id: 'build', label: 'Build', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } }, ]; const edges = [{ id: 'e1', source: 'plan', target: 'build' }]; const ana = render({ nodes, edges }, anaHost); const bo = render({ nodes, edges }, boHost); const sessionAna = createSyncSession(ana.getModel(), hub.connect('ana'), { actor: 'ana' }); const sessionBo = createSyncSession(bo.getModel(), hub.connect('bo'), { actor: 'bo' }); sessionAna.join(); sessionBo.join(); bindPresence(ana, sessionAna, { name: 'Ana' }); bindPresence(bo, sessionBo, { name: 'Bo' }); ``` Give both targets a real height: ```html
``` The two-peer setup for React, Vue, Qwik, and Angular is shown in [Synchronize diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/synchronize-diagrams). For presence, add a display identity to each peer's `collab` value, such as `presence: { name: 'Ana' }`; the remote canvas then labels that participant's cursor. In Qwik, set the same `collab` prop on each [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow). Wrap each live transport configuration in `noSerialize()` and create it in a visible task; the [Qwik live-cursors demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/apps/demos-qwik/gallery/demos/live-cursors.tsx) shows the binding. [`bindPresence`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) accepts a mounted instance and a sync session; its options include the display name and cursor smoothing. Use it when you need to bind presence yourself rather than enabling it through a framework’s `collab` prop. [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) exposes the model used by a session and the viewport used by the renderer. ## Follow a presenter Use a viewport channel when one participant drives the camera and others follow. [`InMemoryViewportChannel`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) is the shipped in-process channel for two canvases on one page; replace it with a channel backed by your application’s network transport when participants are on separate clients. [`presentTo`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) broadcasts the presenter’s camera, and [`followPresenter`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) applies it to a follower. The follower retains its own canvas size while matching the presenter’s world center and zoom. ```js import { render } from '@grafloria/element'; import { InMemoryViewportChannel, followPresenter, presentTo } from '@grafloria/element'; const spec = { nodes: [ { id: 'a', label: 'Plan', position: { x: 60, y: 80 }, size: { width: 130, height: 60 } }, { id: 'b', label: 'Build', position: { x: 320, y: 80 }, size: { width: 130, height: 60 } }, ], edges: [{ id: 'e1', source: 'a', target: 'b' }], }; const presenterHost = document.createElement('div'); const followerHost = document.createElement('div'); presenterHost.style.cssText = 'height:400px; flex:1'; followerHost.style.cssText = 'height:400px; flex:1'; document.body.append(presenterHost, followerHost); const presenter = render(spec, presenterHost); const follower = render(spec, followerHost); const channel = new InMemoryViewportChannel(); const presenting = presentTo(presenter, channel, { presenterId: 'ana' }); const following = followPresenter(follower, channel, { ignorePresenterId: 'bo' }); presenter.fitView(60); ``` Give both mount targets a height as in the earlier example. Panning or zooming the presenter then moves the follower’s camera to the corresponding view. Keep the returned handles and call their `stop()` methods when presentation ends. To make a follower read-only as well, lock its engine with the presentation-mode API; camera following and document edit permission are separate concerns. Live demo: [Presentation mode](https://grafloria.com/demos/collab/presentation-mode.html). The presentation helpers accept the same host shape used by a mounted [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance), so the setup can run from a framework’s init/ready callback. The wrapper-specific way to capture that instance is shown in the React, Vue, and Qwik bindings’ `onInit` callback and the Angular canvas component’s instance accessors. ## Add anchored comment threads Enable `comments` on the canvas, get its store from the mounted instance, and pass that store to the shipped comment panel. The panel renders the thread list and composer; `createThread()` starts a thread anchored to a node or link, and `reply()` adds a message to it. In this example, the panel shows a two-message conversation attached to the Review node. React and Vue use the same comment-panel setup as their quick starts: [React quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start) and [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start). This page focuses on the added reply, so the thread panel shows a conversation rather than only its opening comment. :::code-group ```tsx title="Qwik" import { $, component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaCommentPanel, GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import type { CommentStore } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'design', position: { x: 80, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Design' } }, { id: 'review', position: { x: 330, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Review' } }, { id: 'ship', position: { x: 580, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Ship' } }, ]; const edges: EdgeSpec[] = [ { id: 'e1', source: 'design', target: 'review' }, { id: 'e2', source: 'review', target: 'ship' }, ]; function panelBound(root: HTMLElement | undefined): Promise { return new Promise((resolve) => { const poll = () => { if (root?.querySelector('[data-grafloria-comment-panel]')) resolve(); else requestAnimationFrame(poll); }; poll(); }); } export default component$(() => { const root = useSignal(); const store = useSignal>(); return (
{ const comments = instance.getCommentStore(); if (!comments) return; store.value = noSerialize(comments); await panelBound(root.value); const thread = comments.createThread({ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?'); comments.reply(thread, 'On it — draft by Friday.'); })} /> {store.value && }
); }); ``` ```ts title="Angular" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent, GrafloriaCommentPanelComponent } from '@grafloria/angular'; import type { CommentStore } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent, GrafloriaCommentPanelComponent], template: `
@if (store) { }
`, }) export class CommentsDemoComponent implements AfterViewInit { canvas = viewChild.required(DiagramCanvasComponent); store: CommentStore | null = null; nodes: NodeSpec[] = [ { id: 'design', position: { x: 80, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Design' } }, { id: 'review', position: { x: 330, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Review' } }, { id: 'ship', position: { x: 580, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Ship' } }, ]; edges: EdgeSpec[] = [ { id: 'e1', source: 'design', target: 'review' }, { id: 'e2', source: 'review', target: 'ship' }, ]; ngAfterViewInit() { this.store = this.canvas().getCommentStore(); if (this.store) { const thread = this.store.createThread({ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?'); this.store.reply(thread, 'On it — draft by Friday.'); } } } ``` ::: For Qwik's component, store, and panel setup, see the [Qwik quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/qwik-quick-start); this page adds a reply so the anchored thread contains a conversation. For plain JavaScript, mount a store on the rendered model and render a conversation from its thread view: ```js import { CommentStore } from '@grafloria/engine'; import { render } from '@grafloria/element'; const diagramHost = document.createElement('div'); const conversationHost = document.createElement('div'); diagramHost.style.height = '400px'; document.body.append(diagramHost, conversationHost); const instance = render({ nodes: [ { id: 'review', label: 'Review', position: { x: 120, y: 90 }, size: { width: 180, height: 70 } }, ], }, diagramHost); const store = new CommentStore(instance.getModel(), { viewer: 'ana' }); const threadId = store.createThread({ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?'); store.reply(threadId, 'On it — draft by Friday.'); const thread = store.thread(threadId); if (thread) conversationHost.textContent = thread.messages.map((message) => message.body).join('\n'); ``` The framework examples use the shipped comment panel instead of hand-building the conversation UI. Live demo: [Threaded comments](https://grafloria.com/demos/collab/comments.html). ## Options and behavior | Option or call | Type | Default | What it does | |---|---|---|---| | `collab` | transport, actor id, and optional sync settings | unset | Joins a collaboration session for the mounted canvas. A framework flow leaves the session when it unmounts. | | `presence` in `collab` | `boolean \| BindPresenceOptions` | off | Enables live cursor and remote selection presence; an object can set identity and cursor smoothing. | | `comments` | `boolean \| CommentStore` | unset | Enables anchored comments; `true` creates a store, or pass a shared store. | | `commentsViewer` | `string` | `'local'` for a created store | Sets the viewer id for a store created by the canvas. | | `bindPresence()` | `(instance, syncSession, options?) => PresenceBinding` | — | Connects a live instance to session awareness; the returned binding has `dispose()`. | | `presentTo()` | `(host, channel, options?) => { stop }` | 50 ms broadcast throttle | Publishes viewport center and zoom; the trailing update sends the final camera position. | | `followPresenter()` | `(host, channel, options?) => { stop }` | — | Applies presenter center and zoom while retaining the follower’s viewport dimensions. | | `createThread()` | `(anchor, body) => string` | — | Creates a thread and returns its id. | | `reply()` | `(threadId, body) => string` | — | Adds a reply and returns the new message id. | The collaboration layer supplies convergence, presence, and comments. Your application still provides rooms, authentication, storage, and a network transport for clients on different devices. `MemoryHub` and `InMemoryViewportChannel` are in-process examples, not network transports. ## Pitfalls - Give each simultaneous peer its own actor id, while keeping peers in the same room on transports that connect them to one another. - `collab` is fixed for the lifetime of a mounted framework instance; supply the transport and actor before mounting. - In Qwik, transports and comment stores are live objects. Keep them out of serializable component state with `noSerialize()` and create them on the client where needed. - A presenter channel broadcasts only camera state. It does not synchronize the document or enforce read-only mode. Use collaboration sync for document changes and the presentation lock when followers must not edit. ## Demos - [Live cursors](https://grafloria.com/demos/collab/live-cursors.html) — move over either pane to see the remote cursor and selection. - [Presentation mode](https://grafloria.com/demos/collab/presentation-mode.html) — pan and zoom the presenter pane to drive the follower’s view. - [Threaded comments](https://grafloria.com/demos/collab/comments.html) — start a comment and reply in its thread. Related: [Synchronize diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/synchronize-diagrams), [the graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document), and [the instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow). # Draw on a whiteboard Use a mounted canvas to draw freehand marks, change a committed mark's style, and remove a mark. Draw a closed outline when you want a freehand shape: it remains ink, not a diagram node. ## When to use this Use the freehand tool for annotations, sketches, arrows, and outlines that belong on top of a diagram. A committed mark is a vector stroke, not a screenshot. The live draw preview follows the pointer; releasing commits the stroke to the mounted diagram. [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) is the mounted canvas facade: its `getModel()` reaches the diagram data and its `renderNow()` repaints after a change. [`createDrawTool`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-interaction-functions#createdrawtool) creates a tool for that live canvas, and [`registerTool`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-ext-functions#registertool) connects it to the canvas's pointer-tool registry. The instance satisfies [`WhiteboardHost`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-interaction-interfaces-v-w#whiteboardhost), which the tool factory accepts. ## Mount the canvas and draw 1. Start with an empty diagram and a red pen; draw a line or return to its starting point to make a closed outline. 2. Register the draw tool after the canvas instance exists. Use the toolbar buttons to recolor the first committed mark or remove it. In the JavaScript, Angular, React, and Vue examples, **Edit first mark** changes the live stroke's style and **Erase first mark** removes it from the live model. Either button does nothing until you draw. Those examples hold the mounted instance in the framework's normal lifecycle state and unregister the tool when that owner unmounts. :::code-group ```js title="JavaScript" import { render } from '@grafloria/element'; import { createDrawTool, registerTool } from '@grafloria/renderer'; const app = document.getElementById('app'); if (!app) throw new Error('Missing #app'); const toolbar = document.createElement('div'); const canvas = document.createElement('div'); canvas.style.height = '400px'; app.append(toolbar, canvas); const editButton = document.createElement('button'); editButton.textContent = 'Edit first mark'; const eraseButton = document.createElement('button'); eraseButton.textContent = 'Erase first mark'; toolbar.append(editButton, eraseButton); const instance = render({ nodes: [], edges: [] }, canvas); const unregisterTool = registerTool( createDrawTool(instance, { color: '#e11d48', width: 3, simplifyEpsilon: 0.8 }) ); editButton.addEventListener('click', () => { const stroke = instance.getModel().getStrokes()[0]; if (!stroke) return; stroke.setStyle({ color: '#2563eb', width: 5 }); instance.renderNow(); }); eraseButton.addEventListener('click', () => { const stroke = instance.getModel().getStrokes()[0]; if (!stroke) return; instance.getModel().removeStroke(stroke.id); instance.renderNow(); }); window.addEventListener('pagehide', () => { unregisterTool(); instance.dispose(); }, { once: true }); ``` ```ts title="Angular" import { AfterViewInit, Component, ElementRef, OnDestroy, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { createDrawTool, registerTool, type WhiteboardHost } from '@grafloria/renderer'; @Component({ standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class WhiteboardComponent implements AfterViewInit, OnDestroy { readonly canvas = viewChild.required(DiagramCanvasComponent); readonly hostElement = viewChild.required>('host'); private unregisterTool: (() => void) | undefined; ngAfterViewInit(): void { const canvas = this.canvas(); const whiteboardHost: WhiteboardHost = { getModel: () => canvas.activeEngine()!.getDiagram()!, getEngine: () => canvas.activeEngine() ?? null, get viewport() { return canvas.viewportController()!; }, container: this.hostElement().nativeElement, render: () => canvas.scheduleRender(), }; this.unregisterTool = registerTool( createDrawTool(whiteboardHost, { color: '#e11d48', width: 3, simplifyEpsilon: 0.8 }) ); } editFirstMark(): void { const canvas = this.canvas(); const stroke = canvas.activeEngine()?.getDiagram()?.getStrokes()[0]; if (!stroke) return; stroke.setStyle({ color: '#2563eb', width: 5 }); canvas.scheduleRender(); } eraseFirstMark(): void { const canvas = this.canvas(); const model = canvas.activeEngine()?.getDiagram(); const stroke = model?.getStrokes()[0]; if (!model || !stroke) return; model.removeStroke(stroke.id); canvas.scheduleRender(); } ngOnDestroy(): void { this.unregisterTool?.(); } } ``` ```tsx title="Qwik" import { $, component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import { createDrawTool, registerTool } from '@grafloria/element'; export default component$(() => { const hostRef = useSignal(); return (
{ registerTool(createDrawTool(instance, { color: '#e11d48', width: 3, simplifyEpsilon: 0.8 })); })} />
); }); ``` ```tsx title="React" import { useEffect, useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import { createDrawTool, registerTool } from '@grafloria/renderer'; export function Whiteboard() { const instanceRef = useRef(null); const unregisterRef = useRef<(() => void) | null>(null); useEffect(() => () => { unregisterRef.current?.(); }, []); const onInit = (instance: DiagramInstance): void => { instanceRef.current = instance; unregisterRef.current = registerTool( createDrawTool(instance, { color: '#e11d48', width: 3, simplifyEpsilon: 0.8 }) ); }; const editFirstMark = (): void => { const instance = instanceRef.current; const stroke = instance?.getModel().getStrokes()[0]; if (!instance || !stroke) return; stroke.setStyle({ color: '#2563eb', width: 5 }); instance.renderNow(); }; const eraseFirstMark = (): void => { const instance = instanceRef.current; const stroke = instance?.getModel().getStrokes()[0]; if (!instance || !stroke) return; instance.getModel().removeStroke(stroke.id); instance.renderNow(); }; return (
); } ``` ```vue title="Vue" ``` ::: For Qwik, mount [`GrafloriaFlow`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow) and register the draw tool from `onInit$`, which receives the mounted instance. See the [Qwik demo gallery](https://grafloria.com/demos-qwik/) for runnable Qwik examples. The drawing interaction is the same in every binding: draw with a mouse, pen, or finger, then release to commit a stroke. For a freehand shape, trace a closed path yourself. In the JavaScript, Angular, React, and Vue examples, the first toolbar action recolors and widens the first live stroke; the second removes that whole stroke. Both update the mounted canvas, not a detached model. ## Tune the pen [`DrawToolOptions`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-interaction-interfaces-a-t#drawtooloptions) controls the created pen. The defaults below come from the tool implementation; `simplifyEpsilon` is left to the model's tuned default when omitted. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `color` | `string` | `'#1f2933'` | Sets the ink and live preview color. | | `width` | `number` | `3` | Sets the stroke width and preview width. | | `opacity` | `number` | Not set | Sets ink opacity; use it for translucent highlighter marks. | | `simplifyEpsilon` | `number` | Model's tuned default | Sets the Douglas–Peucker tolerance applied when the gesture commits. | | `label` | `string` | Not set | Gives committed ink an accessible name. | | `active` | `boolean` | `true` | Set to `false` to create the draw tool inactive. | ## What to know - `registerTool()` is a registry operation, so keep its returned disposer and call it when the owning view unmounts. Registering another tool with the same id replaces the current one; disposing restores the previous registration. - These toolbar actions call model methods directly. They update the document and repaint, but they do not create a user-gesture history command. For command-based undo behavior, see [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history). - The pen stays active for pointer gestures. Do not leave multiple point-agnostic drawing modes active together. ## See the pointer-driven tools The freehand demo shows the pen on a blank canvas; drag a line and release to commit crisp vector ink. Live demo: [Freehand draw](https://grafloria.com/demos/whiteboard/freehand-draw.html) · [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/whiteboard/freehand-draw.html) The rectangle demo starts ready for a drag that creates a box node, rather than a stroke. Live demo: [Rectangle tool](https://grafloria.com/demos/whiteboard/rectangle.html) · [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/whiteboard/rectangle.html) The eraser demo loads with three parallel strokes; its pointer sweep removes whole strokes. Live demo: [Eraser](https://grafloria.com/demos/whiteboard/eraser.html) · [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/whiteboard/eraser.html) The stroke-edit demo loads with the draw and edit controls; draw ink, choose edit, and drag a committed mark to translate it. Live demo: [Stroke edit](https://grafloria.com/demos/whiteboard/stroke-edit.html) · [source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/demos/whiteboard/stroke-edit.html) ## Related - [Command history](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/command-history) — understand how user gestures become undoable commands. - [Build runnable workflows](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/build-runnable-workflows) — mount and configure a usable canvas. # @grafloria/react React bindings for Grafloria Diagrams and Grafloria Dashboards — an MIT diagram and dashboard engine: routing, auto-layout, undo, collaboration and dashboard layouts, native in React. ## Install ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer react react-dom @grafloria/element ``` It expects these alongside it: - `@grafloria/engine` ^0.3.0 - `@grafloria/renderer` ^0.4.16 - `react` ^17.0.0 || ^18.0.0 || ^19.0.0 - `react-dom` ^17.0.0 || ^18.0.0 || ^19.0.0 - `@grafloria/element` ^0.4.3 ## Functions ### `createGrafloriaStore` ```ts function createGrafloriaStore(): GrafloriaStore ``` ### `GrafloriaCommentPanel` ```ts function GrafloriaCommentPanel(props: GrafloriaCommentPanelProps) ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `store` | `CommentStore` | | | | `options?` | `CommentPanelOptions` | | | | `onSelect?` | `(threadId: string) => void` | | | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | **Events** - `onSelect` ### `GrafloriaDashboard` ```ts function GrafloriaDashboard(props: GrafloriaDashboardProps) ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `views?` | `DashboardViewSpec[]` | | Multi-view (tabbed) board. Mutually exclusive with `widgets`. | | `widgets?` | `DashboardWidgetSpec[]` | | Single-view shorthand. | | `options?` | `Partial` | | Board options: columns, gap, sizing, rtl, responsive, binder… | | `widgetTypes?` | `WidgetTypes` | | Maps a widget `kind` to the React component that renders it. | | `activeView?` | `string` | | The visible view (the tab pattern). Omit for kit-managed. | | `layout?` | `"split" \| "grid"` | | LIVE SWITCHES — the toolbar toggles as props. Each is applied at mount | | `sizing?` | `"fit" \| "grow"` | | | | `static?` | `boolean` | | Static board: the viewer's mode — no drag, no resize, no handles. | | `onReady?` | `(handle: DashboardHandle) => void` | | The typed handle, once the board is live. | | `onLayoutChange?` | `(change: { viewId: string; widgets: DashboardWidgetSpec[]; }) => void` | | Mirrors the kit's committed gestures (drag, resize, add, remove). | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | | `children?` | `ReactNode` | | | **Events** - `onReady` — The typed handle, once the board is live. - `onLayoutChange` — Mirrors the kit's committed gestures (drag, resize, add, remove). ### `GrafloriaDiagram` ```ts function GrafloriaDiagram(props: GrafloriaDiagramProps) ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `spec` | `RenderSpec` | | Any kit spec — `erDiagram(...)`, `umlDiagram(...)`, `dashboard(...)`, or DSL text. | | `options?` | `RenderOptions` | | Options passed through to the underlying `createDiagram`. | | `onReady?` | `(instance: DiagramInstance) => void` | | | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | **Events** - `onReady` ### `GrafloriaFlow` ```ts function GrafloriaFlow(props: GrafloriaFlowProps) ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes?` | `NodeSpec[]` | | Controlled nodes. Provide with `onNodesChange` (see `useNodesState`). | | `edges?` | `EdgeSpec[]` | | Controlled edges. | | `groups?` | `(GroupModel \| GroupSpec)[]` | | Controlled groups — zones around some nodes (a spec's `groups`, or the live | | `defaultNodes?` | `NodeSpec[]` | | Uncontrolled nodes — the instance owns them from here on. | | `defaultEdges?` | `EdgeSpec[]` | | | | `defaultGroups?` | `(GroupModel \| GroupSpec)[]` | | | | `onNodesChange?` | `(nodes: NodeModel[]) => void` | | | | `onEdgesChange?` | `(edges: LinkModel[]) => void` | | | | `onSelectionChange?` | `(change: { nodes: NodeModel[]; edges: LinkModel[]; }) => void` | | | | `onConnect?` | `(change: { link: LinkModel; }) => void` | | | | `onNodeClick?` | `(change: { node: NodeModel; world: { x: number; y: number; }; }) => void` | | | | `onEdgeClick?` | `(change: { edge: LinkModel; world: { x: number; y: number; }; }) => void` | | | | `onInit?` | `(instance: DiagramInstance) => void` | | | | `nodeTypes?` | `NodeTypes` | | Custom node components, keyed by node `type`. | | `theme?` | `Theme` | | | | `fitView?` | `boolean` | | | | `enablePan?` | `boolean` | | | | `enableZoom?` | `boolean` | | | | `zoomSensitivity?` | `number` | | | | `dragThreshold?` | `number` | | | | `readonly?` | `boolean` | | | | `minZoom?` | `number` | | | | `maxZoom?` | `number` | | | | `ssr?` | `{ html: string; snapshot: HydrationSnapshot; }` | | The `renderToStaticSVG()` result. Renders server-side, hydrates client-side. | | `layout?` | `string \| { name: string; options?: Record; }` | | Declarative auto-layout — any engine registry name ('elk', 'dagre', | | `onLayoutDone?` | `(result: unknown) => void` | | Fires after each declarative layout completes. | | `plugins?` | `boolean \| CanvasPluginOptions` | | Canvas plugins — `true` mounts minimap + zoom/fit controls + background | | `collab?` | `GrafloriaCollabOptions` | | Real-time collaboration: hand in a transport (BroadcastChannelTransport, | | `onCollabReady?` | `(session: SyncAdapter) => void` | | The live SyncAdapter, right after `join()`. | | `comments?` | `boolean \| CommentStore` | | Anchored comment threads — `true` creates a store, or pass a shared | | `commentsViewer?` | `string` | | Viewer id for a `comments: true`-created store. | | `rendererConfig?` | `Record` | | Renderer config passthrough (parallelLinks, parallelSpacing, jump styles, …). | | `interaction?` | `Record` | | Interaction config passthrough (portVisibility, enableHelperLines, …). | | `tokenBridge?` | `unknown` | | Design-token bridge — adopt the app's shadcn / MUI / Tailwind CSS variables. | | `highlighterConfig?` | `boolean \| Partial` | | The outline layer Angular's canvas draws: outlines around the hovered node, | | `highlightConnected?` | `boolean \| HighlightConnectedOptions` | | Bring the selected nodes' lines forward and fade the rest: `true`, or | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | | `children?` | `ReactNode` | | Overlays (toolbars, panels). Rendered as siblings of the canvas. | **Events** - `onNodesChange` - `onEdgesChange` - `onSelectionChange` - `onConnect` - `onNodeClick` - `onEdgeClick` - `onInit` - `onLayoutDone` — Fires after each declarative layout completes. - `onCollabReady` — The live SyncAdapter, right after `join()`. ### `GrafloriaProvider` Wrap anything that needs `useGrafloria()` outside of ``'s subtree. ```tsx // useGrafloria() works here… // …because the flow publishes its instance to the store ``` `` also creates its own store when there is no provider, so the simple single-canvas case needs no wrapper at all. ```ts function GrafloriaProvider({ children }: GrafloriaProviderProps) ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `children?` | `ReactNode` | | | ### `useEdgesState` Controlled edge state. Mirrors {@link useNodesState}. ```ts function useEdgesState(initial: EdgeSpec[] = []): EdgesState ``` ### `useGrafloria` The live `DiagramInstance`, or `null` until `` has mounted. Works from anywhere inside an `` (a toolbar, a minimap, a sidebar) and from inside ``'s own children. ```tsx const grafloria = useGrafloria(); ``` ```ts function useGrafloria(): DiagramInstance | null ``` ### `useGrafloriaStore` The nearest store, or null when there is no provider above us. ```ts function useGrafloriaStore(): GrafloriaStore | null ``` ### `useNodesState` Controlled node state — the React Flow tuple everyone already knows: ```tsx const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); ``` `onNodesChange` is what closes the loop: the user drags a node, the ENGINE moves it, the instance emits `nodes:change`, `` calls this, and React state catches up. Without it a controlled `` would snap the node back on the next render — the classic controlled-component trap. ```ts function useNodesState(initial: NodeSpec[] = []): NodesState ``` ### `useOnSelectionChange` Fire a callback whenever the selection changes. ```tsx useOnSelectionChange(({ nodes }) => setInspected(nodes[0] ?? null)); ``` The handler is held in a ref, so passing an inline arrow (the common case) does NOT re-subscribe on every render. ```ts function useOnSelectionChange(handler: (change: SelectionChange) => void): void ``` ### `useSelection` The current selection as state (for rendering an inspector panel). ```ts function useSelection(): SelectionChange ``` ### `useViewport` The live camera (zoom + world rect) as state — for a minimap or a zoom badge. ```ts function useViewport(): { zoom: number; x: number; y: number } ``` ## Constants ### `GrafloriaContext` ```ts const GrafloriaContext: any ``` ## Interfaces ### `GrafloriaCommentPanelProps` ```ts interface GrafloriaCommentPanelProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `store` | `CommentStore` | | | | `options?` | `CommentPanelOptions` | | | | `onSelect?` | `(threadId: string \| null) => void` | | | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | ### `GrafloriaDashboardProps` ```ts interface GrafloriaDashboardProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `views?` | `DashboardViewSpec[]` | | Multi-view (tabbed) board. Mutually exclusive with `widgets`. | | `widgets?` | `DashboardWidgetSpec[]` | | Single-view shorthand. | | `options?` | `Partial` | | Board options: columns, gap, sizing, rtl, responsive, binder… | | `widgetTypes?` | `WidgetTypes` | | Maps a widget `kind` to the React component that renders it. | | `activeView?` | `string` | | The visible view (the tab pattern). Omit for kit-managed. | | `layout?` | `'grid' \| 'split'` | | LIVE SWITCHES — the toolbar toggles as props. Each is applied at mount (over `options`) and, when it changes afterwards, through the handle (`setLayout` / `setSizing` / `setStatic`) — no remount, like `activeView`. 'split' is the DevExpress splitter tree; 'grid' the cell grid. | | `sizing?` | `'fit' \| 'grow'` | | | | `static?` | `boolean` | | Static board: the viewer's mode — no drag, no resize, no handles. | | `onReady?` | `(handle: DashboardHandle) => void` | | The typed handle, once the board is live. | | `onLayoutChange?` | `(change: { viewId: string; widgets: DashboardWidgetSpec[] }) => void` | | Mirrors the kit's committed gestures (drag, resize, add, remove). | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | | `children?` | `ReactNode` | | | ### `GrafloriaDiagramProps` ```ts interface GrafloriaDiagramProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `spec` | `RenderSpec` | | Any kit spec — `erDiagram(...)`, `umlDiagram(...)`, `dashboard(...)`, or DSL text. | | `options?` | `RenderOptions` | | Options passed through to the underlying `createDiagram`. | | `onReady?` | `(instance: DiagramInstance) => void` | | | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | ### `GrafloriaFlowProps` ```ts interface GrafloriaFlowProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes?` | `NodeSpec[]` | | Controlled nodes. Provide with `onNodesChange` (see `useNodesState`). | | `edges?` | `EdgeSpec[]` | | Controlled edges. | | `groups?` | `Array` | | Controlled groups — zones around some nodes (a spec's `groups`, or the live GroupModels of a loaded document). Reconciled like `nodes`. | | `defaultNodes?` | `NodeSpec[]` | | Uncontrolled nodes — the instance owns them from here on. | | `defaultEdges?` | `EdgeSpec[]` | | | | `defaultGroups?` | `Array` | | | | `onNodesChange?` | `(nodes: NodeModel[]) => void` | | | | `onEdgesChange?` | `(edges: LinkModel[]) => void` | | | | `onSelectionChange?` | `(change: { nodes: NodeModel[]; edges: LinkModel[] }) => void` | | | | `onConnect?` | `(change: { link: LinkModel }) => void` | | | | `onNodeClick?` | `(change: { node: NodeModel; world: { x: number; y: number } }) => void` | | | | `onEdgeClick?` | `(change: { edge: LinkModel; world: { x: number; y: number } }) => void` | | | | `onInit?` | `(instance: DiagramInstance) => void` | | | | `nodeTypes?` | `NodeTypes` | | Custom node components, keyed by node `type`. | | `theme?` | `Theme` | | | | `fitView?` | `boolean` | | | | `enablePan?` | `boolean` | | | | `enableZoom?` | `boolean` | | | | `zoomSensitivity?` | `number` | | | | `dragThreshold?` | `number` | | | | `readonly?` | `boolean` | | | | `minZoom?` | `number` | | | | `maxZoom?` | `number` | | | | `ssr?` | `{ html: string; snapshot: HydrationSnapshot }` | | The `renderToStaticSVG()` result. Renders server-side, hydrates client-side. | | `layout?` | `string \| { name: string; options?: Record }` | | Declarative auto-layout — any engine registry name ('elk', 'dagre', 'force', 'tree', 'grid', 'auto', …) or `{ name, options }`. Re-runs when the prop VALUE changes, never when node data changes. | | `onLayoutDone?` | `(result: unknown) => void` | | Fires after each declarative layout completes. | | `plugins?` | `boolean \| CanvasPluginOptions` | | Canvas plugins — `true` mounts minimap + zoom/fit controls + background grid with defaults; an object picks and configures them. | | `collab?` | `GrafloriaCollabOptions` | | Real-time collaboration: hand in a transport (BroadcastChannelTransport, WebSocketTransport, MemoryTransport, …) and an actor id — the flow joins a CRDT sync session at mount and leaves on unmount. Fixed for the life of the instance. | | `onCollabReady?` | `(session: SyncAdapter) => void` | | The live SyncAdapter, right after `join()`. | | `comments?` | `boolean \| CommentStore` | | Anchored comment threads — `true` creates a store, or pass a shared `CommentStore`. Read it back with `useGrafloria()?.getCommentStore()`. | | `commentsViewer?` | `string` | | Viewer id for a `comments: true`-created store. | | `rendererConfig?` | `Record` | | Renderer config passthrough (parallelLinks, parallelSpacing, jump styles, …). | | `interaction?` | `Record` | | Interaction config passthrough (portVisibility, enableHelperLines, …). | | `tokenBridge?` | `unknown` | | Design-token bridge — adopt the app's shadcn / MUI / Tailwind CSS variables. | | `highlighterConfig?` | `boolean \| Partial` | | The outline layer Angular's canvas draws: outlines around the hovered node, the selected node, nodes with a validation issue, and valid connection targets. `true` turns every kind on; an object turns kinds on or off one by one. Off when unset. Live: follows the prop by value. | | `highlightConnected?` | `boolean \| HighlightConnectedOptions` | | Bring the selected nodes' lines forward and fade the rest: `true`, or options (depth, stroke, outgoing, dimOpacity). Off when unset. Live: follows the prop by value. | | `className?` | `string` | | | | `style?` | `CSSProperties` | | | | `children?` | `ReactNode` | | Overlays (toolbars, panels). Rendered as siblings of the canvas. | ### `GrafloriaProviderProps` ```ts interface GrafloriaProviderProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `children?` | `ReactNode` | | | ### `GrafloriaStore` The provider + the store behind `useGrafloria()`. React Flow's ergonomics come from exactly this shape: a `` that lets a toolbar, a sidebar or a minimap — components that are SIBLINGS of the canvas, not children of it — reach the live instance. We keep that shape, but the thing being shared is our framework-agnostic `DiagramInstance`, so the provider is a 40-line store and NOT a re-implementation of the diagram. Why a hand-rolled store rather than `useSyncExternalStore`: that hook is React 18+, and this package supports React 17–19. Subscribe + `useState` costs one extra render on attach and works everywhere. ```ts interface GrafloriaStore ``` **Members** - `get(): DiagramInstance | null` — The live instance, or null before `` has mounted. - `set(instance: DiagramInstance | null): void` — Called by `` on mount/unmount. - `subscribe(listener: (instance: DiagramInstance | null) => void): () => void` — Notified whenever the instance is attached or detached. ### `NodeProps` Props a custom node component receives. Deliberately React-Flow-shaped. ```ts interface NodeProps> ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | | | | `data` | `TData` | | | | `selected` | `boolean` | | | | `node` | `NodeModel` | | The live engine model — the escape hatch. | ### `SelectionChange` ```ts interface SelectionChange ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes` | `NodeModel[]` | | | | `edges` | `LinkModel[]` | | | ### `WidgetProps` Props a widget component receives — the `NodeProps` twin for boards. ```ts interface WidgetProps> ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `widget` | `DashboardWidgetSpec` | | | | `data` | `TData` | | | ## Types ### `EdgesState` Also has every member of `Array`, listed on its own entry. ```ts type EdgesState = [ EdgeSpec[], Dispatch>, (edges: LinkModel[]) => void, ]; ``` ### `NodesState` Also has every member of `Array`, listed on its own entry. What `useNodesState` hands back — React Flow's tuple, with our types. ```ts type NodesState = [ NodeSpec[], Dispatch>, (nodes: NodeModel[]) => void, ]; ``` ### `NodeTypes` `nodeTypes` maps a node's `type` to the component that renders it. ```ts type NodeTypes = Record>>; ``` ### `WidgetTypes` ```ts type WidgetTypes = Record>; ``` ## Also exported from here These names are documented with the package that defines them, and can be imported from this one too. - From [@grafloria/renderer](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-overview): `DARK_THEME`, `DiagramInstance`, `EdgeSpec`, `HydrationSnapshot`, `LIGHT_THEME`, `NodeSpec`, `PortSpec`, `renderToStaticSVG`, `StaticRenderOptions`, `StaticRenderResult`, `Theme` # @grafloria/qwik Qwik bindings for Grafloria Diagrams and Grafloria Dashboards — an MIT diagram and dashboard engine: routing, auto-layout, undo, collaboration and dashboard layouts, server-rendered and resumable in Qwik. ## Install ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element ``` It expects these alongside it: - `@grafloria/engine` ^0.3.0 - `@grafloria/renderer` ^0.4.18 - `@builder.io/qwik` ^1.5.0 - `@grafloria/element` ^0.4.3 ## Functions ### `useGrafloria` The live `DiagramInstance` signal. Falls back to a component-local signal when there is no `` above, so the hook is always safe to call — it simply never fills in without a provider or a sibling flow. ```ts function useGrafloria(): GrafloriaStore ``` ### `useOnSelectionChangeQrl` Fire a QRL on every selection change; teardown is automatic. The handler is a QRL rather than a plain function because Qwik has to be able to serialize the subscription and load the handler lazily — that is the whole point of the `$` suffix, and it is why this reads `useOnSelectionChange$(...)` at the call site. ```ts function useOnSelectionChangeQrl(handler: QRL<(change: SelectionChange) => void>): void ``` ### `useSelection` The current selection as reactive state (for an inspector panel). ```ts function useSelection(): Signal ``` ### `useViewport` The live camera (zoom + world origin) as reactive state. ```ts function useViewport(): Signal<{ zoom: number; x: number; y: number }> ``` ## Constants ### `GRAFLORIA_STORE` ```ts const GRAFLORIA_STORE: any ``` ### `GrafloriaCommentPanel` ```ts const GrafloriaCommentPanel: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `store` | `NoSerialize` | | The live comment store. Must be `noSerialize()`d. | | `options?` | `CommentPanelOptions` | | | | `onSelect$?` | `QRL<(threadId: string) => void>` | | | | `class?` | `string` | | | **Events** - `onSelect$` ### `GrafloriaDashboard` ```ts const GrafloriaDashboard: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `views?` | `DashboardViewSpec[]` | | | | `widgets?` | `DashboardWidgetSpec[]` | | | | `options?` | `Partial` | | | | `activeView?` | `string` | | The visible view. | | `layout?` | `"split" \| "grid"` | | LIVE SWITCHES — the toolbar toggles as props: applied at mount (over | | `sizing?` | `"fit" \| "grow"` | | | | `static?` | `boolean` | | Static board: the viewer's mode — no drag, no resize, no handles. | | `widgetTypes?` | `WidgetTypes` | | Qwik components for widget kinds, keyed by `kind`. | | `onReady$?` | `QRL<(handle: DashboardHandle) => void>` | | | | `onLayoutChange$?` | `QRL<(change: { viewId: string; widgets: DashboardWidgetSpec[]; }) => void>` | | | | `class?` | `string` | | | | `style?` | `Record` | | | **Events** - `onReady$` - `onLayoutChange$` ### `GrafloriaDiagram` ```ts const GrafloriaDiagram: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `spec` | `RenderSpec` | | Any kit spec — erDiagram(...), umlDiagram(...), dashboard(...), or DSL text. | | `options?` | `RenderOptions` | | | | `onReady$?` | `QRL<(instance: DiagramInstance) => void>` | | Fires once the kit has rendered, with the live instance. | | `class?` | `string` | | | | `style?` | `Record` | | | **Events** - `onReady$` — Fires once the kit has rendered, with the live instance. ### `GrafloriaFlow` ```ts const GrafloriaFlow: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes?` | `NodeSpec[]` | | | | `edges?` | `EdgeSpec[]` | | | | `groups?` | `(GroupModel \| GroupSpec)[]` | | Controlled groups — zones around some nodes (a spec's `groups`, or the live | | `defaultNodes?` | `NodeSpec[]` | | | | `defaultEdges?` | `EdgeSpec[]` | | | | `defaultGroups?` | `(GroupModel \| GroupSpec)[]` | | | | `onInit$?` | `QRL<(instance: DiagramInstance) => void>` | | | | `onNodesChange$?` | `QRL<(nodes: NodeSpec[]) => void>` | | | | `onEdgesChange$?` | `QRL<(edges: EdgeSpec[]) => void>` | | | | `onSelectionChange$?` | `QRL<(change: SelectionChange) => void>` | | | | `onConnect$?` | `QRL<(change: { link: LinkModel; }) => void>` | | | | `onNodeClick$?` | `QRL<(change: { node: NodeModel; world: { x: number; y: number; }; }) => void>` | | | | `onEdgeClick$?` | `QRL<(change: { edge: LinkModel; world: { x: number; y: number; }; }) => void>` | | | | `onLayoutDone$?` | `QRL<(result: unknown) => void>` | | | | `onCollabReady$?` | `QRL<(session: SyncAdapter) => void>` | | | | `nodeTypes?` | `NodeTypes` | | Custom node components, keyed by node `type`. | | `theme?` | `Theme` | | | | `fitView?` | `boolean` | | | | `enablePan?` | `boolean` | | | | `enableZoom?` | `boolean` | | | | `zoomSensitivity?` | `number` | | | | `dragThreshold?` | `number` | | | | `readonly?` | `boolean` | | | | `minZoom?` | `number` | | | | `maxZoom?` | `number` | | | | `ssr?` | `{ html: string; snapshot: HydrationSnapshot; }` | | The `renderToStaticSVG()` result. Renders server-side; the visible task | | `layout?` | `string \| GrafloriaLayoutRequest` | | Declarative auto-layout — any engine registry name ('elk', 'dagre', | | `plugins?` | `boolean \| CanvasPluginOptions` | | Canvas plugins — `true` mounts minimap + zoom/fit controls + background | | `collab?` | `NoSerialize` | | Real-time collaboration: a transport + actor id. The flow joins a CRDT | | `comments?` | `any` | | Anchored comment threads — `true` creates a store, or pass a shared | | `commentsViewer?` | `string` | | Viewer id for a `comments: true`-created store. | | `rendererConfig?` | `Record` | | Renderer config passthrough (parallelLinks, parallelSpacing, jump styles, …). | | `interaction?` | `Record` | | Interaction config passthrough (portVisibility, enableHelperLines, …). | | `tokenBridge?` | `unknown` | | Design-token bridge — adopt the app's shadcn / MUI / Tailwind CSS variables. | | `highlighterConfig?` | `boolean \| Partial` | | The outline layer: outlines around the hovered node, the selected node, | | `highlightConnected?` | `boolean \| HighlightConnectedOptions` | | Bring the selected nodes' lines forward and fade the rest: `true`, or | | `class?` | `string` | | | | `style?` | `Record` | | | **Events** - `onInit$` - `onNodesChange$` - `onEdgesChange$` - `onSelectionChange$` - `onConnect$` - `onNodeClick$` - `onEdgeClick$` - `onLayoutDone$` - `onCollabReady$` ### `GrafloriaProvider` Makes the nearest ``'s instance reachable by SIBLINGS — toolbars, inspectors, minimaps — through the hooks below. ```ts const GrafloriaProvider: any ``` ### `useOnSelectionChange$` ```ts const useOnSelectionChange$: any ``` ## Interfaces ### `GrafloriaCollabOptions` The uniform collab contract every Grafloria wrapper shares. ```ts interface GrafloriaCollabOptions ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `transport` | `SyncTransport` | | The transport (BroadcastChannelTransport, WebSocketTransport, …). | | `actor` | `string` | | | | `presence?` | `boolean \| BindPresenceOptions` | | Live cursors + remote selection outlines. `true` for defaults. | ### `GrafloriaCommentPanelProps` ```ts interface GrafloriaCommentPanelProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `store` | `NoSerialize` | | The live comment store. Must be `noSerialize()`d. | | `options?` | `CommentPanelOptions` | | | | `onSelect$?` | `QRL<(threadId: string \| null) => void>` | | | | `class?` | `string` | | | ### `GrafloriaDashboardProps` ```ts interface GrafloriaDashboardProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `views?` | `DashboardViewSpec[]` | | | | `widgets?` | `DashboardWidgetSpec[]` | | | | `options?` | `Partial` | | | | `activeView?` | `string` | | The visible view. | | `layout?` | `'grid' \| 'split'` | | LIVE SWITCHES — the toolbar toggles as props: applied at mount (over `options`) and, when they change, through the handle (`setLayout` / `setSizing` / `setStatic`), never by remounting. 'split' is the splitter tree; 'grid' the cell grid. | | `sizing?` | `'fit' \| 'grow'` | | | | `static?` | `boolean` | | Static board: the viewer's mode — no drag, no resize, no handles. | | `widgetTypes?` | `WidgetTypes` | | Qwik components for widget kinds, keyed by `kind`. | | `onReady$?` | `QRL<(handle: DashboardHandle) => void>` | | | | `onLayoutChange$?` | `QRL< (change: { viewId: string; widgets: DashboardWidgetSpec[] }) => void >` | | | | `class?` | `string` | | | | `style?` | `Record` | | | ### `GrafloriaDiagramProps` ```ts interface GrafloriaDiagramProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `spec` | `RenderSpec` | | Any kit spec — erDiagram(...), umlDiagram(...), dashboard(...), or DSL text. | | `options?` | `RenderOptions` | | | | `onReady$?` | `QRL<(instance: DiagramInstance) => void>` | | Fires once the kit has rendered, with the live instance. | | `class?` | `string` | | | | `style?` | `Record` | | | ### `GrafloriaFlowProps` ```ts interface GrafloriaFlowProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes?` | `NodeSpec[]` | | | | `edges?` | `EdgeSpec[]` | | | | `groups?` | `Array` | | Controlled groups — zones around some nodes (a spec's `groups`, or the live GroupModels of a loaded document). Reconciled like `nodes`. | | `defaultNodes?` | `NodeSpec[]` | | | | `defaultEdges?` | `EdgeSpec[]` | | | | `defaultGroups?` | `Array` | | | | `onInit$?` | `QRL<(instance: DiagramInstance) => void>` | | | | `onNodesChange$?` | `QRL<(nodes: NodeSpec[]) => void>` | | | | `onEdgesChange$?` | `QRL<(edges: EdgeSpec[]) => void>` | | | | `onSelectionChange$?` | `QRL<(change: SelectionChange) => void>` | | | | `onConnect$?` | `QRL<(change: { link: LinkModel }) => void>` | | | | `onNodeClick$?` | `QRL<(change: { node: NodeModel; world: { x: number; y: number } }) => void>` | | | | `onEdgeClick$?` | `QRL<(change: { edge: LinkModel; world: { x: number; y: number } }) => void>` | | | | `onLayoutDone$?` | `QRL<(result: unknown) => void>` | | | | `onCollabReady$?` | `QRL<(session: SyncAdapter) => void>` | | | | `nodeTypes?` | `NodeTypes` | | Custom node components, keyed by node `type`. | | `theme?` | `Theme` | | | | `fitView?` | `boolean` | | | | `enablePan?` | `boolean` | | | | `enableZoom?` | `boolean` | | | | `zoomSensitivity?` | `number` | | | | `dragThreshold?` | `number` | | | | `readonly?` | `boolean` | | | | `minZoom?` | `number` | | | | `maxZoom?` | `number` | | | | `ssr?` | `{ html: string; snapshot: HydrationSnapshot }` | | The `renderToStaticSVG()` result. Renders server-side; the visible task adopts the markup instead of rebuilding it, so there is no flash and no re-layout. Put `ssr.css` in your document head. | | `layout?` | `string \| GrafloriaLayoutRequest` | | Declarative auto-layout — any engine registry name ('elk', 'dagre', 'force', 'tree', 'grid', 'auto', …) or `{ name, options }`. Re-runs when the prop VALUE changes, never when node data changes. | | `plugins?` | `boolean \| CanvasPluginOptions` | | Canvas plugins — `true` mounts minimap + zoom/fit controls + background grid with defaults; an object picks and configures them. | | `collab?` | `NoSerialize` | | Real-time collaboration: a transport + actor id. The flow joins a CRDT sync session on mount and leaves on unmount. Fixed for the life of the instance. `collab.transport` must be `noSerialize()`d. | | `comments?` | `boolean \| NoSerialize` | | Anchored comment threads — `true` creates a store, or pass a shared `CommentStore` (which must be `noSerialize()`d). | | `commentsViewer?` | `string` | | Viewer id for a `comments: true`-created store. | | `rendererConfig?` | `Record` | | Renderer config passthrough (parallelLinks, parallelSpacing, jump styles, …). | | `interaction?` | `Record` | | Interaction config passthrough (portVisibility, enableHelperLines, …). | | `tokenBridge?` | `unknown` | | Design-token bridge — adopt the app's shadcn / MUI / Tailwind CSS variables. | | `highlighterConfig?` | `boolean \| Partial` | | The outline layer: outlines around the hovered node, the selected node, nodes with a validation issue, and valid connection targets. `true` turns every kind on; an object turns kinds on or off one by one. Off when unset. Follows the prop by VALUE. | | `highlightConnected?` | `boolean \| HighlightConnectedOptions` | | Bring the selected nodes' lines forward and fade the rest: `true`, or options (depth, stroke, outgoing, dimOpacity). Off when unset. Follows the prop by VALUE. | | `class?` | `string` | | | | `style?` | `Record` | | | ### `GrafloriaLayoutRequest` ```ts interface GrafloriaLayoutRequest ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | | | | `options?` | `Record` | | | ### `NodeProps` Props a custom node component receives. ```ts interface NodeProps> ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | | | | `data` | `TData` | | | | `selected` | `boolean` | | | | `node` | `NodeModel` | | The live engine model — the escape hatch. | ### `SelectionChange` ```ts interface SelectionChange ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes` | `NodeModel[]` | | | | `edges` | `LinkModel[]` | | | ### `WidgetProps` Props a custom widget component receives. ```ts interface WidgetProps> ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `widget` | `DashboardWidgetSpec` | | | | `data` | `TData` | | | ## Types ### `GrafloriaStore` The live instance, or `undefined` until a `` mounts. Always `noSerialize`d — see the note at the top of this file. ```ts type GrafloriaStore = Signal | undefined>; ``` ### `NodeTypes` `nodeTypes` maps a node's `type` to the Qwik component that renders it. Declaring a type here IS the opt-in: specs whose `type` has an entry are flagged `custom` automatically, the same rule the Vue and Angular wrappers use. An explicit `custom` on the spec always wins. ```ts type NodeTypes = Record>>; ``` ### `WidgetTypes` ```ts type WidgetTypes = Record>>; ``` ## Also exported from here These names are documented with the package that defines them, and can be imported from this one too. - From [@grafloria/renderer](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-overview): `DARK_THEME`, `DiagramInstance`, `EdgeSpec`, `GroupSpec`, `HighlightConnectedOptions`, `HydrationSnapshot`, `LIGHT_THEME`, `NodeSpec`, `renderToStaticSVG`, `StaticRenderOptions`, `StaticRenderResult`, `Theme` # @grafloria/vue Vue 3 bindings for Grafloria Diagrams and Grafloria Dashboards — an MIT diagram and dashboard engine: routing, auto-layout, undo, collaboration and dashboard layouts, native in Vue. ## Install ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer vue @grafloria/element ``` It expects these alongside it: - `@grafloria/engine` ^0.3.0 - `@grafloria/renderer` ^0.4.16 - `vue` ^3.4.0 - `@grafloria/element` ^0.4.3 ## Functions ### `useGrafloria` The live `DiagramInstance` ref, `null` until a `` mounts. ```ts function useGrafloria(): ShallowRef ``` ### `useOnSelectionChange` Fire a callback on every selection change; teardown is automatic. ```ts function useOnSelectionChange(handler: (change: SelectionChange) => void): void ``` ### `useSelection` The current selection as reactive state (for an inspector panel). ```ts function useSelection(): Ref ``` ### `useViewport` The live camera (zoom + world rect) as reactive state. ```ts function useViewport(): Ref<{ zoom: number; x: number; y: number }> ``` ## Constants ### `GRAFLORIA_STORE` ```ts const GRAFLORIA_STORE: InjectionKey> ``` ### `GrafloriaCommentPanel` ```ts const GrafloriaCommentPanel: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `store` | `CommentStore` | | | | `options?` | `CommentPanelOptions>, default: () =` | | | **Events** - `select` ### `GrafloriaDashboard` ```ts const GrafloriaDashboard: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `views?` | `DashboardViewSpec[]` | | | | `widgets?` | `DashboardWidgetSpec[]` | | | | `options?` | `Partial>, default: () =` | | | | `activeView?` | `String` | | The visible view — `v-model:active-view`. | | `layout?` | `'grid' \| 'split'` | | LIVE SWITCHES — the toolbar toggles as props: applied at mount (over | | `sizing?` | `'fit' \| 'grow'` | | | | `static?` | `Boolean` | | Static board: the viewer's mode — no drag, no resize, no handles. | **Events** - `update:activeView` - `ready` - `layoutChange` ### `GrafloriaDiagram` ```ts const GrafloriaDiagram: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `spec` | `RenderSpec` | | Any kit spec — erDiagram(...), umlDiagram(...), dashboard(...), or DSL text. | | `options?` | `RenderOptions>, default: () =` | | | **Events** - `ready` ### `GrafloriaFlow` ```ts const GrafloriaFlow: any ``` **Props** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes?` | `NodeSpec[]` | | Controlled nodes — `v-model:nodes`. | | `edges?` | `EdgeSpec[]` | | Controlled edges — `v-model:edges`. | | `groups?` | `Array` | | Controlled groups — zones around some nodes (a spec's `groups`, or the live | | `defaultNodes?` | `NodeSpec[]` | | Uncontrolled initial data. | | `defaultEdges?` | `EdgeSpec[]` | | | | `defaultGroups?` | `Array` | | | | `theme?` | `Theme` | | | | `layout?` | `string \| GrafloriaLayoutRequest` | | Declarative auto-layout — any engine registry name ('elk', 'dagre', | | `plugins?` | `boolean \| CanvasPluginOptions` | | Canvas plugins — `true` mounts minimap + zoom/fit controls + background | | `collab?` | `GrafloriaCollabOptions` | | Real-time collaboration: a transport + actor id — the flow joins a CRDT | | `comments?` | `boolean \| object` | | Anchored comment threads — `true` creates a store; or pass a shared one. | | `commentsViewer?` | `String` | | | | `fitView?` | `Boolean` | | | | `enablePan?` | `Boolean` | | | | `enableZoom?` | `Boolean` | | | | `readonly?` | `Boolean` | | | | `minZoom?` | `Number` | | | | `maxZoom?` | `Number` | | | | `zoomSensitivity?` | `Number` | | | | `rendererConfig?` | `Record` | | Renderer config passthrough (parallelLinks, parallelSpacing, jump styles, …). | | `interaction?` | `Record` | | Interaction config passthrough (portVisibility, enableHelperLines, …). | | `tokenBridge?` | `unknown` | | Design-token bridge — adopt the app's shadcn / MUI / Tailwind CSS variables. | | `highlighterConfig?` | `boolean \| Partial` | | The outline layer Angular's canvas draws: outlines around the hovered node, | | `highlightConnected?` | `boolean \| HighlightConnectedOptions` | | Bring the selected nodes' lines forward and fade the rest: `true`, or | **Events** - `update:nodes` - `update:edges` - `init` - `selectionChange` - `connect` - `nodeClick` - `edgeClick` - `layoutDone` - `collabReady` ### `GrafloriaProvider` Makes the nearest ``'s instance reachable by SIBLINGS — toolbars, inspectors, minimaps — through the composables below. ```ts const GrafloriaProvider: any ``` ## Interfaces ### `GrafloriaCollabOptions` The uniform collab contract every Grafloria wrapper shares. ```ts interface GrafloriaCollabOptions ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `transport` | `SyncTransport` | | | | `actor` | `string` | | | | `presence?` | `boolean \| BindPresenceOptions` | | Live cursors + remote selection outlines. `true` for defaults. | ### `GrafloriaLayoutRequest` ```ts interface GrafloriaLayoutRequest ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | | | | `options?` | `Record` | | | ### `NodeSlotProps` Context handed to `#node-` slots. ```ts interface NodeSlotProps ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `node` | `NodeModel` | | | | `data` | `Record` | | | | `engine` | `DiagramEngine` | | | ### `SelectionChange` ```ts interface SelectionChange ``` **Properties** | Name | Type | Default | Description | | --- | --- | --- | --- | | `nodes` | `NodeModel[]` | | | | `edges` | `LinkModel[]` | | | ## Also exported from here These names are documented with the package that defines them, and can be imported from this one too. - From [@grafloria/renderer](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-overview): `DARK_THEME`, `DiagramInstance`, `EdgeSpec`, `LIGHT_THEME`, `NodeSpec`, `Theme` # @grafloria/canvas-ng Angular canvas integration for the Grafloria diagram engine ## Install ```bash npm install @grafloria/canvas-ng @angular/common @angular/core ``` It expects these alongside it: - `@angular/common` ^18.1.0 - `@angular/core` ^18.1.0 ## Classes ### `CanvasNgCanvasNgComponent` ```ts @Component({ selector: 'lib-canvas-ng-canvas-ng', imports: [CommonModule], templateUrl: './canvas-ng-canvas-ng.component.html', styleUrl: './canvas-ng-canvas-ng.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class CanvasNgCanvasNgComponent ``` Use it as `` in a template. # @grafloria/dashboard Grafloria Dashboards is an MIT JavaScript dashboard layout library: draggable, resizable widgets on a grid or a splitter layout, with undo, nesting and persistence built in, for Angular, React, Vue or plain JavaScript. ## Install ```bash npm install @grafloria/dashboard @grafloria/element @grafloria/engine @grafloria/renderer ``` It expects these alongside it: - `@grafloria/element` ^0.4.9 - `@grafloria/engine` ^0.3.0 - `@grafloria/renderer` ^0.4.0 ## Also exported from here These names are documented with the package that defines them, and can be imported from this one too. - From [@grafloria/element](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-overview): `bindDashboardGrid`, `BUILT_IN_WIDGET_KINDS`, `dashboard`, `DashboardGridHandle`, `DashboardGridOptions`, `DashboardHandle`, `DashboardOptions`, `DashboardSnapshot`, `DashboardSpec`, `DashboardViewSpec`, `DashboardWidgetSpec`, `defaultWidgetRenderer` and 7 more # @grafloria/renderer SVG renderer for the Grafloria diagram engine — interaction, theming, accessibility, and SVG/PNG/PDF vector export ## Install ```bash npm install @grafloria/renderer @grafloria/engine ``` It expects these alongside it: - `@grafloria/engine` ^0.3.18 ## What it exports - [Core](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-core): 17 exports, including `cancelFrame`, `CanvasRect`, `getDocument`, `hasDocument` - [A11y](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-a11y): 51 exports, including `Adjacency`, `analyseTopology`, `boundsOfPoints`, `buildAdjacency` - [Canvas](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-canvas): 79 exports, including `ALWAYS_SAFE_MODE`, `applyMatrix`, `arcToCubics`, `BackendMode` - [Comments](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-comments): 9 exports, including `CommentOverlayController`, `CommentOverlayOptions`, `CommentPanelOptions`, `CommentPanelView` - [Export](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-export): 106 exports, including `Artifact`, `AssetFetcher`, `AVG_CHAR_WIDTH_EM`, `base64ToBytes` - [Ext](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-ext): 115 exports, including `activeRegistryScope`, `AnchorContext`, `AnchorFn`, `AnchorResult` - [Ext — Components](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-ext-components): 15 exports, including `attachCanvasPlugins`, `BACKGROUND_LAYER_CLASS`, `BackgroundHandle`, `BackgroundOptions` - [Instance](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance): 49 exports, including `applyEdges`, `applyEdgeSpec`, `applyGroups`, `applyNodes` - [Interaction](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-interaction): 85 exports, including `AddWaypointResult`, `AlignmentGuide`, `angleAt`, `Announcement` - [Lazy](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-lazy): 13 exports, including `DeferredQuery`, `EntityKind`, `FreezeQuery`, `HostCullMode` - [Perf](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-perf): 8 exports, including `EMPTY_SNAPSHOT`, `formatSnapshot`, `GovernorOptions`, `GovernorState` - [Presence](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-presence): 12 exports, including `actorColor`, `actorInitials`, `bindPresence`, `BindPresenceOptions` - [Presentation](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-presentation): 11 exports, including `FollowOptions`, `followPresenter`, `InMemoryViewportChannel`, `isDocumentLocked` - [Services](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-services): 25 exports, including `AnimationConfig`, `AnimationEventData`, `AnimationLifecycleEvent`, `AnimationLifecycleManager` - [Svg](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-svg): 198 exports, including `applySpread`, `ArrowRenderer`, `assignSpreadLanes`, `autoSizeDiagram` - [Themes](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-themes): 83 exports, including `assertThemeContrast`, `auditThemeContrast`, `BASE_STYLE_RULES`, `BRIDGEABLE_TOKENS` - [Types](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-types): 50 exports, including `BooleanPropertyDefinition`, `BoundingBox`, `CanvasRendererConfig`, `ColorPalette` - [Utils](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-utils): 37 exports, including `AnimationColorSchemes`, `AnimationDescriptor`, `AnimationPresets`, `AnimationPriority` - [Vnode](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-vnode): 17 exports, including `camelToKebab`, `ContainerIdGenerator`, `createDomElement`, `createForeignObject` ## Also exported from here These names are documented with the package that defines them, and can be imported from this one too. - From [@grafloria/engine](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-overview): `NodeModel`, `PortLayoutArgs`, `PortLayoutSpec`, `PortModel` # 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 `` (for an ``, 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-` / `edge-` when the spec omits them (never a nanoid); - ports: rewritten to `__` (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 `` 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 `` 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 `` — the APP's own toggle, which is what `AnimationService` sets. Both must work; only the first used to. ```ts const MOTION_PREFERENCE_CSS: "\n/* ==========================================================================\n REDUCED MOTION — OS preference\n ========================================================================== */\n@media (prefers-reduced-motion: reduce) {\n .link-animated-marching-ants,\n .link-animated-flow,\n .link-animated-pulse {\n animation: none !important;\n stroke-dasharray: none !important;\n }\n\n .node-border-gradient,\n .node-border-gradient::before,\n .node-border-pulse,\n .node-border-pulse-svg,\n .node-border-breathe,\n .node-border-shimmer,\n … ``` ### `MOTION_PREFERENCE_STYLE_ID` Id of the injected `