From 5e17532601e9426f29627100f20f95cd27f098bb Mon Sep 17 00:00:00 2001 From: Googler Date: Mon, 30 Jun 2025 23:16:33 -0700 Subject: [PATCH] Feature Addition ---- Implement External Nodes for Groups This commit introduces the concept of external nodes to Groups. It allows a node in the parent graph to be connected to a node in a group without having to include the external node in the group itself. The main changes are: - External Target: The External target provides a `Map` for each Group - `internalEdges` input in the Graph: This array is used to provide the edges that are connecting the Group with external nodes - Rendering logic: The external target node is rendered with a default proxy node template. - Rendering logic: The edges between the external nodes and internal nodes are rendered. This feature enable the following: - Connect a Node in the main DAG to a Node in a Group without having to declare the node in the Group - Connect a Group with external Nodes - Keep a consistent look for the group. - Create complex diagrams using subgraphs. Limitations - If two nodes are in different groups or have a parental difference of more than 1 then we can't show edges between them yet. - This feature currently works in layout direction left to right or right to left, while using layout top to bottom or bottom to top there is some shifting in x axis of the node PiperOrigin-RevId: 777884537 --- src/app/directed_acyclic_graph.ng.html | 1 + src/app/directed_acyclic_graph.spec.ts | 54 ++++- src/app/directed_acyclic_graph.ts | 2 + src/app/directed_acyclic_graph_raw.ng.html | 30 +++ src/app/directed_acyclic_graph_raw.ts | 198 +++++++++++++++++- .../graph_expanded_with_internal_edges.png | Bin 0 -> 26517 bytes src/app/test_resources/fake_data.ts | 48 +++++ 7 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 src/app/scuba_goldens/directed_acyclic_graph/chrome-linux/graph_expanded_with_internal_edges.png diff --git a/src/app/directed_acyclic_graph.ng.html b/src/app/directed_acyclic_graph.ng.html index 80326b7..ca1158b 100644 --- a/src/app/directed_acyclic_graph.ng.html +++ b/src/app/directed_acyclic_graph.ng.html @@ -72,6 +72,7 @@ [resolveReference]="resolveReference" (edgeLabelClick)="edgeLabelClick.emit($event)" (hoveredEdgeChange)="hoveredEdgeChange.emit($event)" + [internalEdges]="internalEdges" /> diff --git a/src/app/directed_acyclic_graph.spec.ts b/src/app/directed_acyclic_graph.spec.ts index 03b7a74..e26e4a7 100644 --- a/src/app/directed_acyclic_graph.spec.ts +++ b/src/app/directed_acyclic_graph.spec.ts @@ -25,10 +25,11 @@ import {ScreenshotTest} from '../screenshot_test'; import {ColorThemeLoader} from './color_theme_loader'; import {DagStateService} from './dag-state.service'; import {STATE_SERVICE_PROVIDER} from './dag-state.service.provider'; +import {type LayoutOptions, RankDirection} from './data_types_internal'; import {DirectedAcyclicGraph, DirectedAcyclicGraphModule, generateTheme} from './directed_acyclic_graph'; -import {DagNode as Node, type GraphSpec, type NodeRef} from './node_spec'; +import {DagEdge, DagNode as Node, type GraphSpec, type NodeRef} from './node_spec'; import {DirectedAcyclicGraphHarness} from './test_resources/directed_acyclic_graph_harness'; -import {createDagSkeletonWithCustomGroups, createDagSkeletonWithGroups, fakeGraph, fakeGraphWithColoredLabels, fakeGraphWithEdgeOffsets, fakeGraphWithLabelIcons, fakeGraphWithRotatedLabels} from './test_resources/fake_data'; +import {createDagSkeletonWithCustomGroups, createDagSkeletonWithGroups, createDagSkeletonWithNormalGroups, fakeGraph, fakeGraphWithColoredLabels, fakeGraphWithEdgeOffsets, fakeGraphWithLabelIcons, fakeGraphWithRotatedLabels} from './test_resources/fake_data'; import {initTestBed} from './test_resources/test_utils'; const FAKE_DATA: GraphSpec = @@ -167,6 +168,46 @@ describe('Directed Acyclic Graph Renderer', () => { }); }); + describe('with internal edges', () => { + let fixture: ComponentFixture; + + afterEach(fakeAsync(() => { + fixture.destroy(); + })); + + async function setup(options: { + hideControlNodeOnExpand?: boolean, + expanded?: boolean, + internalEdges?: DagEdge[] + } = {}) { + const { + hideControlNodeOnExpand = false, + expanded = false, + internalEdges = [], + } = options; + fixture = TestBed.createComponent(TestComponent); + const skeleton = createDagSkeletonWithNormalGroups(expanded); + const graphSpec = + Node.createFromSkeleton(skeleton.skeleton, skeleton.state); + fixture.componentRef.setInput('internalEdges', internalEdges); + fixture.componentRef.setInput( + 'rankDirection', RankDirection.LEFT_TO_RIGHT); + fixture.componentRef.setInput('graph', graphSpec); + fixture.detectChanges(); + await fixture.whenStable(); + } + + it('renders correctly with group expanded and internal edges visible', + async () => { + await setup({ + expanded: true, + internalEdges: [{from: 'client', to: 'node1'}] + }); + fixture.detectChanges(); + await fixture.whenStable(); + await screenShot.expectMatch(`graph_expanded_with_internal_edges`); + }); + }); describe('with group labels', () => { let fixture: ComponentFixture; @@ -298,6 +339,8 @@ describe('Directed Acyclic Graph Renderer', () => { [loading]="loading" [customNodeTemplates]="{'outlineBasic': outlineBasic}" [theme]="theme" + [internalEdges]="internalEdges" + [layout]="layout" > @@ -337,7 +380,14 @@ describe('Directed Acyclic Graph Renderer', () => { class TestComponent { @ViewChild('dagRender', {static: false}) dagRender!: DirectedAcyclicGraph; @Input() graph: GraphSpec = FAKE_DATA; + @Input() internalEdges: DagEdge[] = []; + @Input() rankDirection: RankDirection = RankDirection.TOP_TO_BOTTOM; @Input() followNode: NodeRef|null = null; @Input() loading = false; @Input() theme = generateTheme({}); + get layout(): LayoutOptions { + return { + rankDirection: this.rankDirection + } + } } diff --git a/src/app/directed_acyclic_graph.ts b/src/app/directed_acyclic_graph.ts index 78b8483..a011659 100644 --- a/src/app/directed_acyclic_graph.ts +++ b/src/app/directed_acyclic_graph.ts @@ -202,6 +202,8 @@ export class DirectedAcyclicGraph implements OnInit, OnDestroy { */ @Input() optimizeForOrm = false; + @Input() internalEdges: DagEdge[] = []; + @Input('sizeConfig') set sizeConfig(config) { this.$sizeConfig = config; diff --git a/src/app/directed_acyclic_graph_raw.ng.html b/src/app/directed_acyclic_graph_raw.ng.html index 90f759f..58e452f 100644 --- a/src/app/directed_acyclic_graph_raw.ng.html +++ b/src/app/directed_acyclic_graph_raw.ng.html @@ -12,6 +12,19 @@ *ngIf="visible" >
+ +
+ \ No newline at end of file diff --git a/src/app/directed_acyclic_graph_raw.ts b/src/app/directed_acyclic_graph_raw.ts index 07e79e7..4d85afa 100644 --- a/src/app/directed_acyclic_graph_raw.ts +++ b/src/app/directed_acyclic_graph_raw.ts @@ -18,7 +18,7 @@ import {LiveAnnouncer} from '@angular/cdk/a11y'; import {CdkDrag, CdkDragMove, CdkDragStart, DragDropModule} from '@angular/cdk/drag-drop'; import {CommonModule} from '@angular/common'; -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, EventEmitter, Input, KeyValueDiffer, KeyValueDiffers, NgModule, OnChanges, OnDestroy, OnInit, Optional, Output, QueryList, SimpleChanges, TemplateRef, ViewChildren} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, EventEmitter, Input, KeyValueDiffer, KeyValueDiffers, NgModule, OnChanges, OnDestroy, OnInit, Optional, Output, QueryList, SimpleChanges, TemplateRef, ViewChild, ViewChildren} from '@angular/core'; import * as dagre from '@dagrejs/dagre'; import {Subscription} from 'rxjs'; @@ -269,6 +269,8 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { private objDiffers: {[s: string]: KeyValueDiffer} = {}; memoizedEdgeCurvePoints: {[key: string]: Array<{x: number, y: number}>} = {}; + private externalTargetsCache = new Map< + string, Map>(); isDragging = false; @@ -356,6 +358,12 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { */ @Input() visible = true; + @Input() internalEdges: DagEdge[] = []; + @Input() + externalTargets: + Map = + new Map(); + @Input('nodes') set nodes(nodes: DagNode[]) { // Avoid pointer/reference stability, so that angular will pick up the @@ -388,6 +396,9 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { return this.$groups as EnhancedDagGroup[]; } + @ViewChild('defaultProxyNodeTemplate', {static: true}) + defaultProxyNodeTemplate!: TemplateRef; + @ViewChildren('subDag') subDags?: QueryList; @Input() hoveredEdge?: DagEdge; @@ -482,7 +493,9 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { } ngOnChanges(changes: SimpleChanges) { - if (changes['nodes'] || changes['edges'] || changes['groups']) { + if (changes['nodes'] || changes['edges'] || changes['groups'] || + changes['externalTargets']) { + this.generateRenderableData(); this.updateGraphLayoutAndReselect(); } } @@ -533,6 +546,176 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { return group.hasControlNode && !(group.hideControlNodeOnExpand && this.isGroupExpanded(group)); } + /** + * Converts a point in the parent's coordinates to the child's coordinates. + * Limitations: + * - The layout must be left to right or right to left then only it works + * properly. + * - This method only works when the group has default template. + * - With custom node template the x coordinate is not properly converted. + */ + convertParentPointToChild(point: Point, groupId: string): Point|undefined { + const group = this.nodeMap.groups[groupId]?.group as EnhancedDagGroup; + + if (!group || group.x === undefined || group.y === undefined) { + console.error(`Group ${groupId} not found or not yet positioned.`); + return undefined; + } + + // group.x and group.y are the center of the group in the parent's + // coordinates. + const groupOriginX = group.x - group.width / 2; + const groupOriginY = group.y - group.height / 2; + + // The padY is added to the height, and the group content is shifted down + // by padY So, we need to account for this shift. + const offsetY = group.padY ? group.padY : 0; + + return { + x: point.x - groupOriginX, + y: point.y - groupOriginY - offsetY, + }; + } + /** + * Calculates which nodes inside a given group are targeted by edges + * originating outside of that group. + * Limitations: + * - For the logic to work one of the endpoints of internal edges should be + * outside a group. + * - In cases where both nodes are in different groups then we can't show + * mapping between them. + */ + getExternalTargetsForGroup(group: DagGroup): + Map { + const targetsMap = + new Map(); + if (!group) return targetsMap; + const cachedResult = this.externalTargetsCache.get(group.id); + if (cachedResult) { + return cachedResult; + } + // Create a Set of all node/group IDs that are inside the target group + // for + // fast lookups. + const internalIds = new Set([ + ...group.nodes.map(n => n.id), + ...group.groups.map(g => g.id), + ]); + + // Find edges that cross the boundary into or out of the group. + for (const edge of this.edges) { + const sourceIsInternal = internalIds.has(edge.from); + const targetIsInternal = internalIds.has(edge.to); + const sourceIsGroup = edge.from === group.id; + const targetIsGroup = edge.to === group.id; + + // An incoming edge has an external source and targets the group. + if (!sourceIsInternal && targetIsGroup && edge.points?.length) { + let intersectionPoint = edge.points[edge.points.length - 1]; + + const childIntersectionPoint = this.convertParentPointToChild( + intersectionPoint, + group.id, + ); + if (childIntersectionPoint) { + intersectionPoint = childIntersectionPoint; + if (!targetsMap.has(edge.from)) { + targetsMap.set( + edge.from, {nodes: [], point: intersectionPoint, type: 'in'}); + } + } + } + // An outgoing edge has the group as source and targets an external node. + else if (sourceIsGroup && !targetIsInternal && edge.points?.length) { + let intersectionPoint = edge.points[0]; + + const childIntersectionPoint = this.convertParentPointToChild( + intersectionPoint, + group.id, + ); + if (childIntersectionPoint) { + intersectionPoint = childIntersectionPoint; + if (!targetsMap.has(edge.to)) { + targetsMap.set( + edge.to, {nodes: [], point: intersectionPoint, type: 'out'}); + } + } + } + } + + // Find internal nodes connected to the external nodes + for (const edge of this.internalEdges) { + const sourceIsExternal = targetsMap.has(edge.from); + const targetIsInternal = internalIds.has(edge.to); + const sourceIsInternal = internalIds.has(edge.from); + const targetIsExternal = targetsMap.has(edge.to); + + if (sourceIsExternal && targetIsInternal) { + targetsMap.get(edge.from)!.nodes.push(edge.to); + } else if (sourceIsInternal && targetIsExternal) { + targetsMap.get(edge.to)!.nodes.push(edge.from); + } + } + // Remove entries with no node connections + for (const [key, value] of targetsMap.entries()) { + if (value.nodes.length === 0) { + targetsMap.delete(key); + } + } + + this.externalTargetsCache.set(group.id, targetsMap); + return targetsMap; + } + + generateRenderableData() { + // Remove existing proxy nodes and edges + this.nodes = this.nodes.filter(node => !node.id.startsWith('__proxy_in_')); + this.edges = + this.edges.filter(edge => !edge.from.startsWith('__proxy_in_')); + this.edges = this.edges.filter(edge => !edge.to.startsWith('__proxy_in_')) + + let proxyNodes: DagNode[] = []; + let proxyEdges: DagEdge[] = []; + // Start with the original data + // If there are external targets, create and inject the proxy node and + // edges + if (this.externalTargets && this.externalTargets.size > 0) { + // Sort the external targets based on the Y coordinate of their + // intersection point + const sortedTargetIds = + Array.from(this.externalTargets.keys()).sort((a, b) => { + const pointY_A = this.externalTargets.get(a)!.point.y; + const pointY_B = this.externalTargets.get(b)!.point.y; + return pointY_A - pointY_B; + }); + + for (const targetId of sortedTargetIds) { + const targetData = this.externalTargets.get(targetId)!; + const PROXY_NODE_ID = `__proxy_in_${targetId}`; + const proxyNode = new CustomNode( + new DagNode(PROXY_NODE_ID, 'artifact'), + 'proxyNode', // Or your custom template ref + 5, // Width + 5, // Height + ); + proxyNodes.push(proxyNode); + for (let j = 0; j < targetData.nodes.length; j++) { + const targetNodeId = targetData.nodes[j]; + const PROXY_EDGE_ID = `${PROXY_NODE_ID}_${targetNodeId}`; + const isOutgoing = targetData.type === 'out'; + const proxyEdge = { + from: isOutgoing ? targetNodeId : PROXY_NODE_ID, + to: isOutgoing ? PROXY_NODE_ID : targetNodeId, + style: 'dashed', + }; + proxyEdges.push(proxyEdge); + } + } + // Add the new elements to our renderable arrays + this.nodes = [...proxyNodes, ...this.nodes]; + this.edges = [...this.edges, ...proxyEdges]; + } + } showGroupLabel(group: DagGroup) { if (!group.groupLabel) return false; @@ -598,6 +781,9 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { getCustomNodeTemplateFor(node: CustomNode) { const {templateRef} = node; + if (templateRef === 'proxyNode' && !this.customNodeTemplates['proxyNode']) { + return this.defaultProxyNodeTemplate; + } return this.customNodeTemplates[templateRef]; } @@ -672,7 +858,7 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { this.cdr.detectChanges(); return; } - + this.externalTargetsCache.clear(); this.getNodesAndWatch(); const g = new dagre.graphlib.Graph(); this.dagreGraph = g; @@ -1084,6 +1270,12 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy { ]; } + buildProxyPath(start: Point, end: Point): string { + const [cp1, cp2] = this.getControlPointsForBezierCurve(start, end); + return `M${start.x},${start.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${ + end.x},${end.y}`; + } + /** * When the edge is in a reversed direction, in order to avoid overlapping * between the edges and nodes, the position of the edges in the reversed diff --git a/src/app/scuba_goldens/directed_acyclic_graph/chrome-linux/graph_expanded_with_internal_edges.png b/src/app/scuba_goldens/directed_acyclic_graph/chrome-linux/graph_expanded_with_internal_edges.png new file mode 100644 index 0000000000000000000000000000000000000000..9efc563e66cd1aacad72e9e051063ecfd096cd17 GIT binary patch literal 26517 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 literal 0 HcmV?d00001 diff --git a/src/app/test_resources/fake_data.ts b/src/app/test_resources/fake_data.ts index 96652e1..c9895ee 100644 --- a/src/app/test_resources/fake_data.ts +++ b/src/app/test_resources/fake_data.ts @@ -612,6 +612,54 @@ export function createDagSkeletonWithCustomGroups(expanded: boolean): }; } +export function createDagSkeletonWithNormalGroups(expanded: boolean): + DagSkeleton { + return { + skeleton: [{ + id: 'client', + type: 'execution', + next: [ + { + id: 'normalGroup', + type: 'group', + definition: [ + { + id: 'node1', + type: 'execution', + next: [ + { + id: 'node2', + type: 'execution', + }, + ], + }, + ], + next: [ + { + id: 'backend', + type: 'execution', + }, + ], + }, + ], + } as DagNodeSkeleton], + state: { + 'client': { + state: 'NO_STATE_RUNTIME', + }, + 'backend': { + state: 'NO_STATE_RUNTIME', + }, + 'normalGroup': { + state: 'NO_STATE_RUNTIME', + displayName: 'Custom group node', + hasControlNode: true, + expanded, + }, + } as StateTable, + }; +} + export function createDagSkeletonWithGroups(treatAsLoop: boolean): DagSkeleton { return { state: {