Skip to content

Commit 1b6dd60

Browse files
Googlercopybara-github
authored andcommitted
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<string, { nodes: string[], point: Point, type: 'in'|'out' }>` 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. PiperOrigin-RevId: 777884537
1 parent 62b3548 commit 1b6dd60

4 files changed

Lines changed: 209 additions & 2 deletions

File tree

src/app/directed_acyclic_graph.ng.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
[resolveReference]="resolveReference"
7373
(edgeLabelClick)="edgeLabelClick.emit($event)"
7474
(hoveredEdgeChange)="hoveredEdgeChange.emit($event)"
75+
[internalEdges]="internalEdges"
7576
/>
7677
</section>
7778
</figure>

src/app/directed_acyclic_graph.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ export class DirectedAcyclicGraph implements OnInit, OnDestroy {
202202
*/
203203
@Input() optimizeForOrm = false;
204204

205+
@Input() internalEdges: DagEdge[] = [];
206+
205207
@Input('sizeConfig')
206208
set sizeConfig(config) {
207209
this.$sizeConfig = config;

src/app/directed_acyclic_graph_raw.ng.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@
6969
/>
7070
</g>
7171
</ng-container>
72+
<ng-container *ngFor="let item of externalTargets | keyvalue">
73+
<ng-container *ngFor="let node of nodes">
74+
<path
75+
*ngIf="node.id.startsWith('__proxy_in_')&& node.id.endsWith(item.key)"
76+
class="line proxy-line"
77+
[attr.d]="buildProxyPath(item.value.point,{x:node.x, y:node.y})"
78+
stroke="grey"
79+
stroke-width="2"
80+
fill="none"
81+
style="stroke-dasharray: 5, 5;"
82+
/>
83+
</ng-container>
84+
</ng-container>
7285
</svg>
7386
<figcaption
7487
aria-hidden="true"
@@ -366,6 +379,8 @@
366379
(graphResize)="storeSubDagDims($event, group)"
367380
[resolveReference]="resolveReference"
368381
[visible]="isGroupExpanded(group)"
382+
[externalTargets]="getExternalTargetsForGroup(group)"
383+
[internalEdges]="internalEdges"
369384
></ai-dag-raw>
370385
<ng-container *ngIf="!!(group.treatAsLoop && group._cachedSelection)">
371386
<ai-dag-raw
@@ -430,3 +445,18 @@
430445
</div>
431446
</ng-container>
432447
</ng-template>
448+
449+
<ng-template #defaultProxyNodeTemplate let-node="node">
450+
<div
451+
[style.width.px]="node.width"
452+
[style.height.px]="node.height"
453+
[style.background]="'#666'"
454+
[style.border-radius]="'50%'"
455+
[style.display]="'flex'"
456+
[style.align-items]="'center'"
457+
[style.justify-content]="'center'"
458+
title="Connection Point"
459+
>
460+
<!-- No text content -->
461+
</div>
462+
</ng-template>

src/app/directed_acyclic_graph_raw.ts

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import {LiveAnnouncer} from '@angular/cdk/a11y';
1919
import {CdkDrag, CdkDragMove, CdkDragStart, DragDropModule} from '@angular/cdk/drag-drop';
2020
import {CommonModule} from '@angular/common';
21-
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, EventEmitter, Input, KeyValueDiffer, KeyValueDiffers, NgModule, OnChanges, OnDestroy, OnInit, Optional, Output, QueryList, SimpleChanges, TemplateRef, ViewChildren} from '@angular/core';
21+
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, EventEmitter, Input, KeyValueDiffer, KeyValueDiffers, NgModule, OnChanges, OnDestroy, OnInit, Optional, Output, QueryList, SimpleChanges, TemplateRef, ViewChild, ViewChildren} from '@angular/core';
2222
import * as dagre from '@dagrejs/dagre';
2323
import {Subscription} from 'rxjs';
2424

@@ -356,6 +356,12 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy {
356356
*/
357357
@Input() visible = true;
358358

359+
@Input() internalEdges: DagEdge[] = [];
360+
@Input()
361+
externalTargets:
362+
Map<string, {nodes: string[], point: Point, type: 'in'|'out'}> =
363+
new Map();
364+
359365
@Input('nodes')
360366
set nodes(nodes: DagNode[]) {
361367
// Avoid pointer/reference stability, so that angular will pick up the
@@ -388,6 +394,9 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy {
388394
return this.$groups as EnhancedDagGroup[];
389395
}
390396

397+
@ViewChild('defaultProxyNodeTemplate', {static: true})
398+
defaultProxyNodeTemplate!: TemplateRef<any>;
399+
391400
@ViewChildren('subDag') subDags?: QueryList<DagRaw>;
392401

393402
@Input() hoveredEdge?: DagEdge;
@@ -482,7 +491,9 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy {
482491
}
483492

484493
ngOnChanges(changes: SimpleChanges) {
485-
if (changes['nodes'] || changes['edges'] || changes['groups']) {
494+
if (changes['nodes'] || changes['edges'] || changes['groups'] ||
495+
changes['externalTargets']) {
496+
this.generateRenderableData();
486497
this.updateGraphLayoutAndReselect();
487498
}
488499
}
@@ -533,6 +544,158 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy {
533544
return group.hasControlNode &&
534545
!(group.hideControlNodeOnExpand && this.isGroupExpanded(group));
535546
}
547+
/**
548+
* Converts a point in the parent's coordinates to the child's coordinates.
549+
*/
550+
convertParentPointToChild(point: Point, groupId: string): Point|undefined {
551+
const group = this.nodeMap.groups[groupId]?.group as EnhancedDagGroup;
552+
553+
if (!group || group.x === undefined || group.y === undefined) {
554+
console.error(`Group ${groupId} not found or not yet positioned.`);
555+
return undefined;
556+
}
557+
558+
// group.x and group.y are the center of the group in the parent's
559+
// coordinates.
560+
const groupOriginX = group.x - group.width / 2;
561+
const groupOriginY = group.y - group.height / 2;
562+
563+
// The padY is added to the height, and the group content is shifted down
564+
// by padY So, we need to account for this shift.
565+
const offsetY = group.padY ? group.padY : 0;
566+
// const offsetY = 0;
567+
568+
return {
569+
x: point.x - groupOriginX,
570+
y: point.y - groupOriginY - offsetY,
571+
};
572+
}
573+
/**
574+
* Calculates which nodes inside a given group are targeted by edges
575+
* originating outside of that group.
576+
*/
577+
getExternalTargetsForGroup(group: DagGroup):
578+
Map<string, {nodes: string[], point: Point, type: 'in'|'out'}> {
579+
const targetsMap =
580+
new Map<string, {nodes: string[], point: Point, type: 'in' | 'out'}>();
581+
if (!group) return targetsMap;
582+
583+
// Create a Set of all node/group IDs that are inside the target group
584+
// for
585+
// fast lookups.
586+
const internalIds = new Set([
587+
...group.nodes.map(n => n.id),
588+
...group.groups.map(g => g.id),
589+
]);
590+
591+
// Find edges that cross the boundary into or out of the group.
592+
for (const edge of this.edges) {
593+
const sourceIsInternal = internalIds.has(edge.from);
594+
const targetIsInternal = internalIds.has(edge.to);
595+
const sourceIsGroup = edge.from === group.id;
596+
const targetIsGroup = edge.to === group.id;
597+
598+
// An incoming edge has an external source and targets the group.
599+
if (!sourceIsInternal && targetIsGroup && edge.points?.length) {
600+
let intersectionPoint = edge.points[edge.points.length - 1];
601+
602+
const childIntersectionPoint = this.convertParentPointToChild(
603+
intersectionPoint,
604+
group.id,
605+
);
606+
if (childIntersectionPoint) {
607+
intersectionPoint = childIntersectionPoint;
608+
if (!targetsMap.has(edge.from)) {
609+
targetsMap.set(
610+
edge.from, {nodes: [], point: intersectionPoint, type: 'in'});
611+
}
612+
}
613+
}
614+
// An outgoing edge has the group as source and targets an external node.
615+
else if (sourceIsGroup && !targetIsInternal && edge.points?.length) {
616+
let intersectionPoint = edge.points[0];
617+
618+
const childIntersectionPoint = this.convertParentPointToChild(
619+
intersectionPoint,
620+
group.id,
621+
);
622+
if (childIntersectionPoint) {
623+
intersectionPoint = childIntersectionPoint;
624+
if (!targetsMap.has(edge.to)) {
625+
targetsMap.set(
626+
edge.to, {nodes: [], point: intersectionPoint, type: 'out'});
627+
}
628+
}
629+
}
630+
}
631+
632+
// Find internal nodes connected to the external nodes
633+
for (const edge of this.internalEdges) {
634+
const sourceIsExternal = targetsMap.has(edge.from);
635+
const targetIsInternal = internalIds.has(edge.to);
636+
const sourceIsInternal = internalIds.has(edge.from);
637+
const targetIsExternal = targetsMap.has(edge.to);
638+
639+
if (sourceIsExternal && targetIsInternal) {
640+
targetsMap.get(edge.from)!.nodes.push(edge.to);
641+
} else if (sourceIsInternal && targetIsExternal) {
642+
targetsMap.get(edge.to)!.nodes.push(edge.from);
643+
}
644+
}
645+
646+
return targetsMap;
647+
}
648+
649+
generateRenderableData() {
650+
// Remove existing proxy nodes and edges
651+
this.nodes = this.nodes.filter(node => !node.id.startsWith('__proxy_in_'));
652+
this.edges =
653+
this.edges.filter(edge => !edge.from.startsWith('__proxy_in_'));
654+
this.edges = this.edges.filter(edge => !edge.to.startsWith('__proxy_in_'))
655+
656+
let proxyNodes: DagNode[] = [];
657+
let proxyEdges: DagEdge[] = [];
658+
// Start with the original data
659+
// If there are external targets, create and inject the proxy node and
660+
// edges
661+
if (this.externalTargets && this.externalTargets.size > 0) {
662+
// Sort the external targets based on the Y coordinate of their
663+
// intersection point
664+
const sortedTargetIds =
665+
Array.from(this.externalTargets.keys()).sort((a, b) => {
666+
const pointY_A = this.externalTargets.get(a)!.point.y;
667+
const pointY_B = this.externalTargets.get(b)!.point.y;
668+
return pointY_A - pointY_B;
669+
});
670+
671+
for (const targetId of sortedTargetIds) {
672+
console.log('targetId', this.externalTargets.get(targetId)!.point.y);
673+
const targetData = this.externalTargets.get(targetId)!;
674+
const PROXY_NODE_ID = `__proxy_in_${targetId}`;
675+
const proxyNode = new CustomNode(
676+
new DagNode(PROXY_NODE_ID, 'artifact'),
677+
'proxyNode', // Or your custom template ref
678+
5, // Width
679+
5, // Height
680+
);
681+
proxyNodes.push(proxyNode);
682+
for (let j = 0; j < targetData.nodes.length; j++) {
683+
const targetNodeId = targetData.nodes[j];
684+
const PROXY_EDGE_ID = `${PROXY_NODE_ID}_${targetNodeId}`;
685+
const isOutgoing = targetData.type === 'out';
686+
const proxyEdge = {
687+
from: isOutgoing ? targetNodeId : PROXY_NODE_ID,
688+
to: isOutgoing ? PROXY_NODE_ID : targetNodeId,
689+
style: 'dashed',
690+
};
691+
proxyEdges.push(proxyEdge);
692+
}
693+
}
694+
// Add the new elements to our renderable arrays
695+
this.nodes = [...proxyNodes, ...this.nodes];
696+
this.edges = [...this.edges, ...proxyEdges];
697+
}
698+
}
536699

537700
showGroupLabel(group: DagGroup) {
538701
if (!group.groupLabel) return false;
@@ -598,6 +761,11 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy {
598761

599762
getCustomNodeTemplateFor(node: CustomNode) {
600763
const {templateRef} = node;
764+
if (templateRef === 'proxyNode' && !this.customNodeTemplates['proxyNode']) {
765+
console.log('proxyNode template not found');
766+
console.log(this.defaultProxyNodeTemplate);
767+
return this.defaultProxyNodeTemplate;
768+
}
601769
return this.customNodeTemplates[templateRef];
602770
}
603771

@@ -1084,6 +1252,12 @@ export class DagRaw implements DoCheck, OnInit, OnDestroy {
10841252
];
10851253
}
10861254

1255+
buildProxyPath(start: Point, end: Point): string {
1256+
const [cp1, cp2] = this.getControlPointsForBezierCurve(start, end);
1257+
return `M${start.x},${start.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${
1258+
end.x},${end.y}`;
1259+
}
1260+
10871261
/**
10881262
* When the edge is in a reversed direction, in order to avoid overlapping
10891263
* between the edges and nodes, the position of the edges in the reversed

0 commit comments

Comments
 (0)