# Grafloria # Introduction Grafloria is an MIT diagram and dashboard engine for JavaScript. Use it when you build flowcharts, workflow editors, UML or ER diagrams, or boards of draggable, resizable widgets. Its JavaScript surface and native Angular, React, Vue and Qwik bindings share one headless core, one document format and one undo stack. ## Choose your packages Start with the package for your framework, or the element package for framework-free embedding. The engine owns the graph and behavior; the renderer supplies the visual layer. | Package | What you use it for | Install in your project | | --- | --- | --- | | [`@grafloria/engine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-overview) | Headless graph models, commands and undo, layout, diagram text and collaboration. | `npm install @grafloria/engine` | | [`@grafloria/renderer`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-overview) | SVG rendering, interaction, theming, accessibility and SVG, PNG or vector PDF export. | `npm install @grafloria/renderer @grafloria/engine` | | [`@grafloria/element`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-overview) | The `` custom element and dashboard, UML and ER kits, with or without a framework. | `npm install @grafloria/element @grafloria/engine @grafloria/renderer` | | [`@grafloria/dashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-dashboard) | Dashboard layouts with grid or splitter arrangements, undo, nesting and persistence. | `npm install @grafloria/dashboard @grafloria/element@^0.4.9 @grafloria/engine@^0.3.0 @grafloria/renderer@^0.4.0` | | [`@grafloria/react`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react) | React components, hooks and component-based custom nodes. | `npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom` | | [`@grafloria/vue`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue) | Vue 3 components, `v-model` bindings and slot-based custom nodes. | `npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue` | | [`@grafloria/angular`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-overview) | Angular components, directives and services. | `npm install @grafloria/angular @grafloria/engine @grafloria/renderer @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs` | | [`@grafloria/qwik`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik) | Qwik components, QRL callbacks, custom nodes and resumable server rendering. | `npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik` | The standalone dashboard package at version `0.1.0` declares older peer ranges than the current framework packages. Its install line selects those ranges. For dashboards in a current framework project, start with that framework's binding and the dashboard kit in the element package. ## Take your quick start Choose your stack to install the binding and mount a sized canvas with two connected nodes: - [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start): embed without a framework and keep the live instance for updates and cleanup. - [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start): use controlled node and edge state and an initialization ref. - [Vue quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-quick-start): use typed data, `v-model` bindings and an initialization callback. - [Angular quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-quick-start): use a standalone canvas, two-way model bindings and view-child access. - [Qwik quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-quick-start): use serializable signals, QRL change callbacks and initialization access. To explore before installing, open the [live demo gallery](https://grafloria.com/demos/) or the [fluid dashboard demo](https://grafloria.com/demos/dashboard/fluid-board.html). ## Find your task ### Concepts Start with [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works), then follow [specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models), [instance lifetime](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle), [state and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow), [commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history), and [documents and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/documents-and-kits) to understand how your binding, live graph and saved document fit together. ### Guides The task guides cover shared capabilities; use the framework guides below for binding-specific state and content. - **Edit the graph:** [edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes), [validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections), [route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges), [configure gestures](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/configure-editing-gestures), and [add editor controls](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/add-editor-controls) to turn the canvas into an editor. - **Layout and containment:** [lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram) and [group and nest nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/group-and-nest-nodes) to arrange graphs and give zones, containers and swimlanes real membership. - **Appearance:** [theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) and [highlight and animate state](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/highlight-and-animate-state) to adopt your design system and show graph state without changing graph structure. - **Diagram editors:** build a [workflow editor](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-workflow-editor), [database model editor](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-database-models), [UML class editor](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-uml-classes), or [stencil editor](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-stencil-editor) using shipped capabilities. - **Dashboards:** [build a dashboard](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-dashboard) and [arrange dashboard containers](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/arrange-dashboard-containers) to manage widgets, responsive layouts, nested sections and tabs. - **Collaboration:** [synchronize editors](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/synchronize-editors) and [share presence and comments](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/share-presence-and-comments) to connect peers and add shared cursors and discussion. - **Saving and export:** [save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents), [import diagram text and files](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/import-diagram-text-and-files), and [export images and documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/export-images-and-documents) to persist the live graph and exchange editable or rendered results. - **Whiteboarding and extensions:** [draw and edit ink](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/draw-and-edit-ink), [tune large-graph rendering](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/tune-large-graph-rendering), [extend rendered geometry](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/extend-rendered-geometry), and [extend layout execution](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/extend-layout-execution) when you need ink tools, rendering controls or capabilities beyond the built-ins. ### Framework guides - **JavaScript:** [elements and content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-elements-and-content) covers embedding choices, DOM events, custom renderers and framework-free lifecycle. - **React:** [state and subscriptions](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-state-and-subscriptions) and [custom content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-custom-content) cover controlled ownership, instance access, portals and widget components. - **Vue:** [state and composables](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-state-and-composables) and [slots and widgets](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-slots-and-widgets) cover refs, providers, reactive content and dashboard views. - **Angular:** [state and tooling](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-state-and-tooling) and [templates and handles](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-templates-and-handles) cover model bindings, incremental patches, editor services, node templates and connection handles. - **Qwik:** [state and resumption](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-state-and-resumption) and [custom content and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-custom-content-and-kits) cover QRL boundaries, serializable state and custom content across Qwik containers. ### API reference Use the package reference links in the package table to look up exports and signatures. For the live canvas facade, start with [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance): it provides rendering and spec updates, `getModel()` for data queries, and `getEngine()` for engine access. The [Qwik Vite reference](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik-vite) covers that package's Vite entry point. # JavaScript quick start Render a 400px-high canvas with two connected nodes, keep its instance for later calls, and clean it up when you close the editor. Grafloria's framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. ## Prerequisites Use a browser project with an HTML entry point and an ESM bundler. No UI framework is required. This example uses Vite and TypeScript for typed specs; it runs in the browser, not Node. `@grafloria/element` 0.5.0 requires `@grafloria/engine` `^0.4.0` and `@grafloria/renderer` `^0.5.0` as peers. In your project directory, install all three packages and the example's development tools: ```bash npm install @grafloria/element@0.5.0 @grafloria/engine@^0.4.0 @grafloria/renderer@^0.5.0 npm install --save-dev vite typescript ``` ## 1. Give the canvas a size Create `index.html` at your project root. The canvas has a resolved height before the diagram mounts. The button closes the editor; it does not run during setup. ```html title="index.html" Grafloria quick start

``` For other sizing arrangements, see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas). ## 2. Mount two connected nodes Call [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core) with the spec first, the target second, and instance options third. It mounts the diagram and returns a live [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance). Create `src/main.ts`, the entry file. Type the data with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec), and use the shipped [`LIGHT_THEME`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-constants). The edge names the nodes by their ids; its handles pin the connection to the right side of Ingest and the left side of Publish. Omit `ports` to use the nodes' four default ports. ```ts title="src/main.ts" import { render } from '@grafloria/element'; import { LIGHT_THEME } from '@grafloria/renderer'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const container = document.getElementById('canvas'); const editor = document.getElementById('editor'); const closeControl = document.getElementById('close-editor'); const status = document.getElementById('status'); if (!container || !editor || !closeControl || !status) { throw new Error('The editor elements are missing'); } container.style.height = '400px'; container.style.width = '100%'; const closeButton = document.createElement('button'); closeButton.id = 'close-editor'; closeButton.type = 'button'; closeButton.textContent = 'Close diagram'; closeControl.replaceWith(closeButton); editor.append(closeButton, status, container); const nodes: NodeSpec[] = [ { id: 'a', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, label: 'Ingest', }, { id: 'b', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, label: 'Publish', }, ]; const edges: EdgeSpec[] = [ { id: 'e1', source: 'a', target: 'b', sourceHandle: 'right', targetHandle: 'left', }, ]; const api = render({ nodes, edges }, container, { theme: LIGHT_THEME, fitView: true, }); const model = api.getModel(); status.textContent = `${model.getNodes().length} nodes connected by ${model.getLinks().length} edge`; closeButton.addEventListener('click', () => { api.dispose(); editor.remove(); }, { once: true }); ``` `fitView: true` frames the content on mount. The nodes render side by side with a line between them. ![Look for the edge from Ingest to Publish, with the Close diagram button and model counts above the canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/868c6fde3e07406665ec2ec9aaa36015.png) Pass data to `render()`: an object spec or its JSON string, not Mermaid text. To import text instead, see [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents). ## 3. Keep the instance and clean up on close The entry file keeps the returned instance in `api`, queries its live data for the status, and calls `dispose()` from the close handler before removing the editor. `getModel()` returns the live [`DiagramModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-diagrammodel), not a copy of your input specs. Use the instance for rendering operations such as `fitView()`; use `getEngine()` to reach the [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine) for commands, layout, and validation. See [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) for the next instance operations. On close, `dispose()` detaches interaction listeners, stops scheduled rendering, disconnects resize observation, and removes the diagram's DOM. In an application with route or component lifecycle hooks, put that call in the editor's unmount hook instead. Start the development server from your project directory: ```bash npx vite ``` Open the local URL Vite prints. You have a sized diagram containing Ingest and Publish, their connecting edge, a status showing the mounted model's counts, and a Close diagram button. Closing removes the editor and releases the instance. ## Where next - Explore the [live JavaScript demos](https://grafloria.com/demos/) and their source tabs. - Add HTML node content with [JavaScript elements and content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-elements-and-content). - Add editing actions with [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). - Learn the relationship between specs, models, and the engine in [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works). # React quick start Mount a controlled canvas with two connected nodes, and keep its instance in a ref for a Fit view button. Framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. ## Prerequisites Use a browser-based React project with TypeScript and JSX configured, React and React DOM 17, 18 or 19 (18 or 19 for the mounting code below), and the binding's `@grafloria/element` `^0.5.0` peer; see [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start#prerequisites) for the shared engine and renderer prerequisites. ## 1. Install the binding and its peers Run this in your application's directory: ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` ## 2. Create the controlled canvas Render [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow) with typed [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec) data. The edge's `source` and `target` identify the two nodes. [`useNodesState`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#usenodesstate) and [`useEdgesState`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#useedgesstate) each return a three-element tuple: the specs, a state setter and a canvas change handler. Pass the third element to the corresponding change prop to mirror canvas edits into React state. Store the [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) received by `onInit` in a React ref. The button calls `fitView()` on that mounted instance to frame the content; the `fitView` prop also frames it at startup. `plugins` adds the shipped minimap, zoom/fit controls and background grid. ```tsx title="src/App.tsx" import { useRef } from 'react'; import { GrafloriaFlow, useNodesState, useEdgesState, } from '@grafloria/react'; import type { DiagramInstance, NodeSpec, EdgeSpec, } from '@grafloria/react'; const initialNodes: NodeSpec[] = [ { id: 'a', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Ingest' }, }, { id: 'b', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Publish' }, }, ]; const initialEdges: EdgeSpec[] = [ { id: 'e1', source: 'a', target: 'b' }, ]; export default function App() { const instanceRef = useRef(null); const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, , onEdgesChange] = useEdgesState(initialEdges); return (
{ instanceRef.current = instance; }} fitView plugins />
); } ``` ![Ingest connects to Publish across the dotted canvas. Look for the Fit view button above it, zoom/fit controls at the lower left and the minimap at the lower right.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/41e1adc1ca007ff858e4a059892bebbc.png) The canvas occupies the 400-pixel-high wrapper. For other sizing arrangements, see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas). ### Keep the controlled return path wired Passing controlled `nodes` without `onNodesChange` lets stale React state overwrite user edits. Pass the hook's third element, not its `setNodes` setter: the callback receives live [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel) objects, while the setter accepts `NodeSpec[]` or a state updater. The hook converts those live models back to specs. The second tuple element is omitted in this sample because it does not make application-originated edits. `onInit` runs after the instance is created. The binding disposes that instance when the flow unmounts; do not dispose it after initialization. ## 3. Mount the app For a React 18 or 19 client entry, render `App` into your HTML root element: ```tsx title="src/main.tsx" import { createRoot } from 'react-dom/client'; import App from './App'; const container = document.getElementById('root'); if (!container) throw new Error('Missing #root element'); createRoot(container).render(); ``` Use the HTML entry structure from [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start#1-give-the-canvas-a-size), replacing its editor markup and script with the React root and TSX entry: ```html title="index.html"
``` This HTML entry fits a Vite React TypeScript project. If your framework already mounts `App`, keep its entry point instead. Start your application's development server and open it in the browser. You now have Ingest and Publish nodes joined by an edge, with a Fit view button above the canvas. Drag a node to move it; the change handler mirrors the edit into React state. Pan or zoom, then click Fit view to frame both nodes again. ## Where next - Try the [live React starter](https://stackblitz.com/github/grafloria/grafloria/tree/main/starters/react?file=src/main.jsx), or explore the [React demo gallery](https://grafloria.com/demos-react/). - Read [React: state and subscriptions](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-state-and-subscriptions) for application updates and subscriptions. - Use [React: custom content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-custom-content) to render your own node components. - Read [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) for instance ownership beyond this component. # Vue quick start Mount a Vue canvas with two connected nodes, keep its specs in typed refs, and capture the mounted instance through `@init`. Framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. ## Prerequisites Use a browser-based Vue TypeScript project with single-file component support. This example assumes your project has an `npm run dev` script, as a Vite Vue project does. The Vue binding's peer dependencies are Vue `^3.4.0`, engine `^0.4.0`, renderer `^0.5.0`, and element `^0.5.0`. ## 1. Install the binding and its peers Run this in your project: ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer vue @grafloria/element ``` You now have the Vue component and the shared engine and renderer packages it uses. ## 2. Mount a controlled canvas Replace `src/App.vue` with this Vue single-file component, supplying [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow) through refs typed with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec); see the [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start#2-create-the-controlled-canvas) for the shared canvas and endpoint setup. `v-model:nodes` and `v-model:edges` supply the controlled props and write the component's emitted spec updates back into your refs. The `init` event supplies the mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance). Keep it in a `shallowRef` and call `fitView()` to frame the content. ```vue title="src/App.vue" ``` You get an **Ingest** node connected to a **Publish** node. `:plugins="true"` adds the shipped minimap, zoom/fit controls, and background grid; the component loads these plugins lazily. No stylesheet import is needed. ![Look for the arrow from Ingest to Publish, the zoom/fit controls at the lower left, and the minimap at the lower right.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/8573079d35ab4529eefec47ba14a4a81.png) For a self-contained canvas whose data the instance owns, use `:default-nodes` and `:default-edges` instead of the two `v-model` bindings. Those defaults seed the instance once at mount. For more on the return path and composables, see [Vue: state and composables](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-state-and-composables). ## 3. Open the editor Start your project's development server and open the local URL it prints: ```bash npm run dev ``` Look for **Canvas ready**, the two labelled nodes, and their connecting edge. Drag a node to move it; use the canvas controls to zoom or fit the view. The Vue component disposes its instance and plugins when it unmounts—do not dispose the instance in `onInit`. Explore the [live Vue demos](https://grafloria.com/demos-vue/) for running examples with Vue SFC source. ## Replacing externally edited specs Ordinary controlled updates reconcile into existing live objects rather than remounting the canvas. A full replacement with reused ids needs a different sequence. > **Known issue:** Replacing specs with reused node ids can retain old ports and metadata: the existing-node update path does not rebuild ports or remove omitted metadata. Until it is fixed, clear edges and nodes, await `nextTick()`, then apply the replacement. The intended direct assignments are `nodes.value = importedNodes` and `edges.value = importedEdges`. For a fresh import, use this helper instead; pass the refs from your component and the imported spec arrays. It leaves the replacement diagram on the mounted canvas. ```ts title="src/replace-specs.ts" import { nextTick, type Ref } from 'vue'; import type { NodeSpec, EdgeSpec } from '@grafloria/vue'; export async function replaceSpecs( nodes: Ref, edges: Ref, importedNodes: NodeSpec[], importedEdges: EdgeSpec[], ): Promise { edges.value = []; nodes.value = []; await nextTick(); nodes.value = importedNodes; edges.value = importedEdges; await nextTick(); } ``` The first `nextTick()` lets the empty lists reconcile before you apply the replacement. Without that boundary, Vue batches the assignments and the watchers see only the final lists. ## Where next You now have a sized, controlled Vue canvas and access to its mounted instance. - [Vue: slots and widgets](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-slots-and-widgets) explains custom node content. - [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) explains the instance's renderer and engine access. - [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) covers persistence beyond framework specs. - [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) covers container sizing and visual configuration. # Angular quick start Mount a standalone Angular canvas with two connected nodes, two-way data bindings and a button that frames the diagram through `viewChild`. Framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. ## Prerequisites Use an existing standalone Angular CLI application. The binding supports Angular 18.1, 19, 20, 21 and 22, with RxJS `^7.8.0`. Keep `@angular/common`, `@angular/core`, `@angular/forms` and `@angular/platform-browser` on the same Angular version as your application. This sample runs in the browser. ## 1. Install the binding Run this in your application's directory: ```bash npm install @grafloria/angular@0.14.0 @grafloria/renderer@0.5.0 @grafloria/engine@0.4.0 @grafloria/element@0.5.0 rxjs@^7.8.0 ``` You get the Angular component and its engine, renderer and element peers. Import [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) into your standalone component; the canvas needs no library stylesheet import, NgModule or provider. ## 2. Define the flow and mount the canvas Replace `src/app/app.component.ts` with the following component. Describe the initial data with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec). The binding accepts both specs and live models, so use [`NodeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#nodeinput) and [`EdgeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#edgeinput) for the two-way signals, matching the canvas's readonly, optional collection types. ```ts title="src/app/app.component.ts" import { Component, signal, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { EdgeInput, EdgeSpec, NodeInput, NodeSpec, } from '@grafloria/renderer'; const initialNodes: NodeSpec[] = [ { id: 'a', position: { x: 60, y: 80 }, size: { width: 180, height: 80 }, label: 'Ingest', }, { id: 'b', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, label: 'Publish', }, ]; const initialEdges: EdgeSpec[] = [ { id: 'e1', source: 'a', target: 'b' }, ]; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class AppComponent { readonly nodes = signal(initialNodes); readonly edges = signal(initialEdges); readonly canvas = viewChild.required(DiagramCanvasComponent); fit(): void { this.canvas().fitToContent(40); } } ``` ![Look at the arrow from Ingest to Publish, the Fit diagram button above the dotted canvas, the zoom/fit controls at bottom left and the minimap at bottom right.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/44c320c4bdf4977c74f2b2f07fde3fdc.png) The canvas shows **Ingest** on the left, **Publish** on the right and an edge between them. `[plugins]="true"` adds the shipped minimap, zoom/fit controls and background grid; you do not need to build those controls yourself. The component has an explicit 400-pixel height; see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) for container sizing. `[(nodes)]` and `[(edges)]` form the controlled return path. The canvas reconciles incoming arrays into its live model, then writes new spec arrays back through the model signals when the model changes. Dragging a node therefore updates your application state, not only the rendered box. You do not need separate change handlers for these two-way bindings. The `canvas` query gives you the mounted component. Click **Fit diagram** to call `fitToContent(40)`: it centers the content and chooses a zoom that leaves 40 screen pixels of padding. The method returns `void`. For engine access, `this.canvas().activeEngine()` returns the bound engine or the canvas-owned engine; make engine-touching setup calls in `ngAfterViewInit`, not the constructor. ## 3. Bootstrap and run Use this browser entry point to mount `AppComponent` on the application's `` element: ```ts title="src/main.ts" import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent).catch((error: Error) => { console.error(error); }); ``` Start your application: ```bash ng serve ``` Open the local URL printed by the Angular CLI. Drag **Ingest**, then click **Fit diagram** to bring both nodes back into view. The component cleans up its plugins and rendering resources when Angular destroys it; this controlled sample does not require a manual disposal call. You now have a sized canvas, two connected nodes, application state that receives diagram edits and imperative access through Angular's view query. ## Where next Try the [live Angular starter](https://stackblitz.com/github/grafloria/grafloria/tree/main/starters/angular?file=src/app/app.component.ts), or open the [Angular demo gallery](https://grafloria.com/demos-angular/). - [Angular: state and tooling](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-state-and-tooling) covers signals and component methods beyond this first canvas. - [Angular: templates and handles](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-templates-and-handles) replaces the built-in node appearance with Angular templates. - [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram) arranges nodes automatically. - [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works) explains specs, live models and the engine. # Qwik quick start Mount a 400-pixel-high diagram with two connected nodes, keep edits in serializable signals, and use the live instance from a Fit view button. Framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. ## Prerequisites Use an existing Qwik 1.x application with `@builder.io/qwik` satisfying `^1.5.0` and `@grafloria/element` satisfying `^0.5.0`; see the [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start) for the shared engine and renderer prerequisites. Keep your application's Qwik Vite configuration: no Grafloria-specific Vite plugin is required. This example runs in a server-rendered Qwik City application; the binding creates its live diagram in a browser-only task at document ready. ## 1. Install the binding and its peers Run this in your application directory: ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik@^1.5.0 @grafloria/element ``` The Qwik package re-exports the shared spec types, so the component and its data types use one import site. ## 2. Mount a controlled canvas Bind Qwik signals to [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow) rather than React state; see the [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start) for the shared [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec) canvas setup. The signals contain only plain data. Both change callbacks are QRLs created with Qwik's `$()`: they return the edited specs to the signals bound to `nodes` and `edges`. `onInit$` gives you the mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance). Keep it in a signal marked with `noSerialize()` rather than asking Qwik to serialize a live renderer. The button uses that instance's `fitView()` to frame the content. ```tsx title="src/routes/index.tsx" import { $, component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance, type EdgeSpec, type NodeSpec, } from '@grafloria/qwik'; export default component$(() => { const nodes = useSignal([ { id: 'extract', position: { x: 80, y: 120 }, size: { width: 180, height: 72 }, label: 'Extract', }, { id: 'load', position: { x: 380, y: 120 }, size: { width: 180, height: 72 }, label: 'Load', }, ]); const edges = useSignal([ { id: 'extract-load', source: 'extract', target: 'load', sourceHandle: 'right', targetHandle: 'left', }, ]); const instance = useSignal>(); return (
{ nodes.value = next; })} onEdgesChange$={$((next: EdgeSpec[]) => { edges.value = next; })} onInit$={$((diagram: DiagramInstance) => { instance.value = noSerialize(diagram); })} style={{ height: '400px' }} />
); }); ``` The canvas draws Extract and Load side by side, with an edge from Extract's right port to Load's left port. `fitView` frames the initial content. The Fit view button stays disabled until `onInit$` supplies the instance. ![Extract and Load appear side by side with a right-pointing connecting arrow. The Fit view button sits above the canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/716c8c7fa06f395860eebb304430000e.png) ## 3. Run and edit the diagram Start your Qwik application's development server: ```bash npm run dev ``` Open the address printed by your application. Drag either node and use Fit view to frame the diagram again. The binding projects node and edge change events into specs for your callbacks; changes to the controlled arrays feed back into the mounted instance through `setNodes()` and `setEdges()`. You now have a sized, editable canvas, controlled node and edge state, and instance access for your own controls. The binding disposes its diagram and removes its event subscriptions on unmount; do not dispose it in `onInit$`. ## Choose the state owner Keep `nodes`, `edges`, and their change callbacks when your application owns the specs. For an uncontrolled canvas, use `defaultNodes` and `defaultEdges` instead: they seed the diagram at mount, rather than supplying ongoing controlled updates. For more on serializable state and live objects, see [Qwik: state and resumption](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-state-and-resumption). For canvas sizing, see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas). ## Where next - Try the [live Qwik demos](https://grafloria.com/demos-qwik/) and inspect their source. - Read [State and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow) for controlled editing. - Use [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) for instance access and teardown. - Continue to [Qwik: custom content and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-custom-content-and-kits) for custom node components. # How Grafloria works Grafloria is a diagram engine whose framework bindings share one headless model, so your choice of framework changes how you bind data—not what the diagram means. The five ideas below explain where to put data, edits, persistence and drawing code. ```mermaid flowchart LR A["Application specs"] --> B["Binding / DiagramInstance"] B --> C["Live model: document data"] D["Engine: commands and history"] --> C C --> E["Renderer: geometry and pixels"] C --> F["Shared serialized document"] C --> B B --> A ``` ## 1. Specs describe intent; live models hold data Framework bindings convert plain specs into live models; the examples below trace those specs through reconciliation, command-backed edits and serialization—see the [introduction](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/introduction) for the API entry points. This browser example uses [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core) to mount two connected nodes. Its return value is the same instance facade the bindings expose. Install the packages in your own browser project: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` Use [`RenderSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#renderspec) to check the input. `graph.ts` declares the data and a mounting function; your browser entry point calls it with a sized container. ```ts title="graph.ts" import { render, type RenderSpec } from '@grafloria/element'; export const spec = { nodes: [ { id: 'intake', label: 'Intake', position: { x: 60, y: 80 }, size: { width: 120, height: 48 } }, { id: 'review', label: 'Review', position: { x: 280, y: 80 }, size: { width: 120, height: 48 } }, ], edges: [{ id: 'next', source: 'intake', target: 'review' }], } satisfies RenderSpec; export function mountGraph(host: HTMLElement) { return render(spec, host); } ``` ```ts title="main.ts" import { mountGraph } from './graph'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const instance = mountGraph(host); console.log(instance.getModel().getNode('intake')); ``` You see Intake connected to Review. The query returns the live node, not the input spec. For the full distinction, read [Specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models); for mounting and cleanup, read [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle). The remaining browser entry files call the same mounting function. Run each separately alongside `graph.ts`. ## 2. Pick a state owner Uncontrolled defaults seed the instance once; the instance owns subsequent edits. Controlled inputs make your application the state owner and require a return path for changes. Reconciliation updates existing spec-backed models by id rather than remounting the diagram, preserving live identity and leaving selection alone when you omit `selected`. This sample changes Review's position through the instance's spec surface. The node moves down, and the identity assertion checks that the existing live object remains in use. ```ts title="reconcile.ts" import { mountGraph, spec } from './graph'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const instance = mountGraph(host); const before = instance.getModel().getNode('review'); instance.setNodes(spec.nodes.map(node => node.id === 'review' ? { ...node, position: { x: 280, y: 180 } } : node )); instance.renderNow(); console.assert(before === instance.getModel().getNode('review')); ``` In a controlled component, connect both directions using the binding's own idiom: | Binding | Input and change return path | | --- | --- | | React [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react) | `nodes` / `edges` with `onNodesChange` / `onEdgesChange`; the state hooks convert live models back to specs. | | Vue [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue) | `v-model:nodes` / `v-model:edges` write changes back to your refs. | | Qwik [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik) | `nodes` / `edges` with `onNodesChange$` / `onEdgesChange$` return spec arrays. | | Angular [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent) | `[(nodes)]` / `[(edges)]` round-trip through model signals. | See [State and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow) for this loop, and the [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start) for the controlled-state wiring. ### React Render the same two-node graph with uncontrolled defaults; the canvas owns subsequent edits. Type the arrays with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec). ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` ```tsx title="Editor.tsx" import { GrafloriaFlow } from '@grafloria/react'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { spec } from './graph'; const nodes: NodeSpec[] = spec.nodes; const edges: EdgeSpec[] = spec.edges; export default function Editor() { return
; } ``` ### Vue The Vue component seeds the same graph with plain specs. ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue ``` ```vue title="Editor.vue" ``` ### Qwik The Qwik component passes serializable spec data, not a live instance. ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik ``` ```tsx title="Editor.tsx" import { component$ } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { spec } from './graph'; const nodes: NodeSpec[] = spec.nodes; const edges: EdgeSpec[] = spec.edges; export default component$(() => (
)); ``` ### Angular Angular's two-way bindings keep application arrays in sync with edits to the same graph. Type component data with the library's `NodeSpec` and `EdgeSpec` types. ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer @grafloria/element rxjs ``` ```ts title="editor.component.ts" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { spec } from './graph'; @Component({ selector: 'app-editor', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class EditorComponent { nodes: readonly NodeSpec[] = spec.nodes; edges: readonly EdgeSpec[] = spec.edges; } ``` ## 3. Loading and editing are different intents Setup writes directly to the model; user-facing edits become commands on the engine's history stack. Built-in gestures follow that second path: one drag is one undo step, not one step per position update. Use engine methods that execute commands for your toolbar actions. Here, clicking **Add task** adds a labelled node and returns its live model. The engine's `addNode()` implementation constructs and executes a shipped add-node command, so the edit joins the same history as gestures. ```ts title="edit.ts" import { mountGraph } from './graph'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const instance = mountGraph(host); const button = document.createElement('button'); button.textContent = 'Add task'; document.body.prepend(button); let nextY = 180; button.onclick = async () => { const y = nextY; nextY += 70; const node = await instance.getEngine().addNode({ type: 'task', position: { x: 60, y }, size: { width: 120, height: 48 }, data: { label: 'New task' }, }); instance.renderNow(); console.log(node); }; ``` For domain actions that need several mutations, execute command objects through `commandManager.execute()` rather than treating model writes as edits. Controlled bindings feed the reverted models back to application state after undo. Read [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) for command composition and history controls. ## 4. The document is the API Save the live model in the shared serialization format, not a framework's projection of it. The versioned document carries nodes—including their ports—links, groups and viewport. It is the common representation for persistence and collaboration. This sample adds a **Save document** button. Click it after editing to see JSON from the current live model in the page, including its `schemaVersion`. ```ts title="save.ts" import { mountGraph } from './graph'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const instance = mountGraph(host); const button = document.createElement('button'); button.textContent = 'Save document'; const output = document.createElement('pre'); document.body.prepend(button); document.body.append(output); button.onclick = () => { const document = instance.getModel().serialize(); const camera = instance.viewport.getState(); output.textContent = JSON.stringify({ document, camera }, null, 2); }; ``` > **Known issue:** Serializing the model alone does not save the mounted canvas's current pan and zoom: camera changes do not update `DiagramModel.viewport`, and mounting a restored document does not apply its saved camera. Until it is fixed, save `instance.viewport.getState()` separately, as above, and after mounting restore it with `instance.viewport.setViewport(camera.viewport)` and `instance.viewport.setZoom(camera.zoom)`. Kits follow the same model: ordinary nodes and edges plus a wiring step that attaches behavior to the mounted instance. Loading a document restores saved structure and reattaches built-in kit behavior; your application's custom painters still belong to your application. See [Documents and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/documents-and-kits) for kit mounting, and [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) for restoration. ## 5. Geometry is intent, not pixels Declare what connects and how it routes; let the engine and renderer compute geometry as nodes move. In `EdgeSpec`, `router` says where the line goes, `connector` says how it is drawn, and `waypoints` constrain its bends. Endpoints can name nodes without pinning specific ports. Groups also express intent: [`GroupSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#groupspec) names real children, not merely a rectangle behind them. This sample replaces the connection with an orthogonal route and puts both nodes in one fitted group. You see a group around Intake and Review with a right-angled connection between them. ```ts title="geometry.ts" import { mountGraph, spec } from './graph'; const host = document.createElement('div'); host.style.height = '400px'; document.body.append(host); const instance = mountGraph(host); instance.setNodes(spec.nodes.map(node => node.id === 'review' ? { ...node, position: { x: 280, y: 180 } } : node )); instance.setEdges([{ id: 'next', source: 'intake', target: 'review', type: 'orthogonal', }]); instance.setGroups([{ id: 'order', label: 'Order flow', children: ['intake', 'review'], padding: 30, }]); instance.renderNow(); ``` For custom nodes, your component or renderer paints the inside of an engine-positioned HTML host. It does not take over dragging, selection, ports or connections. Continue with [Route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges), [Group and nest nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/group-and-nest-nodes), or [JavaScript: elements and content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-elements-and-content). ## See it running Try the [live interaction demos](https://grafloria.com/demos/#interaction): drag a node, then undo the gesture. The [demo source](https://github.com/grafloria/grafloria/tree/main/demos) shows the same engine underneath the bindings. # Specs and live models A spec is plain data that describes your diagram's intent; a live model is the identity-bearing object that holds that data while the diagram runs. Follow a spec update through reconciliation to see what changes and which live objects retain their identity; see [Introduction](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/introduction) for the model, engine and instance entry points. ## From intent to live objects The shared input layer converts specs into models and reconciles subsequent spec lists against those models. You do not need to construct engine objects to describe a flow. ```mermaid flowchart LR S["Plain specs"] --> B["Framework binding or renderer instance"] B --> M["Live diagram model"] E["Engine: commands, layout, validation"] --> M M --> R["Renderer: visible geometry"] B --> R ``` | Intent you supply | Live object | What it holds | | --- | --- | --- | | [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) | [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel) | Type, position, size, payload, metadata and ports | | [`PortSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-portspec) | [`PortModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-portmodel) | Direction, side, glyph, data type and connection constraints | | [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec) | [`LinkModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-linkmodel) | Endpoints, routing, connector, labels and bends | | [`GroupSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance) | [`GroupModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-groupmodel) | Membership and frame geometry | Give nodes and edges explicit, stable `id` values when you intend to update them. For a plain spec with an existing id, reconciliation updates the existing object rather than constructing another one. Entries without ids receive `node-` or `edge-` ids, so their identity depends on their position in the list. ## Payload and metadata have different jobs Put application payload in `data`, such as an order status or domain identifier. Put diagram-adjacent settings in `metadata`. The node's top-level `label`, `sublabel` and `shape` fields are conveniences for `metadata.label`, `metadata.sublabel` and `metadata.shape`; they are not writes to `data`. On the node update path, a supplied `data` object replaces the payload dictionary. Metadata entries are applied by key. An omitted `position` leaves an existing node where it is, and an omitted `selected` leaves the user's selection alone. ## Describe a flow, then query its live identity Run this module in the browser. It creates a sized container and mounts the flow with [`render()`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core). It supplies two boxes, a labeled connection and a fitted zone, then changes the first box's label without replacing its live node. Install the packages the module imports: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` ```ts title="configure-flow.ts" import { render } from '@grafloria/element'; import type { EdgeSpec, GroupSpec, NodeSpec, PortSpec, } from '@grafloria/renderer'; export function configureFlow() { const output: PortSpec = { id: 'intake-out', side: 'right', type: 'output', }; const input: PortSpec = { id: 'review-in', side: 'left', type: 'input', }; const nodes: NodeSpec[] = [ { id: 'intake', label: 'Intake', position: { x: 80, y: 100 }, size: { width: 150, height: 60 }, data: { orderId: 'order-42', status: 'received' }, metadata: { domain: 'orders' }, ports: [output], }, { id: 'review', label: 'Review', position: { x: 360, y: 100 }, size: { width: 150, height: 60 }, ports: [input], }, ]; const edges: EdgeSpec[] = [{ id: 'handoff', source: 'intake', target: 'review', sourceHandle: 'intake-out', targetHandle: 'review-in', type: 'orthogonal', label: 'submit', }]; const groups: GroupSpec[] = [{ id: 'stage', label: 'Order processing', children: ['intake', 'review'], padding: 30, style: { fill: '#f3f4f6', stroke: '#d7dbe0' }, }]; const container = document.createElement('div'); container.style.height = '400px'; document.body.appendChild(container); const instance = render({ nodes, edges, groups }, container); const model = instance.getModel(); const intakeBefore = model.getNode('intake'); instance.setNodes(nodes.map(node => node.id === 'intake' ? { ...node, label: 'Received' } : node )); instance.fitView(); return { sameNode: intakeBefore !== undefined && intakeBefore === model.getNode('intake'), outputPort: model.getPortById('intake-out'), handoff: model.getLink('handoff'), intakeIsMember: model.getGroup('stage')?.members.has('intake') ?? false, }; } const result = configureFlow(); console.log(result); ``` ![Received connects to Review through the submit arrow inside the Order processing zone.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/89bdcc2c9adf882e959bb22ce19e4eb8.png) The container has a height of `400px`. The resulting diagram shows **Received** connected to **Review** inside **Order processing**. The returned `sameNode` is `true`; `outputPort` and `handoff` are live objects, and `intakeIsMember` is `true`. The setters return `void`; query the model when you need the objects they created. These setters reconcile whole lists, not individual patches: an omitted node or link is removed. Removing a group through `setGroups()` keeps its boxes. For user-facing edits that belong on the undo stack, use [commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) rather than treating reconciliation as an editing command. ## Geometry is intent, not a drawing instruction Omit `ports` to get four deterministic bidirectional ports: top, right, bottom and left. Their ids use `__`. Supply a port list when you need named endpoints, as the sample does. A handle pins an edge to a port; a bare side such as `'right'` also resolves to a port on that side. In the sample, named handles resolve the edge spec to live ports, and the returned `handoff` exposes the resulting live link; see [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works) for endpoint and geometry intent. See [route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges) and the [live edge demos](https://grafloria.com/demos/#edges). A group is membership, not merely a rectangle behind nodes. Its `children` become members. `bounds` pins the frame; without it, reconciliation fits the frame around the children using `padding` (default `20`). Members travel with their group. See [group and nest nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/group-and-nest-nodes) and the [live group demos](https://grafloria.com/demos/#grouping). ## Specs are not the persistence format The instance also accepts live nodes and links: [`NodeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance) is `NodeSpec | NodeModel`, and [`EdgeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance) is `EdgeSpec | LinkModel`. The repository's Mermaid viewer passes parsed nodes, links and groups directly to the renderer instead of reducing them to a smaller spec projection. Save the live model in the shared, versioned document format, not a framework's projection. Read [documents and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/documents-and-kits) for that boundary, and [state and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow) for returning edits to controlled application state. # Instance and lifecycle A [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance) is the live, renderer-level facade for one mounted diagram: it lets you update specs and pixels without rebuilding the editor. Framework bindings are thin skins over one headless model. Specs describe your intent, live models hold the data, and the engine owns behavior. Keep the instance for the lifetime of its mounted host; update that instance, then dispose it when the host unmounts. ## One instance, three responsibilities Instance access stays tied to the mounted diagram, not disconnected copies; see the [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start) for how to access its model and engine. ```mermaid flowchart TD H["Mounted host or framework binding"] --> I["DiagramInstance"] S["New specs"] --> R["Reconcile by id"] R --> M["Live DiagramModel"] I -->|"getModel()"| M I -->|"getEngine()"| E["DiagramEngine: behavior"] E --> M M --> P["Queued repaint"] I -->|"renderNow()"| F["Synchronous repaint"] H -->|"Unmount"| D["dispose()"] ``` [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core) returns the instance in a browser application. React exposes it through `onInit`, and Vue through `@init`. Those are entry points to the same renderer-level surface, not separate diagram implementations. In React, the binding mounts the instance in an effect and keeps callback props in a ref. New inline callbacks do not recreate it. For controlled and uncontrolled inputs, follow the [Qwik quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-quick-start); [State and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow) covers the change-event return path. ## Reconciliation changes data, not the mount Pass a complete next node list to `setNodes()` and a complete next edge list to `setEdges()`. With plain specs, existing ids update their live objects, new ids create objects, and missing ids remove objects. This preserves object identity rather than remounting the diagram. Omit `selected` to leave the user's selection alone. The input types are [`NodeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec). Nodes can also be live models: a different live model supplied under an existing id replaces that model. Attached links survive only when the replacement has the same port id or a port on the same side; otherwise they are removed. Plain-spec reconciliation and live-model replacement are different paths; see [Specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models). For externally edited imports with reused ids, follow the import guidance in the [Vue quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-quick-start). ## See updates on a mounted instance This browser sample renders two connected nodes. **Reconcile** changes their labels and positions through specs. **Move together** moves both live models down in one batch. **Close** disposes the diagram and removes its host. Install the packages the sample imports in your own browser TypeScript project: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` ```ts title="main.ts" import { render } from '@grafloria/element'; import type { DiagramInstance, EdgeSpec, NodeInput } from '@grafloria/renderer'; function mountEditor(parent: HTMLElement): () => void { const panel = document.createElement('section'); const controls = document.createElement('div'); const host = document.createElement('div'); host.style.height = '400px'; panel.append(controls, host); parent.append(panel); const nodes = [ { id: 'draft', label: 'Draft', position: { x: 80, y: 80 }, size: { width: 140, height: 60 } }, { id: 'review', label: 'Review', position: { x: 320, y: 80 }, size: { width: 140, height: 60 } }, ] satisfies NodeInput[]; const edges: EdgeSpec[] = [ { id: 'workflow', source: 'draft', target: 'review' }, ]; const instance: DiagramInstance = render({ nodes, edges }, host); const reconcile = document.createElement('button'); reconcile.textContent = 'Reconcile'; reconcile.onclick = () => { const nextNodes: NodeInput[] = [ { id: 'draft', label: 'Draft updated', position: { x: 80, y: 140 }, size: { width: 140, height: 60 } }, { id: 'review', label: 'Review updated', position: { x: 320, y: 140 }, size: { width: 140, height: 60 } }, ]; instance.setNodes(nextNodes); instance.setEdges(edges); }; const move = document.createElement('button'); move.textContent = 'Move together'; move.onclick = () => { instance.batchUpdate((model) => { for (const node of model.getNodes()) { node.setPosition(node.position.x, node.position.y + 30); } }); }; const close = document.createElement('button'); close.textContent = 'Close'; const unmount = () => { reconcile.onclick = null; move.onclick = null; close.onclick = null; instance.dispose(); panel.remove(); }; close.onclick = unmount; controls.append(reconcile, move, close); return unmount; } mountEditor(document.body); ``` The initial canvas shows Draft connected to Review from left to right, beneath the Reconcile, Move together, and Close controls. The reconciliation calls schedule painting themselves. The batch follows the library's own example: mutate the models supplied to the callback rather than building an unrelated model. These are direct model updates; use commands for user-facing edits that need history, as described in [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). For an interactive example of instance access and engine behavior, open the [live drag-and-undo demo](https://grafloria.com/demos-react/#/interaction/drag-undo). ## Queued painting versus synchronous painting Model mutation and painting have different timing. A setter updates the live data before the queued frame draws it. Choose the painting method according to what your next statement needs: | Method | Returns | What you get | | --- | --- | --- | | `render()` | `void` | A queued repaint; repeated requests before the frame runs coalesce. | | `renderNow()` | `void` | A synchronous repaint that cancels the pending frame and bypasses idle skipping. Use it before measuring changed diagram DOM. | | `batchUpdate(mutate)` | `void` | Immediate mutations inside the callback, batched model events, and a queued repaint. It does not paint synchronously. | Keep the batch callback synchronous. If you need to measure after a batch, call `renderNow()` after `batchUpdate()` returns. Nested batches remain batched, and a throwing callback does not leave the model stuck in batch mode. ## Dispose at unmount In a plain browser host, call `dispose()` from the host's unmount or close handler, as above. Disposal detaches interaction handlers, cancels scheduled painting, disconnects the resize observer, removes listeners and custom-node hosts, and removes the diagram's DOM. Repeated disposal is a no-op. The React binding performs disposal in its effect cleanup; let the binding own that teardown rather than disposing immediately after `onInit`. If you supply an external engine, instance disposal leaves that engine alive: its owner is responsible for destroying it. For a flat cross-layer facade, [`createDiagramApi`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions) wraps an existing instance; it is not another mount. Continue with [React: state and subscriptions](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-state-and-subscriptions) for framework instance access, [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram) for engine layout, or [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) for persistence from the live model. # State and event flow State ownership determines whether Grafloria keeps edits in its live model or returns them to your application so controlled inputs stay in sync. The framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. You choose the owner separately for nodes, edges and, where supported, groups. ## Choose the state owner | Input | Owner | What happens after mounting | | --- | --- | --- | | `defaultNodes`, `defaultEdges`, `defaultGroups` | The instance | Defaults seed the collections once. Changing a default later does not reconcile the collection. | | `nodes`, `edges` | Your application | Updated inputs reconcile into the live model; change callbacks or two-way bindings return canvas edits. | | `groups` | Your application supplies the group collection | Updated inputs reconcile group membership and frames. There is no corresponding group-change callback in the flow bindings. | React, Vue and Qwik expose these default and controlled inputs. For an uncontrolled Angular canvas, leave `nodes` and `edges` unbound and pass an engine through `[engine]`. Its canvas has no `groups` or `defaultGroups` input. Choose defaults for a self-contained canvas. Choose controlled inputs when an inspector or other application UI needs to mirror edits. Controlled specs reconcile into existing models rather than remounting the canvas. Stable node ids keep live identity; omitting `selected` leaves the current selection alone. ```mermaid flowchart LR A["Application specs"] -->|"Controlled inputs"| B["Reconcile live models"] D["Defaults at mount"] --> B B --> C["Engine and renderer"] C -->|"User edits"| B B -->|"Change callback or model write"| A ``` Groups are zones with real membership, not a second list of nodes. A [`GroupSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#groupspec) names members through `children`; without `bounds`, its frame fits those members with padding. You can also pass a live [`GroupModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-groupmodel#groupmodel). Removing a group through `setGroups()` keeps its nodes. For group-edit persistence, read the live document rather than expecting a group-change callback; see [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents). ## Close the return path in your binding These examples render two connected boxes and a selection count. Drag a box, release it, then select a box or the edge: the application mirrors the node and edge collections and displays the selected counts. React, Vue and Qwik also seed a zone through `defaultGroups`; the zone remains instance-owned. Use the shared [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec) vocabulary in each framework. Put this file beside the component you choose: ```ts title="graph.ts" import type { NodeSpec, EdgeSpec, GroupSpec } from '@grafloria/renderer'; export const initialNodes: NodeSpec[] = [ { id: 'a', label: 'Input', position: { x: 80, y: 100 }, size: { width: 140, height: 70 } }, { id: 'b', label: 'Output', position: { x: 320, y: 100 }, size: { width: 140, height: 70 } }, ]; export const initialEdges: EdgeSpec[] = [ { id: 'ab', source: 'a', target: 'b' }, ]; export const initialGroups: GroupSpec[] = [ { id: 'pipeline', label: 'Pipeline', children: ['a', 'b'], padding: 40 }, ]; ``` ### React [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow) calls `onNodesChange` with live [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel) objects and `onEdgesChange` with live [`LinkModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-linkmodel#linkmodel) objects. [`useNodesState`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#usenodesstate) and [`useEdgesState`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#useedgesstate) convert those models back to specs. Their third tuple elements close the return path; their second elements are application state setters. ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` ```tsx title="Editor.tsx" import { useState } from 'react'; import { GrafloriaFlow, useNodesState, useEdgesState } from '@grafloria/react'; import { initialNodes, initialEdges, initialGroups } from './graph'; export default function Editor() { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, , onEdgesChange] = useEdgesState(initialEdges); const [selection, setSelection] = useState('0 nodes, 0 edges'); return (

