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 and use its registry only to add an algorithm; How Grafloria works explains obtaining it from 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.
| 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.
Install the shared packages and the binding you use in your browser application:
bashnpm 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 defines the host-side message surface, and serveLayout supplies the worker's message loop.
Known issue: The documented
engine.setLayoutPort(worker)andserveLayout(self)calls can fail strict TypeScript checks because the port types accept a plain{ data }event while browser handlers require a fullMessageEvent. 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)).
tsimport { serveLayout, type LayoutServePort, type LayoutRequest } from '@grafloria/engine';
const port: LayoutServePort = {
onmessage: null,
postMessage: (message) => self.postMessage(message),
};
self.addEventListener('message', (event: MessageEvent<LayoutRequest>) => {
port.onmessage?.({ data: event.data });
});
serveLayout(port);
LayoutServePort and LayoutRequest type the worker side. LayoutResponse 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 and EdgeSpec. Chain edges keep all 45 nodes connected so force layout can use its interruptible path.
tsimport 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.
tsimport 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<LayoutResponse>) => {
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, 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 with startLayout() to attach the worker, report progress and cancel the run.
Add the layout call to the mounting patterns for render, DiagramCanvasComponent, Qwik's GrafloriaFlow and Vue's GrafloriaFlow in Edit nodes.
tsimport { 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.
tsimport { 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: `
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
style="display:block;height:400px" />
`,
})
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());
}
}
}
tsximport { 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<NoSerialize<DiagramInstance>>();
useVisibleTask$(({ track, cleanup }) => {
const api = track(() => instance.value);
if (!api) return;
cleanup(startLayout(api.getEngine(), () => {
api.renderNow();
api.fitView(30);
}));
});
return (
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
onInit$={(api: DiagramInstance) => { instance.value = noSerialize(api); }} />
</div>
);
});
vue<script setup lang="ts"> import { GrafloriaFlow, type DiagramInstance } from '@grafloria/vue'; import { nodes, edges } from './graph'; function onInit(instance: DiagramInstance): void { void instance.getEngine().layout('grid', { columns: 9 }).then(() => { instance.fitView(30); }); } </script> <template> <div style="height:400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" @init="onInit" /> </div> </template>
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 wraps a GraphLayoutFn as a RegisteredLayout. It provides canonical input order and disconnected-component packing. Return a LayoutResult rather than mutating node positions yourself.
Register it in the mounted engine's LayoutRegistry. 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 thoughserveLayout(self)cannot resolve your main-thread registration. Until it is fixed, finish any worker run and callengine.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.
tsimport { createLayout, type GraphLayoutFn } from '@grafloria/engine';
import { render } from '@grafloria/element';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
const stageOrder: Record<string, number> = { draft: 0, review: 1, published: 2 };
const arrangeEditorial: GraphLayoutFn = (nodes) => {
const nodePositions = new Map<string, { x: number; y: number }>();
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()).
The algorithm and registry call are framework-independent: use the same registration on the engine obtained in step 3. A LayoutAdapter 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. 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 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
RegisteredLayoutwithout anadapteralso runs inline. Worker-side algorithms must exist in the worker bundle; a main-thread closure cannot crosspostMessage().
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, callengine.setLayoutPort(undefined), then callawait engine.layout('elk')inline.
Live demo and related guides
See Off-thread layout for a real worker, streamed progress, cancellation and a main-thread responsiveness check. Its source shows the same worker wiring.
- Lay out a diagram: declarative layouts and explicit reruns.
- Commands and history: user-facing edits and undo.
- Theme a canvas: sizing and appearance.
Was this page helpful?