diff --git a/projects/assets-library/assets/icons/folder.svg b/projects/assets-library/assets/icons/folder.svg
new file mode 100644
index 000000000..9ccbfdedc
--- /dev/null
+++ b/projects/assets-library/assets/icons/folder.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/projects/assets-library/src/icons/icon-library.module.ts b/projects/assets-library/src/icons/icon-library.module.ts
index 3512b0996..0f33a7edb 100644
--- a/projects/assets-library/src/icons/icon-library.module.ts
+++ b/projects/assets-library/src/icons/icon-library.module.ts
@@ -61,6 +61,7 @@ const iconsRootPath = 'assets/icons';
{ key: IconType.EyeVisibilityOff, url: `${iconsRootPath}/eye-visibility-off.svg` },
{ key: IconType.FileCode, url: `${iconsRootPath}/file-code.svg` },
{ key: IconType.Filter, url: `${iconsRootPath}/filter.svg` },
+ { key: IconType.Folder, url: `${iconsRootPath}/folder.svg` },
{ key: IconType.Go, url: `${iconsRootPath}/go.svg` },
{ key: IconType.Helm, url: `${iconsRootPath}/helm.svg` },
{ key: IconType.Hypertrace, url: `${iconsRootPath}/hypertrace.svg` },
diff --git a/projects/assets-library/src/icons/icon-type.ts b/projects/assets-library/src/icons/icon-type.ts
index ee682b297..bb931a7b4 100644
--- a/projects/assets-library/src/icons/icon-type.ts
+++ b/projects/assets-library/src/icons/icon-type.ts
@@ -66,6 +66,7 @@ export const enum IconType {
FileCode = 'svg:file-code',
Filter = 'svg:filter',
First = 'first_page',
+ Folder = 'svg:folder',
Go = 'svg:go',
Helm = 'svg:helm',
Home = 'home',
diff --git a/projects/observability/src/public-api.ts b/projects/observability/src/public-api.ts
index 58462c7b1..c03881ee7 100644
--- a/projects/observability/src/public-api.ts
+++ b/projects/observability/src/public-api.ts
@@ -297,6 +297,7 @@ export * from './shared/components/topology/renderers/node/topology-node-rendere
export * from './shared/components/topology/renderers/tooltip/topology-tooltip-renderer.service';
export * from './shared/components/topology/topology.component';
export * from './shared/components/topology/topology.module';
+export * from './shared/components/topology/d3/layouts/graph-layout';
export * from './shared/dashboard/data/graphql/topology/topology-data-source.model';
export * from './shared/dashboard/data/graphql/topology/metrics/topology-metric-category.model';
export * from './shared/dashboard/data/graphql/topology/metrics/topology-metric-with-category.model';
@@ -306,8 +307,10 @@ export * from './shared/dashboard/widgets/topology/edge/curved/entity-edge-curve
export * from './shared/dashboard/widgets/topology/node/box/api-node-renderer/api-node-box-renderer.service';
export * from './shared/dashboard/widgets/topology/node/box/backend-node-renderer/backend-node-box-renderer.service';
export * from './shared/dashboard/widgets/topology/node/box/service-node-renderer/service-node-box-renderer.service';
+export * from './shared/dashboard/widgets/topology/node/box/group-node/group-node-box-renderer.service';
export * from './shared/dashboard/widgets/topology/tooltip/topology-entity-tooltip.component';
export * from './shared/dashboard/widgets/topology/visibility-updater';
+export * from './shared/components/topology/utils/topology-group-node.util';
// Topology Metric
export * from './shared/dashboard/widgets/topology/metric/edge-metric-category';
diff --git a/projects/observability/src/shared/components/topology/d3/d3-topology.ts b/projects/observability/src/shared/components/topology/d3/d3-topology.ts
index bef91dcc1..6af510b5e 100644
--- a/projects/observability/src/shared/components/topology/d3/d3-topology.ts
+++ b/projects/observability/src/shared/components/topology/d3/d3-topology.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines */
import { ComponentPortal, DomPortalOutlet } from '@angular/cdk/portal';
import {
ApplicationRef,
@@ -44,6 +45,7 @@ import { CustomTreeLayout } from './layouts/custom-tree-layout';
import { ForceLayout } from './layouts/force-layout';
import { GraphLayout } from './layouts/graph-layout';
import { TreeLayout } from './layouts/tree-layout';
+import { TopologyGroupNodeUtil } from '../utils/topology-group-node.util';
export class D3Topology implements Topology {
private static readonly CONTAINER_CLASS: string = 'topology-internal-container';
@@ -62,8 +64,9 @@ export class D3Topology implements Topology {
protected container?: HTMLDivElement;
protected tooltip?: TopologyTooltip;
protected layout: TopologyLayout;
+ protected readonly supportGroupNode: boolean;
- protected readonly userNodes: TopologyNode[];
+ protected userNodes: TopologyNode[];
protected readonly nodeRenderer: TopologyNodeRenderer;
protected readonly edgeRenderer: TopologyEdgeRenderer;
protected readonly domRenderer: Renderer2;
@@ -84,18 +87,21 @@ export class D3Topology implements Topology {
this.edgeRenderer = config.edgeRenderer;
this.domRenderer = injector.get(Renderer2 as Type);
this.stateManager = new TopologyStateManager(this.config);
+ this.supportGroupNode = this.config.supportGroupNode ?? false;
this.topologyData = this.topologyConverter.convertTopology(
this.userNodes,
this.stateManager,
this.nodeRenderer,
this.domRenderer,
+ undefined,
+ this.supportGroupNode,
);
this.d3Util = injector.get(D3UtilService);
this.drag = new TopologyNodeDrag(this.d3Util, this.domRenderer);
this.hover = new TopologyHover(this.d3Util, this.domRenderer);
this.click = new TopologyClick(this.d3Util, this.domRenderer);
this.zoom = new TopologyZoom();
- this.layout = this.initializeLayout(config.layoutType);
+ this.layout = this.config.customLayout ?? this.initializeLayout(this.config.layoutType);
}
private initializeLayout(layoutType?: TopologyLayoutType): TopologyLayout {
@@ -159,8 +165,15 @@ export class D3Topology implements Topology {
}
private updateLayout(): void {
- this.layout.layout(this.topologyData, this.width, this.height);
- this.updatePositions();
+ if (this.supportGroupNode) {
+ this.runAndDrainCallbacks(this.dataClearCallbacks);
+ this.collapseGroupNodesIfPresent();
+ this.layout.layout(this.topologyData, this.width, this.height);
+ this.drawData(this.topologyData, this.nodeRenderer, this.edgeRenderer);
+ } else {
+ this.layout.layout(this.topologyData, this.width, this.height);
+ this.updatePositions();
+ }
}
private initializeContainer(): HTMLDivElement {
@@ -227,7 +240,9 @@ export class D3Topology implements Topology {
this.dataClearCallbacks.push(() => data.nodes.forEach(node => nodeRenderer.destroyNode(node)));
if (this.config.draggableNodes) {
- const subscription = this.drag.addDragBehavior(data, nodeRenderer).subscribe(event => this.onNodeDrag(event));
+ const subscription = this.drag
+ .addDragBehavior(data, nodeRenderer, this.supportGroupNode)
+ .subscribe(event => this.onNodeDrag(event));
this.dataClearCallbacks.push(() => subscription.unsubscribe());
}
if (this.config.hoverableNodes) {
@@ -323,6 +338,7 @@ export class D3Topology implements Topology {
this.nodeRenderer,
this.domRenderer,
this.topologyData,
+ this.supportGroupNode,
);
this.layout.layout(this.topologyData, this.width, this.height);
this.drawData(this.topologyData, this.nodeRenderer, this.edgeRenderer);
@@ -410,6 +426,10 @@ export class D3Topology implements Topology {
this.neighborhoodFinder.singleNodeNeighborhood(node.userNode),
);
+ if (this.supportGroupNode) {
+ this.checkAndHandleGroupNodeClick(node);
+ }
+
if (!isNil(this.config.nodeInteractionHandler?.click)) {
this.config.nodeInteractionHandler?.click(node.userNode).subscribe(() => this.resetVisibility());
} else if (this.tooltip) {
@@ -420,6 +440,35 @@ export class D3Topology implements Topology {
}
}
+ private checkAndHandleGroupNodeClick(node: RenderableTopologyNode): void {
+ const userNode = node.userNode;
+ if (!TopologyGroupNodeUtil.isTopologyGroupNode(userNode)) {
+ return;
+ }
+
+ this.userNodes = TopologyGroupNodeUtil.getUpdatedNodesOnGroupNodeClick(userNode, this.userNodes);
+ this.runAndDrainCallbacks(this.dataClearCallbacks);
+ this.convertTopology();
+ TopologyGroupNodeUtil.updateLayoutForGroupNode(this.topologyData, userNode);
+ this.drawData(this.topologyData, this.nodeRenderer, this.edgeRenderer);
+ }
+
+ private collapseGroupNodesIfPresent(): void {
+ this.userNodes = TopologyGroupNodeUtil.collapseGroupNodes(this.userNodes);
+ this.convertTopology();
+ }
+
+ private convertTopology(): void {
+ this.topologyData = this.topologyConverter.convertTopology(
+ this.userNodes,
+ this.stateManager,
+ this.nodeRenderer,
+ this.domRenderer,
+ this.topologyData,
+ this.supportGroupNode,
+ );
+ }
+
private onEdgeClick(edge: RenderableTopologyEdge): void {
this.emphasizeTopologyNeighborhood(this.neighborhoodFinder.neighborhoodForEdge(edge.userEdge));
@@ -445,6 +494,9 @@ export class D3Topology implements Topology {
});
break;
case 'drag':
+ if (this.supportGroupNode) {
+ TopologyGroupNodeUtil.updateLayoutOnGroupNodeDrag(dragEvent, this.topologyData);
+ }
this.updatePositions();
break;
default:
diff --git a/projects/observability/src/shared/components/topology/d3/interactions/drag/topology-node-drag.ts b/projects/observability/src/shared/components/topology/d3/interactions/drag/topology-node-drag.ts
index e30615885..cf1a35f9f 100644
--- a/projects/observability/src/shared/components/topology/d3/interactions/drag/topology-node-drag.ts
+++ b/projects/observability/src/shared/components/topology/d3/interactions/drag/topology-node-drag.ts
@@ -5,10 +5,12 @@ import {
RenderableTopology,
RenderableTopologyNode,
TopologyEdge,
+ TopologyGroupNode,
TopologyNode,
TopologyNodeRenderer,
} from '../../../topology';
import { TopologyEventBehavior } from '../topology-event-behavior';
+import { TopologyGroupNodeUtil } from '../../../utils/topology-group-node.util';
export class TopologyNodeDrag extends TopologyEventBehavior {
/**
@@ -18,11 +20,26 @@ export class TopologyNodeDrag extends TopologyEventBehavior {
public addDragBehavior(
topologyData: RenderableTopology,
nodeRenderer: TopologyNodeRenderer,
+ supportGroupNode: boolean,
): Observable {
if (topologyData.nodes.length === 0) {
return EMPTY;
}
- const nodeLookup = this.buildLookupMap(topologyData.nodes, node => nodeRenderer.getElementForNode(node));
+
+ let nodeLookup = this.buildLookupMap(topologyData.nodes, node => nodeRenderer.getElementForNode(node));
+
+ if (supportGroupNode) {
+ const childUserNodesOfGroup = topologyData.nodes
+ .filter(n => TopologyGroupNodeUtil.isTopologyGroupNode(n.userNode))
+ .flatMap(n => (n.userNode as TopologyGroupNode).children);
+ const childRenderableNodesOfGroup = topologyData.nodes.filter(n => childUserNodesOfGroup.includes(n.userNode));
+
+ nodeLookup = this.buildLookupMap(
+ topologyData.nodes.filter(n => !childRenderableNodesOfGroup.includes(n)),
+ node => nodeRenderer.getElementForNode(node),
+ );
+ }
+
const dragSubect = new Subject();
this.d3Utils.selectAll(Array.from(nodeLookup.keys()), this.domRenderer).call(
diff --git a/projects/observability/src/shared/components/topology/d3/layouts/graph-layout.ts b/projects/observability/src/shared/components/topology/d3/layouts/graph-layout.ts
index 50cf181ed..ae857487c 100644
--- a/projects/observability/src/shared/components/topology/d3/layouts/graph-layout.ts
+++ b/projects/observability/src/shared/components/topology/d3/layouts/graph-layout.ts
@@ -1,8 +1,11 @@
import { RenderableTopology, RenderableTopologyNode, TopologyEdge, TopologyNode } from '../../topology';
export class GraphLayout {
- private levelToNodesMap: Map = new Map([[0, []]]);
- private readonly nodeToLevelMap: Map = new Map();
+ protected levelToNodesMap: Map = new Map([
+ [0, []],
+ ]);
+ protected readonly nodeToLevelMap: Map = new Map();
+ private readonly layoutConfig: TopologyGraphLayoutConfig = this.getLayoutConfig();
public layout(topology: RenderableTopology): void {
this.initialize();
@@ -12,12 +15,12 @@ export class GraphLayout {
this.verticallyCenterAlignNodes();
}
- private initialize(): void {
+ protected initialize(): void {
this.levelToNodesMap = new Map([[0, []]]);
this.nodeToLevelMap.clear();
}
- private findRootNodes(topology: RenderableTopology): void {
+ protected findRootNodes(topology: RenderableTopology): void {
topology.nodes.forEach(node => {
if (node.incoming.length === 0) {
this.nodeToLevelMap.set(node, 0);
@@ -26,14 +29,14 @@ export class GraphLayout {
});
}
- private fillNodeAndLevelMaps(): void {
+ protected fillNodeAndLevelMaps(): void {
const goingToBeExploredNodes = [...(this.levelToNodesMap.get(0) ?? [])];
const goingToBeOrAlreadyExploredNodes = new Set([...goingToBeExploredNodes]);
this.levelOrderTraversal(goingToBeExploredNodes, goingToBeOrAlreadyExploredNodes);
}
- private levelOrderTraversal(
+ protected levelOrderTraversal(
goingToBeExploredNodes: RenderableTopologyNode[],
goingToBeOrAlreadyExploredNodes: Set,
): void {
@@ -60,31 +63,59 @@ export class GraphLayout {
this.levelOrderTraversal(goingToBeExploredNodes, goingToBeOrAlreadyExploredNodes);
}
- private assignCoordinatesToNodes(): void {
- let curX = 1;
+ protected assignCoordinatesToNodes(): void {
+ let curX = this.layoutConfig.startX;
Array.from(this.levelToNodesMap.values()).forEach(nodes => {
- let curY = 1;
+ let curY = this.layoutConfig.startY;
let maxWidth = 0;
nodes.forEach(node => {
node.x = curX;
node.y = curY;
- curY += (node.renderedData()?.getBoudingBox()?.height ?? 36) + 20;
- maxWidth = Math.max(maxWidth, node.renderedData()?.getBoudingBox()?.width ?? 400);
+ curY +=
+ (node.renderedData()?.getBoudingBox()?.height ?? this.layoutConfig.defaultNodeHeight) +
+ this.layoutConfig.verticalNodeGap;
+ maxWidth = Math.max(
+ maxWidth,
+ node.renderedData()?.getBoudingBox()?.width ?? this.layoutConfig.defaultNodeWidth,
+ );
});
- curX += maxWidth + 240;
+ curX += maxWidth + this.layoutConfig.horizontalNodeGap;
});
}
- private verticallyCenterAlignNodes(): void {
+ protected verticallyCenterAlignNodes(): void {
const longestLevelLength = Math.max(...Array.from(this.levelToNodesMap.values()).map(nodes => nodes.length));
Array.from(this.levelToNodesMap.values()).forEach(nodes => {
nodes.forEach(node => {
node.y +=
- ((longestLevelLength - nodes.length) * ((node.renderedData()?.getBoudingBox()?.height ?? 36) + 20)) / 2;
+ ((longestLevelLength - nodes.length) *
+ ((node.renderedData()?.getBoudingBox()?.height ?? this.layoutConfig.defaultNodeHeight) +
+ this.layoutConfig.verticalNodeGap)) /
+ 2;
});
});
}
+
+ protected getLayoutConfig(): TopologyGraphLayoutConfig {
+ return {
+ horizontalNodeGap: 240,
+ verticalNodeGap: 20,
+ startX: 1,
+ startY: 1,
+ defaultNodeWidth: 240,
+ defaultNodeHeight: 36,
+ };
+ }
+}
+
+export interface TopologyGraphLayoutConfig {
+ horizontalNodeGap: number;
+ verticalNodeGap: number;
+ startX: number;
+ startY: number;
+ defaultNodeWidth: number;
+ defaultNodeHeight: number;
}
diff --git a/projects/observability/src/shared/components/topology/topology.component.ts b/projects/observability/src/shared/components/topology/topology.component.ts
index 5bcf0d875..a0fa844f3 100644
--- a/projects/observability/src/shared/components/topology/topology.component.ts
+++ b/projects/observability/src/shared/components/topology/topology.component.ts
@@ -13,6 +13,7 @@ import {
TopologyDataSpecifier,
TopologyEdgeInteractionHandler,
TopologyEdgeRenderer,
+ TopologyLayout,
TopologyLayoutType,
TopologyNode,
TopologyNodeInteractionHandler,
@@ -58,9 +59,18 @@ export class TopologyComponent implements OnChanges, OnDestroy {
@Input()
public shouldAutoZoomToFit?: boolean = false;
+ @Input()
+ public draggableNodes?: boolean = true;
+
@Input()
public layoutType?: TopologyLayoutType;
+ @Input()
+ public customLayout?: TopologyLayout; // This will override `layoutType` property
+
+ @Input()
+ public supportGroupNode?: boolean;
+
@ViewChild('topologyContainer', { static: true })
private readonly container!: ElementRef;
@@ -86,9 +96,12 @@ export class TopologyComponent implements OnChanges, OnDestroy {
tooltipRenderer: this.tooltipRenderer,
showBrush: this.showBrush,
shouldAutoZoomToFit: this.shouldAutoZoomToFit,
+ draggableNodes: this.draggableNodes,
nodeInteractionHandler: this.nodeInteractionHandler,
edgeInteractionHandler: this.edgeInteractionHandler,
layoutType: this.layoutType,
+ customLayout: this.customLayout,
+ supportGroupNode: this.supportGroupNode,
});
// Angular doesn't like introducing new child views mid-change detection
diff --git a/projects/observability/src/shared/components/topology/topology.ts b/projects/observability/src/shared/components/topology/topology.ts
index 0a227e5c4..918d1e9ec 100644
--- a/projects/observability/src/shared/components/topology/topology.ts
+++ b/projects/observability/src/shared/components/topology/topology.ts
@@ -97,6 +97,10 @@ export interface TopologyConfiguration {
edgeInteractionHandler?: TopologyEdgeInteractionHandler;
layoutType?: TopologyLayoutType;
+
+ customLayout?: TopologyLayout;
+
+ supportGroupNode?: boolean;
}
export interface TopologyNode {
@@ -113,6 +117,23 @@ export interface TopologyNeighborhood {
edges: TopologyEdge[];
}
+export const enum TopologyInternalNodeType {
+ GroupNode = 'group-node',
+}
+
+export interface TopologyGroupNode {
+ nodeType: TopologyInternalNodeType.GroupNode;
+ edges: TopologyEdge[];
+ expanded: boolean;
+ data: TopologyGroupNodeData;
+ children: TopologyNode[];
+}
+
+export interface TopologyGroupNodeData {
+ title: string;
+ suffixIcon?: string;
+}
+
export interface TopologyNodeRenderer {
drawNode(parentElement: SVGSVGElement | SVGGElement, node: RenderableTopologyNode): void;
getRenderedNodeData(node: RenderableTopologyNode): RenderableTopologyNodeRenderedData | undefined;
diff --git a/projects/observability/src/shared/components/topology/utils/topology-converter.ts b/projects/observability/src/shared/components/topology/utils/topology-converter.ts
index fc754664e..6daebc0ab 100644
--- a/projects/observability/src/shared/components/topology/utils/topology-converter.ts
+++ b/projects/observability/src/shared/components/topology/utils/topology-converter.ts
@@ -10,6 +10,7 @@ import {
TopologyNodeRenderer,
TopologyNodeState,
} from '../topology';
+import { TopologyGroupNodeUtil } from './topology-group-node.util';
export class TopologyConverter {
public convertTopology(
@@ -18,6 +19,7 @@ export class TopologyConverter {
nodeRenderer: TopologyNodeRenderer,
domRenderer: Renderer2,
oldTopology?: RenderableTopology,
+ supportGroupNode: boolean = false,
): RenderableTopology {
const renderableNodeMap = this.buildRenderableNodeMap(
nodes,
@@ -30,7 +32,13 @@ export class TopologyConverter {
return {
nodes: Array.from(renderableNodeMap.values()),
- edges: this.convertEdgesToRenderableEdges(uniqueEdges, renderableNodeMap, domRenderer, stateManager),
+ edges: this.convertEdgesToRenderableEdges(
+ uniqueEdges,
+ renderableNodeMap,
+ domRenderer,
+ stateManager,
+ supportGroupNode,
+ ),
neighborhood: {
nodes: nodes,
edges: uniqueEdges,
@@ -89,8 +97,17 @@ export class TopologyConverter {
nodeMap: Map,
domRenderer: Renderer2,
stateManager: TopologyStateManager,
+ supportGroupNode: boolean = false,
): RenderableTopologyEdge[] {
- return edges.map(edge => {
+ let filteredEdges = edges;
+
+ if (supportGroupNode) {
+ filteredEdges = edges
+ .filter(edge => nodeMap.get(edge.fromNode) !== undefined && nodeMap.get(edge.toNode) !== undefined)
+ .filter(edge => this.handleEdgeFilteringBasedOnGroupNode(edge));
+ }
+
+ return filteredEdges.map(edge => {
const sourceNode = nodeMap.get(edge.fromNode)!;
const targetNode = nodeMap.get(edge.toNode)!;
@@ -98,6 +115,17 @@ export class TopologyConverter {
});
}
+ private handleEdgeFilteringBasedOnGroupNode(edge: TopologyEdge): boolean {
+ if (
+ (TopologyGroupNodeUtil.isTopologyGroupNode(edge.fromNode) && edge.fromNode.expanded) ||
+ (TopologyGroupNodeUtil.isTopologyGroupNode(edge.toNode) && edge.toNode.expanded)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
private buildNewTopologyNode(
node: TopologyNode,
state: TopologyNodeState,
diff --git a/projects/observability/src/shared/components/topology/utils/topology-group-node.util.ts b/projects/observability/src/shared/components/topology/utils/topology-group-node.util.ts
new file mode 100644
index 000000000..343c0d09a
--- /dev/null
+++ b/projects/observability/src/shared/components/topology/utils/topology-group-node.util.ts
@@ -0,0 +1,118 @@
+import { TopologyDragEvent } from '../d3/interactions/drag/topology-node-drag';
+import {
+ RenderableTopology,
+ TopologyEdge,
+ TopologyGroupNode,
+ TopologyInternalNodeType,
+ TopologyNode,
+} from '../topology';
+
+export abstract class TopologyGroupNodeUtil {
+ public static isTopologyGroupNode(node: TopologyNode): node is TopologyGroupNode {
+ return (
+ 'nodeType' in node &&
+ (node as TopologyNode & Partial).nodeType === TopologyInternalNodeType.GroupNode
+ );
+ }
+
+ public static getUpdatedNodesOnGroupNodeClick(
+ userNode: TopologyGroupNode,
+ userNodes: TopologyNode[],
+ ): TopologyNode[] {
+ const childNodes = userNode.children;
+ userNode.expanded = !userNode.expanded;
+
+ if (!userNode.expanded) {
+ return userNodes.filter(n => !childNodes.includes(n));
+ }
+
+ return [userNodes, childNodes].flat();
+ }
+
+ public static updateLayoutForGroupNode(
+ topology: RenderableTopology,
+ groupNode: TopologyGroupNode,
+ ): void {
+ const renderableGroupNode = topology.nodes.find(n => n.userNode === groupNode);
+ const renderableChildNodes = topology.nodes.filter(n => groupNode.children.includes(n.userNode));
+ const paddingLeft = 20;
+
+ if (!renderableGroupNode) {
+ return;
+ }
+
+ const boundingBox = renderableGroupNode.renderedData()?.getBoudingBox();
+ if (!boundingBox) {
+ return;
+ }
+
+ const nodesInPlane = topology.nodes.filter(n => {
+ const nodeBox = n.renderedData()?.getBoudingBox();
+ if (!nodeBox) {
+ return false;
+ }
+
+ return (
+ !groupNode.children.includes(n.userNode) &&
+ ((nodeBox.left >= boundingBox.left && nodeBox.left <= boundingBox.right) ||
+ (nodeBox.right >= boundingBox.left && nodeBox.right <= boundingBox.right)) &&
+ nodeBox.top > boundingBox.bottom
+ );
+ });
+ const space = (boundingBox.height + 20) * groupNode.children.length;
+
+ if (!groupNode.expanded) {
+ nodesInPlane.forEach(n => (n.y = n.y - space));
+
+ return;
+ }
+
+ if (renderableChildNodes.length === 0) {
+ return;
+ }
+
+ let curY = boundingBox.bottom + 20;
+
+ renderableChildNodes.forEach(childNode => {
+ childNode.x = renderableGroupNode.x + paddingLeft;
+ childNode.y = curY;
+ curY += (childNode.renderedData()?.getBoudingBox()?.height ?? 36) + 20;
+ });
+
+ nodesInPlane.forEach(n => (n.y = n.y + space));
+ }
+
+ public static updateLayoutOnGroupNodeDrag(
+ dragEvent: TopologyDragEvent,
+ topologyData: RenderableTopology,
+ ): void {
+ const userNode = dragEvent.node.userNode;
+ if (this.isTopologyGroupNode(userNode)) {
+ const childNodes = userNode.children;
+ let curY = dragEvent.node.y + (dragEvent.node.renderedData()?.getBoudingBox()?.height ?? 36) + 20;
+
+ topologyData.nodes.forEach(n => {
+ if (childNodes.includes(n.userNode)) {
+ n.x = 20 + dragEvent.node.x;
+ n.y = curY;
+ curY += (n.renderedData()?.getBoudingBox()?.height ?? 36) + 20;
+ }
+ });
+ }
+ }
+
+ public static collapseGroupNodes(userNodes: TopologyNode[]): TopologyNode[] {
+ const groupNodes = userNodes.filter((userNode): userNode is TopologyGroupNode =>
+ this.isTopologyGroupNode(userNode),
+ );
+ let updatedNodes: TopologyNode[] = userNodes;
+
+ groupNodes.forEach(groupNode => {
+ const childNodes = groupNode.children;
+ groupNode.expanded = false;
+ updatedNodes = updatedNodes.filter(n => !childNodes.includes(n));
+ });
+
+ return updatedNodes;
+ }
+}
diff --git a/projects/observability/src/shared/dashboard/widgets/topology/node/box/group-node/group-node-box-renderer.service.ts b/projects/observability/src/shared/dashboard/widgets/topology/node/box/group-node/group-node-box-renderer.service.ts
new file mode 100644
index 000000000..b894ff290
--- /dev/null
+++ b/projects/observability/src/shared/dashboard/widgets/topology/node/box/group-node/group-node-box-renderer.service.ts
@@ -0,0 +1,296 @@
+import { Injectable, Renderer2 } from '@angular/core';
+import { Selection } from 'd3-selection';
+import { TopologyNodeRendererDelegate } from '../../../../../../components/topology/renderers/node/topology-node-renderer.service';
+import {
+ TopologyCoordinates,
+ TopologyElementVisibility,
+ TopologyGroupNode,
+ TopologyNode,
+ TopologyNodeState,
+} from '../../../../../../components/topology/topology';
+import { D3UtilService } from '../../../../../../components/utils/d3/d3-util.service';
+import { Color } from '../../../../../../../../../common/src/public-api';
+import { IconType } from '../../../../../../../../../assets-library/src/public-api';
+import { take } from 'rxjs/operators';
+import { SvgUtilService } from '../../../../../../components/utils/svg/svg-util.service';
+import { TopologyGroupNodeUtil } from '../../../../../../components/topology/utils/topology-group-node.util';
+
+@Injectable()
+export class GroupNodeBoxRendererService implements TopologyNodeRendererDelegate {
+ private readonly groupNodeClass: string = 'group-node';
+ private readonly backgroundRectClass: string = 'background-rect';
+ private readonly dataRectClass: string = 'data-rect';
+ private readonly dropShadowFilterId: string = 'group-node-drop-shadow-filter';
+
+ public constructor(protected readonly d3Utils: D3UtilService, private readonly svgUtils: SvgUtilService) {}
+
+ public matches(node: TopologyNode & Partial): node is TopologyGroupNode {
+ return TopologyGroupNodeUtil.isTopologyGroupNode(node);
+ }
+
+ public draw(
+ nodeElement: SVGGElement,
+ node: TopologyGroupNode,
+ _state: TopologyNodeState,
+ domElementRenderer: Renderer2,
+ ): void {
+ const nodeSelection = this.d3Utils
+ .select(nodeElement, domElementRenderer)
+ .classed(this.groupNodeClass, true)
+ .style('cursor', 'pointer');
+
+ this.drawBackgroundRect(nodeSelection, node);
+ this.drawDataRect(nodeSelection);
+ this.drawTotalCountText(nodeSelection, node);
+ this.drawPrefixIcon(nodeSelection, domElementRenderer);
+ this.drawSuffixIcon(nodeSelection, node, domElementRenderer);
+ this.drawNodeTitle(nodeSelection, node);
+ this.defineDropShadowFilterIfNotExists(nodeElement, domElementRenderer);
+ }
+
+ public height(): number {
+ return this.boxHeight();
+ }
+
+ public width(): number {
+ return this.boxWidth();
+ }
+
+ public getAttachmentPoint(angle: number): TopologyCoordinates {
+ if (this.isAnglePerpendicularlyAbove(angle)) {
+ return {
+ x: this.boxWidth() / 2,
+ y: 0,
+ };
+ }
+
+ if (this.isAnglePerpendicularlyBelow(angle)) {
+ return {
+ x: this.boxWidth() / 2,
+ y: this.boxHeight(),
+ };
+ }
+
+ return {
+ x: this.isAngleInIVQuadrant(angle) || this.isAngleInIQuadrant(angle) ? this.boxWidth() : 0,
+ y: this.getCenterY(),
+ };
+ }
+
+ public updateState(
+ element: SVGGElement,
+ _node: TopologyGroupNode,
+ state: TopologyNodeState,
+ domElementRenderer: Renderer2,
+ ): void {
+ const nodeSelection = this.d3Utils.select(element, domElementRenderer);
+
+ if (
+ state.dragging ||
+ [TopologyElementVisibility.Emphasized, TopologyElementVisibility.Focused].includes(state.visibility)
+ ) {
+ nodeSelection.style('filter', `url(#${this.dropShadowFilterId})`);
+ } else {
+ nodeSelection.style('filter', '');
+ }
+
+ if (state.visibility === TopologyElementVisibility.Background) {
+ nodeSelection.style('opacity', 0.5);
+ } else {
+ nodeSelection.style('opacity', 1);
+ }
+ }
+
+ public destroy(_node: TopologyGroupNode): void {
+ // TODO: Add Later
+ }
+
+ // ### Draw functions ###
+
+ private drawBackgroundRect(
+ nodeSelection: Selection,
+ node: TopologyGroupNode,
+ ): void {
+ if (!node.expanded) {
+ nodeSelection
+ .append('rect')
+ .classed(this.backgroundRectClass, true)
+ .attr('x', 3)
+ .attr('y', 3)
+ .attr('width', this.width())
+ .attr('height', this.height())
+ .attr('stroke-width', 1)
+ .attr('rx', '8px')
+ .attr('ry', '8px')
+ .attr('fill', 'white')
+ .attr('stroke', Color.Gray1);
+ }
+ }
+
+ private drawDataRect(nodeSelection: Selection): void {
+ nodeSelection
+ .append('rect')
+ .classed(this.dataRectClass, true)
+ .attr('x', 0)
+ .attr('y', 0)
+ .attr('width', this.width())
+ .attr('height', this.height())
+ .attr('rx', '8px')
+ .attr('ry', '8px')
+ .attr('fill', Color.Gray1);
+ }
+
+ private drawTotalCountText(
+ nodeSelection: Selection,
+ node: TopologyGroupNode,
+ ): void {
+ nodeSelection
+ .append('text')
+ .text(node.children.length)
+ .attr('fill', Color.Blue4)
+ .attr('text-anchor', 'end')
+ .attr('font-size', '14px')
+ .attr('transform', `translate(${this.boxWidth() - 8}, -24)`)
+ .attr('y', this.getCenterY() - 1);
+ }
+
+ private drawPrefixIcon(
+ nodeSelection: Selection,
+ domElementRenderer: Renderer2,
+ ): void {
+ this.d3Utils
+ .buildIcon(IconType.Folder, domElementRenderer)
+ .pipe(take(1))
+ .subscribe(iconContent =>
+ nodeSelection
+ .append('g')
+ .classed('group-node-prefix-icon', true)
+ .append(() => iconContent)
+ .attr('width', this.nodeIconWidth())
+ .attr('height', this.nodeIconHeight())
+ .attr('x', this.boxPaddingLeft())
+ .attr('y', this.getCenterY() - this.nodeIconHeight() / 2),
+ );
+ }
+
+ private drawSuffixIcon(
+ nodeSelection: Selection,
+ node: TopologyGroupNode,
+ domElementRenderer: Renderer2,
+ ): void {
+ if (node.data.suffixIcon) {
+ this.d3Utils
+ .buildIcon(node.data.suffixIcon, domElementRenderer)
+ .pipe(take(1))
+ .subscribe(iconContent =>
+ nodeSelection
+ .append('g')
+ .classed('group-node-suffix-icon', true)
+ .append(() => iconContent)
+ .attr('width', this.nodeIconWidth())
+ .attr('height', this.nodeIconHeight())
+ .attr('x', this.boxWidth() - this.boxPaddingRight() - this.nodeIconWidth())
+ .attr('y', this.getCenterY() - this.nodeIconHeight() / 2),
+ );
+ }
+ }
+
+ private drawNodeTitle(
+ nodeSelection: Selection,
+ node: TopologyGroupNode,
+ ): void {
+ const titleStartX = this.boxPaddingLeft() + this.titleOffsetLeft() + this.nodeIconWidth();
+ const titleEndX =
+ this.boxWidth() - titleStartX - this.titleOffsetRight() - this.nodeIconWidth() - this.boxPaddingRight();
+ const titleWidth = titleEndX - titleStartX;
+ const truncatedText = this.getTruncatedText(node.data.title, titleWidth);
+
+ nodeSelection
+ .append('text')
+ .classed('group-node-title', true)
+ .text(truncatedText)
+ .attr('transform', `translate(${titleStartX} , 0)`)
+ .attr('y', this.getCenterY() - 1)
+ .attr('dominant-baseline', 'central');
+ }
+
+ private getTruncatedText(text: string, maxWidth: number): string {
+ const textWidth = text.length * this.getCharWidth();
+
+ if (textWidth <= maxWidth) {
+ return text;
+ }
+
+ const eachSideTotalChars = Math.floor((maxWidth - 3 * this.getCharWidth()) / 2 / this.getCharWidth());
+
+ return `${text.slice(0, eachSideTotalChars)}...${text.slice(text.length - eachSideTotalChars)}`;
+ }
+
+ private defineDropShadowFilterIfNotExists(element: SVGGElement, domElementRenderer: Renderer2): void {
+ this.svgUtils.addDropshadowFilterToParentSvgIfNotExists(
+ element,
+ this.dropShadowFilterId,
+ domElementRenderer,
+ Color.Gray2,
+ );
+ }
+
+ // ### Node Properties ###
+
+ protected boxWidth(): number {
+ return 240;
+ }
+
+ protected boxHeight(): number {
+ return 36;
+ }
+
+ protected getCenterY(): number {
+ return this.boxHeight() / 2;
+ }
+
+ private isAnglePerpendicularlyAbove(angle: number): boolean {
+ return angle === Math.PI / 2;
+ }
+
+ private isAngleInIVQuadrant(angle: number): boolean {
+ return angle > (3 * Math.PI) / 2 && angle <= 2 * Math.PI;
+ }
+
+ private isAngleInIQuadrant(angle: number): boolean {
+ return angle >= 0 && angle < Math.PI / 2;
+ }
+
+ private isAnglePerpendicularlyBelow(angle: number): boolean {
+ return angle === (3 * Math.PI) / 2;
+ }
+
+ private nodeIconWidth(): number {
+ return 14;
+ }
+
+ private nodeIconHeight(): number {
+ return 14;
+ }
+
+ private boxPaddingLeft(): number {
+ return 12;
+ }
+
+ private boxPaddingRight(): number {
+ return 12;
+ }
+
+ private titleOffsetLeft(): number {
+ return 8;
+ }
+
+ private titleOffsetRight(): number {
+ return 8;
+ }
+
+ // This is the average char width for the font sans-serif
+ private getCharWidth(): number {
+ return 6;
+ }
+}