Selected: {selection}

setSelection(`${pickedNodes.length} nodes, ${pickedEdges.length} edges`)} style={{ height: 400 }} />
); } ``` ![Input connects to Output inside the Pipeline zone. The selection count starts at 0 nodes, 0 edges.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/102cf410caecc0fc6f1a1bd50bcf96dc.png) Keep controlled arrays in state: React's inbound effects depend on their references. For the stale-state pitfall and the full hook contract, see [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start) and [React: state and subscriptions](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-state-and-subscriptions). ### Vue [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow) emits spec arrays through `update:nodes` and `update:edges`; `v-model` writes them into your refs. The `selection-change` event returns the selected live models, not a spec projection. ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue ``` ```vue title="Editor.vue" ``` Vue accepts replacement arrays and in-place changes and skips reapplying its own emitted arrays. See [Vue: state and composables](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-state-and-composables) for the reactive update paths. ### Qwik [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow) projects live models to specs before invoking `onNodesChange$` and `onEdgesChange$`. Store those arrays in signals. The selection callback below stores only a string, not its live-model payload. ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik ``` ```tsx title="Editor.tsx" import { component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { initialNodes, initialEdges, initialGroups } from './graph'; export default component$(() => { const nodes = useSignal(initialNodes); const edges = useSignal(initialEdges); const selection = useSignal('0 nodes, 0 edges'); return (

Selected: {selection.value}

{ nodes.value = next; }} onEdgesChange$={(next) => { edges.value = next; }} onSelectionChange$={(change) => { selection.value = `${change.nodes.length} nodes, ${change.edges.length} edges`; }} style={{ height: '400px' }} />
); }); ``` See [Qwik: state and resumption](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-state-and-resumption) for storing and reaching a browser-only instance. ### Angular [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) exposes `nodes` and `edges` as two-way model signals. Its input types also accept live models, so use those declared unions for your signals. [`SelectionChange`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-interfaces#selectionchange) contains the selected nodes and edges after the change. ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer @grafloria/element rxjs ``` ```ts title="editor.component.ts" import { Component, signal } from '@angular/core'; import { DiagramCanvasComponent, type SelectionChange } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import type { NodeModel, LinkModel } from '@grafloria/engine'; import { initialNodes, initialEdges } from './graph'; @Component({ selector: 'app-editor', standalone: true, imports: [DiagramCanvasComponent], template: `

Selected: {{ selection() }}

`, }) export class EditorComponent { readonly nodes = signal(initialNodes); readonly edges = signal(initialEdges); readonly selection = signal('0 nodes, 0 edges'); onSelection(change: SelectionChange): void { this.selection.set(`${change.nodes.length} nodes, ${change.edges.length} edges`); } } ``` ![Input and Output are connected by an arrow, without a group frame. The selection count reads 0 nodes, 0 edges.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/3c196653c74e554742dbeaea3ccc2f34.png) Alongside the next-array outputs, `(modelChange)` emits an incremental patch of added, removed and modified entities, including groups. Inbound `nodes` and `edges` writes are not echoed as patches. `[skipModelUpdate]="true"` suspends inbound reconciliation while outbound emissions continue. See [Angular: state and tooling](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-state-and-tooling) for persistence and component methods. ## Learn the complete instance event map [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) exposes `on()` for the complete [`DiagramEventMap`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#diagrameventmap). Use your binding's events first; use the instance when it does not expose the event you need. `on()` returns an unsubscribe function; invoke it when your subscriber unmounts. `off()` removes a handler by identity. | Event | Payload | What you receive | | --- | --- | --- | | `nodes:change` | `{ nodes: NodeModel[] }` | The current node collection, not a per-node delta. | | `edges:change` | `{ edges: LinkModel[] }` | The current link collection. | | `selection:change` | `{ nodes: NodeModel[]; edges: LinkModel[] }` | The selected nodes and edges after the change. | | `connect` | `{ link: LinkModel }` | The added link; the model's link-add handler emits this, including programmatic additions. | | `reconnect` | `{ link: LinkModel; endpoint: 'source' \| 'target' }` | The link and endpoint after a successful reconnection gesture. | | `node:click` | `{ node: NodeModel; world: { x: number; y: number } }` | The clicked node and diagram coordinates. | | `node:doubleclick` | `{ node: NodeModel; world: { x: number; y: number } }` | The double-clicked node and diagram coordinates. | | `edge:click` | `{ edge: LinkModel; world: { x: number; y: number } }` | The clicked edge and diagram coordinates. | | `viewport:change` | `{ viewport: Rectangle; zoom: number }` | The camera rectangle and zoom. | | `ready` | `void` | A one-shot notification queued on a microtask after the initial paint. | [`Rectangle`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-types-interfaces-b-s#rectangle) is the viewport rectangle type. The flow callbacks also expose initialization, layout completion and collaboration readiness; these are binding hooks, not extra names in `DiagramEventMap`. React uses `onSelectionChange`, `onConnect`, `onNodeClick` and `onEdgeClick`; Vue uses `@selection-change`, `@connect`, `@node-click` and `@edge-click`; Qwik uses their `$` callback equivalents. Angular exposes `(selectionChange)` but no matching click or connect output on this canvas. ## Changes are not a per-frame state stream In the shared instance, `node:changed` and `link:changed` schedule repainting without emitting collection-change events. Adds, removals and clears emit collections. The built-in node drag emits `nodes:change` after a moved drag ends; it does not send a new spec array on each pointer move. Selection has its own event and does not require rewriting the document collection. Treat React, Vue and Qwik state as a mirror that catches up at edit boundaries, not as the animation clock. An arbitrary direct model mutation can repaint without triggering their collection callbacks. For user-facing edits and history, use the command path described in [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). Angular has a distinct outbound implementation: it captures model mutations and coalesces a burst into a microtask before emitting `modelChange` and the bound arrays. Do not assume its emissions share the instance's drag-commit timing, or that one array event equals one undo step. For interaction feedback while drawing a connection, see [Configure editing gestures](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/configure-editing-gestures). Watch the [live connection-event demo](https://grafloria.com/demos/interaction/connection-events.html) for the connection lifecycle rather than treating collection changes as pointer-move events. ## Related - [Specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models): the data carried in each direction. - [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle): instance access and subscription cleanup. - [Group and nest nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/group-and-nest-nodes): membership and frames. - [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents): persistence beyond framework projections. # Commands and history A command represents an undoable edit, so gestures and application actions can share the engine's history instead of maintaining separate undo stacks. Loading and editing are different intents: setup mutates the live model directly; user-facing edits execute commands. The framework binding renders that model, while the engine owns execution and history. ## Two intents, one live model Use [`DiagramModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-diagrammodel#diagrammodel) for setup and [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#diagramengine) for editing: | Intent | API | Result | | --- | --- | --- | | Seed a document | `diagram.addNode(node)` | Adds the node without recording a history entry. | | Add a node on the user's behalf | `await engine.addNode(node)` | Executes the shipped add-node command and returns the live node. | | Execute an explicit edit | `await engine.commandManager.execute(command)` | Executes and validates the command before recording an undoable entry. | | Reverse or replay an edit | `await engine.undo()` / `await engine.redo()` | Changes the same model the canvas renders. | ```mermaid flowchart LR Setup["Setup / import"] --> Model["Live diagram model"] Gesture["Canvas gesture"] --> Commands["Engine commands"] Action["Application action"] --> Commands Commands --> Model Commands --> History["One history stack"] History --> Reverse["Undo / redo"] Reverse --> Model Model --> Binding["Framework binding"] Binding --> Canvas["Rendered canvas"] Binding --> State["Controlled application state"] ``` Direct model additions are not recorded in history. Use an engine editing method or execute a command when a toolbar action must be undoable. [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) has no `undo()` or `redo()`: reach history through `instance.getEngine().undo()` and `instance.getEngine().redo()`. For a flat instance facade, [`createDiagramApi`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions#creatediagramapi) exposes asynchronous `execute()`, `undo()` and `redo()` and requests a repaint after each call. ## Bundle an application action into one undo step Use the shipped [`AddNodeCommand`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-commands-classes-a-r#addnodecommand) rather than implementing node addition yourself. Wrap independent additions in [`BatchCommand`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-commands-classes-a-r#batchcommand) when they form one user action. The batch executes its children in order and undoes them in reverse order. This browser example mounts Angular's [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) with an engine, following its uncontrolled binding. It starts with a single node labelled **Loaded**. Select **Add pair** to add **Review** and **Ship** together; select **Undo** to remove both in one step. The loaded node remains. Drag a node, then undo: the drag joins that same history. Install the 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 ``` Construct the data with [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel). Replace your root component with this file: ```ts title="app.component.ts" import { Component, OnDestroy, signal } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { AddNodeCommand, BatchCommand, DiagramEngine, NodeModel } from '@grafloria/engine'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: `

{{ status() }}

