Skip to content
D
Documentation

Preserve the layout mental map

how-to
3 min readUpdated

Add a node to an arranged graph with an incremental pass while minimizing movement to existing diagram content.

Use incremental layout after inserting or editing nodes in a graph people already know. A full layout can move the entire graph; the incremental pass confines disturbance around the changed nodes and reports movement.

Add a node with incremental layout

  1. Mount a connected graph and lay it out with layered so its starting positions come from the same layout engine. In JavaScript, render mounts the spec and returns the live instance; framework apps use their canvas component.
  2. Add the new node and its edges to the live DiagramInstance. Mark the new node in changed, then call layoutIncremental() on the engine returned by getEngine().
  3. Repaint and frame the result. The new node appears in the chain, while nodes outside the affected neighborhood remain anchors. The result includes a movement report and a tween plan; the engine returns the plan rather than animating it, so a host can drive the animation if needed.

The task-specific difference is inserting a node into an arranged chain and measuring movement among the existing nodes; see Lay out diagrams for the shared mounting, framework-binding, and typed-spec patterns.

js
import { render } from '@grafloria/element';

async function main() {
const nodes = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({
  id,
  position: { x: 0, y: 0 },
  size: { width: 110, height: 46 },
  label: id,
}));
const edges = [
  { id: 'e0', source: 'n0', target: 'n1' },
  { id: 'e1', source: 'n1', target: 'n2' },
  { id: 'e2', source: 'n2', target: 'n3' },
  { id: 'e3', source: 'n3', target: 'n4' },
  { id: 'e4', source: 'n4', target: 'n5' },
];
const host = document.getElementById('app');
if (!host) throw new Error('Missing #app element');
host.style.height = '400px';
const instance = render({ nodes, edges }, host);
const engine = instance.getEngine();

await engine.layout('layered');
instance.renderNow();
instance.fitView(40);

instance.setNodes([
  ...nodes,
  { id: 'inserted', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: 'inserted' },
]);
instance.setEdges([
  ...edges,
  { id: 'x0', source: 'n2', target: 'inserted' },
  { id: 'x1', source: 'inserted', target: 'n4' },
]);
const result = await engine.layoutIncremental({ name: 'layered', changed: ['inserted'], radius: 1 });
instance.renderNow();
instance.fitView(40);

console.log(result.movement.total, result.tween.movingIds);
}

void main();

In Qwik, mount GrafloriaFlow with defaultNodes={baseNodes} and defaultEdges={baseEdges}, then wrap the handler below with $() and pass it as onInit$. It lays out the mounted chain, inserts a node between n2 and n4, and incrementally lays out the changed neighborhood.

ts
import type { DiagramInstance } from '@grafloria/qwik';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';

export const baseNodes: NodeSpec[] = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({
  id,
  position: { x: 0, y: 0 },
  size: { width: 110, height: 46 },
  label: id,
}));
export const baseEdges: EdgeSpec[] = [
  { id: 'e0', source: 'n0', target: 'n1' },
  { id: 'e1', source: 'n1', target: 'n2' },
  { id: 'e2', source: 'n2', target: 'n3' },
  { id: 'e3', source: 'n3', target: 'n4' },
  { id: 'e4', source: 'n4', target: 'n5' },
];

export async function onInit(instance: DiagramInstance): Promise<void> {
  const engine = instance.getEngine();
  await engine.layout('layered');
  instance.renderNow();
  instance.fitView(40);
  instance.setNodes([
    ...baseNodes,
    { id: 'inserted', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: 'inserted' },
  ]);
  instance.setEdges([
    ...baseEdges,
    { id: 'x0', source: 'n2', target: 'inserted' },
    { id: 'x1', source: 'inserted', target: 'n4' },
  ]);
  await engine.layoutIncremental({ name: 'layered', changed: ['inserted'], radius: 1 });
  instance.renderNow();
  instance.fitView(40);
}

The repository's Qwik dynamic-layouting component uses this onInit$ flow in a Qwik-optimized application.

The visible result is a laid-out chain with inserted connected after n2 and before n4. movement.total reports the total distance traveled by pre-existing nodes; tween.movingIds identifies which existing nodes move. The tween plan is data only: call result.tween.at(t) over normalized time values if your host wants to animate positions.

See the live incremental-layout demo and its source.

Options that shape the incremental pass

OptionTypeDefaultWhat it does
changedstring[][]Identifies newly added or edited node IDs. All other nodes count as existing for the movement report.
radiusnumber1Sets how many graph hops the affected region may spread from changed nodes.
budget{ maxPerNode?: number; averagePerNode?: number }—Sets a maximum permitted movement per existing node or average movement. The returned report includes withinBudget.

The incremental call's name option selects a registered layout. When prior positions came from another engine, an incremental pass can redraw the graph because the engines do not share a common layout geometry.

Pitfalls

  • Include the new node ID in changed; otherwise its default position can distort the baseline used to align the result.
  • A declarative layout prop reruns when the prop value changes, not when node data changes. Call the engine method for an on-demand pass; Angular also exposes applyLayout() for its bound layout.
  • Give the canvas wrapper a resolved height. The canvas fills its parent, so a zero-height parent renders a blank area.
  • In plain JavaScript and React, custom renderers are not used unless the node spec opts into the HTML layer with custom: true; see Lay out diagrams.

Was this page helpful?