Expand/Collapse Subtree Example
since v1.4.0This example shows a collapsible tree diagram. Each node with children has a toggle button. Collapsing a node hides its whole subtree with the model hidden flag, and ELK.js lays out the remaining visible nodes again.
import { type Edge, type Node } from 'ng-diagram';import { NodeTemplateType, type TreeNodeData } from './types';
export const diagramModel: { nodes: Node<TreeNodeData>[]; edges: Edge[];} = { nodes: [ { id: 'root', position: { x: 0, y: 0 }, data: { label: 'Application' }, type: NodeTemplateType.TreeNode, },
{ id: 'frontend', position: { x: 0, y: 0 }, data: { label: 'Frontend' }, type: NodeTemplateType.TreeNode, }, { id: 'components', position: { x: 0, y: 0 }, data: { label: 'Components', collapsed: true }, type: NodeTemplateType.TreeNode, }, // The "Components" subtree starts collapsed. Its children are visible in // the initial model, so they are measured at init. LayoutService sets // their `hidden` flag together with the first layout. { id: 'buttons', position: { x: 0, y: 0 }, data: { label: 'Buttons' }, type: NodeTemplateType.TreeNode, }, { id: 'forms', position: { x: 0, y: 0 }, data: { label: 'Forms' }, type: NodeTemplateType.TreeNode, }, { id: 'services', position: { x: 0, y: 0 }, data: { label: 'Services' }, type: NodeTemplateType.TreeNode, }, { id: 'routing', position: { x: 0, y: 0 }, data: { label: 'Routing' }, type: NodeTemplateType.TreeNode, },
{ id: 'backend', position: { x: 0, y: 0 }, data: { label: 'Backend' }, type: NodeTemplateType.TreeNode, }, { id: 'api', position: { x: 0, y: 0 }, data: { label: 'API' }, type: NodeTemplateType.TreeNode, }, { id: 'rest', position: { x: 0, y: 0 }, data: { label: 'REST' }, type: NodeTemplateType.TreeNode, }, { id: 'graphql', position: { x: 0, y: 0 }, data: { label: 'GraphQL' }, type: NodeTemplateType.TreeNode, }, { id: 'database', position: { x: 0, y: 0 }, data: { label: 'Database' }, type: NodeTemplateType.TreeNode, }, { id: 'auth', position: { x: 0, y: 0 }, data: { label: 'Auth' }, type: NodeTemplateType.TreeNode, },
{ id: 'devops', position: { x: 0, y: 0 }, data: { label: 'DevOps' }, type: NodeTemplateType.TreeNode, }, { id: 'ci', position: { x: 0, y: 0 }, data: { label: 'CI/CD' }, type: NodeTemplateType.TreeNode, }, { id: 'monitoring', position: { x: 0, y: 0 }, data: { label: 'Monitoring' }, type: NodeTemplateType.TreeNode, }, ], edges: [ { id: 'e-root-frontend', source: 'root', sourcePort: 'port-bottom', target: 'frontend', targetPort: 'port-top', data: {}, }, { id: 'e-root-backend', source: 'root', sourcePort: 'port-bottom', target: 'backend', targetPort: 'port-top', data: {}, }, { id: 'e-root-devops', source: 'root', sourcePort: 'port-bottom', target: 'devops', targetPort: 'port-top', data: {}, },
{ id: 'e-frontend-components', source: 'frontend', sourcePort: 'port-bottom', target: 'components', targetPort: 'port-top', data: {}, }, { id: 'e-frontend-services', source: 'frontend', sourcePort: 'port-bottom', target: 'services', targetPort: 'port-top', data: {}, }, { id: 'e-frontend-routing', source: 'frontend', sourcePort: 'port-bottom', target: 'routing', targetPort: 'port-top', data: {}, },
{ id: 'e-components-buttons', source: 'components', sourcePort: 'port-bottom', target: 'buttons', targetPort: 'port-top', data: {}, }, { id: 'e-components-forms', source: 'components', sourcePort: 'port-bottom', target: 'forms', targetPort: 'port-top', data: {}, },
{ id: 'e-backend-api', source: 'backend', sourcePort: 'port-bottom', target: 'api', targetPort: 'port-top', data: {}, }, { id: 'e-backend-database', source: 'backend', sourcePort: 'port-bottom', target: 'database', targetPort: 'port-top', data: {}, }, { id: 'e-backend-auth', source: 'backend', sourcePort: 'port-bottom', target: 'auth', targetPort: 'port-top', data: {}, },
{ id: 'e-api-rest', source: 'api', sourcePort: 'port-bottom', target: 'rest', targetPort: 'port-top', data: {}, }, { id: 'e-api-graphql', source: 'api', sourcePort: 'port-bottom', target: 'graphql', targetPort: 'port-top', data: {}, },
{ id: 'e-devops-ci', source: 'devops', sourcePort: 'port-bottom', target: 'ci', targetPort: 'port-top', data: {}, }, { id: 'e-devops-monitoring', source: 'devops', sourcePort: 'port-bottom', target: 'monitoring', targetPort: 'port-top', data: {}, }, ],};import '@angular/compiler';
import { Component, inject, signal } from '@angular/core';import { initializeModel, NgDiagramBackgroundComponent, NgDiagramComponent, NgDiagramNodeTemplateMap, NgDiagramViewportService, provideNgDiagram, type EdgeDrawEndedEvent, type NgDiagramConfig, type SelectionRemovedEvent,} from 'ng-diagram';import { diagramModel } from './data';import { LayoutService } from './layout.service';import { NodeComponent } from './node/node.component';import { NodeTemplateType } from './types';
/** * Expand/Collapse Subtree Example * * Shows a collapsible tree laid out by ELK.js. Nodes with children have a * toggle button that expands or collapses their subtree. Collapsing sets the * model `hidden` flag on the nodes of the subtree, and the edges leading to * them disappear automatically. Whether a node has children is derived from * its edges, so drawing or deleting an edge only requires a new layout. */@Component({ selector: 'expand-collapse-diagram-example', imports: [NgDiagramComponent, NgDiagramBackgroundComponent], template: ` <div class="not-content diagram" [class.ready]="isLayoutReady()"> <ng-diagram [model]="model" [config]="config" [nodeTemplateMap]="nodeTemplateMap" (edgeDrawEnded)="onEdgeDrawEnded($event)" (selectionRemoved)="onSelectionRemoved($event)" (diagramInit)="onDiagramInit()" > <ng-diagram-background /> </ng-diagram> </div> `, styleUrl: './diagram.component.scss', providers: [provideNgDiagram(), LayoutService],})export class DiagramComponent { private readonly viewportService = inject(NgDiagramViewportService); private readonly layoutService = inject(LayoutService);
protected isLayoutReady = signal(false);
nodeTemplateMap = new NgDiagramNodeTemplateMap([ [NodeTemplateType.TreeNode, NodeComponent], ]);
model = initializeModel(diagramModel);
config: NgDiagramConfig = { resize: { defaultResizable: false, }, nodeRotation: { defaultRotatable: false, }, };
/** * A drawn edge changes the tree structure, so the visible nodes are laid * out again. The event fires after the edge is committed to the model. */ async onEdgeDrawEnded(event: EdgeDrawEndedEvent): Promise<void> { if (!event.success) return;
await this.layoutService.applyLayout(); }
/** * Deleted edges change the tree structure, so the visible nodes are laid * out again. The event fires after the removal is committed to the model. */ async onSelectionRemoved(event: SelectionRemovedEvent): Promise<void> { if (event.deletedEdges.length === 0) return;
await this.layoutService.applyLayout(); }
/** * Hide the subtrees flagged as collapsed, run the ELK tree layout, then * fit the viewport to show all visible nodes. */ async onDiagramInit(): Promise<void> { await this.layoutService.applyInitialLayout(); await this.viewportService.zoomToFit(); this.isLayoutReady.set(true); }}import { inject, Injectable } from '@angular/core';import { NgDiagramModelService, NgDiagramService } from 'ng-diagram';import { performLayout, type PositionUpdate } from './perform-layout';import { type TreeNodeData } from './types';
/** * Manages tree layout and expand/collapse behavior. * * Uses ELK.js (via `performLayout`) to position visible nodes in a * top-down tree. Hidden nodes (inside collapsed subtrees) are excluded * from the layout pass so the tree stays compact. */@Injectable()export class LayoutService { private readonly diagramService = inject(NgDiagramService); private readonly modelService = inject(NgDiagramModelService);
/** * Apply the initial collapsed state and lay out the tree. * * Every node is visible in the initial model, so all of them are measured * by the time the diagram initializes. The subtrees of nodes marked * `collapsed` are hidden here, in the same transaction as the first * layout. From now on each node has a real size for every layout pass. * Nodes that are already hidden in the initial model stay hidden and are * left out of the layout. */ async applyInitialLayout(): Promise<void> { const hiddenIds = this.collapsedSubtreeIds(); const visibleIds = this.visibleNodeIds(); for (const id of hiddenIds) { visibleIds.delete(id); }
const positionUpdates = await this.computeLayout(visibleIds);
await this.diagramService.transaction(() => { this.modelService.updateNodes( [...hiddenIds].map((id) => ({ id, hidden: true })) ); this.modelService.updateNodes(positionUpdates); }); }
/** * Run the ELK tree layout on all visible nodes and edges and commit * the new positions. */ async applyLayout(): Promise<void> { const positionUpdates = await this.computeLayout(this.visibleNodeIds()); await this.modelService.updateNodes(positionUpdates); }
/** * Toggle the collapsed state of a node's subtree. * * The layout for the tree after the toggle is computed first. Then the * collapsed flag, the `hidden` flags of the subtree and the new positions * are committed in a single transaction, so nodes that appear are rendered * at their final position right away. */ async toggleCollapsed(nodeId: string): Promise<void> { const node = this.modelService.getNodeById<TreeNodeData>(nodeId);
if (!node) { return; }
const collapsed = !node.data.collapsed; const subtreeIds = this.computeAvailableSubtreeIds(nodeId);
const visibleIds = this.visibleNodeIds(); for (const id of subtreeIds) { if (collapsed) { visibleIds.delete(id); } else { visibleIds.add(id); } }
const positionUpdates = await this.computeLayout(visibleIds);
await this.diagramService.transaction(() => { this.modelService.updateNodeData<TreeNodeData>(nodeId, { ...node.data, collapsed, });
// Edges leading to hidden nodes disappear automatically: an edge is // hidden whenever one of its endpoint nodes is hidden. this.modelService.updateNodes( [...subtreeIds].map((id) => ({ id, hidden: collapsed })) );
this.modelService.updateNodes(positionUpdates); }); }
/** * Compute tree positions for the given nodes and the edges between them. * The root node is pinned to its current position so the tree doesn't * jump after a re-layout. */ private async computeLayout(nodeIds: Set<string>): Promise<PositionUpdate[]> { // Read through getModel(): right after an awaited update the // nodes()/edges() signals may not have refreshed yet. const model = this.modelService.getModel(); const nodes = model.getNodes().filter((node) => nodeIds.has(node.id)); const edges = model .getEdges() .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target));
const positionUpdates = await performLayout(nodes, edges);
// Offset every node so the root, the node without an incoming edge, // stays where it was before the layout. const targetIds = new Set(edges.map((edge) => edge.target)); const root = nodes.find((node) => !targetIds.has(node.id)); const laidOutRoot = positionUpdates.find( (update) => update.id === root?.id ); if (!root || !laidOutRoot) { return positionUpdates; } const dx = root.position.x - laidOutRoot.position.x; const dy = root.position.y - laidOutRoot.position.y;
return positionUpdates.map(({ id, position }) => ({ id, position: { x: position.x + dx, y: position.y + dy }, })); }
/** Ids of the nodes that are currently shown. */ private visibleNodeIds(): Set<string> { return new Set( this.modelService .getModel() .getNodes() .filter((node) => !node.hidden) .map((node) => node.id) ); }
/** * Ids of every node that has a `collapsed` ancestor. The walk starts below * each collapsed node and goes down through the whole subtree, visiting * every node once. */ private collapsedSubtreeIds(): Set<string> { const hiddenIds = new Set<string>(); const stack = this.modelService .getModel() .getNodes() .filter((node) => (node.data as TreeNodeData).collapsed) .flatMap((node) => this.childIds(node.id));
while (stack.length > 0) { const id = stack.pop()!; if (hiddenIds.has(id)) { continue; } hiddenIds.add(id); stack.push(...this.childIds(id)); }
return hiddenIds; }
/** * Collect the ids of all descendants of `nodeId`. The walk does not go * into children that are collapsed themselves, so their subtrees stay * hidden when a parent is expanded. */ private computeAvailableSubtreeIds(nodeId: string): Set<string> { const childrenIds = new Set<string>(); const stack = [nodeId];
while (stack.length > 0) { const parentId = stack.pop()!; for (const childId of this.childIds(parentId)) { childrenIds.add(childId);
const child = this.modelService.getNodeById<TreeNodeData>(childId); if (!child?.data.collapsed) { stack.push(childId); } } }
return childrenIds; }
/** Ids of the direct children of a node: the targets of its outgoing edges. */ private childIds(nodeId: string): string[] { return this.modelService .getConnectedEdges(nodeId) .filter((edge) => edge.source === nodeId) .map((edge) => edge.target); }}import { Component, computed, inject, input } from '@angular/core';import { NgDiagramBaseNodeTemplateComponent, NgDiagramModelService, NgDiagramPortComponent, type NgDiagramNodeTemplate, type Node,} from 'ng-diagram';import { LayoutService } from '../layout.service';import { type TreeNodeData } from '../types';
/** * Custom tree node template. * * Renders a labeled node with top/bottom ports for edge connections. * When the node has outgoing edges, a toggle button is shown to expand or * collapse the subtree. Nodes inside a collapsed subtree have the model * `hidden` flag set, so the library hides them and ignores them in every * interaction. The template does not need any visibility handling of its * own. */@Component({ imports: [NgDiagramPortComponent, NgDiagramBaseNodeTemplateComponent], templateUrl: './node.component.html', styleUrls: ['./node.component.scss'], host: { '[class.ng-diagram-port-hoverable-over-node]': 'true', },})export class NodeComponent implements NgDiagramNodeTemplate<TreeNodeData> { private readonly layoutService = inject(LayoutService); private readonly modelService = inject(NgDiagramModelService);
node = input.required<Node<TreeNodeData>>();
/** Whether the node has children, derived from its outgoing edges. */ hasChildren = computed(() => { // getConnectedEdges() is not signal-based; read edges() first so this // computed re-evaluates when the user draws or deletes edges. this.modelService.edges(); const id = this.node().id; return this.modelService .getConnectedEdges(id) .some((edge) => edge.source === id); });
/** Toggle the collapsed state of this node's subtree and re-layout. */ onToggle(): void { this.layoutService.toggleCollapsed(this.node().id); }}import ELK, { type ElkNode } from 'elkjs';import { type Edge, type Node, type Point } from 'ng-diagram';
/** A node position update addressed by node id. */export interface PositionUpdate { id: string; position: Point;}
// Single ELK instance reused across all layout calls.const elk = new ELK();
const layoutOptions = { 'elk.algorithm': 'mrtree', 'elk.direction': 'DOWN', 'spacing.nodeNode': '80',};
/** * Compute tree positions for the given nodes with ELK.js. A node that ELK * did not place keeps its current position. */export async function performLayout( nodes: Node[], edges: Edge[]): Promise<PositionUpdate[]> { const graph: ElkNode = { id: 'root-graph', layoutOptions, // ELK only needs the id and the measured size of each node. children: nodes.map(({ id, size }) => ({ id, ...size })), edges: edges.map(({ id, source, target }) => ({ id, sources: [source], targets: [target], })), };
const { children = [] } = await elk.layout(graph); const laidOut = new Map(children.map((node) => [node.id, node]));
return nodes.map(({ id, position }) => ({ id, position: { x: laidOut.get(id)?.x ?? position.x, y: laidOut.get(id)?.y ?? position.y, }, }));}export enum NodeTemplateType { TreeNode = 'treeNode',}
export interface TreeNodeData { label: string; collapsed?: boolean;}<!-- Base node template provides default selection, dragging, and sizing behavior. The default left/right ports are removed: a tree only links top to bottom. --><ng-diagram-base-node-template [node]="node()" [removeDefaultPorts]="true"> <div class="tree-node-content"> <span class="node-label">{{ node().data.label }}</span> </div></ng-diagram-base-node-template>
<!-- Expand/collapse button, rendered only when the node has outgoing edges. Stopping pointerdown prevents the click from selecting or dragging the node. -->@if (hasChildren()) { <button type="button" class="toggle-btn" [attr.aria-expanded]="!node().data.collapsed" [attr.aria-label]=" node().data.collapsed ? 'Expand subtree' : 'Collapse subtree' " (pointerdown)="$event.stopPropagation()" (click)="onToggle()" > <!-- Plus while collapsed, minus while expanded. --> <svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"> <path d="M1.5 6h9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> @if (node().data.collapsed) { <path d="M6 1.5v9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> } </svg> </button>}
<!-- Connection ports: the data links a parent's bottom port to a child's top port. --><ng-diagram-port id="port-bottom" type="both" side="bottom" /><ng-diagram-port id="port-top" type="both" side="top" />.diagram { display: flex; height: var(--ng-diagram-height); border: var(--ng-diagram-border); margin-top: 0;
// All nodes start at (0, 0), and the first ELK layout runs only after the // diagram has initialized. Keep the canvas invisible until the first layout // and zoom-to-fit are done. visibility: hidden;
&.ready { visibility: visible; }}:host { .tree-node-content { min-width: 10rem; display: flex; align-items: center; padding: 0 0.5rem; }
.node-label { flex: 1; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toggle-btn { width: 1.5rem; height: 1.5rem; display: flex; align-items: center; justify-content: center; padding: 0; border: 1px solid var(--ngd-node-stroke-primary-default); border-radius: 0.375rem; background: var(--ngd-node-bg-primary-default); box-shadow: 0 0 0 2px var(--ngd-node-bg-primary-default); color: var(--ngd-txt-primary-default); cursor: pointer; position: absolute; top: -0.75rem; right: -0.75rem; z-index: 2; transition: border-color 120ms ease, color 120ms ease;
&:hover { border-color: var(--ngd-node-stroke-primary-hover); color: var(--ngd-node-stroke-primary-hover); }
&:focus-visible { outline: 2px solid var(--ngd-node-stroke-primary-hover); outline-offset: 1px; }
svg { display: block; } }}Additional Explanation
Section titled “Additional Explanation”Key Concepts
Section titled “Key Concepts”- Hiding with the
hiddenflag: Collapsing a subtree setshiddenon its nodes in oneupdateNodescall. Edges do not need their own flag: an edge is hidden automatically when one of its endpoint nodes is hidden. Hidden elements are ignored by hit-testing, selection,zoomToFitbounds and measurement waits, so no CSS workarounds are needed. - Layout of visible nodes only: The ELK layout receives only visible nodes and edges, so a collapsed tree stays compact. Hidden nodes keep their size and position and are measured again automatically when they are expanded.
- Measured before hidden: Every node is visible in the initial model, so all nodes are measured before the first layout. The subtrees marked
collapsedare hidden in the same transaction as that layout. This way every node has a real size for each layout pass. A node that is alreadyhiddenin the initial model is never measured. If you need that, give the node asizeand mark its parent ascollapsed. Otherwise the layout has no size for the node after it is expanded. - Layout first, then one transaction: When a subtree is toggled, the layout for the new set of visible nodes is computed first. Then the collapsed flag, the
hiddenflags of the subtree and the new positions are saved in a singletransaction. Nodes that appear are rendered at their final position right away. - Fresh reads after awaited updates: Right after an awaited update, the
nodes()andedges()signals may not be refreshed yet. The layout service reads the latest committed state throughgetModel()instead (see State Management). - Toggle button derived from the edges: The node template shows the toggle button when the node has an outgoing edge. The check is a
computedthat reads theedges()signal, so the button appears and disappears as the user draws or deletes edges. No extra flag in the node data is needed. TheedgeDrawEndedandselectionRemovedevents only run the layout again.
Learn more: Conditional Visibility guide →