`, }) export class AppComponent implements OnDestroy { readonly engine = new DiagramEngine(); readonly busy = signal(false); readonly status = signal('Loaded is setup data, not an undo step.'); private nextPair = 0; constructor() { const diagram = this.engine.createDiagram('History example'); const loaded = this.makeNode('loaded', 'Loaded', 60, 60); diagram.addNode(loaded); } private makeNode(id: string, label: string, x: number, y: number): NodeModel { const node = new NodeModel({ id, type: 'default', position: { x, y }, size: { width: 140, height: 50 }, }); node.setData('label', label); return node; } async addPair(): Promise { const pair = ++this.nextPair; const y = 140 + (pair - 1) * 70; const review = this.makeNode(`review-${pair}`, 'Review', 60, y); const ship = this.makeNode(`ship-${pair}`, 'Ship', 260, y); await this.run(() => this.engine.commandManager.execute( new BatchCommand('Add review and ship', [ new AddNodeCommand(review), new AddNodeCommand(ship), ]), )); } async undo(canvas: DiagramCanvasComponent): Promise { await this.run(() => canvas.undo()); } async redo(canvas: DiagramCanvasComponent): Promise { await this.run(() => canvas.redo()); } private async run(action: () => Promise): Promise { if (this.busy()) return; this.busy.set(true); try { await action(); this.status.set( `Can undo: ${this.engine.canUndo()}; can redo: ${this.engine.canRedo()}`, ); } catch (error) { this.status.set(error instanceof Error ? error.message : 'Command failed.'); } finally { this.busy.set(false); } } ngOnDestroy(): void { this.engine.dispose(); } } ``` The canvas's `undo()` and `redo()` methods delegate to its active engine and schedule rendering. The status text reports history availability after a toolbar operation; it is not a subscription to every gesture. Use `engine.canUndo()` and `engine.canRedo()` to drive availability in your own controls. Batching history is different from batching rendering. `instance.batchUpdate()` applies model mutations as one frame; it does not turn direct model writes into undoable commands. ## Await execution and handle refusal [`Command`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-commands-classes-a-r#command) permits both synchronous and asynchronous `execute()` and `undo()` implementations. Its default `redo()` re-executes the command. The command manager awaits these operations, so await execution before reading the resulting model or issuing a dependent edit. `execute()`, `undo()` and `redo()` on the manager return `Promise`, not a success flag. History contains only successfully executed, validated commands that declare themselves undoable through `isUndoable()`. Refusal leaves no new entry: - If `canExecute(context)` returns `false`, execution rejects before applying the command. For example, `AddNodeCommand` refuses an id already in the diagram. Catch the rejection and report it to the user. - With real-time validation enabled and strict validation configured, an invalid result is undone and execution rejects. The failed command does not enter history. - A read-only document refuses execution without throwing; the manager emits `command:refused` and returns. Promise resolution alone therefore does not prove that an edit occurred. A batch checks every child's `canExecute()` against the pre-batch state. Do not put an action in a batch if it becomes executable only after an earlier child runs. The pair above works because both node ids are absent before execution. ## Keep application state on the same history An undo changes the engine's live models, not a separate framework snapshot. In controlled mode, the binding returns those changes to your application: Angular writes through `[(nodes)]` and `[(edges)]`; Vue updates `v-model:nodes`; React calls `onNodesChange`. Keep that return path connected rather than maintaining another undo stack over your specs. See [State and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow) for controlled bindings and [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) for instance ownership. For a gesture-driven example, open the [live interaction demos](https://grafloria.com/demos/#interaction), drag a node, then press ⌘Z or Ctrl+Z. One drag is one undo step, not one step per position update. ## Related - [Specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models) explains the data commands mutate. - [Add editor controls](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/add-editor-controls) connects application controls to editing. - [Synchronize editors](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/synchronize-editors) covers sharing document changes between peers. # Documents and kits A document is Grafloria's versioned, shared representation of a diagram; a kit turns domain data into specs plus the wiring that makes those specs interactive. The document is the API: save the live model, not a framework's projection of it. Framework bindings are thin skins over that model, so the persistence boundary does not depend on which binding draws the canvas. ## How the parts fit together ```mermaid flowchart LR Data["Domain data"] --> Kit["Kit builder"] Kit --> Spec["Specs + finalize"] Spec --> Mount["Mounted instance"] Mount --> Save["Serialize live model"] Save --> Document["Shared document"] Document --> Load["fromDocument()"] Load --> Spec ``` [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) mounts a spec and returns a [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance). It invokes the spec's `finalize` after creating the instance. That ordering matters: a kit can declare nodes and edges before mounting, but row interactions, positioned labels and dashboard grid bindings need the live instance. [`erDiagram`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-diagram-kit-functions#erdiagram) builds table cards with column-level ports. [`umlDiagram`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-diagram-kit-functions#umldiagram) builds class cards; its finalization adds multiplicity labels. [`dashboard`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-dashboard-kit-functions#dashboard) builds widgets and wires their boards. Pass the whole returned spec to the host rather than extracting only its `nodes` and `edges`. ## Save data, restore runtime behavior [`DiagramSerializer`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-serialization#diagramserializer) provides two persistence shapes: | Shape | What you get | Use | | --- | --- | --- | | `serialize(model)` | A flat object with serializer format `version` and the model's `diagramVersion` | Existing flat-format storage | | `serializeEnvelope(model)` | A portable envelope containing the document, writer identity, creation time and an integrity checksum | New persistence | The inner document has a `schemaVersion` separate from the model's mutation counter. Loading runs schema migrations; an envelope checksum mismatch throws instead of silently loading altered data. The [`DiagramModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-diagrammodel#diagrammodel) serializes nodes, links, groups, metadata and viewport, plus ink and comments when present. [`fromDocument`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#fromdocument) accepts either persistence shape or its JSON string. It returns a [`LoadedDiagramSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#loadeddiagramspec) containing live models, not rebuilt spec projections. Its finalization restores groups and ink and reattaches ER/UML interaction wiring and dashboard binders. Its widget painter uses the same shipped renderer as the dashboard authoring path. Functions do not travel in JSON. Supply your application widget painter again through `fromDocument`'s `renderWidget` option. A loaded dashboard also lacks the runtime `responsive` configuration; it starts with the saved column count. See [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) for the distinction between documents and framework spec projections. ### A mounted document round trip Run this in a browser project. The first canvas shows Customer and Order table cards joined at their `id` and `customer_id` rows; the second opens the saved document with the same cards and field-port connection. ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer ``` Use this small kit builder in the examples below. Its return value is typed by the library's builder rather than a handwritten copy of its spec type. ```ts title="schema.ts" import { erDiagram } from '@grafloria/element'; export function buildSchema() { return erDiagram({ entities: [ { id: 'CUSTOMER', name: 'Customer', position: { x: 40, y: 60 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'email', type: 'varchar' }, ] }, { id: 'ORDER', name: 'Order', position: { x: 360, y: 60 }, columns: [ { name: 'id', type: 'int', pk: true }, { name: 'customer_id', type: 'int', fk: true }, ] }, ], relationships: [{ from: 'ORDER.customer_id', to: 'CUSTOMER.id' }], }); } ``` > **Known issue:** `render(fromDocument(json), host)` installs the loaded entities into a new model but does not adopt the saved diagram identity, diagram-level metadata, comments or viewport. Until it is fixed, attach the loaded model to an engine and pass its camera values explicitly when mounting. The workaround still uses `render` for mounting and kit finalization. [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#diagramengine) is needed here only to retain the entire deserialized model, rather than transferring its entities into a fresh one. > **Known issue:** Panning and zooming the mounted canvas do not update `model.viewport`. Until it is fixed, copy the instance camera into the model immediately before serialization, as `saveDocument()` does below. Call it again whenever you save after a camera change. ```ts title="main.ts" import { render, fromDocument } from '@grafloria/element'; import { DiagramEngine, DiagramSerializer } from '@grafloria/engine'; import { buildSchema } from './schema'; const originalHost = document.createElement('div'); const reopenedHost = document.createElement('div'); for (const host of [originalHost, reopenedHost]) { host.style.height = '400px'; document.body.appendChild(host); } const original = render(buildSchema(), originalHost); const serializer = new DiagramSerializer(); export function saveDocument() { const model = original.getModel(); model.viewport = { ...original.viewport.getViewport(), zoom: original.viewport.getZoom(), }; return JSON.stringify(serializer.serializeEnvelope(model)); } const json = saveDocument(); const loaded = fromDocument(json); const engine = new DiagramEngine(); engine.setDiagram(loaded.model); const reopened = render(loaded, reopenedHost, { engine, viewport: { x: loaded.model.viewport.x, y: loaded.model.viewport.y }, zoom: loaded.model.viewport.zoom, }); // Call when your application removes these canvases. export function unmount() { reopened.dispose(); original.dispose(); reopenedHost.remove(); originalHost.remove(); } ``` ![Customer and Order table cards with their field-port connection on the original and reopened canvases.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/8f8382a48c2da78208bfc9693ed024b7.png) This saves document data, not an undo stack or a serialized renderer. The loader reconstructs the kit's runtime wiring after mounting. ## The same kit contract in each binding Each host below renders the same two table cards from `schema.ts` and runs their finalization through `render`. Start in your framework project; install the binding you use in addition to the shared packages above. ### React Use [`GrafloriaDiagram`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriadiagram) for the complete kit spec. The component disposes its instance on unmount. ```bash npm install @grafloria/react react react-dom ``` ```tsx title="SchemaView.tsx" import { GrafloriaDiagram } from '@grafloria/react'; import { buildSchema } from './schema'; const spec = buildSchema(); export default function SchemaView() { return
; } ``` ![React renders Customer and Order cards with PK and FK rows joined by a connection.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/be27f1ec56a544029199bbad11ba90a9.png) ### Vue Pass the spec to Vue's [`GrafloriaDiagram`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriadiagram). Its `ready` event provides the mounted instance when you need to serialize it. ```bash npm install @grafloria/vue vue ``` ```vue title="SchemaView.vue" ``` Vue renders Customer and Order cards with their column types and field-port connection. ### Angular [`GrafloriaDiagramComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-classes#grafloriadiagramcomponent) is the generic kit host, not a canvas assembled from projected node arrays. Its `ready` output provides the live instance. ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` ```ts title="schema-view.component.ts" import { Component } from '@angular/core'; import { GrafloriaDiagramComponent } from '@grafloria/angular'; import { buildSchema } from './schema'; @Component({ selector: 'app-schema-view', standalone: true, imports: [GrafloriaDiagramComponent], template: '', }) export class SchemaViewComponent { readonly spec = buildSchema(); } ``` Angular renders Customer and Order table cards connected at their rows. ### Qwik Qwik's [`GrafloriaDiagram`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriadiagram) builds the kit in the browser through `spec$`. Do not put function-bearing kit specs in resumable state. The builder runs once per mount; change the component `key` when new schema data needs a fresh build. ```bash npm install @grafloria/qwik @builder.io/qwik ``` ```tsx title="schema-view.tsx" import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaDiagram } from '@grafloria/qwik'; import type { DiagramInstance } from '@grafloria/renderer'; import { buildSchema } from './schema'; export default component$(() => { const schemaVersion = useSignal(1); const instance = useSignal>(); return ( buildSchema()} onReady$={(ready) => { instance.value = noSerialize(ready); }} style={{ height: '400px' }} /> ); }); ``` Qwik renders Customer and Order table cards with PK and FK badges and a connection. Create live transports and stores in `useVisibleTask$` and retain them with `noSerialize()`, as with the instance above. A [`CommentStore`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-comments#commentstore) holds subscribers, not resumable data. On [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow), a live collaboration object likewise needs `noSerialize()`; plain node and edge data do not. See [Qwik: custom content and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/qwik-custom-content-and-kits) for browser-side kit construction. ## Mermaid is a body plus a sidecar `instance.exportText()` returns Mermaid-compatible text with a `%%grafloria:document` comment by default. Mermaid ignores that comment; Grafloria reads the document inside it. This preserves data that the Mermaid body cannot express, including exact geometry and styling. The sidecar deliberately excludes selected, hovered and focused entity state and derived link polylines. User-authored manual bends remain. “Lossless” here means document intent, not every viewer's transient state or every routed pixel. The following browser sample uses a [`DiagramSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#diagramspec) to mount a labeled connection, exports its text, then loads it into a second canvas. You see Start connected to Finish on both canvases. ```ts title="text-round-trip.ts" import { render, type DiagramSpec } from '@grafloria/element'; const spec: DiagramSpec = { nodes: [ { id: 'start', label: 'Start', position: { x: 40, y: 80 } }, { id: 'finish', label: 'Finish', position: { x: 340, y: 80 } }, ], edges: [{ id: 'route', source: 'start', target: 'finish', label: 'next' }], }; const firstHost = document.createElement('div'); const secondHost = document.createElement('div'); for (const host of [firstHost, secondHost]) { host.style.height = '400px'; document.body.appendChild(host); } const first = render(spec, firstHost); const text = first.exportText(); const second = render(spec, secondHost); const result = second.loadText(text); console.log(result.source, result.bodyEdited, result.sidecarInvalid); export function unmount() { second.dispose(); first.dispose(); secondHost.remove(); firstHost.remove(); } ``` ![Both canvases show Start connected to Finish by an arrow labeled next.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/4e46898c885b232a73d861d427ead8e5.png) `loadText()` returns an [`ImportTextResult`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-serialization#importtextresult) and reconciles into the existing model, preserving its listeners and plugins. In the default `prefer: 'auto'` mode, an unchanged body uses the sidecar; an edited body applies structure and labels on top of the sidecar data. Invalid sidecar JSON sets `sidecarInvalid` and falls back to parsing the body. Unsupported or invalid diagram text makes `loadText()` throw before changing the canvas. `loadText()` is an entity reconciliation path, not a whole-document camera or comment restoration path. Use the document loader above when opening a complete saved document. See [Import diagram text and files](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/import-diagram-text-and-files) for format selection and parse results. ## Dashboard snapshots are a domain format A dashboard combines geometry, widget state, and persistence/history in one document. A widget's kind, payload and grid cell describe the same live board that gestures edit; you do not maintain a second widget registry beside the layout. The dashboard handle's `toJSON()` returns dashboard authoring data that `dashboard()` accepts again. It reads live cells and membership, including nested containers, rather than the original widget arrays. Unlike a generic framework spec projection, it is a supported domain round trip. Unlike the shared diagram document, it describes dashboard options, views and widgets rather than arbitrary diagram entities. This browser sample renders the shipped KPI widget twice: once from authoring data and once from the mounted board's snapshot. No custom widget painter is needed. ```ts title="dashboard-round-trip.ts" import { dashboard, render } from '@grafloria/element'; const firstHost = document.createElement('div'); const secondHost = document.createElement('div'); for (const host of [firstHost, secondHost]) { host.style.height = '400px'; document.body.appendChild(host); } const spec = dashboard({ columns: 4, widgets: [{ id: 'revenue', kind: 'kpi', span: 2, data: { label: 'Revenue', value: '$6.8M', delta: 12.4 }, }], }); const first = render(spec, firstHost); const saved = spec.handle.toJSON(); const restoredSpec = dashboard(saved); const second = render(restoredSpec, secondHost); export function unmount() { second.dispose(); first.dispose(); secondHost.remove(); firstHost.remove(); } ``` ![Original and restored Revenue KPI cards each show $6.8M and a green 12.4% comparison.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/00cb2d812acd12e0111a1c0f66e7672f.png) The snapshot omits the `renderWidget` and `onLayoutChange` function seams; supply them again when rebuilding a board that uses them. Use `DiagramSerializer` instead when saving the shared diagram document, and `fromDocument` to regain both its live models and its dashboard handle. ## Explore next - [Live ER diagram](https://grafloria.com/demos/diagrams/table-er.html) — table cards and relationships from domain data. - [Live UML diagram](https://grafloria.com/demos/diagrams/class-uml.html) — class compartments and relationship markers. - [Live dashboard builder](https://grafloria.com/demos/dashboard/dashboard-builder.html) — widgets, board geometry and editing together. - [Build a dashboard](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-dashboard) and [Arrange dashboard containers](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/arrange-dashboard-containers) — author and edit boards. - [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) — persistence procedures and projection boundaries. - [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) — how user-facing edits join the history stack. # Edit nodes Use an external inspector when your application needs to edit a node without putting every control inside the canvas. The examples below render a fixed-size node and a content-sized node, then let you edit a target by `nodeId`, add and delete nodes, and undo committed payload changes. Specs describe intent; live models hold data. Use the mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) to get the model and engine. Loading and editing are different intents: tracked model setters update live data, while commands put user-facing changes on the history stack. ## 1. Share the data and inspector Create `inspector.ts` in your browser application's source directory. The framework examples in the next step import this file. The data uses [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec). `fixed` starts at 200 × 80; `auto` starts at 60 × 36 and fits its longer label through `metadata.sizing.auto`. The inspector uses the mounted [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#diagramengine). `addNode()` returns the live [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel) and executes the shipped [`AddNodeCommand`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-commands-classes-a-r#addnodecommand); `removeNode()` executes [`RemoveNodeCommand`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-commands-classes-a-r#removenodecommand). You do not need to construct those commands yourself. For payload commits, execute the shipped [`SetNodeDataCommand`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-commands-classes-r-u#setnodedatacommand). This is the command surface for an inspector edit: it changes only the supplied data keys and restores those keys on undo. ```ts title="inspector.ts" import { NodeModel, SetNodeDataCommand, type DiagramEngine } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; export const initialNodes: NodeSpec[] = [ { id: 'fixed', position: { x: 80, y: 80 }, size: { width: 200, height: 80 }, label: 'Draft', data: { note: 'Review pending' }, }, { id: 'auto', position: { x: 80, y: 230 }, size: { width: 60, height: 36 }, label: 'This node fits its longer label', metadata: { sizing: { auto: true, padding: 10 } }, data: { note: 'Content-sized' }, }, ]; export const initialEdges: EdgeSpec[] = [ { id: 'connection', source: 'fixed', target: 'auto' }, ]; let nextNodeId = 0; export function mountInspector( host: HTMLElement, engine: DiagramEngine, repaint: () => void, ): () => void { engine.setInteractionConfig({ enableInPlaceTextEdit: true }); const abort = new AbortController(); const panel = document.createElement('div'); panel.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;padding:12px;font:14px sans-serif'; host.append(panel); function field(caption: string, value: string, type = 'text'): HTMLInputElement { const label = document.createElement('label'); label.append(`${caption} `); const input = document.createElement('input'); input.type = type; input.value = value; input.style.width = type === 'number' ? '70px' : '160px'; label.append(input); panel.append(label); return input; } const target = field('nodeId', 'fixed'); const label = field('Label (live)', 'Draft'); const note = field('Note', 'Review pending'); const width = field('Width', '200', 'number'); const height = field('Height', '80', 'number'); width.min = height.min = '1'; const status = document.createElement('output'); panel.append(status); function node(): NodeModel | undefined { return engine.getDiagram()?.getNode(target.value); } function refresh(): void { const current = node(); if (!current) { status.textContent = 'Target not found'; return; } label.value = current.getLabel() ?? ''; note.value = typeof current.data.note === 'string' ? current.data.note : ''; width.value = String(current.size.width); height.value = String(current.size.height); status.textContent = `Current note: ${note.value}`; } function button(caption: string, action: () => Promise): void { const control = document.createElement('button'); control.type = 'button'; control.textContent = caption; control.addEventListener('click', () => { void action().then(() => { repaint(); refresh(); }).catch((error: Error) => { status.textContent = error.message; }); }, { signal: abort.signal }); panel.append(control); } target.addEventListener('input', refresh, { signal: abort.signal }); label.addEventListener('input', () => { node()?.setMetadata('label', label.value); repaint(); }, { signal: abort.signal }); const resize = () => { const w = Number(width.value); const h = Number(height.value); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { node()?.setSize(w, h); repaint(); } }; width.addEventListener('input', resize, { signal: abort.signal }); height.addEventListener('input', resize, { signal: abort.signal }); button('Apply note', async () => { if (!node()) throw new Error('Choose an existing nodeId'); await engine.commandManager.execute( new SetNodeDataCommand(target.value, { note: note.value }), ); }); button('Add', async () => { let id: string; do { id = `inspector-node-${++nextNodeId}`; } while (engine.getDiagram()?.getNode(id)); const added = new NodeModel({ id, type: 'rect', position: { x: 420, y: 80 }, size: { width: 180, height: 80 }, }); added.setMetadata('label', 'New node'); const live = await engine.addNode(added); target.value = live.id; }); button('Delete target', async () => { if (!node()) throw new Error('Choose an existing nodeId'); await engine.removeNode(target.value); }); button('Undo', async () => { await engine.undo(); }); button('Redo', async () => { await engine.redo(); }); refresh(); return () => { abort.abort(); panel.remove(); }; } ``` The label and dimension fields are **live, non-history updates**. The Note field is a draft until you press **Apply note**; that button records one undoable payload edit. Add and delete also enter history. For undoable label editing, use the canvas's in-place editor described below rather than the live label field. ## 2. Mount the canvas in your framework Install the packages for your framework in your own project. JavaScript: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` Angular: ```bash npm install @grafloria/angular @grafloria/engine @grafloria/renderer @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Qwik: ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik ``` React: ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` Vue: ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue ``` JavaScript mounts with [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render). Angular uses [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) and its `activeEngine()`. React's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow), Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow), and Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow) hand you the instance through their initialization callback or event. Each sample mounts the same inspector above a 400-pixel canvas. The React, Vue and Qwik samples use uncontrolled defaults so the live instance owns edits. Angular uses two-way node and edge bindings so model changes return to application state. :::code-group ```ts title="JavaScript" // main.ts — run in the browser; call the returned cleanup when removing this view. import { render } from '@grafloria/element'; import { initialNodes, initialEdges, mountInspector } from './inspector'; export function mountNodeEditor(parent: HTMLElement): () => void { const inspector = document.createElement('div'); const canvas = document.createElement('div'); canvas.style.height = '400px'; parent.append(inspector, canvas); const instance = render({ nodes: initialNodes, edges: initialEdges }, canvas); const removeInspector = mountInspector( inspector, instance.getEngine(), () => instance.renderNow(), ); return () => { removeInspector(); instance.dispose(); inspector.remove(); canvas.remove(); }; } const root = document.createElement('div'); document.body.append(root); export const unmountNodeEditor = mountNodeEditor(root); ``` ```ts title="Angular" // node-editor.component.ts import { AfterViewInit, Component, ElementRef, OnDestroy, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { initialNodes, initialEdges, mountInspector } from './inspector'; @Component({ selector: 'app-node-editor', standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class NodeEditorComponent implements AfterViewInit, OnDestroy { nodes: ReturnType = initialNodes; edges: ReturnType = initialEdges; canvas = viewChild.required(DiagramCanvasComponent); inspector = viewChild.required>('inspector'); private removeInspector?: () => void; ngAfterViewInit(): void { const canvas = this.canvas(); const engine = canvas.activeEngine(); if (!engine) return; this.removeInspector = mountInspector( this.inspector().nativeElement, engine, () => canvas.scheduleRender(), ); } ngOnDestroy(): void { this.removeInspector?.(); } } ``` ```tsx title="Qwik" // node-editor.tsx import { component$, $, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { DiagramInstance } from '@grafloria/renderer'; import { initialNodes, initialEdges, mountInspector } from './inspector'; export default component$(() => { const instance = useSignal>(); const inspector = useSignal(); useVisibleTask$(({ track, cleanup }) => { const api = track(() => instance.value); const host = inspector.value; if (!api || !host) return; cleanup(mountInspector(host, api.getEngine(), () => api.renderNow())); }); return <>
{ instance.value = noSerialize(api); })} />
; }); ``` ```tsx title="React" // NodeEditor.tsx import { useEffect, useRef } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance } from '@grafloria/renderer'; import { initialNodes, initialEdges, mountInspector } from './inspector'; export default function NodeEditor() { const inspector = useRef(null); const instance = useRef(null); const removeInspector = useRef<(() => void) | null>(null); function onInit(api: DiagramInstance): void { instance.current = api; removeInspector.current?.(); if (inspector.current) { removeInspector.current = mountInspector( inspector.current, api.getEngine(), () => api.renderNow(), ); } } useEffect(() => () => { removeInspector.current?.(); }, []); return <>
; } ``` ```vue title="Vue" ``` ::: ## 3. Edit the target and test history The JavaScript view starts with the inspector targeting Draft and an edge leading to the wider content-sized node. ![JavaScript inspector above Draft and the connected content-sized node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/c85f76f3cbe887b58b082a4e7e2d97dc.png) Angular, Qwik, React and Vue render the same initial nodes and the inspector's Apply note, Add, Delete target, Undo and Redo controls. ![Angular inspector targeting fixed, with the two connected nodes below.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/5439724aabab52b5f7ed588b0ab2f928.png) 1. Keep `nodeId` set to `fixed`. Type in **Label (live)**: the canvas text follows the input. Change Width or Height: the box changes size through `setSize()`. 2. Change Note and press **Apply note**. The Current note output shows the committed payload. Press Undo to restore the previous note, then Redo to reapply it. Payload data is separate from the display label; changing `note` does not replace the node's label. 3. Press Add. A new node appears to the right, and the inspector targets its generated id. Press Delete target to remove it. Undo restores it. Look up the node again by id after undo rather than retaining a model reference: deletion undo reconstructs models. 4. Set `nodeId` to `auto`. Lengthen its label: content-aware sizing grows it during rendering when the text needs more room. With this unconstrained node, manually enlarging the box survives subsequent renders; shortening the label does not shrink it. Removing a node also removes its connected links and descendants; undo restores them. `removeNode()` rejects if the target does not exist, which the inspector reports instead of silently ignoring it. ### Commit labels in place The shared inspector setup enables `enableInPlaceTextEdit` through `setInteractionConfig()`. Double-click a node to open the shipped label editor. Enter or blur commits the rename through a command; Escape cancels it. Undo and Redo then act on that rename. Angular also enables its in-place editing input by default. For an external Rename action on an instance, call `beginLabelEdit({ type: 'node', nodeId: 'fixed' })`. It returns whether an editor opens; a missing, non-editable or read-only target returns `false`. An optional `{ seed: 'R' }` second argument starts with replacement text instead of selecting the existing label. See the live [in-place label editing demo](https://grafloria.com/demos/nodes/edit-label.html) to try Enter, blur and Escape. ## Options that matter | Option | Type | Default | What it does | |---|---|---|---| | `NodeSpec.id` | `string` | Optional | Gives your inspector a stable target for model lookup and commands. | | `NodeSpec.label` | `string` | Optional | Supplies `metadata.label`, the display label. | | `NodeSpec.data` | Payload record | Optional | Carries application data separately from the label. | | `NodeSpec.size` | `{ width: number; height: number }` | Optional | Declares the box dimensions. | | `metadata.sizing.auto` | `boolean` | Off unless `true` | Enables content-aware fitting during rendering. | | `metadata.sizing.padding` | `number` | `8` | Adds padding around measured label content. | | `metadata.sizing.minWidth`, `minHeight` | `number` | No per-node constraint | Sets lower bounds for content sizing and interactive resizing. | | `metadata.sizing.maxWidth`, `maxHeight` | `number` | No per-node constraint | Sets upper bounds; `maxWidth` also supplies the label's wrap width during measurement. | ## Pitfalls - Use tracked setters such as `setMetadata()`, `setData()` and `setSize()`, not raw assignments to live fields. Setters notify the model's change tracking. They do not themselves create history entries; use a command for a committed inspector action. - `metadata.label` takes precedence over a legacy `data.label`. A payload command that writes `data.label` does not rename a node with a canonical label; use the label editor for that task. - With auto-sizing enabled, the renderer grows the current dimensions to accommodate content, subject to sizing constraints. A manual enlargement of the unconstrained `auto` node remains; a manual reduction can grow again if the label needs more room. - For controlled inputs, keep the framework's change-event return path. See the [React quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-quick-start) and [Vue quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-quick-start). - For engine history, explicit layout after edits, and repainting custom HTML content, see [commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history), [lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram), and [JavaScript elements and content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-elements-and-content). ## Live demos and related guides - [Updating nodes](https://grafloria.com/demos/nodes/updating-nodes.html): live label, background and width controls. [Source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/nodes/updating-nodes.html). - [Auto-sizing](https://grafloria.com/demos/nodes/auto-sizing.html): compare a content-sized node with a fixed-size control. - [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents): persist the live document rather than a framework projection. # Validate port connections Use declared ports when your editor needs named inputs and outputs rather than interchangeable attachment points. The example renders a number pipeline, `A → B → C`, plus a string input. Number ports paint blue; the string port paints purple. Matching types connect, full inputs refuse another wire, and a cycle validator prevents `C → A`. Ports decide where links attach and which connections are legal: declare them in specs, let the engine enforce the rules, and let the renderer draw the glyphs. ## 1. Declare the ports and their rules In your browser application's source directory, create `ports.ts`. All five bindings below import this file. Use the library's [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`PortSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-portspec) vocabulary rather than constructing live ports yourself. [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec) names the ports used by the initial pipeline through `sourceHandle` and `targetHandle`. The two inputs on B inherit a square glyph, inside labels, and an evenly spaced left-edge column from `metadata.portGroups.inputs`. Each member supplies its own id and label text. A and C use diamond outputs. The setup function receives the mounted [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine) so the cycle rule queries its current diagram. > **Known issue:** Declaring `type: 'input'` or `type: 'output'` alone does not enforce start-only/end-only direction: the connection rule rejects equal non-`bi` types, but does not reject an input-to-output wire. Until it is fixed, also set `gating.isConnectableStart: false` on inputs and `gating.isConnectableEnd: false` on outputs, as below. ```ts title="ports.ts" import { portTypeRegistry, type DiagramEngine } from '@grafloria/engine'; import { registerConnectionValidator, type NodeSpec, type PortSpec, type EdgeSpec, type DiagramInstance, } from '@grafloria/renderer'; function input(id: string, dataType: string): PortSpec { return { id, side: 'left', type: 'input', dataType, shape: { shape: 'square', size: 12 }, label: { text: dataType, layout: 'inside' }, gating: { isConnectableStart: false, toMaxLinks: 1 }, }; } function output(id: string): PortSpec { return { id, side: 'right', type: 'output', dataType: 'number', shape: { shape: 'diamond', size: 14 }, label: { text: 'out', layout: 'inside' }, gating: { isConnectableEnd: false }, }; } export const nodes: NodeSpec[] = [ { id: 'a', label: 'A', position: { x: 60, y: 100 }, size: { width: 150, height: 100 }, ports: [input('a-in', 'number'), output('a-out')], }, { id: 'b', label: 'B', position: { x: 290, y: 100 }, size: { width: 150, height: 100 }, metadata: { portGroups: { inputs: { id: 'inputs', side: 'left', shape: { shape: 'square', size: 12 }, label: { layout: 'inside' }, layout: { strategy: 'sideLinear', args: { padding: 10 } }, }, }, }, ports: [ { id: 'b-x', group: 'inputs', type: 'input', dataType: 'number', label: { text: 'x' }, gating: { isConnectableStart: false, toMaxLinks: 1 }, }, { id: 'b-y', group: 'inputs', type: 'input', dataType: 'number', label: { text: 'y' }, gating: { isConnectableStart: false, toMaxLinks: 1 }, }, output('b-out'), ], }, { id: 'c', label: 'C', position: { x: 520, y: 100 }, size: { width: 150, height: 100 }, ports: [input('c-in', 'number'), output('c-out')], }, { id: 's', label: 'String input', position: { x: 520, y: 290 }, size: { width: 150, height: 100 }, ports: [input('s-in', 'string')], }, ]; export const edges: EdgeSpec[] = [ { id: 'ab', source: 'a', target: 'b', sourceHandle: 'a-out', targetHandle: 'b-x', }, { id: 'bc', source: 'b', target: 'c', sourceHandle: 'b-out', targetHandle: 'c-in', }, ]; export function installPortRules(engine: DiagramEngine): () => void { engine.setInteractionConfig({ enableLinkReconnection: false }); portTypeRegistry.registerAll([ { name: 'number', color: '#2563eb', compatibleWith: ['number'] }, { name: 'string', color: '#9333ea', compatibleWith: ['string'] }, ]); const model = engine.getDiagram(); if (!model) throw new Error('Mount the canvas before installing port rules.'); return registerConnectionValidator(({ sourceNode, targetNode }) => { // The registry is global. Apply this rule only to this mounted model. if (model.getNode(sourceNode.id) !== sourceNode || model.getNode(targetNode.id) !== targetNode) return true; const seen = new Set(); const stack = [targetNode.id]; while (stack.length > 0) { const current = stack.pop()!; if (current === sourceNode.id) return 'Refused: would create a cycle'; if (seen.has(current)) continue; seen.add(current); for (const edge of model.getLinks()) { const from = model.getNodeByPortId(edge.sourcePortId)?.id; const to = model.getNodeByPortId(edge.targetPortId)?.id; if (from === current && to !== undefined) stack.push(to); } } return true; }); } export function configurePorts(instance: DiagramInstance): () => void { const dispose = installPortRules(instance.getEngine()); instance.renderNow(); return dispose; } ``` [`portTypeRegistry`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-ports) supplies both the type palette and compatibility rules. Identical type names match; an untyped endpoint imposes no type restriction. `compatibleWith` permits additional target types in the source-to-target direction—it does not automatically permit the reverse conversion. [`registerConnectionValidator`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions) returns a disposer. Its callback receives both nodes and both ports for new connections. Return `true` to allow the candidate, or `false` or a reason string to veto it. Every registered validator must pass for a new connection. > **Known issue:** Endpoint reconnection does not invoke registered validators, despite the callback type's optional `link` field intended for that task. Until it is fixed, disable endpoint reconnection with `engine.setInteractionConfig({ enableLinkReconnection: false })`, as `installPortRules()` does, so reconnection cannot bypass the cycle rule. The cycle rule traverses the mounted instance's live links, not the initial `edges` array. It rejects a proposed edge when its target already reaches its source. Moving the boxes does not change that graph rule. ## 2. Mount the same editor in your framework Choose one installation command for your existing project: ```bash # JavaScript npm install @grafloria/element @grafloria/engine @grafloria/renderer # React npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom # Vue npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue # Qwik npm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer @builder.io/qwik # Angular npm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Add `configurePorts()` (or `installPortRules()` in Angular) to apply the type colors and cycle rule to this editor; see [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) for mounting and ready callbacks. Each wrapper has a resolved height. Ports stay visible through `interaction.portVisibility` in JavaScript, React, Vue and Qwik. The Angular sample keeps the default hover visibility. Keep the returned validator disposer until unmount; the framework binding owns its canvas teardown. :::code-group ```ts title="JavaScript" // main.ts import { render } from '@grafloria/element'; import { nodes, edges, configurePorts } from './ports'; export function mountPorts(container: HTMLElement): () => void { container.style.height = '460px'; const instance = render( { nodes, edges }, container, { interaction: { portVisibility: 'always' } }, ); const disposeRules = configurePorts(instance); return () => { disposeRules(); instance.dispose(); }; } const container = document.createElement('div'); document.body.append(container); export const unmountPorts = mountPorts(container); // Call unmountPorts() when your host removes this view. ``` ```tsx title="React" // App.tsx import { useCallback, useEffect, useRef } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, configurePorts } from './ports'; export default function App() { const instanceRef = useRef(null); useEffect(() => { const instance = instanceRef.current; if (!instance) return; return configurePorts(instance); }, []); const onInit = useCallback((instance: DiagramInstance) => { instanceRef.current = instance; }, []); return (
); } ``` ```vue title="Vue" ``` ```tsx title="Qwik" // App.tsx import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize, } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, configurePorts } from './ports'; export default component$(() => { const instance = useSignal>(); useVisibleTask$(({ track, cleanup }) => { const mounted = track(() => instance.value); if (!mounted) return; const disposeRules = configurePorts(mounted); cleanup(disposeRules); }); return (
{ instance.value = noSerialize(mounted); }} />
); }); ``` ```ts title="Angular" // app.component.ts import { Component, viewChild, type AfterViewInit, type OnDestroy } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, installPortRules } from './ports'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class AppComponent implements AfterViewInit, OnDestroy { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = structuredClone(nodes); edges: EdgeSpec[] = structuredClone(edges); private disposeRules: (() => void) | undefined; ngAfterViewInit(): void { const engine = this.canvas().activeEngine(); if (!engine) return; this.disposeRules = installPortRules(engine); this.canvas().scheduleRender(); } ngOnDestroy(): void { this.disposeRules?.(); } } ``` ::: The JavaScript canvas starts with A → B → C and a separate purple string input. ![JavaScript: two wires connect A, B and C; blue square inputs and diamond outputs stay visible.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/13c8320171e2a24f4374dbd673631522.png) The React canvas displays the same initial pipeline. ![React: A → B → C appears above the separate String input node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/f2c8f04406e795cb82f9511e74dca53a.png) The Qwik canvas displays the blue number ports and purple string port. ![Qwik: the number pipeline has two wires, while String input remains unconnected.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/4a50e70d7e77ad1e8127d9a5c735fb07.png) Qwik registers the types and validator in a browser visible task, after the ready callback supplies the instance. The live instance stays in `noSerialize()` state rather than entering the server's serialized state. ## 3. Try the connection rules The initial canvas contains two wires. Test each layer by dragging from the diamond output on A: 1. Drop on B's `y` input. A number-to-number wire appears, and that input becomes full. 2. Try that same input again. No second wire appears; the one incoming-link cap is per port, not per node. 3. Drop on the purple input of String input. No wire appears because `number` cannot flow into `string`. 4. Drag from C's output to A's input. No wire appears: A already reaches C, so the proposed edge closes a cycle. To use hover ports instead, omit the `interaction` prop/options in JavaScript, React, Vue and Qwik. Angular already uses hover ports in this sample. Hovering a node then reveals its ports. For container sizing, see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas). ## Options that matter Use the built-in glyphs and port layouts before registering custom geometry. These options belong to each port unless noted otherwise. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `shape.shape` | `'circle' \| 'square' \| 'diamond' \| 'triangle' \| 'path'` | `'circle'` | Selects the glyph; `path` needs SVG path data in `shape.path`. | | `shape.size` | `number` | Twice `portDefaultRadius` | Sets the glyph box width and height; for circles, this is the diameter. | | `label.layout` | `'inside' \| 'outside' \| 'orthogonal' \| 'radial'` | `'outside'` | Places text toward the body, away from it, across the normal, or radially from the node center. | | `label.offset` | `number` | `6` | Sets the gap from the glyph edge in pixels. | | `layout.strategy` | `'shape' \| 'absolute' \| 'line' \| 'sideLinear' \| 'ellipse' \| 'ellipseSpread'` | Shape anchor | Uses the shape silhouette, a fixed point, a segment, an edge column, or an ellipse arrangement. | | `group` | `string` | Unset | Inherits configuration from the named group in `metadata.portGroups`; port-level layout, shape and label fields override it. | | `dataType` | `string` | Unset | Names the data-flow type used for color and compatibility. | | `gating.isConnectableStart`, `gating.isConnectableEnd` | `boolean` | `true` | Permit or veto the corresponding end of a proposed wire. | | `gating.fromMaxLinks`, `gating.toMaxLinks` | `number \| null` | Unlimited | Cap outgoing or incoming links separately. | | `maxConnections` | `number` | Unlimited | Caps all connections on this port. | | `gating.allowedTypes` | `string[]` | No restriction | Requires the opposite port's data type or system type to appear in the list. | | `gating.allowSelfLink` | `boolean` | `false` | Permits a same-node link only when both endpoints allow it. | | `gating.allowDuplicateLinks` | `boolean` | `true` | When false on either endpoint, rejects another link between the same two ports, including the reverse direction. | | `interaction.portVisibility` | `'always' \| 'on-hover' \| 'hidden'` | `'on-hover'` | Controls canvas-wide port visibility; hidden ports disable drag-to-connect. | For many-port nodes, `sideLinear` spaces ports along an edge, `line` spaces them along `args.start` → `args.end` in node-local pixels, and `ellipseSpread` fans them around the inscribed ellipse. The port arrangement travels with the node. [Route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges) covers the wires attached to those ports. ## Clean up global validators Connection validators are process-global, not per canvas. Keep each registration's disposer and call it on unmount, as the samples do. The identity check in the cycle callback limits its rule to the intended live model; the disposer removes the registration itself. Use [`clearConnectionValidators`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions) only when you intend a clean slate for the whole process. It removes every registered validator, including registrations owned by other canvases. Do not substitute it for disposing one view's rule. ## Live demos and related pages - [Typed ports](https://grafloria.com/demos/ports/typed-ports.html): compare matching and mismatched types. - [Port groups and layouts](https://grafloria.com/demos/ports/port-groups-and-layouts.html): compare columns, segments and rings. - [Connection limit](https://grafloria.com/demos/nodes/connection-limit.html): try a second wire on a full port. - [Preventing cycles](https://grafloria.com/demos/interaction/preventing-cycles.html) and its [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/interaction/preventing-cycles.html): follow directed reachability through live links. - [Specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models): understand the data queried by the cycle callback. - [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle): own a mounted instance and its teardown. # Route and label edges Use routing, labels and endpoint markers when a diagram needs to distinguish a connection from a crossing, or several relationships between the same nodes. The example below draws a right-angle detour around an obstacle, two crossing wires with a jump-over, three separate parallel lanes, and a self-loop outside its node. An edge stores intent; the renderer turns that intent into geometry as nodes move. Describe connections with [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec) rather than calculating SVG paths yourself. ## 1. Describe the routes and their labels Create `edge-data.ts` in your browser application. The [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) arrays provide actual endpoints and an obstacle. Every edge has a stable id so you can identify the corresponding live link later. The first edge pins its endpoints halfway down the right and left sides. Its `router` chooses the route; its `connector` rounds the bends. The label sits above the line, the source carries a circle, and the target carries an arrow. ```ts title="edge-data.ts" import type { EdgeSpec, NodeSpec, SVGRendererConfig } from '@grafloria/renderer'; export const nodes: NodeSpec[] = [ { id: 'a', label: 'A', position: { x: 40, y: 100 }, size: { width: 110, height: 60 } }, { id: 'b', label: 'B', position: { x: 750, y: 100 }, size: { width: 110, height: 60 } }, { id: 'wall', label: 'Obstacle', position: { x: 400, y: 60 }, size: { width: 120, height: 140 } }, { id: 'c', label: 'C', position: { x: 40, y: 270 }, size: { width: 110, height: 44 } }, { id: 'd', label: 'D', position: { x: 750, y: 270 }, size: { width: 110, height: 44 } }, { id: 'e', label: 'E', position: { x: 40, y: 430 }, size: { width: 110, height: 44 } }, { id: 'f', label: 'F', position: { x: 750, y: 430 }, size: { width: 110, height: 44 } }, { id: 'g', label: 'G', position: { x: 40, y: 560 }, size: { width: 110, height: 60 } }, { id: 'h', label: 'H', position: { x: 480, y: 560 }, size: { width: 110, height: 60 } }, { id: 'self', label: 'Self', position: { x: 750, y: 560 }, size: { width: 110, height: 60 } }, ]; export const edges: EdgeSpec[] = [ { id: 'detour', source: 'a', target: 'b', sourceHandle: 'right@50%', targetHandle: 'left@50%', type: 'orthogonal', router: 'orthogonal', connector: 'rounded', label: 'depends on', labelPlacement: 'above', labelStyle: { color: '#15803d', fontSize: 12 }, style: { stroke: '#15803d', strokeWidth: 2, arrowTail: { type: 'circle', size: 6, filled: true }, arrowHead: { type: 'arrow', size: 10, filled: true }, }, }, { id: 'cf', source: 'c', target: 'f', type: 'direct', sourceHandle: 'right', targetHandle: 'left', style: { jumpPoints: { enabled: true, size: 10, detectMode: 'all' } }, }, { id: 'ed', source: 'e', target: 'd', type: 'direct', sourceHandle: 'right', targetHandle: 'left', style: { jumpPoints: { enabled: true, size: 10, detectMode: 'all' } }, }, { id: 'p1', source: 'g', target: 'h', type: 'direct', label: 'request' }, { id: 'p2', source: 'g', target: 'h', type: 'direct', label: 'response' }, { id: 'p3', source: 'g', target: 'h', type: 'direct', label: 'audit' }, { id: 'loop', source: 'self', target: 'self', label: 'retry', style: { selfLoop: { side: 'top', size: 40 } }, }, ]; export const rendererConfig: Partial = { parallelLinks: true, parallelSpacing: 24, jumpOwnership: 'single', }; ``` [`SVGRendererConfig`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-types-svgrendererconfig#svgrendererconfig) controls the whole diagram's lane spacing and crossing ownership. `jumpOwnership: 'single'` gives each crossing one hop even though both crossing edges enable jump points. ## 2. Mount the diagram Choose your framework's tab and place its file beside `edge-data.ts`. Install that tab's packages in your application: ```bash # JavaScript npm install @grafloria/element @grafloria/engine @grafloria/renderer # Angular npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element # Qwik npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element # React npm install @grafloria/react @grafloria/engine @grafloria/renderer react react-dom @grafloria/element # Vue npm install @grafloria/vue @grafloria/engine @grafloria/renderer vue @grafloria/element ``` For JavaScript, [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) mounts the spec and returns a live [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance). The JavaScript tab uses TypeScript in `main.ts` and creates its container in the browser. For Angular, render [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) in `app.component.ts`; its two-way bindings return node and edge edits to your arrays. For Qwik, React and Vue, render the binding's component: [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow), [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow), or [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow), respectively. These tabs seed an uncontrolled instance with `defaultNodes` and `defaultEdges`. :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import { nodes, edges, rendererConfig } from './edge-data'; const container = document.createElement('div'); container.style.height = '700px'; document.body.append(container); const instance = render({ nodes, edges }, container, { renderer: rendererConfig, }); instance.fitView(); ``` ```ts title="Angular" import { AfterViewInit, Component, ViewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, rendererConfig } from './edge-data'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class AppComponent implements AfterViewInit { nodes: NodeSpec[] = nodes; edges: EdgeSpec[] = edges; rendererConfig = rendererConfig; @ViewChild(DiagramCanvasComponent) canvas?: DiagramCanvasComponent; ngAfterViewInit(): void { requestAnimationFrame(() => this.canvas?.fitToContent()); } } ``` ```tsx title="Qwik" import { component$ } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import { nodes, edges, rendererConfig } from './edge-data'; export default component$(() => (
)); ``` ```tsx title="React" import { GrafloriaFlow } from '@grafloria/react'; import { nodes, edges, rendererConfig } from './edge-data'; export default function App() { return (
); } ``` ```vue title="Vue" ``` ::: ![JavaScript: the green A → B route passes below Obstacle, with a crossing hop, three labeled G → H lanes and a retry loop above Self.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/f759742d29597fc92d6a48fd8d8227af.png) Angular draws the same connections in its canvas. ![Angular: a labeled green detour, a hop at the diagonal crossing, three parallel lanes and the Self retry loop.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/6cf4585d3939d76c009470f5a1a193d7.png) Qwik renders the seeded edge specs. ![Qwik: the obstacle detour, crossing hop, request, response and audit lanes, and retry loop.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/05f8b7b9c57bb5267daab051038bb49d.png) React renders the same edge geometry. Vue renders the routes and labels from the shared data. Follow A → B around the obstacle, then inspect the middle crossing: the hop distinguishes two unrelated wires from a junction. At the bottom, G → H has three individually selectable lanes, and Self → Self leaves the node body before returning. Drag the obstacle or an endpoint node to see the routes update. ## Choose the geometry `type` is a shorthand for the line's shape. Explicit `router` and `connector` fields let you choose its path and its drawing independently. | `type` | Router when omitted | Connector when omitted and no explicit router is set | | --- | --- | --- | | `direct` | `straight` | `straight` | | `smooth` | `straight` | `smooth` | | `orthogonal` | `orthogonal` | `rounded` | | `bezier` | `straight` | `bezier` | An explicit `orthogonal`, `manhattan`, `avoid` or `elk` router implies a `rounded` connector unless you name a connector yourself. Use `straight` for sharp polyline bends, `rounded` for rounded corners, or `smooth` / `bezier` for curved drawing. | Router | What you get | | --- | --- | | `straight` | A direct route between endpoints. | | `orthogonal` | Right-angle segments respecting port exit directions; the sample detours around the obstacle. | | `manhattan` | Grid-based right-angle routing with turn minimization. | | `avoid` | Obstacle-avoiding routing through the built-in A* router. | | `elk` | The synchronous renderer currently substitutes `orthogonal`. | > **Known issue:** `router: 'elk'` does not produce ELK routing through the mounted renderer: ELK routing is async-only, and this rendering path substitutes `orthogonal`. Until it is fixed, request `router: 'orthogonal'` explicitly, as the sample does; use `router: 'avoid'` when you want the built-in A* obstacle router. For node placement by ELK, see [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram). ## Choose attachments, labels and markers Use handles when a connection must stay at a particular port or position on a box. Omit both handles to let the attachment follow the real port on the side facing its partner. For true perimeter floating, set the edge's `metadata` to `{ connectionPoint: 'smart' }`; this permits attachment along the outline rather than only at a port. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `sourceHandle`, `targetHandle` | `string` | Port-facing when neither is named | Pin to a port id, side name, or point along a side. `right@36` is 36 px down the right side; `bottom@138` is 138 px from its left end; `left@50%` is halfway down. | | `waypoints` | Point array | No supplied bends | Supply interior bends in world coordinates; endpoints stay attached to ports. | | `labelPlacement` | `'on' \| 'above' \| 'below'` | `'on'` | Draw a label chip on the line, or text off the line without a box unless its style requests a background. | | `labelStyle` | Label style | No override | Set the label's own color, font size, weight, family or background. | | `style.arrowHead` | Arrow style | Filled arrow, size `10` | Choose the target marker. Supply `type`, `size` and `filled`. | | `style.arrowTail` | Arrow style | No source marker | Choose the source marker with the same fields. | | `parallelLinks` | `boolean` | `true` | Fan links between the same unordered pair of nodes into separate lanes, including reverse-direction links. | | `parallelSpacing` | `number` | `16` | Set adjacent parallel-lane spacing in pixels. | | `jumpOwnership` | `'both' \| 'single'` | `'both'` | Choose whether both jump-enabled links or one link draws a crossing hop. | | `style.jumpPoints.enabled` | `boolean` | Not enabled without configuration | Enable crossing decorations on that edge. | | `style.jumpPoints.size` | `number` | `10` | Set the jump size in pixels. | | `style.selfLoop.size` | `number` | `40` | Set how far a self-loop bulges outside its node. | | `style.selfLoop.side` | `'auto' \| 'top' \| 'right' \| 'bottom' \| 'left'` | `'auto'` | Choose the loop's side; automatic uses its source port's side. | Endpoint markers include `arrow`, `circle`, `square` and `diamond`, plus ER markers such as `crow-foot` and `zero-or-many`, and UML markers such as `generalization` and `hollow-diamond`. Set `type: 'none'` to suppress an endpoint marker. Use the shipped shapes before registering your own geometry. For more than one label on a link, obtain its live [`LinkModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-linkmodel#linkmodel) through `instance.getModel().getLink(id)`. `addLabel({ text: 'condition', position: 0.25 })` adds text a quarter of the way along its path; call `instance.renderNow()` after setup mutations to repaint. A fractional label position follows the route instead of remaining at an absolute canvas coordinate. ## Pitfalls - A connection-point strategy that accepts the edge owns both endpoints before per-end `metadata.sourceAnchor` / `metadata.targetAnchor` are considered. Do not combine a floating strategy with per-end anchors expecting the anchors to take precedence. - `above` and `below` are relative to the run's direction: on a vertical run, `above` places text to its left. - Changing a live link's router through `setRouter()` clears its cached points and manual-waypoint flag. Changing `setConnector()` leaves routed points intact. Choose the router before adding bends you need to keep. - Jump points do not replace a two-point smooth or bezier curve with a chord-based hop. Use direct or right-angle crossing wires, as above, when you need jump-overs. - For user-facing edits that belong in undo history, follow [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history), rather than treating setup model mutations as commands. ## Live demos and related tasks - [Edge routing](https://grafloria.com/demos/edges/edge-routing.html): drag or remove the obstacle and inspect the detour. - [Edge labels](https://grafloria.com/demos/edges/edge-labels.html): drag a label along its edge, then move an endpoint. - [Parallel links and self-loops](https://grafloria.com/demos/edges/parallel-links-and-self-loops.html): inspect separate lanes and the outside loop. - [Jump-overs](https://grafloria.com/demos/edges/jump-overs.html): move the crossing and watch its hop follow. See [Validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections) for allowed connections, [Configure editing gestures](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/configure-editing-gestures) for interaction settings, and [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) for preserving the live document. # Configure editing gestures Use interaction settings to choose how users connect nodes, reconnect endpoints and bend wires without writing pointer handlers. In the React example pictured below, A connects to B and C sits below B: reconnect the wire to C, switch to bend editing, or copy and duplicate a selected node. The engine owns these edits; framework bindings expose the mounted canvas and its change events. ## 1. Define the editing policy In your browser application, create this shared file. The data uses the library's [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec) types. The policy checks its fields against [`InteractionConfig`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-config). [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine) exposes the commands used by the buttons. `copy()`, `paste()` and `duplicate()` return `Promise`, not new specs. Read the edited graph through the mounted canvas, or use the binding's change events. ```ts title="editing.ts" import type { DiagramEngine, InteractionConfig } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; export const nodes: NodeSpec[] = [ { id: 'a', label: 'A', position: { x: 80, y: 160 }, size: { width: 120, height: 60 } }, { id: 'b', label: 'B', position: { x: 480, y: 80 }, size: { width: 120, height: 60 } }, { id: 'c', label: 'C', position: { x: 480, y: 300 }, size: { width: 120, height: 60 } }, ]; export const edges: EdgeSpec[] = [ { id: 'ab', source: 'a', target: 'b', sourceHandle: 'right', targetHandle: 'left', type: 'direct' }, ]; export const interaction = { dragThreshold: 8, enableLinkReconnection: true, showLinkEndpointHandles: true, enableWaypointEditing: false, showWaypointHandles: true, enableHelperLines: true, enableKeyboardNudge: true, } satisfies Partial; export const actions = ['Reconnect', 'Edit bends', 'Copy', 'Paste', 'Duplicate', 'Lock / unlock'] as const; export type EditingAction = typeof actions[number]; export async function act(engine: DiagramEngine, action: EditingAction): Promise { switch (action) { case 'Reconnect': engine.setInteractionConfig({ enableWaypointEditing: false }); break; case 'Edit bends': engine.setInteractionConfig({ enableWaypointEditing: true, showWaypointHandles: true }); break; case 'Copy': if (engine.getDiagram()?.getSelectedNodes().length) { await engine.copy(); } break; case 'Paste': if (engine.hasClipboardData()) { await engine.paste({ offset: { x: 30, y: 30 }, selectPasted: true }); } break; case 'Duplicate': if (engine.getDiagram()?.getSelectedNodes().length) { await engine.duplicate({ offset: { x: 30, y: 30 }, selectDuplicated: true }); } break; case 'Lock / unlock': { const diagram = engine.getDiagram(); if (diagram) diagram.setReadonly(!diagram.isReadonly()); break; } } } ``` The initial policy enables alignment helper lines and arrow-key nudging. Reconnection starts enabled; bend editing starts disabled. Copy a selected node, then paste to add and select an offset copy. Copy does nothing in this toolbar when no node is selected; the engine's copy command requires a selected node. Duplicate performs the copy-like operation without requiring an earlier Copy click. The clipboard belongs to the engine. ## 2. Mount it in your framework Choose one implementation below. Each uses the same shared file and displays the same six controls. Select a node before Copy or Duplicate. The selection count shows the current selection, not keyboard focus. ### JavaScript / TypeScript Start with the mounted canvas in [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start), then use the shared editing policy above. ### React ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` This [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react) example adds the shared `interaction` policy, an 8 px `dragThreshold` and editing actions on the mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance); see [Validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections) for initialization and selection-callback wiring. ```tsx title="Editor.tsx" import { useRef, useState } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, interaction, actions, act, type EditingAction } from './editing'; export default function Editor() { const instance = useRef(null); const [selected, setSelected] = useState(0); async function run(action: EditingAction) { const current = instance.current; if (!current) return; await act(current.getEngine(), action); current.renderNow(); } return <>
{actions.map(action => )}

