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
- Mount a connected graph and lay it out with
layeredso its starting positions come from the same layout engine. In JavaScript,rendermounts the spec and returns the live instance; framework apps use their canvas component. - Add the new node and its edges to the live
DiagramInstance. Mark the new node inchanged, then calllayoutIncremental()on the engine returned bygetEngine(). - 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.
jsimport { 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();
tsimport { Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { LinkModel, NodeModel } from '@grafloria/engine';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
@Component({
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<button type="button" (click)="insertNode()">Insert node</button>
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
[layout]="'layered'" style="display:block; height:400px" />
`,
})
export class AppComponent {
readonly canvas = viewChild.required(DiagramCanvasComponent);
nodes: readonly (NodeSpec | NodeModel)[] | undefined = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({
id,
position: { x: 0, y: 0 },
size: { width: 110, height: 46 },
label: id,
}));
edges: readonly (EdgeSpec | LinkModel)[] | undefined = [
{ id: 'e0', source: 'n0', target: 'n1' },
{ id: 'e1', source: 'n1', target: 'n2' },
{ id: 'e2', source: 'n2', target: 'n3' },
{ id: 'e3', source: 'n3', target: 'n4' },
{ id: 'e4', source: 'n4', target: 'n5' },
];
insertNode(): void {
const engine = this.canvas().activeEngine();
if (!engine) return;
this.nodes = [
...(this.nodes ?? []),
{ id: 'inserted', position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: 'inserted' },
];
this.edges = [
...(this.edges ?? []),
{ id: 'x0', source: 'n2', target: 'inserted' },
{ id: 'x1', source: 'inserted', target: 'n4' },
];
setTimeout(() => {
const liveEngine = this.canvas().activeEngine();
if (liveEngine) void liveEngine.layoutIncremental({ name: 'layered', changed: ['inserted'], radius: 1 });
}, 0);
}
}
vue<script setup lang="ts"> import { GrafloriaFlow, type DiagramInstance } from '@grafloria/vue'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'].map((id) => ({ id, position: { x: 0, y: 0 }, size: { width: 110, height: 46 }, label: id, })); 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' }, ]; async function onInit(instance: DiagramInstance): Promise<void> { 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); } </script> <template> <div style="height:400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" @init="onInit" /> </div> </template>
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.
tsimport 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
| Option | Type | Default | What it does |
|---|---|---|---|
changed | string[] | [] | Identifies newly added or edited node IDs. All other nodes count as existing for the movement report. |
radius | number | 1 | Sets how many graph hops the affected region may spread from changed nodes. |
budget | { maxPerNode?: number; averagePerNode?: number } | — | Sets a maximum permitted movement per existing node or average movement. The returned report includes withinBudget. |
The incremental call's name option selects a registered layout. When prior positions came from another engine, an incremental pass can redraw the graph because the engines do not share a common layout geometry.
Pitfalls
- Include the new node ID in
changed; otherwise its default position can distort the baseline used to align the result. - A declarative
layoutprop reruns when the prop value changes, not when node data changes. Call the engine method for an on-demand pass; Angular also exposesapplyLayout()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.
Related
- Lay out diagrams for layout choices and declarative layout.
- Add nodes from palettes for reconciliation behavior when node IDs remain in the data.
- Command history for undoing user edits.
Was this page helpful?