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 0000000..9efc563 Binary files /dev/null and b/src/app/scuba_goldens/directed_acyclic_graph/chrome-linux/graph_expanded_with_internal_edges.png differ 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: {