Selected: {selected}

{ instance.current = current; }} onSelectionChange={change => setSelected(change.nodes.length + change.edges.length)} />
; } ``` ![React displays A connected to B, C below B, six editing controls and an initial selection count of zero.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/b3de3fe4d74d02287ef161cac22d8fd7.png) ### Vue ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue ``` Render [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue), retain its instance with `@init`, and read selection through `@selection-change`. > **Known issue:** Vue has no `dragThreshold` prop, and the shared DOM binder captures its threshold at creation instead of reading the engine's live setting. The intended `interaction: { dragThreshold: 8 }` does not change Vue's mouse threshold. Until the binding forwards it, keep the default 4 px threshold in Vue; the other settings below still apply. ```vue title="Editor.vue" ``` Vue renders the same initial diagram and controls pictured in the React section. ### Qwik ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik ``` Pass the shared editing policy and an 8 px `dragThreshold` to [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik); see [Validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections) for the binding's initialization and selection events. Keep the live instance out of serialized state with `noSerialize()`. ```tsx title="Editor.tsx" import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, interaction, actions, act } from './editing'; export default component$(() => { const instance = useSignal>(); const selected = useSignal(0); return <>
{actions.map(action => ( ))}

Selected: {selected.value}

{ instance.value = noSerialize(current); }} onSelectionChange$={change => { selected.value = change.nodes.length + change.edges.length; }} />
; }); ``` Qwik renders the same initial diagram and six controls pictured in the React section. Use those controls to switch between reconnection and bend editing, copy, paste, duplicate or toggle the document lock. ### Angular ```bash npm install @grafloria/angular @grafloria/engine @grafloria/renderer @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Render [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent) with two-way specs. Read its mounted engine through `activeEngine()`. Angular exposes snapping and keyboard navigation as inputs; enable them directly. Use the [`SelectionChange`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-interfaces) output payload to update the count. ```ts title="editor.component.ts" import { AfterViewInit, Component, signal, viewChild } from '@angular/core'; import { DiagramCanvasComponent, type SelectionChange } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, interaction, actions, act, type EditingAction } from './editing'; @Component({ selector: 'app-editor', standalone: true, imports: [DiagramCanvasComponent], template: `
@for (action of actions; track action) { }

Selected: {{ selected() }}

