Skip to content
D
Documentation

Extend layout execution

how-to
5 min readUpdated

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.

GraphLayout nameArrangement
Pipelines and DAGselk, dagre, layeredLayered ranking
Systems with zonesarchitectureRegions composed on a grid
HierarchiestreeParent-centered branches
Networksforce, community, spectralPhysical spread or clusters
Catalogsgrid, circular, radialUniform placement
No explicit choiceautoGraph 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:

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 defines the host-side message surface, and serveLayout 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
import { 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.

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
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<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.

ts
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.

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 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
import { 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()).
Draft, Review and Published arranged left to right with connecting arrows.

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.

OptionTypeDefaultWhat it does
signalAbortSignalNot suppliedRequests cooperative cancellation
onProgress(progress: LayoutProgress) => voidNot suppliedReports progress on the caller's thread
sliceMsnumber12Sets computation time between event-loop yields
timeBudgetMsnumberNo budgetStops an interruptible run with a partial result and reason: 'timeout'
stopAfterIterationnumberNo capStops at an iteration count with reason: 'iteration-cap'
seednumberFixed constantMakes randomized layouts reproducible
iterationsnumber300 for forceSets 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 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.

See Off-thread layout for a real worker, streamed progress, cancellation and a main-thread responsiveness check. Its source shows the same worker wiring.

Was this page helpful?