Expand/Collapse Groups Example
since v1.4.0This example shows collapsible groups, including nested ones. Collapsing a group hides its members with the model hidden flag and shrinks the group to a header bar. Edges that cross the group boundary are temporarily rerouted to the group, so connections to the outside stay visible.
import { ChangeDetectionStrategy, Component, computed, inject, input,} from '@angular/core';import { NgDiagramGroupHighlightedDirective, NgDiagramModelService, NgDiagramNodeResizeAdornmentComponent, NgDiagramNodeSelectedDirective, NgDiagramService, type Edge, type GroupNode, type NgDiagramGroupNodeTemplate, type Size,} from 'ng-diagram';import { type CollapsibleGroupData, type ReroutedEdgeInfo } from '../types';
/** A partial edge update addressed by edge id. */type EdgeUpdate = Pick<Edge, 'id'> & Partial<Edge>;
/** A collapsed group shrinks to its header bar. */const COLLAPSED_SIZE: Size = { width: 200, height: 48 };
/** Size restored on expand when the group had none recorded. */const DEFAULT_EXPANDED_SIZE: Size = { width: 300, height: 200 };
/** The `source` or `target` end of an edge, pointed at the given node and port. */function endpointPatch( endpoint: 'source' | 'target', nodeId: string, portId?: string): Partial<Edge> { return endpoint === 'source' ? { source: nodeId, sourcePort: portId } : { target: nodeId, targetPort: portId };}
/** * Group node that can be collapsed to a header bar and expanded back. * * Collapsing sets the model `hidden` flag on the direct children of the group * and shrinks the group to a header bar. The library does the rest: descendants * of a hidden child group and edges connected to hidden nodes disappear * automatically, and hidden elements are ignored by hit-testing, selection and * zoomToFit bounds. Edges that cross the group boundary are temporarily * rerouted to the group node itself, so connections to the outside stay * visible while the group is collapsed. */@Component({ selector: 'app-collapsible-group-node', imports: [ NgDiagramNodeResizeAdornmentComponent, NgDiagramNodeSelectedDirective, NgDiagramGroupHighlightedDirective, ], templateUrl: './collapsible-group-node.component.html', styleUrls: ['./collapsible-group-node.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush,})export class CollapsibleGroupNodeComponent implements NgDiagramGroupNodeTemplate<CollapsibleGroupData> { private readonly modelService = inject(NgDiagramModelService); private readonly diagramService = inject(NgDiagramService);
node = input.required<GroupNode<CollapsibleGroupData>>();
collapsed = computed(() => !!this.node().data.collapsed); childCount = computed(() => { // getChildrenNested() is not signal-based; read nodes() first so this // computed re-evaluates when the model changes. this.modelService.nodes(); return this.modelService.getChildrenNested(this.node().id).length; });
/** Toggle between collapsed and expanded states. */ toggleCollapse(): void { if (this.collapsed()) { this.expandGroup(); } else { this.collapseGroup(); } }
/** * Collapse the group: hide its direct children, reroute boundary-crossing * edges to the group, and shrink the group to its header. */ private collapseGroup(): void { const node = this.node(); const groupId = node.id; const directChildren = this.modelService.getChildren(groupId); const nestedChildIds = new Set( this.modelService.getChildrenNested(groupId).map((child) => child.id) ); const reroutedEdges = this.findBoundaryEdges(nestedChildIds, groupId);
this.diagramService.transaction(() => { // Hiding the direct children is enough: descendants of a hidden child // group and edges connected to hidden nodes are hidden automatically // by the library. this.modelService.updateNodes( directChildren.map(({ id }) => ({ id, hidden: true })) );
// The endpoint inside the group now points at the group itself. this.modelService.updateEdges( reroutedEdges.map(({ edgeId, endpoint }) => ({ id: edgeId, ...endpointPatch(endpoint, groupId), })) );
this.modelService.updateNode(groupId, { size: COLLAPSED_SIZE, data: { ...node.data, collapsed: true, expandedSize: node.size ?? DEFAULT_EXPANDED_SIZE, reroutedEdges, }, }); }); }
/** * Expand the group: restore rerouted edges, unhide the direct children, * and restore the original size. */ private expandGroup(): void { const node = this.node(); const { expandedSize, reroutedEdges = [], ...data } = node.data; const restoreUpdates = this.buildRestoreUpdates(reroutedEdges, node.id); const directChildren = this.modelService.getChildren(node.id);
this.diagramService.transaction(() => { this.modelService.updateEdges(restoreUpdates);
// Unhiding the direct children is enough. A nested group that was // collapsed earlier keeps the hidden flags of its own children, so it // stays collapsed. this.modelService.updateNodes( directChildren.map(({ id }) => ({ id, hidden: false })) );
// The data no longer needs expandedSize and reroutedEdges once expanded. this.modelService.updateNode(node.id, { size: expandedSize ?? DEFAULT_EXPANDED_SIZE, data: { ...data, collapsed: false }, }); }); }
/** * Find edges with exactly one endpoint inside the group. That endpoint is * pointed at the group while the group is collapsed. Edges fully inside * the group need no handling: they are hidden together with their endpoints. */ private findBoundaryEdges( childIdSet: Set<string>, groupId: string ): ReroutedEdgeInfo[] { const reroutedEdges: ReroutedEdgeInfo[] = [];
for (const edge of this.modelService.getModel().getEdges()) { const sourceIsChild = childIdSet.has(edge.source); const targetIsChild = childIdSet.has(edge.target); if (sourceIsChild === targetIsChild) continue; // An edge between a child and the group itself would get both endpoints // on the group node, so it is skipped. if (edge.source === groupId || edge.target === groupId) continue;
reroutedEdges.push( sourceIsChild ? { edgeId: edge.id, endpoint: 'source', originalNodeId: edge.source, originalPortId: edge.sourcePort, } : { edgeId: edge.id, endpoint: 'target', originalNodeId: edge.target, originalPortId: edge.targetPort, } ); }
return reroutedEdges; }
/** * Edge updates that restore the recorded endpoints. An endpoint is restored * only when it still points at this group: an outer group may have pointed * it at itself in the meantime, and that change must stay until the outer * group expands. This keeps collapse and expand correct in any order. */ private buildRestoreUpdates( reroutedEdges: ReroutedEdgeInfo[], groupId: string ): EdgeUpdate[] { const updates: EdgeUpdate[] = [];
for (const { edgeId, endpoint, originalNodeId, originalPortId, } of reroutedEdges) { const edge = this.modelService.getEdgeById(edgeId); if (edge?.[endpoint] !== groupId) continue;
updates.push({ id: edgeId, ...endpointPatch(endpoint, originalNodeId, originalPortId), }); }
return updates; }}import { type Edge, type GroupNode, type Node } from 'ng-diagram';import { NodeTemplateType, type CollapsibleGroupData } from './types';
/** * Initial diagram model: an org-chart-like hierarchy with nested * collapsible groups and edges that cross group boundaries. */export const diagramModel: { nodes: (Node<{ label: string }> | GroupNode<CollapsibleGroupData>)[]; edges: Edge[];} = { nodes: [ // ── Standalone node ────────────────────────────────────────────── { id: 'cto', position: { x: 700, y: 20 }, data: { label: 'CTO' }, },
// ── Engineering (top-level group) ──────────────────────────────── { id: 'engineering', position: { x: 50, y: 140 }, size: { width: 1500, height: 780 }, autoSize: false, data: { title: 'Engineering' }, type: NodeTemplateType.CollapsibleGroupNode, isGroup: true, highlighted: false, },
// ── Team Alpha (nested inside Engineering) ─────────────────────── { id: 'team-alpha', position: { x: 100, y: 220 }, size: { width: 580, height: 470 }, autoSize: false, data: { title: 'Team Alpha' }, type: NodeTemplateType.CollapsibleGroupNode, isGroup: true, highlighted: false, groupId: 'engineering', },
// ── Frontend Squad (nested inside Team Alpha — 3 levels deep) ──── { id: 'frontend-squad', position: { x: 135, y: 300 }, size: { width: 510, height: 170 }, autoSize: false, data: { title: 'Frontend Squad' }, type: NodeTemplateType.CollapsibleGroupNode, isGroup: true, highlighted: false, groupId: 'team-alpha', }, { id: 'alice', position: { x: 170, y: 365 }, data: { label: 'Alice' }, groupId: 'frontend-squad', }, { id: 'bob', position: { x: 420, y: 365 }, data: { label: 'Bob' }, groupId: 'frontend-squad', },
// Direct children of Team Alpha (next to Frontend Squad) { id: 'charlie', position: { x: 170, y: 560 }, data: { label: 'Charlie' }, groupId: 'team-alpha', }, { id: 'diana', position: { x: 420, y: 560 }, data: { label: 'Diana' }, groupId: 'team-alpha', },
// ── Team Beta (nested inside Engineering) ──────────────────────── { id: 'team-beta', position: { x: 860, y: 220 }, size: { width: 580, height: 400 }, autoSize: false, data: { title: 'Team Beta' }, type: NodeTemplateType.CollapsibleGroupNode, isGroup: true, highlighted: false, groupId: 'engineering', }, { id: 'dave', position: { x: 900, y: 300 }, data: { label: 'Dave' }, groupId: 'team-beta', }, { id: 'eve', position: { x: 1190, y: 300 }, data: { label: 'Eve' }, groupId: 'team-beta', }, { id: 'frank', position: { x: 1040, y: 470 }, data: { label: 'Frank' }, groupId: 'team-beta', },
// Direct child of Engineering (next to the team groups) { id: 'tech-lead', position: { x: 700, y: 790 }, data: { label: 'Tech Lead' }, groupId: 'engineering', }, ], // Edges need no visibility handling of their own: an edge is hidden // whenever one of its endpoint nodes is hidden. edges: [ // Internal edge within Frontend Squad { id: 'e-alice-bob', source: 'alice', target: 'bob', data: {} },
// Internal edges within Team Alpha { id: 'e-charlie-diana', source: 'charlie', target: 'diana', data: {} }, { id: 'e-bob-charlie', source: 'bob', target: 'charlie', data: {} },
// Internal edges within Team Beta { id: 'e-dave-eve', source: 'dave', target: 'eve', data: {} }, { id: 'e-dave-frank', source: 'dave', target: 'frank', data: {} },
// Cross-group edge (Team Alpha child → Team Beta child) { id: 'e-diana-dave', source: 'diana', target: 'dave', data: {} },
// Engineering internal (child → Tech Lead) { id: 'e-charlie-techlead', source: 'charlie', target: 'tech-lead', data: {}, }, { id: 'e-frank-techlead', source: 'frank', target: 'tech-lead', data: {} },
// External edges (crossing the Engineering boundary) { id: 'e-cto-techlead', source: 'cto', target: 'tech-lead', data: {} }, { id: 'e-eve-cto', source: 'eve', target: 'cto', data: {} }, ],};import '@angular/compiler';
import { Component } from '@angular/core';import { initializeModel, NgDiagramBackgroundComponent, NgDiagramComponent, NgDiagramNodeTemplateMap, provideNgDiagram, type NgDiagramConfig,} from 'ng-diagram';import { diagramModel } from './data';import { CollapsibleGroupNodeComponent } from './node/collapsible-group-node.component';import { NodeTemplateType } from './types';
/** * Expand/Collapse Groups Example * * Demonstrates collapsible groups. Collapsing a group hides its members with * the `hidden` flag and shrinks the group to a header bar. Edges that cross the * group boundary are rerouted to the group, so external connections stay * visible. Nested groups keep their own collapsed state. */@Component({ selector: 'expand-collapse-groups-example', imports: [NgDiagramComponent, NgDiagramBackgroundComponent], template: ` <div class="not-content diagram"> <ng-diagram [model]="model" [config]="config" [nodeTemplateMap]="nodeTemplateMap" > <ng-diagram-background /> </ng-diagram> </div> `, styleUrl: './diagram.component.scss', providers: [provideNgDiagram()],})export class DiagramComponent { nodeTemplateMap = new NgDiagramNodeTemplateMap([ [NodeTemplateType.CollapsibleGroupNode, CollapsibleGroupNodeComponent], ]);
model = initializeModel(diagramModel);
config: NgDiagramConfig = { zoom: { zoomToFit: { onInit: true, }, }, resize: { defaultResizable: false, allowResizeBelowChildrenBounds: false, }, nodeRotation: { defaultRotatable: false, }, };}import { type Size } from 'ng-diagram';
/** Template identifiers for node types used in the diagram. */export enum NodeTemplateType { CollapsibleGroupNode = 'collapsibleGroupNode',}
/** * Records which end of an edge was redirected to the group during collapse, * so that exactly this end can be restored on expand. */export interface ReroutedEdgeInfo { /** ID of the rerouted edge. */ edgeId: string; /** The endpoint that was redirected to the group. */ endpoint: 'source' | 'target'; /** Node the endpoint pointed at before the reroute. */ originalNodeId: string; /** Port the endpoint used before the reroute, if any. */ originalPortId?: string;}
/** * Data of a collapsible group node. Holds the collapse state and the * information needed to restore the group on expand. */export interface CollapsibleGroupData { /** Display title rendered in the group header. */ title: string; /** Whether the group is currently collapsed. */ collapsed?: boolean; /** * Size of the group before it was collapsed, * used to restore the size on expand. */ expandedSize?: Size; /** * Edges that were rerouted to point at the group * during collapse. */ reroutedEdges?: ReroutedEdgeInfo[];}<!-- Groups stay resizable while expanded (the input overrides the diagram config, which turns resizing off for the member nodes). --><ng-diagram-node-resize-adornment [defaultResizable]="!collapsed()"> <div class="collapsible-group-node" [class.collapsed]="collapsed()" ngDiagramNodeSelected [node]="node()" ngDiagramGroupHighlighted > <div class="collapsible-group-node-header"> <button type="button" class="collapse-toggle" [attr.aria-expanded]="!collapsed()" [attr.aria-label]="collapsed() ? 'Expand group' : 'Collapse group'" (pointerdown)="$event.stopPropagation()" (click)="toggleCollapse()" > <!-- Chevron pointing right; rotates to point down while expanded. --> <svg class="collapse-toggle-icon" [class.expanded]="!collapsed()" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" > <path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" /> </svg> </button> <div class="collapsible-group-node-header-title"> {{ node().data.title }} </div> @if (collapsed()) { <span class="child-count">{{ childCount() }}</span> } </div> </div></ng-diagram-node-resize-adornment>:host { display: flex; width: 100%; height: 100%; border-radius: var(--ngd-group-border-radius);}
.collapsible-group-node { flex: 1; display: flex; flex-direction: column; background-color: var(--ngd-ui-bg-tertiary-default); border: 2px dashed var(--ngd-group-border-color); border-radius: var(--ngd-group-border-radius); overflow: hidden; transition: border-color 120ms ease;
&:hover { border-color: var(--ngd-group-border-color-hover); }}
.collapsible-group-node-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--ngd-ui-bg-tertiary-default); border-bottom: 1px solid var(--ngd-group-border-color); min-width: 130px;
.collapsed & { border-bottom: none; }}
.collapsible-group-node-header-title { font-size: 1rem; font-weight: 700; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ngd-txt-primary-default);}
.collapse-toggle { display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; padding: 0; border: 1px solid var(--ngd-node-stroke-primary-default); border-radius: 0.375rem; background: var(--ngd-node-bg-primary-default); color: var(--ngd-txt-primary-default); cursor: pointer; 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; }}
.collapse-toggle-icon { display: block; transition: transform 150ms ease;
&.expanded { transform: rotate(90deg); }}
.child-count { display: flex; align-items: center; justify-content: center; min-width: 1.375rem; height: 1.375rem; padding: 0 0.375rem; border-radius: 0.6875rem; background: var(--ngd-ui-bg-primary-default); border: 1px solid var(--ngd-node-stroke-primary-default); font-size: 0.75rem; font-weight: 600; color: var(--ngd-txt-primary-default);}.diagram { display: flex; height: var(--ng-diagram-height); border: var(--ng-diagram-border); margin-top: 0;}Additional Explanation
Section titled “Additional Explanation”Key Concepts
Section titled “Key Concepts”- Hiding with the
hiddenflag: Collapsing a group setshiddenonly on the group’s direct children, in oneupdateNodescall. The library does the rest: descendants of a hidden child group are hidden automatically, and so is every edge connected to a hidden node. - Nested groups keep their state: A collapsed nested group keeps the
hiddenflags of its own children. Collapsing and expanding an outer group does not change the collapsed state of the inner group, so you do not need to track nested visibility yourself. - No interaction workarounds: The library ignores hidden members in hit-testing,
selectAll, box selection,zoomToFitbounds and the minimap. Nopointer-eventsor CSS visibility workarounds are needed. - Boundary edge rerouting: An edge with exactly one endpoint inside the collapsed group would disappear together with its hidden endpoint. The example points that endpoint at the group node while the group is collapsed, and restores it on expand. Each group changes only the endpoint on its own side, so sibling and nested groups can be collapsed and expanded in any order.
- Group behavior: The group template uses the resize adornment,
ngDiagramNodeSelectedandngDiagramGroupHighlighted. See the Groups guide. An expanded group can be resized, but not below the bounds of its visible children.
Learn more: Conditional Visibility guide →