`, }) export class EditorComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); readonly actions = actions; readonly selected = signal(0); nodes: NodeSpec[] = nodes; edges: EdgeSpec[] = edges; ngAfterViewInit(): void { this.canvas().activeEngine()?.setInteractionConfig(interaction); } selectionChanged(change: SelectionChange): void { this.selected.set(change.nodes.length + change.edges.length); } async run(action: EditingAction): Promise { const engine = this.canvas().activeEngine(); if (!engine) return; await act(engine, action); this.canvas().scheduleRender(); } } ``` Angular renders the same initial diagram and controls pictured in the React section. ## 3. Try connections, reconnections and bends 1. Hover a node to reveal its ports. Drag a port to a compatible target port to create a connection. 2. Click the A → B wire to select it. Drag its endpoint handle at B onto C. The existing link now connects A to C. 3. Click Edit bends. Click the selected wire's body to insert a waypoint, then drag that waypoint. The route bends through the point while its endpoints stay attached. 4. Click Reconnect before dragging an endpoint again. Waypoints are model constraints, not independent painted handles. They persist with the edge and participate in undo; see [Route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges) for routing around those constraints. > **Known issue:** With waypoint editing enabled, a path hit can insert a waypoint before the endpoint-reconnection branch runs. The intended combined policy is `setInteractionConfig({ enableWaypointEditing: true, enableLinkReconnection: true })`; until the hit-order conflict is fixed, disable waypoint editing while reconnecting. The two mode buttons above do that without rebuilding the canvas. ## Options that matter Defaults below are engine defaults unless a surface is named explicitly. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `dragThreshold` | `number` | `4` | Separates a pointer click from a drag in screen pixels. JavaScript, React and Qwik also expose a creation-level option/prop. | | `enableLinkReconnection` | `boolean` | `true` | Allows moving an existing link endpoint. | | `showLinkEndpointHandles` | `boolean` | `true` | Displays endpoint handles on selected links. | | `enableWaypointEditing` | `boolean` | `false` | Enables inserting, moving and removing bends. | | `showWaypointHandles` | `boolean` | `true` | Displays handles on selected links. It does not enable editing by itself. | | `snapToPortRadius` | `number` | `30` | Sets the connection's port-snap radius. | | `enableHelperLines` | `boolean` | `false` | Snaps a single top-level node to sibling alignments/equal spacing and displays dashed guides during dragging. | | `enableKeyboardNudge` | `boolean` | `false` | Enables selected-node nudging in the shared DOM binding. | | `enableSnapping` (Angular) | `boolean` | `true` | Enables the canvas's snapping layer. | | `enableKeyboardNavigation` (Angular) | `boolean` | `true` | Enables focus navigation, nudging, keyboard connection and announcements. | | `readonly` (JavaScript/React/Vue/Qwik) | `boolean` | `false` in the DOM binder | Prevents editing gestures while retaining viewing interaction. | For grid snapping, set `waypointEditor.snapToGrid` and `waypointEditor.gridSize` in the interaction policy. The shipped snapping layer reads the same grid settings as the waypoint editor. Preserve the other editor fields when changing this nested object: `setInteractionConfig()` merges only the top level. Use `getInteractionConfig().waypointEditor` as the starting point. Angular additionally exposes `canvasBounds` for keeping dragged boxes inside a rectangle. ## Keyboard, touch and read-only operation In the React and Vue examples, click a node, then press an arrow to nudge it by one world unit; Shift increases the step to ten. The policy explicitly enables nudging: it is not enabled by the engine default. Ctrl/⌘+A selects all; Ctrl/⌘+C copies, Ctrl/⌘+V pastes, and Ctrl/⌘+D duplicates selected nodes. Ctrl/⌘+Z undoes, and Ctrl/⌘+Shift+Z or Ctrl/⌘+Y redoes. Text-entry targets keep their keys. Angular's enabled keyboard navigation also draws a focus ring. Tab and Shift+Tab walk nodes and links; arrows nudge a selection or move focus spatially when nothing is selected. On a focused node, C starts keyboard connection, arrows choose a port, Tab chooses a target and Enter commits. Escape cancels that keyboard connection. Focus is distinct from selection. Touch uses the shipped gesture handling: one finger taps to select, drags a node to move it, drags from a port to connect, or drags empty canvas to pan. Two fingers pan and pinch to zoom. A 500 ms long press emits a context-menu event; it does not create your menu UI. The binding sets `touch-action: none`; do not override it with native browser scrolling on the canvas. Touch uses its own 10 px movement tolerance, rather than the mouse's `dragThreshold`. Lock / unlock toggles the live document's `setReadonly()` state. Selection and viewing remain available, but document writes are refused. For a canvas that starts view-only, pass `readonly: true` to `render()` or `readonly` to the React, Vue or Qwik component. Angular has no `readonly` input on this canvas: set the mounted document lock through its engine, as the shared action does. Keep viewing controls separate with `enablePan` and the surface's zoom switch (`enableZoom`, or Angular's `enableMouseWheelZoom`). Use Delete or Backspace to remove the selection through the canvas's keyboard handler. [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history) explains the undo stack those edits join. > **Known issue:** `engine.deleteSelection()` checks the store's selection sets, whereas a shared DOM-binding mouse selection lives on the diagram. The intended call can throw “No entities selected” after a visible selection. Until the helper synchronizes those sources, use the canvas's Delete/Backspace handler, which reads the diagram selection and executes removal commands. Angular's keyboard handler synchronizes selection before calling the helper. ## Live demos and related tasks - [Reconnect an edge](https://grafloria.com/demos/edges/reconnect-edge.html): select the wire and move its target from B to C. - [Edit waypoints](https://grafloria.com/demos/edges/editable-edge.html): insert and drag bends. - [Keyboard and screen-reader operation](https://grafloria.com/demos/interaction/keyboard-a11y.html): explore focus, connections and announcements. - [Validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections): control which targets accept a wire. - [Add editor controls](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/add-editor-controls): add shipped toolbars and canvas controls. - [State and event flow](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/state-and-event-flow): mirror edits outside the canvas. # Add editor controls Use editor controls when readers need to navigate a diagram and act on its nodes and edges. The examples below add a dotted background, a live minimap, zoom and fit buttons, node actions, a right-click menu, and an edge-delete button to a two-node diagram. ## 1. Install the packages Run the command for your framework in your own project. The shared action code uses the shipped Angular action presets even when the canvas uses another binding, except in the Qwik workaround below. JavaScript: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Angular: ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer @grafloria/element rxjs ``` Qwik: ```bash npm install @grafloria/qwik @builder.io/qwik @grafloria/element @grafloria/renderer @grafloria/engine ``` React: ```bash npm install @grafloria/react react react-dom @grafloria/element @grafloria/renderer @grafloria/engine @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Vue: ```bash npm install @grafloria/vue vue @grafloria/element @grafloria/renderer @grafloria/engine @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` ## 2. Share the data and action wiring Specs describe the boxes and wire; the mounted instance owns their live models. Type the data with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec). The node strip uses Duplicate and Delete from [`createStandardPreset`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-functions#createstandardpreset). The right-click menu takes those same two actions from [`createContextMenuPreset`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-functions#createcontextmenupreset). Choosing only these actions keeps this example focused on copying and removing boxes rather than supplying an editing dialog. The edge button uses the Delete action from [`createDefaultLinkActions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-functions#createdefaultlinkactions). Angular also offers its built-in path-anchored toolbar through `enableLinkToolbar`; that toolbar includes Insert node and Delete by default. Inserting a node splits the link as one undoable step. > **Known issue:** The shipped node Delete action removes the node directly from the model, bypassing undo history. Until it is fixed, replace that action's `onClick` with `engine.removeNode(node.id)` as below. The helper accepts the library's [`CanvasPluginHost`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-components#canvaspluginhost) surface. Pass the live [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) in JavaScript, React and Vue; the Angular example supplies its canvas's active engine and camera. It returns a cleanup function for unmount. Qwik uses the separate component below. ```ts title="editor.ts" import { createStandardPreset, createContextMenuPreset, createDefaultLinkActions, } from '@grafloria/angular'; import type { CanvasPluginHost, NodeSpec, EdgeSpec } from '@grafloria/renderer'; export const nodes: NodeSpec[] = [ { id: 'a', label: 'Draft', data: { label: 'Draft' }, selected: true, position: { x: 100, y: 130 }, size: { width: 150, height: 70 } }, { id: 'b', label: 'Publish', data: { label: 'Publish' }, position: { x: 410, y: 130 }, size: { width: 150, height: 70 } }, ]; export const edges: EdgeSpec[] = [ { id: 'e', source: 'a', target: 'b', type: 'smooth' }, ]; export function installEditor(host: CanvasPluginHost, repaint: () => void): () => void { const engine = host.getEngine(); const model = host.getModel(); const container = host.container; const bar = document.createElement('div'); const menu = document.createElement('div'); const edgeBar = document.createElement('div'); const coordinates = document.createElement('output'); for (const element of [bar, menu, edgeBar]) { element.style.cssText = 'position:absolute;z-index:10;display:none;gap:6px;' + 'padding:6px;background:#fff;border:1px solid #64748b;border-radius:6px'; container.appendChild(element); } coordinates.style.cssText = 'position:absolute;top:8px;right:8px;z-index:10;' + 'background:white;padding:4px;font:12px monospace'; coordinates.textContent = 'Move the pointer to read world coordinates'; container.appendChild(coordinates); const standard = createStandardPreset(engine); const context = createContextMenuPreset(engine); const nodeActions = (standard.actionGroups ?? []).flatMap(group => group.actions) .filter(action => action.id === 'duplicate' || action.id === 'delete'); const menuActions = (context.actionGroups ?? []).flatMap(group => group.actions) .filter(action => action.id === 'duplicate' || action.id === 'delete'); // Intended shipped call: action.onClick(node). // Replace only the defective node-delete implementation. for (const action of [...nodeActions, ...menuActions]) { if (action.id === 'delete') { action.onClick = node => { void engine.removeNode(node.id).then(repaint); }; } } let selectedId: string | undefined; let menuId: string | undefined; function addButton(parent: HTMLElement, label: string, run: () => void) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('pointerdown', event => event.stopPropagation()); button.addEventListener('click', event => { event.stopPropagation(); run(); }); parent.appendChild(button); } for (const action of nodeActions) { addButton(bar, action.label, () => { const node = selectedId ? model.getNode(selectedId) : undefined; if (node) action.onClick(node); repaint(); }); } for (const action of menuActions) { addButton(menu, action.label, () => { const node = menuId ? model.getNode(menuId) : undefined; if (node) action.onClick(node); menu.style.display = 'none'; repaint(); }); } const deleteLink = createDefaultLinkActions(engine) .find(action => action.id === 'delete-link'); if (deleteLink) { addButton(edgeBar, deleteLink.label, () => { const link = model.getLink('e'); if (!link) return; const point = link.points[Math.floor(link.points.length / 2)]; if (point) deleteLink.onClick({ link, engine, t: 0.5, point }); }); } function openMenu(event: MouseEvent) { if (!(event.target instanceof Element)) return; const id = event.target.closest('[data-node-id]')?.getAttribute('data-node-id'); if (!id || !model.getNode(id)) return; event.preventDefault(); menuId = id; const rect = container.getBoundingClientRect(); menu.style.left = `${event.clientX - rect.left}px`; menu.style.top = `${event.clientY - rect.top}px`; menu.style.display = 'flex'; } function dismiss(event: PointerEvent) { if (!(event.target instanceof Node) || !menu.contains(event.target)) { menu.style.display = 'none'; } } function escape(event: KeyboardEvent) { if (event.key === 'Escape') menu.style.display = 'none'; } function readPoint(event: PointerEvent) { const world = host.viewport.clientToWorld( event.clientX, event.clientY, container.getBoundingClientRect(), ); coordinates.textContent = `world: ${world.x.toFixed(1)}, ${world.y.toFixed(1)}`; } container.addEventListener('contextmenu', openMenu); container.addEventListener('pointermove', readPoint); document.addEventListener('pointerdown', dismiss); document.addEventListener('keydown', escape); let frame = 0; function positionOverlays() { const rect = container.getBoundingClientRect(); const selected = model.getNodes().filter(node => node.isSelected()); const node = selected.length === 1 ? selected[0] : undefined; selectedId = node?.id; bar.style.display = node ? 'flex' : 'none'; if (node) { const point = host.viewport.worldToClient( node.position.x + node.size.width / 2, node.position.y, rect, ); bar.style.left = `${point.x - rect.left}px`; bar.style.top = `${point.y - rect.top}px`; bar.style.transform = 'translate(-50%, calc(-100% - 8px))'; } const link = model.getLink('e'); const points = link?.points; const first = points?.[0]; const last = points?.[points.length - 1]; edgeBar.style.display = first && last ? 'flex' : 'none'; if (first && last) { const point = host.viewport.worldToClient( (first.x + last.x) / 2, (first.y + last.y) / 2, rect, ); edgeBar.style.left = `${point.x - rect.left}px`; edgeBar.style.top = `${point.y - rect.top}px`; edgeBar.style.transform = 'translate(-50%, -50%)'; } frame = requestAnimationFrame(positionOverlays); } positionOverlays(); return () => { cancelAnimationFrame(frame); container.removeEventListener('contextmenu', openMenu); container.removeEventListener('pointermove', readPoint); document.removeEventListener('pointerdown', dismiss); document.removeEventListener('keydown', escape); for (const element of [bar, menu, edgeBar, coordinates]) element.remove(); }; } ``` The node strip stays a constant screen size: `worldToClient()` positions it outside the camera-transformed layer. The edge-delete strip sits between this example's two endpoints; it is not an arc-length anchor for a bent route. Use Angular's built-in toolbar for a routed-path anchor, or follow the [edge toolbar demo](https://grafloria.com/demos/edges/edge-toolbar.html). ## 3. Mount the controls in your framework Build on the mounting and instance wiring in [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes) by adding a background, minimap and zoom/fit controls with [`attachCanvasPlugins`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-components#attachcanvasplugins) after [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) in JavaScript, or with `plugins` on [`GrafloriaFlow` for React](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow), [`GrafloriaFlow` for Vue](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow), [`GrafloriaFlow` for Qwik](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow), or [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent). JavaScript, Angular, React and Vue start with Draft selected and show its floating Duplicate/Delete strip. The separate Qwik component starts with Draft selected and displays Duplicate a, Delete a and Delete edge above the canvas; it does not load the Angular presets. Right-click either node to act on that node. Delete on the wire (or Qwik's Delete edge button) removes the wire, not either endpoint. Move the pointer over the canvas to read world coordinates. > **Known issue:** Importing `createStandardPreset`, `createContextMenuPreset` and `createDefaultLinkActions` from `@grafloria/angular` in a Qwik app also loads Angular's decorated canvas class through the package's barrel export, causing a decorator parse error. Until this integration is fixed, use the Qwik component below, which invokes the mounted engine directly and does not import `editor.ts`. Angular's two-way arrays accept specs and live [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel) and [`LinkModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-linkmodel#linkmodel) objects; declare that union so updates from the canvas retain their types. :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import { attachCanvasPlugins } from '@grafloria/renderer'; import { nodes, edges, installEditor } from './editor'; export function mountEditor(container: HTMLElement): () => void { container.style.cssText = 'height:480px;position:relative'; const instance = render({ nodes, edges }, container, { minZoom: 0.25, maxZoom: 2, zoomSensitivity: 0.15, enablePan: true, enableZoom: true, }); const plugins = attachCanvasPlugins(instance, { background: { variant: 'dots' }, minimap: true, controls: true, }); const cleanup = installEditor(instance, () => instance.render()); return () => { cleanup(); plugins.dispose(); instance.dispose(); }; } const container = document.createElement('div'); document.body.appendChild(container); export const unmountEditor = mountEditor(container); // Call unmountEditor() when your host removes this editor. ``` ```ts title="Angular" import { AfterViewInit, Component, ElementRef, OnDestroy, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeModel, LinkModel } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, installEditor } from './editor'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class AppComponent implements AfterViewInit, OnDestroy { nodes: readonly (NodeSpec | NodeModel)[] = nodes; edges: readonly (EdgeSpec | LinkModel)[] = edges; canvas = viewChild.required(DiagramCanvasComponent); host = viewChild.required>('host'); private cleanup?: () => void; ngAfterViewInit() { const canvas = this.canvas(); const engine = canvas.activeEngine(); const viewport = canvas.viewportController(); const model = engine?.getDiagram(); if (!engine || !viewport || !model) return; this.cleanup = installEditor({ container: this.host().nativeElement, viewport, getEngine: () => engine, getModel: () => model, fitView: padding => canvas.fitToContent(padding), }, () => canvas.scheduleRender()); } ngOnDestroy() { this.cleanup?.(); } } ``` ```tsx title="Qwik" import { component$, $, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance, type NodeSpec, type EdgeSpec } from '@grafloria/qwik'; export default component$(() => { const instance = useSignal>(); const target = useSignal('a'); const menu = useSignal(false); const coordinates = useSignal('Move the pointer to read world coordinates'); const nodes: NodeSpec[] = [ { id: 'a', label: 'Draft', selected: true, position: { x: 100, y: 130 }, size: { width: 150, height: 70 } }, { id: 'b', label: 'Publish', position: { x: 410, y: 130 }, size: { width: 150, height: 70 } }, ]; const edges: EdgeSpec[] = [{ id: 'e', source: 'a', target: 'b', type: 'smooth' }]; const duplicate = $(async () => { const api = instance.value; const node = api?.getModel().getNode(target.value); if (!api || !node) return; await api.getEngine().addNode({ type: node.type, data: { label: node.getMetadata('label') }, position: { x: node.position.x + 20, y: node.position.y + 20 }, size: { width: node.size.width, height: node.size.height }, }); menu.value = false; }); const remove = $(async () => { const api = instance.value; if (api?.getModel().getNode(target.value)) { await api.getEngine().removeNode(target.value); } menu.value = false; }); return (
{coordinates.value}
{ if (!(event.target instanceof Element)) return; const id = event.target.closest('[data-node-id]')?.getAttribute('data-node-id'); if (!id) return; event.preventDefault(); target.value = id; menu.value = true; }} onPointerMove$={(event) => { const api = instance.value; if (!api) return; const point = api.viewport.clientToWorld( event.clientX, event.clientY, api.container.getBoundingClientRect(), ); coordinates.value = `world: ${point.x.toFixed(1)}, ${point.y.toFixed(1)}`; }}> { const node = change.nodes[0]; if (node) target.value = node.id; }} onInit$={$((api: DiagramInstance) => { instance.value = noSerialize(api); })} /> {menu.value &&
}
); }); ``` ```tsx title="React" import { useEffect, useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import { nodes, edges, installEditor } from './editor'; export default function App() { const teardown = useRef<(() => void) | null>(null); useEffect(() => () => teardown.current?.(), []); function onInit(instance: DiagramInstance) { teardown.current?.(); teardown.current = installEditor(instance, () => instance.render()); } return (
); } ``` ```vue title="Vue" ``` ::: ![JavaScript: Draft is selected, with Duplicate and Delete above it and Delete on the wire. Zoom and fit controls sit at the lower left; the minimap sits at the lower right.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/b3e25a4a5d19b705ad3552e342a96c03.png) The React sample displays the same two-node editor with a coordinate readout at the top right. ![React: Draft connects to Publish over a dotted background, with node actions, an edge-delete button, zoom and fit controls, and a minimap.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/6bd4b0810b0644f0ded0597ebd8af22e.png) Angular also draws its built-in selection handles and action icons around Draft. ![Angular: Draft has resize handles and selection actions alongside the Duplicate/Delete strip; the wire has a Delete button.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/9a6a15dae869914b8a67193c82452036.png) Vue renders the same floating action strips and canvas furniture as React. Both initially display a prompt to move the pointer rather than numeric coordinates. Qwik's separate component renders its actions above the diagram without importing the shared Angular-based helper. ![Qwik: selected Draft connects to Publish on a white canvas. Duplicate a, Delete a, Delete edge and a pointer prompt appear above the canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/e4e38f964523a5ff6bfe9024448c0684.png) The minimap mirrors the node boxes and shows the camera rectangle. Click or drag inside it to move the camera. Fit view frames the content. Ctrl/⌘+wheel zooms at the pointer; plain wheel scroll pans. ## Options that matter Use [`CanvasPluginOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-components#canvaspluginoptions) to select furniture. `plugins: true` in a binding enables all three; an options object enables only the entries you supply. Calling `attachCanvasPlugins(instance)` with no options enables none. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `background` | `boolean` or background options | Off when omitted | Adds the grid; the store's `gridEnabled` flag controls visibility when bound. | | `minimap` | `boolean` or minimap options | Off when omitted | Adds node rectangles and the camera rectangle. | | `controls` | `boolean` or controls options | Off when omitted | Adds zoom and fit buttons. | | `bindToStore` | `boolean` | `true` | Keeps furniture visibility and the engine store in sync. | | `controls.showLock` | `boolean` | `false` | Adds the lock toggle. | | `controls.zoomStep` | `number` | `1.2` | Multiplies zoom per Zoom in click; Zoom out divides by it. | | `minZoom` | `number` | `0.1` | Lower camera scale limit. | | `maxZoom` | `number` | `3` | Upper camera scale limit. | | `zoomSensitivity` | `number` | `0.1` | Wheel zoom uses a factor of `1 + zoomSensitivity`. | | `enablePan` | `boolean` | `true` | Enables pan gestures. | | `enableZoom` | `boolean` | `true` | Enables wheel zoom in JavaScript, React, Vue and Qwik. Angular calls this input `enableMouseWheelZoom`. | Pan and wheel-zoom switches govern gestures, not imperative camera calls or the plugin buttons. To remove zoom buttons, pass `controls: { showZoom: false }`; to remove all furniture, disable the binding's `plugins` prop. ## Coordinate conversion and palettes Use the mounted camera, not a new [`ViewportController`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-core#viewportcontroller), for conversions. `clientToWorld(event.clientX, event.clientY, rect)` returns a world point, as the sample's coordinate readout demonstrates. `worldToClient(x, y, rect)` returns client coordinates; subtract `rect.left` and `rect.top` to position an absolute overlay inside the canvas. The camera's `x` and `y` are world coordinates, but its `width` and `height` are CSS-pixel dimensions. `getViewBox()` returns the visible world rectangle after zoom. Do not divide a camera rectangle by zoom before handing it to a renderer: the renderer applies zoom itself. For a shape palette, use the shipped searchable stencil palette described in [Build a stencil editor](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/build-a-stencil-editor). It provides categorized masters and drag-to-place rather than a second set of node templates you maintain yourself. A palette drop needs the same client-to-world conversion shown here. ## Pitfalls and related tasks - Keep a node toolbar in screen space if its buttons must retain their size. A [`createViewportPortal`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions#createviewportportal) lives in the transformed HTML layer: it pans **and scales** with the scene. - Keep the node menu's target separate from selection. The example's right-click handler reads the node id under the pointer, so right-clicking Publish does not delete a previously selected Draft. - Keep teardown in the framework's unmount hook. The bindings own canvas disposal; dispose only the overlays you add yourself. - For app-owned specs, follow the change-event return path in [React state and subscriptions](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/react-state-and-subscriptions), [Vue state and composables](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/vue-state-and-composables), or [Angular state and tooling](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/angular-state-and-tooling). - For more toolbar edits on the shared history stack, see [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). Try the live [minimap and controls](https://grafloria.com/demos/misc/minimap-and-controls.html), [node toolbar](https://grafloria.com/demos/nodes/node-toolbar.html), [edge toolbar](https://grafloria.com/demos/edges/edge-toolbar.html), and [context menu](https://grafloria.com/demos/interaction/context-menu.html) demos. The [minimap demo source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/misc/minimap-and-controls.html) shows the same one-call furniture attachment. # Lay out a diagram Use a shipped layout when you want a graph arranged from its connections rather than hand-authored coordinates. Start with a left-to-right pipeline, rerun it from a button, then insert a node with an incremental pass that preserves positions outside the affected neighborhood. Specs describe the graph; the engine owns its geometry. The framework component's `layout` prop selects the initial arrangement. For subsequent work, get the [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#diagramengine) through the mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) and await `layout()`. ## 1. Choose an algorithm You do not need to register adapters before using these names. | Graph | Layout name | What you get | | --- | --- | --- | | Flowcharts, pipelines, DAGs | `elk`, `layered`, `dagre` | Layered ranking; ELK also handles ports and nesting. | | System diagrams with zones | `architecture` | Regions on a grid, boxes sized to their words, and bends in the gutters. | | Hierarchies, org charts | `tree` | A tidy, parent-centered hierarchy. | | Networks, clusters | `force`, `community`, `spectral` | Physical spread or grouping by related nodes. | | Catalogs, galleries | `grid`, `circular`, `radial` | Uniform placement. | | No predetermined choice | `auto` | Algorithm selection based on the graph. | An unknown name throws an error listing the registered layouts. Calling `layout()` without a name uses `auto`. Install the packages for your framework in your own browser application. JavaScript: ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer ``` Angular: ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element ``` Qwik: ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element ``` React: ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer react react-dom @grafloria/element ``` Vue: ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer vue @grafloria/element ``` ## 2. Define a pipeline and its insertion operation Save this shared file beside the framework sample you choose below. The typed [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec) arrays describe six connected boxes, initially stacked at the origin. The `layered` layout separates them left to right. [`UnifiedLayoutOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-unifiedlayoutoptions#unifiedlayoutoptions) types the shared request's options. `insertNode()` adds a labeled box and two connections to the live graph. Its incremental pass allows the new box and its immediate neighbors to move, while anchoring the rest. It returns the layout result, including a movement report and a tween plan; the samples repaint the committed positions rather than animate the plan. This page requires the next release of `@grafloria/engine`: `direction: 'LR'` is unreleased and is not available in version 0.4.0. Use the samples after that release is available. ```ts title="layout-demo.ts" import type { DiagramEngine, UnifiedLayoutOptions } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; export const nodes: NodeSpec[] = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({ id, label: id, position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, })); export const edges: 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' }, ]; const options: UnifiedLayoutOptions = { direction: 'LR', nodeSpacing: 40, rankSpacing: 80 }; export const layout = { name: 'layered', options, }; export async function insertNode(engine: DiagramEngine) { const model = engine.getDiagram(); const source = model?.getNode('n2'); const target = model?.getNode('n4'); const sourcePort = source?.getPorts().find((port) => port.alignment.side === 'right'); const targetPort = target?.getPorts().find((port) => port.alignment.side === 'left'); if (!sourcePort || !targetPort) throw new Error('The pipeline is not mounted'); const inserted = await engine.addNode({ type: 'rect', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, }); inserted.setLabel('Inserted'); const input = inserted.getPorts().find((port) => port.alignment.side === 'left'); const output = inserted.getPorts().find((port) => port.alignment.side === 'right'); if (!input || !output) throw new Error('The new node has no side ports'); await engine.addLink({ sourcePortId: sourcePort.id, targetPortId: input.id }); await engine.addLink({ sourcePortId: output.id, targetPortId: targetPort.id }); return engine.layoutIncremental({ changed: [inserted.id], direction: 'LR', radius: 1 }); } ``` The new [`NodeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel#nodemodel) comes from `addNode()`; its default side ports supply the endpoints for `addLink()`. The helper never replaces the existing nodes with their original coordinates. Each inserted node gets the engine's generated id. ## 3. Mount, rerun, and insert Build on the framework mounting patterns in [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes): here, the layout request arranges the pipeline, and the buttons rerun it or insert a node with an incremental pass. On load you see `n0` through `n5` arranged left to right. Drag a box, then choose **Rerun layout** to arrange the whole graph again. Choose **Insert node** to add a branch through **Inserted** from `n2` to `n4`; the incremental pass leaves nodes outside that one-hop region in place. The readout reports the total distance traveled by pre-existing nodes, in pixels. :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import { nodes, edges, layout, insertNode } from './layout-demo'; export async function mountPipeline(container: HTMLElement): Promise<() => void> { container.innerHTML = ` Preparing layout
`; const canvas = container.querySelector('[data-canvas]')!; const rerun = container.querySelector('[data-rerun]')!; const insert = container.querySelector('[data-insert]')!; const report = container.querySelector('[data-report]')!; const instance = render({ nodes, edges }, canvas); async function run(add: boolean) { rerun.disabled = insert.disabled = true; try { if (add) { const result = await insertNode(instance.getEngine()); report.textContent = `Existing nodes moved ${result.movement.total.toFixed(1)} px`; } else { const result = await instance.getEngine().layout(layout.name, layout.options); report.textContent = result.algorithm; } instance.fitView(40); } finally { rerun.disabled = insert.disabled = false; } } rerun.onclick = () => { void run(false); }; insert.onclick = () => { void run(true); }; await run(false); return () => { instance.dispose(); container.replaceChildren(); }; } const container = document.createElement('section'); document.body.appendChild(container); void mountPipeline(container); ``` ```ts title="Angular" import { ChangeDetectorRef, Component, inject, signal, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, layout, insertNode } from './layout-demo'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: ` {{ report() }} `, }) export class AppComponent { private readonly cdr = inject(ChangeDetectorRef); canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = nodes; edges: EdgeSpec[] = edges; layout = layout; busy = signal(false); ready = signal(false); report = signal('Preparing layout'); onLayoutDone() { this.canvas().fitToContent(40); this.ready.set(true); this.report.set('layered'); } async run(add: boolean) { const canvas = this.canvas(); const engine = canvas.activeEngine(); if (!engine) return; this.busy.set(true); try { if (add) { const result = await insertNode(engine); this.report.set(`Existing nodes moved ${result.movement.total.toFixed(1)} px`); } else { await canvas.applyLayout(); } canvas.scheduleRender(); canvas.fitToContent(40); } finally { this.busy.set(false); this.cdr.detectChanges(); } } } ``` ```tsx title="Qwik" import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import { nodes, edges, layout, insertNode } from './layout-demo'; export default component$(() => { const instance = useSignal>(); const ready = useSignal(false); const busy = useSignal(false); const report = useSignal('Preparing layout'); return (
{report.value}
{ instance.value = noSerialize(api); }} onLayoutDone$={() => { instance.value?.renderNow(); instance.value?.fitView(40); ready.value = true; report.value = 'layered'; }} />
); }); ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import { nodes, edges, layout, insertNode } from './layout-demo'; export default function Pipeline() { const instance = useRef(null); const [ready, setReady] = useState(false); const [busy, setBusy] = useState(false); const [report, setReport] = useState('Preparing layout'); async function run(add: boolean) { const api = instance.current; if (!api) return; setBusy(true); try { if (add) { const result = await insertNode(api.getEngine()); setReport(`Existing nodes moved ${result.movement.total.toFixed(1)} px`); } else { const result = await api.getEngine().layout(layout.name, layout.options); setReport(result.algorithm); } api.fitView(40); } finally { setBusy(false); } } return (
{report}
{ instance.current = api; }} onLayoutDone={() => { instance.current?.fitView(40); setReady(true); setReport('layered'); }} />
); } ``` ```vue title="Vue" ``` ::: The JavaScript sample shows the six-node chain and its two layout buttons. ![JavaScript: n0 through n5 run left to right below Rerun layout and Insert node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/1f24bad8a50be77f63e6d3da6d6d95e7.png) Angular renders the same chain through its canvas component. Its Rerun layout and Insert node buttons sit above the six connected boxes and the layered readout. Qwik renders the initial pipeline before any insertion. React starts with the same layered arrangement. Vue also starts with all six boxes in a single row. The JavaScript mount function returns a cleanup function: call it when your application removes this view. Framework bindings own their canvas teardown. ### Why layout does not follow every data change Changing node data does not rerun the `layout` prop. That is deliberate: a drag can round-trip through your state without an automatic layout undoing the user's placement. Change the layout request to select another algorithm; to rerun the same request, await `instance.getEngine().layout(layout.name, layout.options)`. The canvas repaints the changed positions. In Angular, `applyLayout()` without an argument reruns the bound request and resolves with its result; it returns `undefined` when there is no engine or request. ### Preserve the mental map Start with `layered` when you intend to use incremental layout. `layoutIncremental()` defaults to that engine because it honors anchors during coordinate assignment. An initial layout from a different engine can require a substantial rearrangement on the first incremental pass. The result's `movement` measures pre-existing nodes, excluding ids in `changed`. Use `total`, `average`, `max`, and `withinBudget` to judge disruption in your own graph. A budget is measured and reported; do not treat `withinBudget` as a guarantee that the engine refuses an over-budget result. The returned `tween` is a plan for a host-driven animation, not an animation that runs automatically. ## 4. Compose architecture zones Use `architecture` when the drawing is a composition of regions rather than a graph ranking. Declare zone membership through [`GroupSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#groupspec): `children` contains node ids, and `direction: 'LR'` lays the zone's boxes in a row. Without explicit bounds, a zone fits its children. Relations such as `sourceHandle: 'top'` can place a connected region above another, and a node's `near` relation places a note beside its subject. This complete JavaScript sample draws a user outside a services zone, with **Auth** and **Billing** inside it. It uses the same shipped composition that the framework `layout="architecture"` prop selects. ```ts title="architecture.ts" import { render } from '@grafloria/element'; import type { NodeSpec, EdgeSpec, GroupSpec } from '@grafloria/renderer'; export function mountArchitecture(container: HTMLElement): () => void { container.style.height = '400px'; const nodes: NodeSpec[] = [ { id: 'user', label: 'User' }, { id: 'auth', label: 'Auth' }, { id: 'billing', label: 'Billing' }, ]; const edges: EdgeSpec[] = [ { source: 'user', target: 'auth' }, { source: 'auth', target: 'billing' }, ]; const groups: GroupSpec[] = [ { id: 'services', label: 'SERVICES', children: ['auth', 'billing'], direction: 'LR' }, ]; const instance = render({ nodes, edges, groups, layout: 'architecture' }, container); instance.fitView(40); return () => instance.dispose(); } const container = document.createElement('section'); document.body.appendChild(container); mountArchitecture(container); ``` ![User sits outside the SERVICES zone; Auth and Billing sit inside, connected left to right.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/dfeb8df92e717a828cd98951ca073334.png) For React, Vue, or Qwik, pass these typed arrays as `defaultNodes`, `defaultEdges`, and `defaultGroups`, and select `layout="architecture"` on the flow component. For Angular, zones belong to the canvas's active engine: await `addGroup({ name: 'SERVICES' })`, then await `addToGroup(group.id, nodeId)` for each member before calling `applyLayout('architecture')`. These are memberships, not decorative rectangles; see [Group and nest nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/group-and-nest-nodes). ## Options that matter Pass common graph-layout options in the request's `options` object or as the second argument to `layout()`. `UnifiedLayoutOptions` normalizes adapter vocabulary so you use `direction`, not adapter-specific direction keys. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `direction` | `'LR' \| 'RL' \| 'TB' \| 'BT'` | Algorithm-dependent | Sets the primary flow direction. | | `nodeSpacing` | `number` | Algorithm-dependent | Sets the gap between nodes in a rank or row. | | `rankSpacing` | `number` | Algorithm-dependent | Sets the gap between ranks or layers. | | `seed` | `number` | `0x5eed` | Makes randomized layouts reproducible. | | `nested` | `boolean` | Enabled when groups exist | Arranges grouped content recursively; `architecture` composes its own containers. | | `removeOverlaps` | `boolean` | `true` | Separates boxes left overlapping by an algorithm. | | `columns` | `number` | `ceil(sqrt(n))` | Sets the number of columns for `grid`. | For incremental passes, use [`IncrementalOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-incremental#incrementaloptions), not the separate adapter-level incremental options interface. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `changed` | `string[]` | `[]` | Identifies newly added or edited nodes. | | `strategy` | `'region' \| 'pin-existing' \| 'minimal-shift'` | `'region'` | Allows neighborhood movement, anchors all unchanged nodes, or allows free movement with realignment. | | `radius` | `number` | `1` | Expands the changed region by graph hops. | | `budget` | `{ maxPerNode?: number; averagePerNode?: number }` | No budget limits | Sets thresholds for the returned movement report. | ## Live demos and related guides - [Auto layout](https://grafloria.com/demos/layout/auto-layout.html): switch among algorithms on one graph. [Source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/layout/auto-layout.html). - [Layout portfolio](https://grafloria.com/demos/layout/layout-portfolio.html): compare tree, radial, circular, grid, and force arrangements. - [Dynamic layouting](https://grafloria.com/demos/layout/dynamic-layouting.html): compare incremental movement with a full relayout. - [Architecture layout](https://grafloria.com/demos/diagrams/architecture-layout.html): compare architecture composition with layered ranking on the same text. - [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) covers canvas sizing and presentation. - [Import diagram text and files](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/import-diagram-text-and-files) covers architecture composition from Mermaid. - [Extend layout execution](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/extend-layout-execution) covers customizing how layouts run. # Group and nest nodes Use groups when a frame needs real membership, not merely a rectangle behind some nodes. This example renders styled Billing and Archive zones, a nested Team container around two selected nodes, and a Delivery pool with three swimlanes. Its toolbar groups the current selection, fits and collapses the latest container, and adds lanes. ## 1. Define the data and group actions Put this shared browser-side code in `groups.ts`. Describe nodes with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec), links with [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec), and zones with [`GroupSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#groupspec). A zone's `children` become members; without `bounds`, its frame fits those members with padding. A custom `style` replaces the theme's title-band frame with a captioned zone. The mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) supplies the live model and [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine#diagramengine). `addGroup()` returns a [`GroupModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-groupmodel#groupmodel); `addToGroup()` adds each selected node through an undoable command. The shipped [`SwimlaneService`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#swimlaneservice) creates ordinary groups tiled into lane bands—no custom layout is needed. ```ts title="groups.ts" import type { DiagramInstance, EdgeSpec, GroupSpec, NodeSpec } from '@grafloria/renderer'; import { SwimlaneService } from '@grafloria/engine'; export const nodes: NodeSpec[] = [ { id: 'n1', label: 'Review', position: { x: 100, y: 130 }, size: { width: 120, height: 50 }, selected: true }, { id: 'n2', label: 'Approve', position: { x: 270, y: 130 }, size: { width: 120, height: 50 }, selected: true }, { id: 'n3', label: 'Archived', position: { x: 600, y: 130 }, size: { width: 120, height: 50 } }, { id: 'invoice', label: 'Loose invoice', position: { x: 600, y: 310 }, size: { width: 120, height: 50 } }, { id: 'login', label: 'Login page', position: { x: 160, y: 470 }, size: { width: 140, height: 40 } }, { id: 'search', label: 'Search API', position: { x: 160, y: 590 }, size: { width: 140, height: 40 } }, { id: 'pdf', label: 'Export PDF', position: { x: 160, y: 720 }, size: { width: 140, height: 40 } }, ]; export const edges: EdgeSpec[] = [ { id: 'a', source: 'n3', target: 'n1' }, { id: 'b', source: 'n3', target: 'n2' }, { id: 'c', source: 'n1', target: 'n2' }, ]; export const zones: GroupSpec[] = [ { id: 'billing', label: 'Billing', bounds: { x: 40, y: 40, width: 440, height: 250 }, style: { fill: '#eff6ff', stroke: '#2563eb', borderRadius: 12, color: '#1d4ed8' }, labelPlacement: 'top-left', }, { id: 'archive', label: 'Archive', children: ['n3'], padding: 30, style: { fill: '#f0fdf4', stroke: '#16a34a', strokeDasharray: '6 4', color: '#166534' }, }, ]; export async function setupGroups(instance: DiagramInstance) { const engine = instance.getEngine(); const diagram = instance.getModel(); engine.setInteractionConfig({ enableGroupMembershipOnDrop: true, enableGroupDrag: true }); instance.setGroups(zones); let latestId: string | undefined; async function groupSelected() { const ids = diagram.getSelectedNodes().map(node => node.id); if (ids.length === 0) return; const group = await engine.addGroup({ name: 'Team' }); for (const id of ids) await engine.addToGroup(group.id, id); group.fitToContents(diagram); latestId = group.id; instance.renderNow(); } async function nestLatest() { if (!latestId) return; await engine.addToGroup('billing', latestId); engine.getGroup('billing')?.fitToContents(diagram, { deepRecursive: true }); instance.renderNow(); } function fitLatest() { if (!latestId) return; engine.getGroup(latestId)?.fitToContents(diagram, { mode: 'exact', deepRecursive: true }); instance.renderNow(); } async function collapseLatest() { if (!latestId) return; await engine.collapseGroup(latestId, { proxyLabel: info => `${info.count}×` }); instance.renderNow(); } async function expandLatest() { if (!latestId) return; await engine.expandGroup(latestId); instance.renderNow(); } await groupSelected(); await nestLatest(); const lanesService = new SwimlaneService(diagram); const { pool, lanes } = lanesService.createPool({ name: 'Delivery', orientation: 'horizontal', bounds: { x: 40, y: 430, width: 740, height: 360 }, headerSize: 40, lanes: [ { name: 'Backlog', weight: 1 }, { name: 'In progress', weight: 2 }, { name: 'Done', weight: 1 }, ], }); lanes[0]?.addMember('login', diagram); lanes[1]?.addMember('search', diagram); lanes[2]?.addMember('pdf', diagram); function addLane() { lanesService.addLane(pool, { name: 'Follow-up', weight: 1 }); instance.renderNow(); } instance.renderNow(); instance.fitView(30); return { groupSelected, nestLatest, fitLatest, collapseLatest, expandLatest, addLane }; } ``` The initial selection includes Review and Approve, not Archived. Setup groups that pair and embeds Team in Billing. `deepRecursive: true` fits descendant frames first, then fits the parent around their outer frames. Positions remain absolute world coordinates; nesting does not make node positions parent-relative. > **Known issue:** `GroupSpec.children` ignores group IDs, so declaring `children: ['team']` does not embed an existing Team group. Until it is fixed, use `await engine.addToGroup('billing', latestId)` after creating Team, as `nestLatest()` does. ## 2. Mount it in your framework Choose the install command for your existing project's framework. JavaScript: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` React: ```bash npm install @grafloria/react @grafloria/element @grafloria/renderer @grafloria/engine react react-dom ``` Vue: ```bash npm install @grafloria/vue @grafloria/element @grafloria/renderer @grafloria/engine vue ``` Qwik: ```bash npm install @grafloria/qwik @grafloria/element @grafloria/renderer @grafloria/engine @builder.io/qwik ``` Angular: ```bash npm install @grafloria/angular @grafloria/element @grafloria/renderer @grafloria/engine @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` JavaScript mounts with [`render()`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render). React's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow), Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow), and Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow) deliver the instance through their initialization callbacks. Qwik stores the live actions with `noSerialize()`. In Angular, use [`GrafloriaDiagramComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-classes#grafloriadiagramcomponent) for this example's frame-dragging interaction, following the drop-to-contain demo. Each component owns its diagram's teardown. The JavaScript mount returns an unmount function for your host to call when removing the view. Save the chosen component as `App.tsx`, `App.vue`, or `app.component.ts`. Save the JavaScript entry as `main.ts`; it creates its own host and controls. :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import { nodes, edges, setupGroups } from './groups'; export async function mountGroups(parent: HTMLElement) { const view = document.createElement('section'); const toolbar = document.createElement('div'); const canvas = document.createElement('div'); canvas.style.height = '650px'; view.append(toolbar, canvas); parent.append(view); const instance = render({ nodes, edges }, canvas); const actions = await setupGroups(instance); const buttons = [ ['Group selected', actions.groupSelected], ['Nest latest', actions.nestLatest], ['Fit latest', actions.fitLatest], ['Collapse latest', actions.collapseLatest], ['Expand latest', actions.expandLatest], ['Add lane', actions.addLane], ] as const; for (const [label, action] of buttons) { const button = document.createElement('button'); button.textContent = label; button.addEventListener('click', () => { void action(); }); toolbar.append(button); } return () => { instance.dispose(); view.remove(); }; } void mountGroups(document.body); ``` ```tsx title="React" import { useRef } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, setupGroups } from './groups'; export default function App() { const actions = useRef> | null>(null); const live = useRef(null); async function onInit(instance: DiagramInstance) { live.current = instance; const next = await setupGroups(instance); if (live.current === instance) actions.current = next; } return
; } ``` ```vue title="Vue" ``` ```tsx title="Qwik" import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, setupGroups } from './groups'; export default component$(() => { const actions = useSignal>>>(); return
{ actions.value = noSerialize(await setupGroups(instance)); }} />
; }); ``` ```ts title="Angular" import { Component } from '@angular/core'; import { GrafloriaDiagramComponent } from '@grafloria/angular'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges, setupGroups } from './groups'; @Component({ selector: 'app-root', standalone: true, imports: [GrafloriaDiagramComponent], template: `
`, }) export class AppComponent { readonly spec = { nodes, edges }; actions?: Awaited>; async onReady(instance: DiagramInstance) { this.actions = await setupGroups(instance); } } ``` ::: The JavaScript mount shows Team nested in Billing, Archive to the right, and Delivery below the loose invoice. ![JavaScript: the nested Team frame, Archive zone, and three Delivery lanes beneath the six toolbar buttons.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/85903f2e5819ca1d142044f58cf627a5.png) React renders the selected Review and Approve nodes inside Team. ![React: Review and Approve have blue selection outlines inside Team and Billing.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/d79336b530518c10e813164d27103168.png) Vue renders the same container structure and lane tickets. Qwik renders the loose invoice outside both upper zones. Angular's diagram host renders the styled zones and nested frame. ## 3. Use the containers - Ctrl-click nodes or marquee a selection, then press **Group selected**. A fitted Team frame wraps exactly the selected nodes. With no selected nodes, the button does nothing. - Press **Nest latest** to embed that container in Billing. Press **Fit latest** after moving its members to recompute its frame, including nested descendants. - Drag Loose invoice into Billing or Archive and release. It joins the target; drag an empty part of that frame and its members travel with it. Drop the invoice on empty canvas to detach it, or into the other frame to transfer membership. The innermost group wins a drop when frames overlap. - Press **Collapse latest**. Review and Approve hide behind a placeholder for the initial Team; their two incoming crossings from Archived aggregate into a proxy labelled `2×`. Press **Expand latest** to restore the original members and links. Use the engine calls, not `GroupModel.collapse()` alone, for this reversible link transformation. - Drag Search API between Delivery lanes. Lanes constrain tickets to the pool, but allow transfer to sibling lanes. Press **Add lane** to add Follow-up and redistribute the bands. In progress starts with twice the height of either other lane because its weight is `2`. ### Group a selection on the Angular canvas If you use [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent), access its `activeEngine()` after the view initializes. This smaller example groups Review and Approve on the mounted canvas, following the selection-grouping demo. Use the diagram host above for dragging group frames. ```ts title="selection.component.ts" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; @Component({ selector: 'app-selection', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class SelectionComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes: NodeSpec[] = [ { id: 'review', label: 'Review', position: { x: 100, y: 140 }, size: { width: 120, height: 50 }, selected: true }, { id: 'approve', label: 'Approve', position: { x: 300, y: 140 }, size: { width: 120, height: 50 }, selected: true }, { id: 'other', label: 'Leave out', position: { x: 550, y: 280 }, size: { width: 120, height: 50 } }, ]; edges: EdgeSpec[] = [{ source: 'review', target: 'approve' }]; async ngAfterViewInit() { const engine = this.canvas().activeEngine(); const diagram = engine?.getDiagram(); if (!engine || !diagram) return; const ids = diagram.getSelectedNodes().map(node => node.id); const group = await engine.addGroup({ name: 'Team' }); for (const id of ids) await engine.addToGroup(group.id, id); group.fitToContents(diagram); this.canvas().scheduleRender(); } } ``` ## Options that matter | Option | Type | Default | What it does | | --- | --- | --- | --- | | `GroupSpec.bounds` | `{ x: number; y: number; width: number; height: number }` | Not set | Supplies the authored frame rather than initially fitting it. | | `GroupSpec.padding` | `number` | `20` | Adds space around members in a fitted zone. Caption room can add extra space. | | `GroupSpec.labelPlacement` | `'top-left' \| 'top' \| 'top-right' \| 'bottom-left' \| 'bottom' \| 'bottom-right'` | `'top-left'` | Positions the zone caption. | | `enableGroupMembershipOnDrop` | `boolean` | `true` | Enables drag-end membership changes. | | `enableGroupDrag` | `boolean` | `true` | Enables moving frames with their members. | | `GroupModel.fitMode` | `'exact' \| 'grow-only' \| 'shrink-only'` | `'exact'` | Controls whether fitting can grow, shrink, or do both. | | `GroupModel.constrainChildren` | `boolean` | `false` | Keeps direct member nodes inside the inner extent; swimlanes set it to `true`. | | [`LaneSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#lanespec).`weight` | `number` | `1` | Shares remaining cross-axis space proportionally. | | `LaneSpec.fixedSize` | `number` | Not set | Pins a lane's cross-axis size instead of using its weight. | | [`CollapseOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#collapseoptions).`proxyLabel` | `(info: ProxyLabelInfo) => string` | Count for multiple crossings; no synthetic label for one | Labels aggregated crossing links using [`ProxyLabelInfo`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-interaction#proxylabelinfo). | ## Pitfalls `setGroups()` reconciles the entire group set, not a patch. Include every group you want to retain; omitted groups are removed while their nodes remain. After setup, the example uses engine methods rather than resending `zones`, so Team and the swimlane groups stay in the model. An authored frame can grow when a new member lies outside it. Use `constrainChildren` when the frame is an extent that members must stay within. Fit a nonempty group: `fitToContents()` does nothing when it has no positioned members. The grouping toolbar performs several engine commands: creating the group and adding each member are separate history operations. For command composition and undo controls, see [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). For saving membership and collapsed state, use the live document format described in [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents). ## Live demos and related guides Try [Selection grouping](https://grafloria.com/demos/grouping/selection-grouping.html), [Drop to contain](https://grafloria.com/demos/grouping/drop-to-contain.html), [Collapse & expand](https://grafloria.com/demos/grouping/collapse-expand.html), and [Swimlanes](https://grafloria.com/demos/grouping/swimlanes.html). The [swimlane demo source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/grouping/swimlanes.html) also shows lane counts and resizing. - [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram) explains composing layouts and zone directions. - [Configure editing gestures](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/configure-editing-gestures) covers interaction settings beyond membership. - [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) covers canvas styling and container sizing. # Theme a canvas Use a theme for canvas-wide defaults and spec styles for individual nodes and edges. One [`Theme`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-types-interfaces-s-v) object drives node defaults, link colors and selection states; changing it repaints the mounted diagram without replacing your data. The examples below draw four nodes: a theme-default node, an orange named-style node, a theme-bound warning node, and a gradient-filled node with a shadow. The buttons switch palettes or request a host-token bridge. ## 1. Install your binding Run the command for your existing framework project. The JavaScript example runs in the browser through your project's bundler. JavaScript: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` React: ```bash npm install @grafloria/react @grafloria/element @grafloria/renderer @grafloria/engine react react-dom ``` Vue: ```bash npm install @grafloria/vue @grafloria/element @grafloria/renderer @grafloria/engine vue ``` Angular: ```bash npm install @grafloria/angular @grafloria/element @grafloria/renderer @grafloria/engine @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Qwik: ```bash npm install @grafloria/qwik @grafloria/element @grafloria/renderer @grafloria/engine @builder.io/qwik ``` ## 2. Describe the paint once Create `canvas-theme.ts` beside your component. Type the data with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec); the bindings turn these specs into live models. Start with the shipped [`LIGHT_THEME`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-constants), [`DARK_THEME`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-constants), [`HIGH_CONTRAST_LIGHT_THEME`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-constants) and [`HIGH_CONTRAST_DARK_THEME`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-constants). Use [`themeRef`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-functions) when a property expresses a meaning rather than a fixed color: `category.warning` reads the active theme's warning palette, and `numbers.emphasis` reads its numeric scale. Register reusable paint with [`defineStyles`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-functions). This is the named-style extension point; the mounted canvas consumes the definitions through `style.styleClass`. The initialization helper receives a mounted [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance). The shipped [`muiBridge`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-functions) supplies the host-token map used by the buttons. ```ts title="canvas-theme.ts" import { LIGHT_THEME, DARK_THEME, HIGH_CONTRAST_LIGHT_THEME, HIGH_CONTRAST_DARK_THEME, defineStyles, themeRef, muiBridge, type Theme, type NodeSpec, type EdgeSpec, type DiagramInstance, } from '@grafloria/renderer'; export const palettes: Record = { light: LIGHT_THEME, dark: DARK_THEME, contrastLight: HIGH_CONTRAST_LIGHT_THEME, contrastDark: HIGH_CONTRAST_DARK_THEME, }; export function registerPaint(): void { defineStyles({ 'orders-warning': { fill: '#fed7aa', stroke: '#9a3412', strokeWidth: 2 }, 'orders-bold': { strokeWidth: 5 }, }); } export const nodes: NodeSpec[] = [ { id: 'plain', label: 'Theme default', position: { x: 50, y: 70 }, size: { width: 180, height: 76 } }, { id: 'named', label: 'Named warning', position: { x: 350, y: 70 }, size: { width: 180, height: 76 }, style: { styleClass: 'orders-warning orders-bold' } }, { id: 'bound', label: 'Theme-bound warning', position: { x: 50, y: 230 }, size: { width: 180, height: 76 }, style: { fill: themeRef('category.warning'), stroke: themeRef('category.warning'), strokeWidth: themeRef('numbers.emphasis'), } }, { id: 'gradient', label: 'Gradient + shadow', position: { x: 350, y: 230 }, size: { width: 180, height: 76 }, style: { fill: { type: 'linear', x1: 0, y1: 0, x2: 1, y2: 0, stops: [ { offset: 0, color: '#ddd6fe' }, { offset: 1, color: '#fbcfe8' }, ], }, stroke: '#7c3aed', strokeWidth: 2, shadow: { offsetX: 4, offsetY: 6, blur: 8, color: 'rgba(0,0,0,0.3)' }, } }, ]; export const edges: EdgeSpec[] = [ { id: 'named-edge', source: 'plain', target: 'named', style: { styleClass: 'orders-warning', strokeDasharray: '6 3' } }, { id: 'bound-edge', source: 'bound', target: 'gradient', style: { stroke: themeRef('category.warning'), strokeWidth: themeRef('numbers.emphasis'), } }, ]; export function prepareCanvas(instance: DiagramInstance): void { registerPaint(); instance.renderNow(); } export const hostBridge = muiBridge(); export function useHostTokens( instance: DiagramInstance | null | undefined, enabled: boolean, ): void { if (!instance) return; instance.setTokenBridge(enabled ? hostBridge : null); instance.renderNow(); const node = instance.container.querySelector('[data-node-id="plain"] rect.diagram-node'); if (!node) return; const paint = getComputedStyle(node); let readout = instance.container.querySelector('output'); if (!readout) { readout = document.createElement('output'); readout.style.cssText = 'position:absolute; bottom:0; left:0; background:white; color:black'; instance.container.append(readout); } readout.textContent = `Resolved default paint: fill ${paint.fill}; stroke ${paint.stroke}`; } export const hostCSS = ` .orders-host { --mui-palette-background-paper: #fffbf2; --mui-palette-divider: #b8860b; --mui-palette-text-primary: #1c1b1f; --mui-palette-primary-main: #6750a4; } `; ``` The named node gets the orange fill and a five-unit border: later names in the space-separated `styleClass` list win conflicts. An element's own `fill`, `stroke` or `strokeWidth` overrides its named styles; interaction state sits above both. The cascade is **theme → type-default → named-class → element-inline → state**. Gradient objects produce SVG paint servers, and shadow objects produce drop-shadow filters. Use a linear gradient's normalized endpoints and stops as above, or a radial gradient with `type: 'radial'`, `cx`, `cy`, `r` and `stops`. Edge `stroke` accepts the same gradient objects; an edge is a line, not a filled box. ## 3. Mount and switch the palette Choose your framework tab. Each sample uses the shared file from step 2 and gives the drawing a resolved height of 400px. **Light**, **Dark**, **Contrast light** and **Contrast dark** change the theme. **Host tokens** requests the MUI bridge; **Theme tokens** requests its removal. Pass `theme` to JavaScript's [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core), or bind it on React's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react), Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue), Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik) or Angular's [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent); see [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes) for mounting and instance access. :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import { nodes, edges, palettes, prepareCanvas, useHostTokens, hostCSS, } from './canvas-theme'; const wrapper = document.createElement('section'); wrapper.className = 'orders-host'; const style = document.createElement('style'); style.textContent = hostCSS; const toolbar = document.createElement('div'); const canvas = document.createElement('div'); canvas.style.height = '400px'; wrapper.append(style, toolbar, canvas); document.body.append(wrapper); const instance = render({ nodes, edges }, canvas, { theme: palettes.light }); prepareCanvas(instance); for (const [key, label] of [ ['light', 'Light'], ['dark', 'Dark'], ['contrastLight', 'Contrast light'], ['contrastDark', 'Contrast dark'], ]) { const button = document.createElement('button'); button.textContent = label; button.onclick = () => instance.setTheme(palettes[key]); toolbar.append(button); } for (const enabled of [true, false]) { const button = document.createElement('button'); button.textContent = enabled ? 'Host tokens' : 'Theme tokens'; button.onclick = () => useHostTokens(instance, enabled); toolbar.append(button); } ``` ```tsx title="React" import { useRef, useState } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance, Theme } from '@grafloria/renderer'; import { nodes, edges, palettes, prepareCanvas, useHostTokens, hostCSS, } from './canvas-theme'; export default function App() { const [theme, setTheme] = useState(palettes.light); const instance = useRef(null); return (
{ instance.current = api; prepareCanvas(api); }} />
); } ``` ```vue title="Vue" ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { Theme, TokenBridge, NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, palettes, registerPaint, hostBridge, hostCSS, } from './canvas-theme'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], styles: [hostCSS], template: `
`, }) export class AppComponent { readonly palettes = palettes; readonly hostBridge = hostBridge; theme: Theme = palettes['light']; bridge: TokenBridge | undefined; nodes: NodeSpec[] = nodes; edges: EdgeSpec[] = edges; constructor() { registerPaint(); } } ``` ```tsx title="Qwik" import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import type { DiagramInstance, Theme } from '@grafloria/renderer'; import { nodes, edges, palettes, prepareCanvas, useHostTokens, hostCSS, } from './canvas-theme'; export default component$(() => { const theme = useSignal(palettes.light); const instance = useSignal>(); return (
`); await writeFile('diagram.svg', svg, 'utf8'); const png = await createSharpBackend(sharp).rasterize({ svg, width, height, mimeType: 'image/png', }); const payload = png.slice(png.indexOf(',') + 1); await writeFile('diagram.png', Buffer.from(payload, 'base64')); } void exportFiles(); ``` ```bash npx tsx server-export.ts ``` Open `diagram.svg` or `diagram.png`: both show Author connected to Review in a 520×300 image. Keep specs, options, fonts and rasterizer versions fixed when you need reproducible raster bytes; deterministic SVG alone does not pin an external encoder's environment. The shipped [`createResvgBackend`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-export-functions-b-s) accepts the `@resvg/resvg-js` module and encodes PNG only; it rejects JPEG and WebP. Sharp supports all three raster formats. In the browser, exports default to [`createDomRasterBackend`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-export-functions-b-s), so you do not need either native package there. See the live [server-side export demo](https://grafloria.com/demos/misc/server-side-export.html). ## Options and fidelity | Option | Type | Default | What it does | | --- | --- | --- | --- | | `scale` | `number` | `1` | Multiplies intrinsic output size without changing the world-space picture. | | `quality` | `number` | `0.92` | Sets JPEG/WebP encoder quality, from 0 to 1. | | `backgroundColor` | `string` | Transparent; JPEG uses white | Paints the export backdrop. | | `padding` | `number` | `20` | Adds world-space margin around content; ignored with explicit `viewport`. | | `scope` | `'content' \| 'viewport' \| 'selection'` | `'content'` | Chooses whole content, an explicit slice, or selected entities. | | `includeIds` | `Iterable` | Not set | Prunes output to the named entities. | | `maxSize` | `number` | `4000` for raster output | Reduces scale to fit the cap, rather than cropping. SVG has no automatic cap. | | `embedModel` | `boolean` | Not set | Carries editable data in SVG/PNG. | | `embedModelCreatedAt` | `string` | Wall-clock timestamp when embedding | Pins the envelope timestamp for repeatable embedded exports. | | `customNodeTimeout` | `number` | `5000` ms | Bounds waiting for tracked async painters; reports unfinished paint. | Prefer async export when custom nodes or external images matter. It waits for tracked painters and fetches external images; `onWarnings` reports fidelity gaps. `exportSvgString()` and `exportPdf()` capture current custom-node content synchronously and do not fetch assets. Angular names the synchronous SVG method `exportSvg()`. If an image server blocks CORS, use `assetFetcher` to route through your own allowlisted proxy, or supply `resolvedAssets` with bytes already encoded as data URLs. Never expose an unrestricted image proxy: arbitrary upstream URLs create an SSRF risk. An unresolved image remains an SVG reference but is missing from PDF, with a warning. Browser file export does not preserve CSS animations. Fonts are not embedded automatically; use `embedFonts` or `embedFontCss` when the SVG must carry its own glyph resources. Inspect warnings before distributing files with custom HTML content. ## Related - [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle) — keep the renderer alive until unmount. - [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) — persist the full document rather than a framework projection. - [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) — size the mounted view and choose the theme its exports use. # Draw and edit ink Use the shipped whiteboard tools to annotate a mounted diagram with vector ink. The example starts with a teal stroke and four toolbar buttons: draw more ink, erase whole strokes, drag out rectangle nodes, or move committed ink. Ink belongs to the live document, not to the framework's node array. Freehand marks are [`StrokeModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-strokemodel#strokemodel) entities; rectangles are nodes. The tools use the canvas's existing pointer and touch gesture pipeline. ## 1. Install the packages Run the common install command in your application, then add your framework binding if you use one. ```bash npm install @grafloria/renderer @grafloria/engine ``` JavaScript: ```bash npm install @grafloria/element ``` Angular: ```bash npm install @grafloria/angular @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs ``` Qwik: ```bash npm install @grafloria/qwik @builder.io/qwik ``` React: ```bash npm install @grafloria/react react react-dom ``` Vue: ```bash npm install @grafloria/vue vue ``` ## 2. Attach the tools and toolbar Create `ink-tools.ts` in your browser application's source directory. The next step imports it from each framework's mounted canvas. [`createDrawTool`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-interaction-functions#createdrawtool), [`createEraserTool`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-interaction-functions#createerasertool), [`createRectangleTool`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-interaction-functions#createrectangletool) and [`createStrokeEditTool`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-interaction-functions#createstrokeedittool) return live tools. Attach each with [`registerTool`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions#registertool), and use `setActive()` to switch modes. Each registration returns a cleanup function. The factories take a [`WhiteboardHost`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-interaction-interfaces-v-w#whiteboardhost): the mounted model, viewport, container and repaint hook, plus the engine for history. A [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) already supplies these members. Angular supplies the same members through its canvas component. This helper seeds real ink, registers the shipped tools, and creates the toolbar. It keeps exactly one tool active, including disabling stroke editing in eraser mode. ```ts title="ink-tools.ts" import { StrokeModel } from '@grafloria/engine'; import { createDrawTool, createEraserTool, createRectangleTool, createStrokeEditTool, registerTool, type WhiteboardHost, } from '@grafloria/renderer'; export function attachInkTools(host: WhiteboardHost, toolbar: HTMLElement): () => void { host.getModel().addStroke(new StrokeModel( [ { x: 80, y: 140 }, { x: 160, y: 110 }, { x: 240, y: 150 }, { x: 320, y: 120 }, ], { color: '#0f766e', width: 4 }, { id: 'ink-seed', label: 'Example annotation' }, )); const draw = createDrawTool(host, { color: '#0f766e', width: 4, simplifyEpsilon: 0.8, }); const eraser = createEraserTool(host, { radius: 10, active: false }); const rectangle = createRectangleTool(host, { fill: '#dbeafe', stroke: '#2563eb', strokeWidth: 2, label: 'Box', active: false, }); const edit = createStrokeEditTool(host, { tolerance: 6, active: false }); const tools = [draw, eraser, rectangle, edit]; const unregister = tools.map(tool => registerTool(tool)); const abort = new AbortController(); const buttons: HTMLButtonElement[] = []; const names = ['Draw', 'Erase', 'Rectangle', 'Edit ink']; const status = document.createElement('output'); status.setAttribute('aria-live', 'polite'); toolbar.style.cssText = 'display:flex;gap:8px;padding:8px;font:14px sans-serif'; function activate(index: number): void { tools.forEach((tool, i) => tool.setActive(i === index)); status.textContent = `Active tool: ${names[index]}`; buttons.forEach((button, i) => { button.setAttribute('aria-pressed', String(i === index)); button.style.background = i === index ? '#0f766e' : '#fff'; button.style.color = i === index ? '#fff' : '#111'; }); } names.forEach((name, index) => { const button = document.createElement('button'); button.type = 'button'; button.textContent = name; button.style.cssText = 'padding:6px 12px;border:1px solid #94a3b8;border-radius:4px'; button.addEventListener('pointerdown', () => activate(index), { signal: abort.signal }); button.addEventListener('click', () => activate(index), { signal: abort.signal }); buttons.push(button); toolbar.append(button); }); toolbar.append(status); activate(0); host.render(); return () => { abort.abort(); for (const off of unregister.reverse()) off(); for (const button of buttons) button.remove(); status.remove(); }; } ``` ## 3. Mount a canvas in your framework Each tab renders the same initial annotation in a 400-pixel-high canvas. Keep `ink-tools.ts` beside the file below. The framework owns the diagram's lifetime; its unmount hook removes the tool registrations. Pass the JavaScript [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) instance to `attachInkTools()`, or adapt Angular's [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) to `WhiteboardHost` as below; see [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes) for canvas setup and typed node/edge bindings. Connect `attachInkTools()` to React's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react), Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue), or Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik) as below; see [Documents and kits](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/documents-and-kits) for instance initialization and Qwik's browser-only setup. :::code-group ```ts title="JavaScript" // main.ts — your HTML contains
. import { render } from '@grafloria/element'; import { attachInkTools } from './ink-tools'; export function mountInkBoard(target: HTMLElement): () => void { const toolbar = document.createElement('div'); const canvas = document.createElement('div'); canvas.style.cssText = 'height:400px;position:relative;touch-action:none'; target.append(toolbar, canvas); const instance = render({ nodes: [], edges: [] }, canvas); const removeTools = attachInkTools(instance, toolbar); return () => { removeTools(); instance.dispose(); toolbar.remove(); canvas.remove(); }; } const target = document.getElementById('app'); if (!target) throw new Error('Missing #app'); export const unmountInkBoard = mountInkBoard(target); ``` ```ts title="Angular" // ink-board.component.ts import { AfterViewInit, Component, ElementRef, OnDestroy, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeInput, EdgeInput, WhiteboardHost } from '@grafloria/renderer'; import { attachInkTools } from './ink-tools'; @Component({ selector: 'app-ink-board', standalone: true, imports: [DiagramCanvasComponent], template: `
`, }) export class InkBoardComponent implements AfterViewInit, OnDestroy { readonly canvas = viewChild.required(DiagramCanvasComponent); readonly host = viewChild.required>('host'); readonly toolbar = viewChild.required>('toolbar'); nodes: NodeInput[] = []; edges: EdgeInput[] = []; private removeTools?: () => void; ngAfterViewInit(): void { const canvas = this.canvas(); const engine = canvas.activeEngine(); const model = engine?.getDiagram(); const viewport = canvas.viewportController(); if (!engine || !model || !viewport) throw new Error('Canvas not initialized'); const host: WhiteboardHost = { getModel: () => model, getEngine: () => engine, viewport, container: this.host().nativeElement, render: () => canvas.scheduleRender(), }; this.removeTools = attachInkTools(host, this.toolbar().nativeElement); } ngOnDestroy(): void { this.removeTools?.(); } } ``` ```tsx title="Qwik" // ink-board.tsx import { $, component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import { attachInkTools } from './ink-tools'; export default component$(() => { const toolbar = useSignal(); const instance = useSignal>(); useVisibleTask$(({ track, cleanup }) => { const mounted = track(() => instance.value); const controls = track(() => toolbar.value); if (!mounted || !controls) return; cleanup(attachInkTools(mounted, controls)); }); return
{ instance.value = noSerialize(mounted); })} />
; }); ``` ```tsx title="React" // ink-board.tsx import { useEffect, useRef } from 'react'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react'; import { attachInkTools } from './ink-tools'; export default function InkBoard() { const toolbar = useRef(null); const removeTools = useRef<(() => void) | undefined>(undefined); useEffect(() => () => { removeTools.current?.(); }, []); function onInit(instance: DiagramInstance): void { if (!toolbar.current) throw new Error('Missing toolbar'); removeTools.current = attachInkTools(instance, toolbar.current); } return
; } ``` ```vue title="Vue" ``` ::: The JavaScript sample starts with Draw selected above a teal annotation. ![Draw, Erase, Rectangle and Edit ink buttons above the teal stroke in JavaScript.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/c0c9b7dad27f2d103f58358a51f637bc.png) Call the JavaScript tab's exported `unmountInkBoard()` when your application removes that view. The framework tabs leave instance disposal to their binding. ### Try the gestures Click a mode button and look for `Active tool: ` beside the buttons, where `` is the selected tool's name. The status text changes in the same handler that activates the tool. 1. With Draw active, press and drag in the canvas, then release. The growing preview becomes one simplified vector stroke in the live model. 2. Click Edit ink, press on the seeded stroke, and drag. A ghost follows the pointer; release moves the whole stroke. Escape cancels the move without changing the document. 3. Click Erase and sweep across ink. Release removes every stroke the sweep crosses, including the entire stroke rather than a pixel-wide gap. Nodes remain untouched. 4. Click Rectangle and drag corner to corner. Release creates a node labelled Box, positioned and sized by the drag. Either drag direction works; a drag smaller than `minSize` in either dimension creates nothing. Drawing, erasing and translating ink each commit one command when the host supplies `getEngine()`. For history controls, see [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). > **Known issue:** The rectangle tool adds its node directly to the model, so rectangle creation does not join the engine's undo stack. Use the shipped tool for drawing boxes; if your application requires undoable creation, add a node through `getEngine().addNode()` from your own creation control, as shown in [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes). ## Options that matter Pass these options to the corresponding factory. Sizes and hit tolerances are in world coordinates, so zoom changes their apparent screen size. | Option | Type | Default | What it does | | --- | --- | --- | --- | | Draw `color` | `string` | `'#1f2933'` | Sets ink and preview color. | | Draw `width` | `number` | `3` | Sets ink width. | | Draw `simplifyEpsilon` | `number` | Model's simplification default | Sets Douglas–Peucker tolerance at commit. | | Draw `label` | `string` | Omitted | Names each committed stroke for accessibility. | | Eraser `radius` | `number` | `8` | Adds a hit radius around the swept path. | | Rectangle `minSize` | `number` | `4` | Rejects drags whose width or height is smaller. | | Rectangle `label` | `string` | Omitted | Labels the created node. | | Edit `tolerance` | `number` | `6` | Adds a hit radius around committed ink. | | Edit `highlightColor` | `string` | `'#2563eb'` | Colors the selected stroke's highlight. | | All tools `active` | `boolean` | `true` | Enables gesture claims; change it later with `setActive()`. | Anonymous ink is hidden from the accessibility tree. A stroke with a `label`, such as the seeded annotation, is exposed as an image with that accessible name. ## Tool ownership and pitfalls - Keep at most one of draw, eraser and rectangle active. They claim every press while active, including presses over nodes. Select Edit ink to let the built-in node and empty-canvas interactions run wherever there is no ink. - If you keep stroke editing active alongside drawing, a press on ink edits instead of drawing over it: the ink-specific tool has higher priority. Disable editing when you want the eraser to consume ink. The example's mode switch does this. - Tool registrations are global, and the shipped tools have fixed ids. Use this example for one active whiteboard at a time, and remove its registrations on unmount rather than leaving tools attached to an old canvas. - Save the live document rather than treating a node array as the ink document. See [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents). ## Live demos and related tasks Try [Freehand draw](https://grafloria.com/demos/whiteboard/freehand-draw.html), [Eraser](https://grafloria.com/demos/whiteboard/eraser.html), [Rectangle tool](https://grafloria.com/demos/whiteboard/rectangle.html), and [Stroke edit](https://grafloria.com/demos/whiteboard/stroke-edit.html). The [stroke-edit demo source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/whiteboard/stroke-edit.html) shows the same `setActive()` toolbar switch. - [Configure editing gestures](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/configure-editing-gestures) covers the built-in interaction modes. - [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas) covers canvas sizing and appearance. # Tune large-graph rendering Use renderer configuration, level of detail (LOD), and batched mutations when a large diagram spends too much time painting. The examples below mount a 900-node mesh, let you compare near and overview zooms, and move every node in one batch. A performance overlay reports measurements from the mounted scene; Angular also exposes its own render-loop metrics. ## 1. Prepare the scene and renderer policy In your existing framework project, install the packages for your framework. JavaScript: ```bash npm install @grafloria/element @grafloria/renderer @grafloria/engine ``` Angular: ```bash npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element ``` Qwik: ```bash npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik ``` React: ```bash npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom ``` Vue: ```bash npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue ``` Create this shared file beside your component or browser entry point. Type the input data with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec). Pass [`SVGRendererConfig`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-types-svgrendererconfig#svgrendererconfig) through the binding's `rendererConfig` prop, or JavaScript's `renderer` option. The renderer ships an adaptive [`QualityGovernor`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-perf#qualitygovernor); enable it through configuration rather than creating a separate governor that the canvas never uses. It measures renderer frame times, lowers detail when the budget is exceeded, and restores detail after sustained headroom. Zoom determines what detail is useful; the governor determines what the machine can afford. The live [`DiagramModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-diagrammodel#diagrammodel) holds the [`LODConfig`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-types-interfaces-b-r#lodconfig). This example keeps the shipped feature sets and raises the medium tier's lower zoom bound from `0.5` to `0.6`. At zooms between `0.5` and `0.6`, the diagram therefore uses sketch detail before any governor bias. ```ts title="scene.ts" import type { DiagramModel, LODConfig } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec, SVGRendererConfig } from '@grafloria/renderer'; export const nodes: NodeSpec[] = []; export const edges: EdgeSpec[] = []; const side = 30; const id = (row: number, col: number) => `n${row * side + col}`; for (let row = 0; row < side; row++) { for (let col = 0; col < side; col++) { nodes.push({ id: id(row, col), label: `${row * side + col}`, position: { x: col * 120, y: row * 80 }, size: { width: 92, height: 46 }, }); if (col + 1 < side) { edges.push({ source: id(row, col), target: id(row, col + 1) }); } if (row + 1 < side) { edges.push({ source: id(row, col), target: id(row + 1, col) }); } } } export const rendererConfig = { enableCaching: true, maxCacheSize: 2000, qualityGovernor: { budgetMs: 16.7 }, } satisfies SVGRendererConfig; export function configureLOD(model: DiagramModel): void { const policy: LODConfig = { tiers: model.getLODConfig().tiers.map(tier => ({ ...tier, minZoom: tier.name === 'medium' ? 0.6 : tier.minZoom, })), }; model.setLODConfig(policy); } ``` ## 2. Measure and batch on the mounted instance For JavaScript, Qwik, React, and Vue, use the [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) received at initialization. `getQualityState()` returns the tier actually painted and, when enabled, the governor's last verdict. Read it after painting, not immediately after changing zoom. The instance's `viewport` is a [`ViewportController`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-core#viewportcontroller); call its `setZoom()` method to change the camera scale. Use the shipped [`PerfHud`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-perf#perfhud) to display a [`PerfSnapshot`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-perf#perfsnapshot). It is an opt-in DOM overlay with `pointer-events: none`; it does not collect measurements for you. This helper times a synchronous repaint and counts node and link elements in the diagram container. It refreshes on initialization and after each button action, not continuously. The HUD's FPS, dirty, and routed counters remain `0` here because this helper does not instrument those counters. `frameMs` measures the explicit repaint, including the synchronous DOM work; it is not an average frame duration. `mountedViews` counts node elements in this SVG-only sample. Use the visible-to-total ratios to inspect culling, and the tier and governor verdict to explain reduced detail. ```ts title="inspection.ts" import { PerfHud, type DiagramInstance, type PerfSnapshot } from '@grafloria/renderer'; import { configureLOD } from './scene'; export function refresh(api: DiagramInstance, hud: PerfHud): void { const start = performance.now(); api.renderNow(); const frameMs = performance.now() - start; const model = api.getModel(); const quality = api.getQualityState(); const visibleNodes = api.container.querySelectorAll('[data-node-id]').length; const snapshot: PerfSnapshot = { fps: 0, frameMs, nodes: model.getNodes().length, links: model.getLinks().length, visibleNodes, visibleLinks: api.container.querySelectorAll('[data-link-id]').length, mountedViews: visibleNodes, dirtyNodes: 0, dirtyLinks: 0, routedLinks: 0, tier: quality.tier, governor: quality.governor, }; hud.update(snapshot); } export function initialize(api: DiagramInstance, host: HTMLElement): PerfHud { configureLOD(api.getModel()); const hud = new PerfHud(host); hud.show(); api.fitView(40); api.viewport.setZoom(0.7); refresh(api, hud); return hud; } export function zoom(api: DiagramInstance, hud: PerfHud, value: number): void { api.viewport.setZoom(value); refresh(api, hud); } export function shift(api: DiagramInstance, hud: PerfHud): void { api.batchUpdate(model => { for (const node of model.getNodes()) { node.setPosition(node.position.x + 20, node.position.y); } }); refresh(api, hud); } ``` `batchUpdate()` runs a synchronous mutator against the live model and coalesces the resulting repaint. The extra `renderNow()` above forces that queued paint to finish before measuring and reading the quality state. For ordinary updates without immediate inspection, use the queued repaint instead of forcing every frame. ## 3. Mount the mesh in your framework Choose one tab. Each sample starts at `0.7×`, shows a slice of the mesh, and leaves all 900 nodes in the model. Click **Overview** to zoom out; click **Move all +20** to shift the entire graph right. Detail can fall below the zoom-derived tier when the governor detects expensive frames. These mounts add renderer configuration, LOD setup, and performance controls to the framework mounting patterns in [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes). :::code-group ```ts title="JavaScript" // main.ts — mount() returns cleanup; call it when your host view unmounts. import { render } from '@grafloria/element'; import { nodes, edges, rendererConfig } from './scene'; import { initialize, zoom, shift } from './inspection'; export function mount(parent: HTMLElement): () => void { const view = document.createElement('section'); view.innerHTML = `
`; parent.appendChild(view); const host = view.querySelector('[data-canvas]')!; const hudHost = view.querySelector('[data-hud]')!; const api = render({ nodes, edges }, host, { renderer: rendererConfig }); const hud = initialize(api, hudHost); view.querySelector('[data-near]')!.onclick = () => zoom(api, hud, 0.7); view.querySelector('[data-overview]')!.onclick = () => zoom(api, hud, 0.15); view.querySelector('[data-move]')!.onclick = () => shift(api, hud); return () => { hud.hide(); api.dispose(); view.remove(); }; } const parent = document.getElementById('app')!; export const unmount = mount(parent); ``` ```ts title="Angular" // app.component.ts import { AfterViewInit, Component, ElementRef, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, rendererConfig, configureLOD } from './scene'; @Component({ selector: 'app-root', standalone: true, imports: [DiagramCanvasComponent], template: `
Click Inspect metrics after a repaint.
`, }) export class AppComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); readonly readout = viewChild.required>('readout'); nodes: NodeSpec[] = nodes; edges: EdgeSpec[] = edges; readonly rendererConfig = rendererConfig; ngAfterViewInit(): void { const model = this.canvas().activeEngine()?.getDiagram(); if (model) configureLOD(model); this.canvas().fitToContent(40); this.setZoom(0.7); } setZoom(value: number): void { this.canvas().viewportController()?.setZoom(value); this.canvas().scheduleRender(); } move(): void { const model = this.canvas().activeEngine()?.getDiagram(); if (!model) return; model.beginBatch(); try { for (const node of model.getNodes()) { node.setPosition(node.position.x + 20, node.position.y); } } finally { model.endBatch(); } this.canvas().scheduleRender(); } inspect(): void { this.readout().nativeElement.textContent = JSON.stringify(this.canvas().getPerformanceMetrics(), null, 2); } } ``` ```tsx title="Qwik" // app.tsx import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow } from '@grafloria/qwik'; import { type DiagramInstance, type PerfHud } from '@grafloria/renderer'; import { nodes, edges, rendererConfig } from './scene'; import { initialize, zoom, shift } from './inspection'; export default component$(() => { const host = useSignal(); const api = useSignal>(); const hud = useSignal>(); useVisibleTask$(({ cleanup }) => { cleanup(() => hud.value?.hide()); }); return
{ api.value = noSerialize(instance); if (host.value) hud.value = noSerialize(initialize(instance, host.value)); }} />
; }); ``` ```tsx title="React" // App.tsx import { useEffect, useRef } from 'react'; import { GrafloriaFlow } from '@grafloria/react'; import type { DiagramInstance, PerfHud } from '@grafloria/renderer'; import { nodes, edges, rendererConfig } from './scene'; import { initialize, zoom, shift } from './inspection'; export default function App() { const host = useRef(null); const api = useRef(null); const hud = useRef(null); useEffect(() => () => { hud.current?.hide(); }, []); function setZoom(value: number) { if (api.current && hud.current) zoom(api.current, hud.current, value); } return
{ api.current = instance; hud.current?.hide(); if (host.current) hud.current = initialize(instance, host.current); }} />
; } ``` ```vue title="Vue" ``` ::: The JavaScript sample opens with a labeled mesh and the performance overlay. ![JavaScript: a slice of the numbered mesh, the performance HUD, and Near, Overview, and Move all +20 buttons.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/48bab42b59a91cc17c456801749ec64d.png) The Angular sample adds an explicit metrics inspection button. ![Angular: numbered nodes and directional links below the controls and the initial metrics prompt.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/7d1a377efc6ed489c1e977f963933560.png) The Qwik sample shows the mesh and its overlay. ![Qwik: a grid of connected boxes beneath three controls, with the performance HUD overlaying the upper-left corner.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/94c0d7857d93c6df01798c72f3d8a0fd.png) The React sample shows the numbered nodes behind the overlay. ![React: numbered boxes connected horizontally and vertically, with the HUD reporting medium detail.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/53f714e8876ca8288c7ddc10239fd5f3.png) The Vue sample opens on the same mesh slice. ![Vue: the labeled mesh, performance overlay, and Near, Overview, and Move all +20 controls.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/de035c34eff0bcfa0fce4d28c819dba4.png) Angular does not expose the renderer-level instance through this component. Batch through the model obtained from `activeEngine()`, then queue the component's repaint. Click **Inspect metrics** after a paint to get `fps`, `frameTime`, `droppedFrames`, and `sampleCount`. Its ring buffers retain up to 60 painted frames; skipped frames do not add samples. `frameTime` is the average duration in that window. The source counts a dropped frame when rendering exceeds **32 ms**, not the governor's `16.7 ms` budget. This return shape differs from the renderer's [`PerformanceMetrics`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-types-interfaces-b-s#performancemetrics). ## Options that matter | Option | Type | Default | What it does | | --- | --- | --- | --- | | `enableCaching` | `boolean` | `true` | Enables VNode caching. | | `maxCacheSize` | `number` | `1000` | Bounds the renderer's LRU VNode cache. | | `qualityGovernor` | `boolean \| GovernorOptions` | `true` | Uses defaults with `true`, tunes the governor with an object, or disables adaptive bias with `false`. | | `qualityGovernor.budgetMs` | `number` | `16.7` | Sets the frame-time budget in milliseconds. | | `qualityGovernor.window` | `number` | `12` | Sets the normal decision window. | | `qualityGovernor.recoveryWindows` | `number` | `3` | Requires this many consecutive fast windows before restoring one tier. | | `qualityGovernor.maxBias` | `0 \| 1 \| 2` | `2` | Limits how many tiers below the zoom-derived tier the governor can render. | [`GovernorOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-perf#governoroptions) also controls the down/up thresholds and catastrophic-frame escalation. The defaults use a median, a dead band, and slower recovery to avoid oscillating between detail levels. The shipped LOD policy uses these inclusive lower bounds. The sample changes only the medium bound to `0.6`. | Tier | Shipped zoom range | What renders | | --- | --- | --- | | `high` | `zoom >= 1` | All LOD features. | | `medium` | `0.5 <= zoom < 1` | Labels, borders, ports, decorations, handles, routing, link detail, and gradients. | | `sketch` | `0.2 <= zoom < 0.5` | Borders, routing, and link detail; no labels or ports. | | `low` | `zoom < 0.2` | No optional LOD features: plain boxes and direct lines. | ## Pitfalls - Pass `qualityGovernor: false` when comparing zoom tiers deterministically. Otherwise the reported tier can be simpler than the zoom policy requests. - Configure React, Vue, and Qwik renderer options when mounting: their bindings pass `rendererConfig` into instance creation. Angular recreates its renderer when that input changes, so keep the object stable during ordinary data edits. - Keep the `batchUpdate()` callback synchronous. It ends the model batch when the callback returns, not when asynchronous work finishes. - Render batching is not an undoable edit. For user-facing operations that need history, use [commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). - At fit-to-content, the full mesh can be visible, so a high visible-node count does not by itself indicate failed culling. Compare it with the near view. - For container sizing, see [theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas). ## Live demos and related tasks - [Stress test](https://grafloria.com/demos/nodes/stress-test.html): compare viewport culling and layout on a 900-node mesh. - [Contextual zoom](https://grafloria.com/demos/interaction/contextual-zoom.html): compare the four rendered detail tiers. - [Perf HUD and quality governor](https://grafloria.com/demos/misc/perf-hud.html), with [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/misc/perf-hud.html): inspect the shipped overlay and governor behavior. - [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram): arrange the graph rather than only changing render cost. # Extend rendered geometry Use a geometry extension when the shipped figures, arrowheads or line styles cannot express your notation. Register the geometry by name, then use that name in the specs you pass to your mounted diagram. The renderer owns the geometry as nodes move; your extension supplies an outline, a path or the SVG elements that paint it. Start with the built-ins: figures include `diamond`, `cylinder`, `document` and `actor`; markers include `open-arrow`, `crow-foot` and `hollow-diamond`. For routing and corner treatment, see [Route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges). Go below the component's props only to define geometry the renderer does not already provide. ## 1. Register a silhouette, pipe and marker This example draws a built-in rectangle connected to a custom chevron by a two-stroke pipe, then connects the chevron to a sink with a feather marker. The pipe follows the routed path rather than a fixed picture. Use [`registerPathShape`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-functions-p-w#registerpathshape) for an SVG-path silhouette. It derives boundary points and port anchors from the outline and reuses the outline for the body, selection and shadow. Use [`registerShape`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-functions-p-w#registershape) only when you need to implement the full [`ShapeDefinition`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-shapedefinition#shapedefinition) contract yourself: `outline`, `boundaryPoint` and `portAnchor`, with an optional `innerRect` for the label. [`registerLinkTemplate`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-functions-p-w#registerlinktemplate) receives the current routed points, `pathData` and selection state. Its output replaces the visible edge rendering; the renderer retains the link wrapper and hit area. [`registerMarker`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-functions-p-w#registermarker) defines an endpoint glyph. Put the supplied `transform` on its root element and declare `tipOffset` so its visual tip meets the endpoint. Create this shared file in your browser application. Its nodes use the library's [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) type and its edges use [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec). ```ts title="geometry.ts" import { registerPathShape, registerLinkTemplate, registerMarker, type NodeSpec, type EdgeSpec, } from '@grafloria/renderer'; export function registerGeometry(): void { registerPathShape( 'notation-chevron', 'M2,4 L14,4 L22,12 L14,20 L2,20 L10,12 Z', { viewBox: { x: 0, y: 0, w: 24, h: 24 }, innerRect: (w, h) => ({ x: w * 0.42, y: h * 0.35, w: w * 0.2, h: h * 0.3 }), }, ); registerLinkTemplate('notation-pipe', (ctx) => { const stroke = ctx.selected ? '#2563eb' : '#0ea5e9'; return [ { type: 'path', props: { d: ctx.pathData, fill: 'none', stroke, 'stroke-width': 10, 'stroke-opacity': 0.35, 'stroke-linecap': 'round', }, }, { type: 'path', props: { d: ctx.pathData, fill: 'none', stroke, 'stroke-width': 2.5 }, }, ]; }); registerMarker('notation-feather', { tipOffset: (style) => style.size, render: (ctx) => ({ type: 'path', props: { d: `M0,0 L${ctx.size},0 M${ctx.size * 0.4},-4 L${ctx.size},0 L${ctx.size * 0.4},4`, stroke: ctx.color, fill: 'none', 'stroke-width': 1.5, transform: ctx.transform, }, }), }); } export const nodes: NodeSpec[] = [ { id: 'source', position: { x: 40, y: 80 }, size: { width: 120, height: 64 }, label: 'Source', shape: { type: 'rect', fill: '#dbeafe', stroke: '#2563eb' }, }, { id: 'gate', position: { x: 260, y: 160 }, size: { width: 140, height: 100 }, label: 'Go', shape: { type: 'notation-chevron', fill: '#dbeafe', stroke: '#2563eb' }, }, { id: 'sink', position: { x: 500, y: 80 }, size: { width: 120, height: 64 }, label: 'Sink', shape: { type: 'rect', fill: '#dbeafe', stroke: '#2563eb' }, }, ]; export const edges: EdgeSpec[] = [ { id: 'pipe', source: 'source', target: 'gate', type: 'smooth', style: { template: 'notation-pipe' }, }, { id: 'feather', source: 'gate', target: 'sink', style: { arrowHead: { type: 'notation-feather', size: 14, filled: false } }, }, ]; ``` The registration function returns no instance. The next step mounts real nodes and edges that consume all three names. ## 2. Mount the diagram in your framework Install the shared packages and the binding you use: ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer # Add one binding for a framework application: npm install @grafloria/react npm install @grafloria/vue npm install @grafloria/angular npm install @grafloria/qwik ``` This task adds geometry registration to the existing mounting pattern; see [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes) for the component and instance entry points. The browser entry point calls [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) after registration. ```ts title="JavaScript" import { render } from '@grafloria/element'; import { nodes, edges, registerGeometry } from './geometry'; registerGeometry(); const wrapper = document.createElement('section'); const host = document.createElement('div'); host.style.height = '400px'; const remove = document.createElement('button'); remove.textContent = 'Remove diagram'; wrapper.append(host, remove); document.body.append(wrapper); const instance = render({ nodes, edges }, host); remove.addEventListener('click', () => { instance.dispose(); wrapper.remove(); }, { once: true }); ``` For React, register before rendering [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react); see [Route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges) for the default-spec mounting pattern. ```tsx title="GeometryDiagram.tsx" import { GrafloriaFlow } from '@grafloria/react'; import { nodes, edges, registerGeometry } from './geometry'; registerGeometry(); export default function GeometryDiagram() { return
; } ``` For Vue, register in the setup script before [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue) mounts; see [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works) for default-spec ownership. ```vue title="GeometryDiagram.vue" ``` For Angular, register in the constructor before [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent) paints; see [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works) for the two-way bindings. ```ts title="geometry-diagram.component.ts" import { Component } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { nodes, edges, registerGeometry } from './geometry'; @Component({ selector: 'app-geometry-diagram', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class GeometryDiagramComponent { nodes: NodeSpec[] = nodes; edges: EdgeSpec[] = edges; constructor() { registerGeometry(); } } ``` For Qwik, register in [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik)'s browser initialization callback and repaint its live [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance); see [Route and label edges](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/route-and-label-edges) for the default-spec mounting pattern. ```tsx title="GeometryDiagram.tsx" import { component$, $ } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import { nodes, edges, registerGeometry } from './geometry'; export default component$(() => (
{ registerGeometry(); instance.renderNow(); })} />
)); ``` The chevron labelled Go connects to Source through the blue two-stroke pipe; its thinner outgoing line ends with the feather marker at Sink. You see Source, the chevron labelled Go, and Sink. Drag the chevron to change both routes; the pipe repaints from the routed `pathData`, and the feather receives the endpoint's new transform. Select the pipe to switch its strokes to the selection blue. Register before the first paint in JavaScript, React, Vue and Angular. In Qwik, register from the browser's `onInit$` callback and call `renderNow()` to repaint with the definitions; do not rely on a server-side registration surviving resumption. The specs contain names, not the template functions. See the live [Shapes](https://grafloria.com/demos/nodes/shapes.html), [Custom edges](https://grafloria.com/demos/edges/custom-edges.html) and [Edge markers](https://grafloria.com/demos/edges/edge-markers.html) demos for the individual extensions. ## Choose the remaining geometry seams Keep the stages separate: an anchor chooses one endpoint, a connection-point strategy chooses both endpoints, a router computes the polyline, and a connector turns that polyline into an SVG path. An edge template replaces the visible rendering after that path has been computed. | Extension | Select it with | Input and result | | --- | --- | --- | | [`registerAnchor`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions#registeranchor) | `metadata.sourceAnchor` or `metadata.targetAnchor` on the edge | Reads this end, the opposite end, the default point and arguments; returns `{ point, side? }`. Use the world-space node rectangle for a notation-specific attachment. | | [`registerConnectionPoint`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions#registerconnectionpoint) | Edge `metadata.connectionPoint`, or the renderer's `connectionPoint` default | Reads both ends and their defaults; returns `{ start, end, sourceDirection?, targetDirection? }`, or `null` to defer to the default pipeline. The context supplies a shape-boundary solver. | | [`registerConnector`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions#registerconnector) | Edge `connector` | Reads the routed world-space points, style and resolved corner radius; returns a complete SVG path string. | | [`registerLabelTemplate`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-functions-p-w#registerlabeltemplate) | A live link label's `template` | Reads the label, link, world-space anchor, rotation and theme; returns SVG elements or a `foreignObject`, or `null` to suppress the label. | | [`registerPortLayout`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-functions-p-w#registerportlayout) | Port `layout.strategy`, or a group's layout under node `metadata.portGroups` | Reads node-local width, height, side, rank, count and shape type, plus layout arguments; returns node-local `{ x, y }`. | For floating attachment, try the shipped `smart` connection-point strategy before implementing a two-ended strategy. For connectors, try `straight`, `rounded`, `smooth` or `bezier`. For port layouts, the shipped choices are `shape`, `absolute`, `line`, `sideLinear`, `ellipse` and `ellipseSpread`; `shape` preserves the silhouette's own anchors. To select a label template during setup, obtain the live link through `instance.getModel().getLink(id)` and call its `addLabel()` with `text`, `position` or `slot`, and `template`. The top-level edge `label` is the text convenience field, not a label-template selector. Repaint through the instance after setup mutations. For user-facing edits that need undo, use [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history). ## Options that matter The shape options belong to [`PathShapeOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-svg-interfaces-o-s#pathshapeoptions). | Option | Type | Default | What it does | | --- | --- | --- | --- | | `viewBox` | `{ x: number; y: number; w: number; h: number }` | `{ x: 0, y: 0, w: 1, h: 1 }` | Scales a static path's reference box into the node box. Set it to the art box you authored. | | `sampleSteps` | `number` | `24` | Sets curve subdivision when sampling the outline. | | `portAnchor` | `ShapeDefinition['portAnchor']` | Derived from the path | Supplies exact node-local port anchors instead of sampled anchors. | | `boundaryPoint` | `ShapeDefinition['boundaryPoint']` | Derived from the path | Supplies exact world-space floating attachments instead of sampled boundaries. | | `innerRect` | `ShapeDefinition['innerRect']` | Padded bounding box | Gives the label a box inside a slanted or concave silhouette. | ## Pitfalls and registration lifetime - An edge template owns the visible edge, including any markers and labels you want it to show. Setting `arrowHead` on the pipe edge does not add the built-in marker rendering to its output. The example puts the feather on a separate, non-templated edge. - Use a distinct custom connector name. The built-in connector names use internal rendering branches; registering one of those names does not replace that branch. - Global registration functions share their names across diagrams. Prefix your notation's names to avoid replacing another extension. Shape, edge-template, label-template, marker and port-layout registration functions return `void`; anchor, connection-point and connector registration functions return a disposer that restores the previous definition. Run disposers when the owning extension unmounts, not immediately after registration. - For shape, marker, template and link-pipeline definitions private to one diagram, use the corresponding methods on `instance.registry`. Those methods return restoring disposers and resolve local definitions before global ones. Port layouts use the global port-layout registry. ## Related - [Validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections) — connection rules rather than port geometry. - [JavaScript: elements and content](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-elements-and-content) — HTML content inside engine-positioned node hosts. - [Save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents) — persist the model; register the named geometry in the application that renders it. # Extend layout execution Use a shipped layout first. Extend the registry when your domain needs an arrangement the shipped algorithms do not express, and attach a worker when layout computation must leave the main thread. The samples below render a connected graph and an editorial workflow. Run layouts through the mounted [`DiagramEngine`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine) and use its registry only to add an algorithm; [How Grafloria works](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/how-grafloria-works) explains obtaining it from [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance) through `getEngine()`. ## 1. Choose a shipped layout The engine registers its built-ins for you. You do not need to construct adapters or call [`createBuiltInLayoutAdapters`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-functions-a-t). | Graph | Layout name | Arrangement | | --- | --- | --- | | Pipelines and DAGs | `elk`, `dagre`, `layered` | Layered ranking | | Systems with zones | `architecture` | Regions composed on a grid | | Hierarchies | `tree` | Parent-centered branches | | Networks | `force`, `community`, `spectral` | Physical spread or clusters | | Catalogs | `grid`, `circular`, `radial` | Uniform placement | | No explicit choice | `auto` | Graph classification and dispatch | Calling `engine.layout()` selects `auto`. An unknown name throws an error listing the registered names. For ordinary declarative layout and on-demand reruns, see [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram). Install the shared packages and the binding you use in your browser application: ```bash npm install @grafloria/engine @grafloria/renderer @grafloria/element # Angular npm install @grafloria/angular # Qwik npm install @grafloria/qwik # Vue npm install @grafloria/vue ``` ## 2. Serve layout in a module worker Your application creates the worker; the engine does not choose a bundler or worker URL for you. [`LayoutPort`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-i-l) defines the host-side message surface, and [`serveLayout`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-functions-a-t) supplies the worker's message loop. > **Known issue:** The documented `engine.setLayoutPort(worker)` and `serveLayout(self)` calls can fail strict TypeScript checks because the port types accept a plain `{ data }` event while browser handlers require a full `MessageEvent`. Until it is fixed, forward browser events through the typed port objects below. Create these shared files beside your application component or entry point. Use a toolchain that bundles module workers created with `new Worker(new URL(..., import.meta.url))`. ```ts title="layout.worker.ts" import { serveLayout, type LayoutServePort, type LayoutRequest } from '@grafloria/engine'; const port: LayoutServePort = { onmessage: null, postMessage: (message) => self.postMessage(message), }; self.addEventListener('message', (event: MessageEvent) => { port.onmessage?.({ data: event.data }); }); serveLayout(port); ``` [`LayoutServePort`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-l-u) and [`LayoutRequest`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-types) type the worker side. [`LayoutResponse`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-types) types the messages returned to the host. The worker resolves the algorithm by name in its own bundle. Registering a function in the main thread does not transfer that function to the worker. The data uses the library's [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec). Chain edges keep all 45 nodes connected so force layout can use its interruptible path. ```ts title="graph.ts" import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; export const nodes: NodeSpec[] = Array.from({ length: 45 }, (_, i) => ({ id: `n${i}`, label: String(i), position: { x: (i % 9) * 90, y: Math.floor(i / 9) * 90 }, size: { width: 40, height: 40 }, })); export const edges: EdgeSpec[] = Array.from({ length: 44 }, (_, i) => ({ id: `e${i}`, source: `n${i}`, target: `n${i + 1}`, type: 'direct', })); ``` This shared function first applies the shipped `grid` layout, then attaches the worker and requests a long `force` run. Its progress callback requests cancellation at 10%, and its completion handler prints the returned status. The returned function aborts and waits for settlement before terminating the worker; call it during unmount. ```ts title="execution.ts" import type { DiagramEngine, LayoutPort, LayoutResponse } from '@grafloria/engine'; export function startLayout(engine: DiagramEngine, repaint: () => void): () => void { const controller = new AbortController(); const worker = new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module', }); const port: LayoutPort = { onmessage: null, postMessage: (message) => worker.postMessage(message), }; worker.addEventListener('message', (event: MessageEvent) => { port.onmessage?.({ data: event.data }); }); let disposed = false; const running = (async () => { await engine.layout('grid', { columns: 9 }); if (disposed) return; repaint(); engine.setLayoutPort(port); const result = await engine.layout('force', { seed: 0x5eed, iterations: 4000, threshold: 0, sliceMs: 0, signal: controller.signal, onProgress: (progress) => { console.log('Layout progress', progress.progress, progress.phase); if (progress.progress >= 0.1) controller.abort(); }, }); console.log('Layout result', result.partial, result.reason, result.iteration); if (!disposed) repaint(); })().catch((error: Error) => { if (!disposed) console.error(error); }); return () => { disposed = true; controller.abort(); void running.finally(() => { engine.setLayoutPort(undefined); worker.terminate(); }); }; } ``` Cancellation is not an exception: `layout()` resolves with a [`UnifiedLayoutResult`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-l-u), including `nodePositions`, `bounds`, `partial`, `reason`, and iteration counts. The engine commits the returned positions even when `partial` is true. Keep that picture; do not reset the nodes after an abort. ## 3. Run against the mounted diagram The JavaScript, Angular and Vue tabs run the shipped `grid` layout on their mounted engine and show numbered nodes in five rows. The Qwik tab replaces the browser-side rule setup from [Validate port connections](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/validate-port-connections#2-mount-the-same-editor-in-your-framework) with `startLayout()` to attach the worker, report progress and cancel the run. Add the layout call to the mounting patterns for [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core), [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent), Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik) and Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue) in [Edit nodes](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes#2-mount-the-canvas-in-your-framework). :::code-group ```ts title="JavaScript" import { render } from '@grafloria/element'; import { nodes, edges } from './graph'; export function mountLayout(container: HTMLElement): () => void { container.style.height = '400px'; const instance = render({ nodes, edges }, container); void instance.getEngine().layout('grid', { columns: 9 }).then(() => { instance.fitView(30); }); return () => { instance.dispose(); }; } const container = document.createElement('div'); document.body.append(container); export const unmount = mountLayout(container); // Call unmount() when your application removes this view. ``` ```ts title="Angular" import { AfterViewInit, Component, viewChild } from '@angular/core'; import { DiagramCanvasComponent } from '@grafloria/angular'; import { nodes, edges } from './graph'; @Component({ selector: 'app-layout', standalone: true, imports: [DiagramCanvasComponent], template: ` `, }) export class LayoutComponent implements AfterViewInit { readonly canvas = viewChild.required(DiagramCanvasComponent); nodes = nodes; edges = edges; ngAfterViewInit(): void { const canvas = this.canvas(); const engine = canvas.activeEngine(); if (engine) { void engine.layout('grid', { columns: 9 }).then(() => canvas.scheduleRender()); } } } ``` ```tsx title="Qwik" import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik'; import { nodes, edges } from './graph'; import { startLayout } from './execution'; export default component$(() => { const instance = useSignal>(); useVisibleTask$(({ track, cleanup }) => { const api = track(() => instance.value); if (!api) return; cleanup(startLayout(api.getEngine(), () => { api.renderNow(); api.fitView(30); })); }); return (
{ instance.value = noSerialize(api); }} />
); }); ``` ```vue title="Vue" ``` ::: The JavaScript sample initially shows the numbered nodes in five rows with connecting arrows. The Angular sample shows the same grid at the left edge of its canvas. The Qwik sample shows the nodes spread into a compact network. Connecting arrows link the numbered nodes in the Qwik canvas. The Vue sample initially shows the five-row grid framed in the canvas. ## 4. Register a domain-specific layout only when needed Suppose your editorial workflow requires a fixed reading order: Draft, Review, Published. [`createLayout`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-functions-a-t) wraps a [`GraphLayoutFn`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-types) as a [`RegisteredLayout`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-l-u). It provides canonical input order and disconnected-component packing. Return a [`LayoutResult`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-i-l) rather than mutating node positions yourself. Register it in the mounted engine's [`LayoutRegistry`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-classes). `register()` returns a disposer that restores the previous layout under that name, if one existed. > **Known issue:** A layout created with `createLayout()` exposes an adapter, so an attached worker receives its name even though `serveLayout(self)` cannot resolve your main-thread registration. Until it is fixed, finish any worker run and call `engine.setLayoutPort(undefined)` before running this custom layout inline. The intended invocation is `await engine.layout('editorial')` after registration, including when a worker is attached. The sample below includes the inline workaround and renders the three stages from left to right. ```ts title="editorial.ts" import { createLayout, type GraphLayoutFn } from '@grafloria/engine'; import { render } from '@grafloria/element'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; const stageOrder: Record = { draft: 0, review: 1, published: 2 }; const arrangeEditorial: GraphLayoutFn = (nodes) => { const nodePositions = new Map(); let width = 0; let height = 0; for (const node of nodes) { const x = (stageOrder[node.id] ?? 0) * 220; nodePositions.set(node.id, { x, y: 0 }); width = Math.max(width, x + (node.size?.width ?? 140)); height = Math.max(height, node.size?.height ?? 60); } return { nodePositions, bounds: { x: 0, y: 0, width, height } }; }; export async function mountEditorial(container: HTMLElement): Promise<() => void> { const nodes: NodeSpec[] = [ { id: 'draft', label: 'Draft', size: { width: 140, height: 60 } }, { id: 'review', label: 'Review', size: { width: 140, height: 60 } }, { id: 'published', label: 'Published', size: { width: 140, height: 60 } }, ]; const edges: EdgeSpec[] = [ { source: 'draft', target: 'review' }, { source: 'review', target: 'published' }, ]; container.style.height = '400px'; const instance = render({ nodes, edges }, container); const engine = instance.getEngine(); const unregister = engine.getLayoutRegistry().register( createLayout('editorial', arrangeEditorial), ); engine.setLayoutPort(undefined); await engine.layout('editorial'); instance.fitView(30); return () => { unregister(); instance.dispose(); }; } const container = document.createElement('div'); document.body.append(container); export const unmountEditorial = mountEditorial(container); // On unmount, use unmountEditorial.then((unmount) => unmount()). ``` ![Draft, Review and Published arranged left to right with connecting arrows.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/f836353434d5ed3931de709cd4f95b65.png) The algorithm and registry call are framework-independent: use the same registration on the engine obtained in step 3. A [`LayoutAdapter`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-i-l) additionally defines `applyIncremental()` and `validateOptions()`. The adapter produced by `createLayout()` throws for `applyIncremental()`; do not treat this wrapper as an incremental-layout implementation. ## Options that control execution These fields belong to [`UnifiedLayoutOptions`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-unifiedlayoutoptions). Run controls stay on the host side rather than crossing the worker boundary as callbacks or signals. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `signal` | `AbortSignal` | Not supplied | Requests cooperative cancellation | | `onProgress` | `(progress: LayoutProgress) => void` | Not supplied | Reports progress on the caller's thread | | `sliceMs` | `number` | `12` | Sets computation time between event-loop yields | | `timeBudgetMs` | `number` | No budget | Stops an interruptible run with a partial result and `reason: 'timeout'` | | `stopAfterIteration` | `number` | No cap | Stops at an iteration count with `reason: 'iteration-cap'` | | `seed` | `number` | Fixed constant | Makes randomized layouts reproducible | | `iterations` | `number` | `300` for force | Sets the force simulation's iteration limit | [`LayoutProgress`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-layout-interfaces-i-l) contains `progress` from 0 to 1, `phase`, `iteration`, and `totalIterations`. A completed force run reaches 1; a cancelled run reports its actual stopping point. A time budget depends on wall-clock timing; use `stopAfterIteration` when you need a reproducible partial result. ## Execution limits - Mid-run cancellation and iteration progress require the steppable path. The shipped force adapter uses it for connected graphs. Disconnected force graphs take the packed, one-shot path instead; they retain readable component placement but lose mid-run cancellation. - One-shot adapters, including dagre, spectral and community, cannot stop inside their algorithm call. They report start and completion rather than simulation iterations. - Grouped diagrams use the nested-container path by default. That path runs inline and returns a complete single-pass result; attaching a worker does not move it off-thread. - A `RegisteredLayout` without an `adapter` also runs inline. Worker-side algorithms must exist in the worker bundle; a main-thread closure cannot cross `postMessage()`. > **Known issue:** Requesting `engine.layout('elk')` through the module worker can fail while constructing ELK's nested worker. Until it is fixed, settle the current run, call `engine.setLayoutPort(undefined)`, then call `await engine.layout('elk')` inline. ## Live demo and related guides See [Off-thread layout](https://grafloria.com/demos/layout/off-thread-layout.html) for a real worker, streamed progress, cancellation and a main-thread responsiveness check. Its [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/layout/off-thread-layout.html) shows the same worker wiring. - [Lay out a diagram](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram): declarative layouts and explicit reruns. - [Commands and history](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history): user-facing edits and undo. - [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas): sizing and appearance. # JavaScript: elements and content Use the custom element for HTML-driven embeds and [`render`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#render) for programmatic mounts. Both give you an engine-positioned box whose inside you paint: your content does not take over dragging, selection, ports or connections. This page mounts connected cards, wires events, and updates a custom dashboard widget without rebuilding the board. ## 1. Choose the embedding surface [`GrafloriaFlowElement`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#grafloriaflowelement) implements ``. Importing `@grafloria/element` registers that tag. Pass arrays through the element's `nodes` and `edges` properties; use JSON attributes when the data lives in markup. `render()` takes the spec first, a container or CSS selector second, and options third. It returns a live [`DiagramInstance`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-diagraminstance#diagraminstance). Its input is an object or JSON, not Mermaid text; see [Import diagram text and files](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/import-diagram-text-and-files) for text parsing. | Choose | What you get | | --- | --- | | Element attributes | An HTML embed with JSON data and named DOM events | | Element properties | Array inputs without JSON encoding, plus `diagram` and `fitView()` | | `render(spec, target, options)` | A direct instance return, instance events, and kit finalization | Run the JavaScript examples in a browser project with an ESM bundler, such as the project from the [JavaScript quick start](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/javascript-quick-start). Install the packages they import: ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer ``` This property-based embed uses [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec#nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec#edgespec) to draw Build and Publish with a connection between them. Register the DOM listener before appending the element. The ready event's detail contains `{ diagram }`; the example reads the typed `diagram` property instead of casting an untyped DOM event payload. ```ts title="src/element.ts" import { GrafloriaFlowElement } from '@grafloria/element'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'build', label: 'Build', position: { x: 60, y: 100 }, size: { width: 180, height: 80 } }, { id: 'publish', label: 'Publish', position: { x: 360, y: 100 }, size: { width: 180, height: 80 } }, ]; const edges: EdgeSpec[] = [{ id: 'release', source: 'build', target: 'publish' }]; const flow = document.createElement('grafloria-flow'); if (!(flow instanceof GrafloriaFlowElement)) throw new Error('Element not registered'); flow.style.height = '400px'; flow.setAttribute('theme', 'light'); flow.setAttribute('fit-view', ''); flow.nodes = nodes; flow.edges = edges; const status = document.createElement('p'); status.textContent = 'Starting diagram'; flow.addEventListener('grafloria-ready', () => { status.textContent = `Ready: ${flow.diagram?.getModel().getNodes().length} nodes`; }); flow.addEventListener('grafloria-selection-change', () => { const selected = flow.diagram?.getModel().getNodes() .filter((node) => node.isSelected()).map((node) => node.id) ?? []; status.textContent = selected.length ? `Selected: ${selected.join(', ')}` : 'No selection'; }); const close = document.createElement('button'); close.textContent = 'Close diagram'; close.onclick = () => { flow.remove(); status.remove(); close.remove(); }; document.body.append(status, close, flow); ``` ![Build connects to Publish below the Ready: 2 nodes status and Close diagram button.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/ee724717516cfd0c222576690ebd69b1.png) Assigning `flow.nodes` or `flow.edges` after mounting calls the instance's corresponding setter. Removing the element disposes its instance. For parent sizing arrangements, see [Theme a canvas](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/theme-a-canvas). ### Attribute options The following defaults come from the element's mount logic. Set mount-only attributes before inserting the element. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `nodes`, `edges` | JSON array strings | Empty arrays | Supplies node and edge specs; prefer properties for rich data | | `theme` | `'light'` or `'dark'` | `'light'` | Selects a shipped theme; unknown names resolve to light | | `fit-view` | Presence flag | Absent | Frames the content on mount | | `readonly` | Presence flag | Absent | Configures read-only interaction on mount | | `pan` | String | Enabled | `"false"` disables pan on mount | | `wheel-zoom` | String | Enabled | `"false"` disables zoom interaction on mount | | `zoom` | Numeric string | Not supplied by the element | Supplies the initial zoom | | `min-zoom`, `max-zoom` | Numeric strings | Not supplied by the element | Supplies mount-time zoom bounds | | `highlight-connected` | Flag, `"false"`, `"trace"`, or depth string | Off | Highlights selected nodes' connections; `"trace"` follows every path | ### DOM events Every forwarded event bubbles and is composed, so it can cross a surrounding shadow boundary. Read the payload from `CustomEvent.detail` when your host supplies a typed event adapter. | DOM event | Detail | | --- | --- | | `grafloria-ready` | `{ diagram }` | | `grafloria-nodes-change` | `{ nodes }` with live node models | | `grafloria-edges-change` | `{ edges }` with live link models | | `grafloria-selection-change` | `{ nodes, edges }` | | `grafloria-connect` | `{ link }` | | `grafloria-node-click` | `{ node, world }` | | `grafloria-edge-click` | `{ edge, world }` | | `grafloria-viewport-change` | `{ viewport, zoom }` | For a programmatic mount, use `instance.on('selection:change', handler)` instead. It infers the payload type and returns a function that removes the subscription. The programmatic example in the next step shows that cleanup on Close. ### Rename the tag [`Grafloria`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-element-core#grafloria) exposes the intended call `Grafloria.define('project-flow')`. > **Known issue:** `Grafloria.define('project-flow')` reuses the constructor already registered as `grafloria-flow`, which the browser rejects for a second tag. Until it is fixed, register a subclass under your own tag. This browser example registers the subclass once and mounts a Release node under ``: ```ts title="src/renamed-element.ts" import { GrafloriaFlowElement } from '@grafloria/element'; import type { NodeSpec } from '@grafloria/renderer'; if (!customElements.get('project-flow')) { class ProjectFlow extends GrafloriaFlowElement {} customElements.define('project-flow', ProjectFlow); } const flow = document.createElement('project-flow'); if (!(flow instanceof GrafloriaFlowElement)) throw new Error('Unexpected tag constructor'); const nodes: NodeSpec[] = [ { id: 'release', label: 'Release', position: { x: 80, y: 90 }, size: { width: 180, height: 80 } }, ]; flow.nodes = nodes; flow.style.height = '400px'; flow.setAttribute('fit-view', ''); document.body.append(flow); ``` ![A Release node is centered in the renamed element's canvas.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/03e17d9c53b8449fd7aa963625fb6dfe.png) ## 2. Fill custom node hosts Register a JavaScript renderer before mounting, and set `custom: true` on each node that uses it. Registering a type alone does not opt JavaScript or React nodes into HTML rendering. React uses `nodeTypes`; Vue exact `#node-` slots and Angular typed templates opt matching specs in automatically. Vue's wildcard `#node` slot still needs `custom: true`. A JavaScript renderer is a mount hook, not a data binding: it runs once when the host is created. Later node-data changes do not reinvoke it. Update the DOM you own. A missing renderer leaves an empty host, and registering later does not back-fill that host. Style a child of the host; the engine owns the host's position and dimensions. Use `NodeSpec` and `EdgeSpec` in the shared file below. All framework tabs import it. The cards display Build and Publish, and the wire remains attached when you drag either card. ```ts title="content.ts" import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; export const nodes: NodeSpec[] = [ { id: 'build', type: 'card', custom: true, position: { x: 60, y: 100 }, size: { width: 220, height: 110 }, data: { title: 'Build' } }, { id: 'publish', type: 'card', custom: true, position: { x: 380, y: 100 }, size: { width: 220, height: 110 }, data: { title: 'Publish' } }, ]; export const edges: EdgeSpec[] = [{ id: 'release', source: 'build', target: 'publish' }]; export const cardStyle = { boxSizing: 'border-box' as const, height: '100%', padding: '12px', border: '1px solid #94a5f0', borderRadius: '10px', background: '#fff', color: '#232a3d', fontFamily: 'system-ui', }; ``` Install your binding in addition to the three shared packages above. JavaScript: ```bash npm install @grafloria/element @grafloria/engine @grafloria/renderer ``` Angular: ```bash npm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer ``` Qwik: ```bash npm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer ``` React: ```bash npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer ``` Vue: ```bash npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer ``` Use these components in an existing framework project. React's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#grafloriaflow) maps types to components accepting [`NodeProps`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-react#nodeprops). Qwik's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#grafloriaflow) uses Qwik components and its own [`NodeProps`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-qwik#nodeprops). Vue's [`GrafloriaFlow`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-vue#grafloriaflow) uses named slots. Angular's [`DiagramCanvasComponent`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) uses `ng-template[grafloriaNode]`, declared by [`GrafloriaNodeDefDirective`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-angular-components-classes#graflorianodedefdirective). Each tab exposes a card-local Mark complete button. It changes that card's content to Complete, without pretending that a mount hook is a reactive binding. Selection updates the status outside the canvas through the binding's own event. :::code-group ```ts title="JavaScript" import { Grafloria, render } from '@grafloria/element'; import { nodes, edges, cardStyle } from './content'; Grafloria.registerNodeType('card', (node, host) => { const card = document.createElement('div'); Object.assign(card.style, cardStyle); const title = document.createElement('strong'); title.textContent = String(node.getData('title') ?? ''); const complete = document.createElement('button'); complete.textContent = 'Mark complete'; complete.onclick = () => { title.textContent = 'Complete'; }; card.append(title, document.createElement('br'), complete); host.replaceChildren(card); }); const canvas = document.createElement('div'); canvas.style.height = '400px'; const status = document.createElement('p'); status.textContent = 'Select a card'; const close = document.createElement('button'); close.textContent = 'Close diagram'; document.body.append(status, close, canvas); const instance = render({ nodes, edges }, canvas, { fitView: true }); const off = instance.on('selection:change', ({ nodes: selected }) => { status.textContent = selected.map((node) => node.id).join(', ') || 'No selection'; }); close.onclick = () => { off(); instance.dispose(); canvas.remove(); status.remove(); close.remove(); }; ``` ```ts title="Angular" import { Component } from '@angular/core'; import { DiagramCanvasComponent, GrafloriaNodeDefDirective } from '@grafloria/angular'; import { nodes, edges } from './content'; @Component({ selector: 'app-custom-content', standalone: true, imports: [DiagramCanvasComponent, GrafloriaNodeDefDirective], template: `

