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; + } +}