{{ status }}

{{ completed.has(node.id) ? 'Complete' : data['title'] }}
`, }) export class CustomContentComponent { nodes = nodes; edges = edges; status = 'Select a card'; completed = new Set(); } ``` ```tsx title="Qwik" import { component$, useSignal } from '@builder.io/qwik'; import { GrafloriaFlow, type NodeProps } from '@grafloria/qwik'; import { nodes, edges, cardStyle } from './content'; const Card = component$((props: NodeProps) => { const complete = useSignal(false); return
{complete.value ? 'Complete' : String(props.data['title'] ?? '')}
; }); export default component$(() => { const status = useSignal('Select a card'); return <>

{status.value}

{ status.value = `${change.nodes.length} selected`; }} />
; }); ``` ```tsx title="React" import { useState } from 'react'; import { GrafloriaFlow, type NodeProps } from '@grafloria/react'; import { nodes, edges, cardStyle } from './content'; function Card({ data }: NodeProps) { const [complete, setComplete] = useState(false); return
{complete ? 'Complete' : String(data['title'] ?? '')}
; } export default function CustomContent() { const [status, setStatus] = useState('Select a card'); return <>

{status}

setStatus(`${selected.length} selected`)} />
; } ``` ```vue title="Vue" ``` ::: The JavaScript mount includes a Close diagram button above the connected cards. ![JavaScript shows Build and Publish cards with Mark complete buttons, a connecting arrow, and Close diagram.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/305ba9c55380f7834dc5b43bf921bf43.png) The Angular template fills both cards and leaves the surrounding node outlines and ports visible. ![Angular shows connected Build and Publish cards with Mark complete buttons, orange outlines, and gray port dots.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/e9ecdd6cb5c709ac252151a1faf34f43.png) The Qwik components draw the card content inside the connected hosts. ![Qwik shows Build and Publish cards with Mark complete buttons below Select a card.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/9e3b916fdaa9ea8ecdff1f3b5fde7293.png) The React components and Vue slot components produce the same initial card content: Build and Publish, each with a Mark complete button. Vue slots do not repaint for in-place node-data changes. Put a component in the slot that subscribes to your own reactive source, rather than relying on slot reinvocation. The Vue card above owns a reactive completion flag. Qwik custom nodes occupy separate Qwik containers: keep them self-contained and pass their initial payload through node data, not surrounding app context. ### Use a template for markup-driven cards For a CMS or static page, place a `