This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 8c8d0c442d6 NIFI-15654 - Avoid overlapping connections, warn of 
existing overlaps (#10943)
8c8d0c442d6 is described below

commit 8c8d0c442d6660fc653a0dba945da896f272465f
Author: Rob Fellows <[email protected]>
AuthorDate: Thu Mar 5 13:00:03 2026 -0500

    NIFI-15654 - Avoid overlapping connections, warn of existing overlaps 
(#10943)
    
    * NIFI-15654 - Avoid overlapping connections, warn of existing overlaps
    
    * address review feedback - align overlap warning banner with the graph 
controls
---
 .../behavior/connectable-behavior.service.ts       | 139 +-------
 .../service/canvas-utils.service.spec.ts           | 356 ++++++++++++++++++++-
 .../flow-designer/service/canvas-utils.service.ts  | 168 ++++++++++
 .../service/manager/connection-manager.service.ts  |  30 ++
 .../pages/flow-designer/state/flow/flow.actions.ts |   6 +
 .../pages/flow-designer/state/flow/flow.effects.ts |  22 ++
 .../flow-designer/state/flow/flow.selectors.ts     |  15 +
 .../app/pages/flow-designer/state/flow/index.ts    |   5 +
 .../flow-designer/ui/canvas/canvas.component.html  |   6 +
 .../flow-designer/ui/canvas/canvas.component.scss  |  13 +
 .../ui/canvas/canvas.component.spec.ts             |   2 +
 .../flow-designer/ui/canvas/canvas.component.ts    |  14 +
 .../pages/flow-designer/ui/canvas/canvas.module.ts |   4 +-
 .../graph-controls/graph-controls.component.scss   |   2 +-
 .../navigation-control.component.html              |  35 +-
 .../edit-connection/edit-connection.component.ts   |  19 +-
 .../app/ui/common/overlap-detection.utils.spec.ts  | 250 +++++++++++++++
 .../src/app/ui/common/overlap-detection.utils.ts   | 131 ++++++++
 ...apping-connections-banner.component-theme.scss} |  23 +-
 .../overlapping-connections-banner.component.html  |  36 +++
 .../overlapping-connections-banner.component.scss} |   8 +-
 ...verlapping-connections-banner.component.spec.ts | 103 ++++++
 .../overlapping-connections-banner.component.ts}   |  22 +-
 .../src/main/frontend/apps/nifi/src/styles.scss    |   4 +
 24 files changed, 1250 insertions(+), 163 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
index d76d65eca58..68fb1e574dc 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
@@ -24,7 +24,6 @@ import { openNewConnectionDialog, selectComponents } from 
'../../state/flow/flow
 import { ConnectionManager } from '../manager/connection-manager.service';
 import { Position } from '../../state/shared';
 import { CreateConnectionRequest } from '../../state/flow';
-import { NiFiCommon } from '@nifi/shared';
 
 @Injectable({
     providedIn: 'root'
@@ -32,7 +31,6 @@ import { NiFiCommon } from '@nifi/shared';
 export class ConnectableBehavior {
     private store = inject<Store<CanvasState>>(Store);
     private canvasUtils = inject(CanvasUtils);
-    private nifiCommon = inject(NiFiCommon);
 
     private readonly connect: any;
     private origin: any;
@@ -254,143 +252,8 @@ export class ConnectableBehavior {
             });
     }
 
-    /**
-     * Calculate bend points for a new Connection if necessary.
-     *
-     * @param sourceData
-     * @param destinationData
-     */
     private calculateInitialBendPoints(sourceData: any, destinationData: any): 
Position[] {
-        const bends: Position[] = [];
-
-        if (sourceData.id == destinationData.id) {
-            const rightCenter: Position = {
-                x: sourceData.position.x + sourceData.dimensions.width,
-                y: sourceData.position.y + sourceData.dimensions.height / 2
-            };
-
-            const xOffset = ConnectionManager.SELF_LOOP_X_OFFSET;
-            const yOffset = ConnectionManager.SELF_LOOP_Y_OFFSET;
-            bends.push({
-                x: rightCenter.x + xOffset,
-                y: rightCenter.y - yOffset
-            });
-            bends.push({
-                x: rightCenter.x + xOffset,
-                y: rightCenter.y + yOffset
-            });
-        } else {
-            const existingConnections: any[] = [];
-
-            // get all connections for the source component
-            const connectionsForSourceComponent: any[] = 
this.canvasUtils.getComponentConnections(sourceData.id);
-            
connectionsForSourceComponent.forEach((connectionForSourceComponent) => {
-                // get the id for the source/destination component
-                const connectionSourceComponentId =
-                    
this.canvasUtils.getConnectionSourceComponentId(connectionForSourceComponent);
-                const connectionDestinationComponentId =
-                    
this.canvasUtils.getConnectionDestinationComponentId(connectionForSourceComponent);
-
-                // if the connection is between these same components, 
consider it for collisions
-                if (
-                    (connectionSourceComponentId === sourceData.id &&
-                        connectionDestinationComponentId === 
destinationData.id) ||
-                    (connectionDestinationComponentId === sourceData.id &&
-                        connectionSourceComponentId === destinationData.id)
-                ) {
-                    // record all connections between these two components in 
question
-                    existingConnections.push(connectionForSourceComponent);
-                }
-            });
-
-            // if there are existing connections between these components, 
ensure the new connection won't collide
-            if (existingConnections) {
-                const avoidCollision = 
existingConnections.some((existingConnection) => {
-                    // only consider multiple connections with no bend points 
a collision, the existence of
-                    // bend points suggests that the user has placed the 
connection into a desired location
-                    return this.nifiCommon.isEmpty(existingConnection.bends);
-                });
-
-                // if we need to avoid a collision
-                if (avoidCollision) {
-                    // determine the middle of the source/destination 
components
-                    const sourceMiddle: Position = {
-                        x: sourceData.position.x + sourceData.dimensions.width 
/ 2,
-                        y: sourceData.position.y + 
sourceData.dimensions.height / 2
-                    };
-                    const destinationMiddle: Position = {
-                        x: destinationData.position.x + 
destinationData.dimensions.width / 2,
-                        y: destinationData.position.y + 
destinationData.dimensions.height / 2
-                    };
-
-                    // detect if the line is more horizontal or vertical
-                    const slope = (sourceMiddle.y - destinationMiddle.y) / 
(sourceMiddle.x - destinationMiddle.x);
-                    const isMoreHorizontal = slope <= 1 && slope >= -1;
-
-                    // find the midpoint on the connection
-                    const xCandidate = (sourceMiddle.x + destinationMiddle.x) 
/ 2;
-                    const yCandidate = (sourceMiddle.y + destinationMiddle.y) 
/ 2;
-
-                    // attempt to position this connection so it doesn't 
collide
-                    let xStep = isMoreHorizontal ? 0 : 
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
-                    let yStep = isMoreHorizontal ? 
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT : 0;
-
-                    let positioned = false;
-                    while (!positioned) {
-                        // consider above and below, then increment and try 
again (if necessary)
-                        if (!this.collides(existingConnections, xCandidate - 
xStep, yCandidate - yStep)) {
-                            bends.push({
-                                x: xCandidate - xStep,
-                                y: yCandidate - yStep
-                            });
-                            positioned = true;
-                        } else if (!this.collides(existingConnections, 
xCandidate + xStep, yCandidate + yStep)) {
-                            bends.push({
-                                x: xCandidate + xStep,
-                                y: yCandidate + yStep
-                            });
-                            positioned = true;
-                        }
-
-                        if (isMoreHorizontal) {
-                            yStep += 
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT;
-                        } else {
-                            xStep += 
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
-                        }
-                    }
-                }
-            }
-        }
-
-        return bends;
-    }
-
-    /**
-     * Determines if the specified coordinate collides with another connection.
-     *
-     * @param existingConnections
-     * @param x
-     * @param y
-     */
-    private collides(existingConnections: any[], x: number, y: number): 
boolean {
-        return existingConnections.some((existingConnection) => {
-            if (!this.nifiCommon.isEmpty(existingConnection.bends)) {
-                let labelIndex = existingConnection.labelIndex;
-                if (labelIndex >= existingConnection.bends.length) {
-                    labelIndex = 0;
-                }
-
-                // determine collision based on y space or x space depending 
on whether the connection is more horizontal
-                return (
-                    existingConnection.bends[labelIndex].y - 25 < y &&
-                    existingConnection.bends[labelIndex].y + 25 > y &&
-                    existingConnection.bends[labelIndex].x - 100 < x &&
-                    existingConnection.bends[labelIndex].x + 100 > x
-                );
-            }
-
-            return false;
-        });
+        return 
this.canvasUtils.calculateBendPointsForCollisionAvoidance(sourceData, 
destinationData);
     }
 
     /**
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
index 7ed80573fcc..f22a537737a 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
@@ -23,8 +23,8 @@ import { flowFeatureKey } from '../state/flow';
 import * as fromFlow from '../state/flow/flow.reducer';
 import { transformFeatureKey } from '../state/transform';
 import * as fromTransform from '../state/transform/transform.reducer';
-import { provideMockStore } from '@ngrx/store/testing';
-import { selectFlowState } from '../state/flow/flow.selectors';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { selectConnections, selectCurrentProcessGroupId, selectFlowState } 
from '../state/flow/flow.selectors';
 import { controllerServicesFeatureKey } from '../state/controller-services';
 import * as fromControllerServices from 
'../state/controller-services/controller-services.reducer';
 import { selectCurrentUser } from 
'../../../state/current-user/current-user.selectors';
@@ -659,4 +659,356 @@ describe('CanvasUtils', () => {
             expect(bulletinIcon.classed('info')).toBe(false);
         });
     });
+
+    describe('calculateBendPointsForCollisionAvoidance', () => {
+        it('should return self-loop bend points when source and destination 
are the same', () => {
+            const componentData = {
+                id: 'proc-a',
+                position: { x: 100, y: 100 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(componentData, componentData);
+
+            expect(bends).toHaveLength(2);
+            expect(bends[0].x).toBeGreaterThan(componentData.position.x + 
componentData.dimensions.width);
+            expect(bends[1].x).toBeGreaterThan(componentData.position.x + 
componentData.dimensions.width);
+        });
+
+        it('should return empty bends when no existing connections between 
components', () => {
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(0);
+        });
+
+        it('should exclude specified connection from collision checks', () => {
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData, 
'conn-to-exclude');
+
+            expect(bends).toHaveLength(0);
+        });
+
+        it('should add a bend point when an existing straight-line connection 
exists between the same components', () => {
+            const store = TestBed.inject(MockStore);
+
+            const existingConnection = {
+                id: 'existing-conn',
+                sourceId: 'proc-a',
+                sourceGroupId: 'root',
+                destinationId: 'proc-b',
+                destinationGroupId: 'root',
+                bends: [],
+                labelIndex: 0
+            };
+
+            store.overrideSelector(selectConnections, [existingConnection] as 
any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(1);
+            expect(bends[0]).toEqual(expect.objectContaining({ x: 
expect.any(Number), y: expect.any(Number) }));
+        });
+
+        it('should not add a bend point when existing connection already has 
bends', () => {
+            const store = TestBed.inject(MockStore);
+
+            const existingConnection = {
+                id: 'existing-conn',
+                sourceId: 'proc-a',
+                sourceGroupId: 'root',
+                destinationId: 'proc-b',
+                destinationGroupId: 'root',
+                bends: [{ x: 300, y: 100 }],
+                labelIndex: 0
+            };
+
+            store.overrideSelector(selectConnections, [existingConnection] as 
any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(0);
+        });
+
+        it('should place bend point offset vertically for horizontal 
connections', () => {
+            const store = TestBed.inject(MockStore);
+
+            const existingConnection = {
+                id: 'existing-conn',
+                sourceId: 'proc-a',
+                sourceGroupId: 'root',
+                destinationId: 'proc-b',
+                destinationGroupId: 'root',
+                bends: [],
+                labelIndex: 0
+            };
+
+            store.overrideSelector(selectConnections, [existingConnection] as 
any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 500, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(1);
+            const midX = (100 + 600) / 2;
+            expect(bends[0].x).toBe(midX);
+            expect(bends[0].y).not.toBe(50);
+        });
+
+        it('should place bend point offset horizontally for vertical 
connections', () => {
+            const store = TestBed.inject(MockStore);
+
+            const existingConnection = {
+                id: 'existing-conn',
+                sourceId: 'proc-a',
+                sourceGroupId: 'root',
+                destinationId: 'proc-b',
+                destinationGroupId: 'root',
+                bends: [],
+                labelIndex: 0
+            };
+
+            store.overrideSelector(selectConnections, [existingConnection] as 
any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 0, y: 500 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(1);
+            const midY = (50 + 550) / 2;
+            expect(bends[0].y).toBe(midY);
+            expect(bends[0].x).not.toBe(100);
+        });
+
+        it('should detect collision for bidirectional connections (destination 
to source)', () => {
+            const store = TestBed.inject(MockStore);
+
+            const existingConnection = {
+                id: 'existing-conn',
+                sourceId: 'proc-b',
+                sourceGroupId: 'root',
+                destinationId: 'proc-a',
+                destinationGroupId: 'root',
+                bends: [],
+                labelIndex: 0
+            };
+
+            store.overrideSelector(selectConnections, [existingConnection] as 
any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(1);
+        });
+
+        it('should exclude a specific connection by ID during collision 
check', () => {
+            const store = TestBed.inject(MockStore);
+
+            const existingConnection = {
+                id: 'conn-being-updated',
+                sourceId: 'proc-a',
+                sourceGroupId: 'root',
+                destinationId: 'proc-b',
+                destinationGroupId: 'root',
+                bends: [],
+                labelIndex: 0
+            };
+
+            store.overrideSelector(selectConnections, [existingConnection] as 
any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData, 
'conn-being-updated');
+
+            expect(bends).toHaveLength(0);
+        });
+
+        it('should avoid colliding with existing bend points when placing new 
bend', () => {
+            const store = TestBed.inject(MockStore);
+
+            const midX = (100 + 500) / 2;
+            const midY = 50;
+
+            const connections = [
+                {
+                    id: 'straight-conn',
+                    sourceId: 'proc-a',
+                    sourceGroupId: 'root',
+                    destinationId: 'proc-b',
+                    destinationGroupId: 'root',
+                    bends: [],
+                    labelIndex: 0
+                },
+                {
+                    id: 'bent-conn',
+                    sourceId: 'proc-a',
+                    sourceGroupId: 'root',
+                    destinationId: 'proc-b',
+                    destinationGroupId: 'root',
+                    bends: [{ x: midX, y: midY - 75 }],
+                    labelIndex: 0
+                }
+            ];
+
+            store.overrideSelector(selectConnections, connections as any);
+            store.overrideSelector(selectCurrentProcessGroupId, 'root');
+            store.refreshState();
+
+            const sourceData = {
+                id: 'proc-a',
+                position: { x: 0, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+            const destData = {
+                id: 'proc-b',
+                position: { x: 400, y: 0 },
+                dimensions: { width: 200, height: 100 }
+            };
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+            expect(bends).toHaveLength(1);
+            const existingBendY = midY - 75;
+            expect(Math.abs(bends[0].y - existingBendY) > 25 || 
Math.abs(bends[0].x - midX) > 100).toBe(true);
+        });
+    });
+
+    describe('calculateBendPointsForCollisionAvoidanceByIds', () => {
+        afterEach(() => {
+            document.querySelectorAll('[id^="id-"]').forEach((el) => 
el.remove());
+        });
+
+        function createDomComponent(
+            id: string,
+            position: { x: number; y: number },
+            dimensions: { width: number; height: number }
+        ): void {
+            const el = document.createElementNS('http://www.w3.org/2000/svg', 
'g');
+            el.setAttribute('id', 'id-' + id);
+            document.body.appendChild(el);
+            d3.select(el).datum({ id, position, dimensions });
+        }
+
+        it('should return empty array when source element is not in the DOM', 
() => {
+            createDomComponent('proc-b', { x: 400, y: 0 }, { width: 200, 
height: 100 });
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidanceByIds('missing-id', 'proc-b');
+
+            expect(bends).toHaveLength(0);
+        });
+
+        it('should return empty array when destination element is not in the 
DOM', () => {
+            createDomComponent('proc-a', { x: 0, y: 0 }, { width: 200, height: 
100 });
+
+            const bends = 
service.calculateBendPointsForCollisionAvoidanceByIds('proc-a', 'missing-id');
+
+            expect(bends).toHaveLength(0);
+        });
+
+        it('should delegate to calculateBendPointsForCollisionAvoidance with 
resolved data', () => {
+            createDomComponent('proc-a', { x: 0, y: 0 }, { width: 200, height: 
100 });
+            createDomComponent('proc-b', { x: 400, y: 0 }, { width: 200, 
height: 100 });
+
+            const spy = jest.spyOn(service, 
'calculateBendPointsForCollisionAvoidance');
+
+            service.calculateBendPointsForCollisionAvoidanceByIds('proc-a', 
'proc-b', 'conn-exclude');
+
+            expect(spy).toHaveBeenCalledWith(
+                { id: 'proc-a', position: { x: 0, y: 0 }, dimensions: { width: 
200, height: 100 } },
+                { id: 'proc-b', position: { x: 400, y: 0 }, dimensions: { 
width: 200, height: 100 } },
+                'conn-exclude'
+            );
+        });
+    });
 });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index 9131bbd1135..cbd7455986d 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -44,6 +44,17 @@ import { Overlay, OverlayRef, PositionStrategy } from 
'@angular/cdk/overlay';
 import { ComponentPortal } from '@angular/cdk/portal';
 import { initialState as initialTransformState } from 
'../state/transform/transform.reducer';
 import { selectScale } from '../state/transform/transform.selectors';
+import { ConnectionManager } from './manager/connection-manager.service';
+
+export interface CollisionConnection {
+    id: string;
+    sourceId: string;
+    sourceGroupId: string;
+    destinationId: string;
+    destinationGroupId: string;
+    bends: Position[];
+    labelIndex: number;
+}
 
 @Injectable({
     providedIn: 'root'
@@ -2320,4 +2331,161 @@ export class CanvasUtils {
 
         return allProcessors || allLabels;
     }
+
+    /**
+     * Calculates bend points for a connection to avoid collision with 
existing connections
+     * between the same source and destination components.
+     *
+     * @param sourceData          Source component data with id, position, and 
dimensions
+     * @param destinationData     Destination component data with id, 
position, and dimensions
+     * @param connectionIdToExclude  Optional connection ID to exclude from 
collision checks (for updates)
+     */
+    public calculateBendPointsForCollisionAvoidance(
+        sourceData: { id: string; position: Position; dimensions: { width: 
number; height: number } },
+        destinationData: { id: string; position: Position; dimensions: { 
width: number; height: number } },
+        connectionIdToExclude?: string
+    ): Position[] {
+        const bends: Position[] = [];
+
+        if (sourceData.id === destinationData.id) {
+            const rightCenter: Position = {
+                x: sourceData.position.x + sourceData.dimensions.width,
+                y: sourceData.position.y + sourceData.dimensions.height / 2
+            };
+
+            bends.push({
+                x: rightCenter.x + ConnectionManager.SELF_LOOP_X_OFFSET,
+                y: rightCenter.y - ConnectionManager.SELF_LOOP_Y_OFFSET
+            });
+            bends.push({
+                x: rightCenter.x + ConnectionManager.SELF_LOOP_X_OFFSET,
+                y: rightCenter.y + ConnectionManager.SELF_LOOP_Y_OFFSET
+            });
+        } else {
+            const existingConnections: CollisionConnection[] = [];
+
+            const connectionsForSourceComponent: any[] = 
this.getComponentConnections(sourceData.id);
+            connectionsForSourceComponent.forEach((connection) => {
+                if (connectionIdToExclude && connection.id === 
connectionIdToExclude) {
+                    return;
+                }
+
+                const connectionSourceComponentId = 
this.getConnectionSourceComponentId(connection);
+                const connectionDestinationComponentId = 
this.getConnectionDestinationComponentId(connection);
+
+                if (
+                    (connectionSourceComponentId === sourceData.id &&
+                        connectionDestinationComponentId === 
destinationData.id) ||
+                    (connectionDestinationComponentId === sourceData.id &&
+                        connectionSourceComponentId === destinationData.id)
+                ) {
+                    existingConnections.push(connection);
+                }
+            });
+
+            if (existingConnections.length > 0) {
+                const avoidCollision = 
existingConnections.some((existingConnection) => {
+                    return this.nifiCommon.isEmpty(existingConnection.bends);
+                });
+
+                if (avoidCollision) {
+                    const sourceMiddle: Position = {
+                        x: sourceData.position.x + sourceData.dimensions.width 
/ 2,
+                        y: sourceData.position.y + 
sourceData.dimensions.height / 2
+                    };
+                    const destinationMiddle: Position = {
+                        x: destinationData.position.x + 
destinationData.dimensions.width / 2,
+                        y: destinationData.position.y + 
destinationData.dimensions.height / 2
+                    };
+
+                    const slope = (sourceMiddle.y - destinationMiddle.y) / 
(sourceMiddle.x - destinationMiddle.x);
+                    const isMoreHorizontal = slope <= 1 && slope >= -1;
+
+                    const xCandidate = (sourceMiddle.x + destinationMiddle.x) 
/ 2;
+                    const yCandidate = (sourceMiddle.y + destinationMiddle.y) 
/ 2;
+
+                    let xStep = isMoreHorizontal ? 0 : 
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
+                    let yStep = isMoreHorizontal ? 
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT : 0;
+
+                    const MAX_ATTEMPTS = 100;
+                    let positioned = false;
+                    let attempts = 0;
+                    while (!positioned && attempts < MAX_ATTEMPTS) {
+                        attempts++;
+                        if (!this.collidesWith(existingConnections, xCandidate 
- xStep, yCandidate - yStep)) {
+                            bends.push({
+                                x: xCandidate - xStep,
+                                y: yCandidate - yStep
+                            });
+                            positioned = true;
+                        } else if (!this.collidesWith(existingConnections, 
xCandidate + xStep, yCandidate + yStep)) {
+                            bends.push({
+                                x: xCandidate + xStep,
+                                y: yCandidate + yStep
+                            });
+                            positioned = true;
+                        }
+
+                        if (isMoreHorizontal) {
+                            yStep += 
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT;
+                        } else {
+                            xStep += 
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
+                        }
+                    }
+
+                    if (!positioned) {
+                        bends.push({
+                            x: xCandidate - xStep,
+                            y: yCandidate - yStep
+                        });
+                    }
+                }
+            }
+        }
+
+        return bends;
+    }
+
+    /**
+     * Convenience wrapper that resolves component data from the canvas DOM by 
ID,
+     * then delegates to calculateBendPointsForCollisionAvoidance.
+     */
+    public calculateBendPointsForCollisionAvoidanceByIds(
+        sourceComponentId: string,
+        destinationComponentId: string,
+        connectionIdToExclude?: string
+    ): Position[] {
+        const sourceElement: any = d3.select('#id-' + sourceComponentId);
+        const destinationElement: any = d3.select('#id-' + 
destinationComponentId);
+
+        if (sourceElement.empty() || destinationElement.empty()) {
+            return [];
+        }
+
+        return this.calculateBendPointsForCollisionAvoidance(
+            sourceElement.datum(),
+            destinationElement.datum(),
+            connectionIdToExclude
+        );
+    }
+
+    private collidesWith(existingConnections: CollisionConnection[], x: 
number, y: number): boolean {
+        return existingConnections.some((existingConnection) => {
+            if (!this.nifiCommon.isEmpty(existingConnection.bends)) {
+                let labelIndex = existingConnection.labelIndex;
+                if (labelIndex >= existingConnection.bends.length) {
+                    labelIndex = 0;
+                }
+
+                return (
+                    existingConnection.bends[labelIndex].y - 25 < y &&
+                    existingConnection.bends[labelIndex].y + 25 > y &&
+                    existingConnection.bends[labelIndex].x - 100 < x &&
+                    existingConnection.bends[labelIndex].x + 100 > x
+                );
+            }
+
+            return false;
+        });
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
index 82dfc63ea6f..48b662fe418 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
@@ -46,6 +46,7 @@ import { filter, Subject, switchMap, takeUntil } from 'rxjs';
 import { ComponentType, NiFiCommon, SelectOption } from '@nifi/shared';
 import { QuickSelectBehavior } from 
'../behavior/quick-select-behavior.service';
 import { ClusterConnectionService } from 
'../../../../service/cluster-connection.service';
+import { wouldRemovalCauseOverlap } from 
'../../../../ui/common/overlap-detection.utils';
 
 export class ConnectionRenderOptions {
     updatePath?: boolean;
@@ -305,6 +306,17 @@ export class ConnectionManager implements OnDestroy {
                                     x: rightCenter.x + 
ConnectionManager.SELF_LOOP_X_OFFSET,
                                     y: rightCenter.y + 
ConnectionManager.SELF_LOOP_Y_OFFSET
                                 });
+                            } else if 
(self.nifiCommon.isEmpty(connectionData.bends)) {
+                                const sourceComponentId =
+                                    
self.canvasUtils.getConnectionSourceComponentId(connectionData);
+                                const collisionBends = 
self.canvasUtils.calculateBendPointsForCollisionAvoidanceByIds(
+                                    sourceComponentId,
+                                    destinationData.id,
+                                    connectionData.id
+                                );
+                                if (collisionBends.length > 0) {
+                                    payload.component.bends = collisionBends;
+                                }
                             }
 
                             self.store.dispatch(
@@ -1172,6 +1184,24 @@ export class ConnectionManager implements OnDestroy {
                                 return;
                             }
 
+                            if (newBends.length === 0 && sourceComponentId !== 
destinationComponentId) {
+                                const wouldOverlap = wouldRemovalCauseOverlap(
+                                    connectionData.id,
+                                    self.connections,
+                                    self.currentProcessGroupId
+                                );
+                                if (wouldOverlap) {
+                                    self.store.dispatch(
+                                        showOkDialog({
+                                            title: 'Connection',
+                                            message:
+                                                'This bend point cannot be 
removed because it would cause this connection to overlap with another 
connection between the same components.'
+                                        })
+                                    );
+                                    return;
+                                }
+                            }
+
                             const connectionRemovedBend: any = {
                                 id: connectionData.id,
                                 bends: newBends
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
index 32614579b65..a7fdc481555 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
@@ -68,6 +68,7 @@ import {
     MoveComponentsRequest,
     MoveToFrontRequest,
     NavigateToComponentRequest,
+    NavigateToComponentsRequest,
     NavigateToControllerServicesRequest,
     NavigateToManageComponentPoliciesRequest,
     NavigateToParameterContext,
@@ -568,6 +569,11 @@ export const navigateToComponent = createAction(
     props<{ request: NavigateToComponentRequest }>()
 );
 
+export const navigateToComponents = createAction(
+    `${CANVAS_PREFIX} Navigate To Components`,
+    props<{ request: NavigateToComponentsRequest }>()
+);
+
 export const navigateWithoutTransform = createAction(
     `${CANVAS_PREFIX} Navigate Without Transform`,
     props<{ url: string[] }>()
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index 94553f29f76..658b630af5d 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -2941,6 +2941,28 @@ export class FlowEffects {
         { dispatch: false }
     );
 
+    navigateToComponents$ = createEffect(
+        () =>
+            this.actions$.pipe(
+                ofType(FlowActions.navigateToComponents),
+                map((action) => action.request),
+                concatLatestFrom(() => 
this.store.select(selectCurrentProcessGroupId)),
+                tap(([request, currentProcessGroupId]) => {
+                    if (request.processGroupId) {
+                        this.router.navigate([
+                            '/process-groups',
+                            request.processGroupId,
+                            'bulk',
+                            request.ids.join(',')
+                        ]);
+                    } else {
+                        this.router.navigate(['/process-groups', 
currentProcessGroupId, 'bulk', request.ids.join(',')]);
+                    }
+                })
+            ),
+        { dispatch: false }
+    );
+
     navigateWithoutTransform$ = createEffect(
         () =>
             this.actions$.pipe(
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
index 18193590c92..27b84bddbb0 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
@@ -19,6 +19,10 @@ import { flowFeatureKey, FlowState, SelectedComponent } from 
'./index';
 import { createSelector } from '@ngrx/store';
 import { CanvasState, selectCanvasState } from '../index';
 import { ComponentType, selectCurrentRoute } from '@nifi/shared';
+import {
+    detectOverlappingConnections,
+    OverlappingConnectionGroup
+} from '../../../../ui/common/overlap-detection.utils';
 
 export const selectFlowState = createSelector(selectCanvasState, (state: 
CanvasState) => state[flowFeatureKey]);
 
@@ -279,3 +283,14 @@ export const selectMaxZIndex = (componentType: 
ComponentType.Connection | Compon
 };
 
 export const selectFlowAnalysisOpen = createSelector(selectFlowState, (state: 
FlowState) => state.flowAnalysisOpen);
+
+export const selectOverlappingConnections = createSelector(
+    selectConnections,
+    selectCurrentProcessGroupId,
+    (connections: any[], processGroupId: string): OverlappingConnectionGroup[] 
=> {
+        if (!connections) {
+            return [];
+        }
+        return detectOverlappingConnections(connections, processGroupId);
+    }
+);
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
index 832c608d620..fd9ac29d306 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
@@ -511,6 +511,11 @@ export interface NavigateToComponentRequest {
     processGroupId?: string;
 }
 
+export interface NavigateToComponentsRequest {
+    ids: string[];
+    processGroupId?: string;
+}
+
 export interface ReplayLastProvenanceEventRequest {
     componentId: string;
     nodes: string;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
index 2f5b308b421..3a5edf53565 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
@@ -24,6 +24,12 @@
             </mat-sidenav>
             <mat-sidenav-content>
                 <graph-controls></graph-controls>
+                <overlapping-connections-banner
+                    class="canvas-overlap-banner"
+                    [class.palettes-expanded]="!navigationCollapsed() || 
!operationCollapsed()"
+                    [overlappingGroups]="(overlappingConnections$ | async) || 
[]"
+                    
(navigateToGroup)="navigateToOverlappingConnections($event)">
+                </overlapping-connections-banner>
                 <div
                     id="canvas-container"
                     class="canvas-background select-none h-full w-full"
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
index 5fb106d35cf..99cd8242724 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
@@ -15,6 +15,19 @@
  * limitations under the License.
  */
 
+.canvas-overlap-banner {
+    position: absolute;
+    z-index: 99;
+    top: 16px;
+    left: 46px;
+    right: 16px;
+    transition: left 150ms ease;
+
+    &.palettes-expanded {
+        left: 310px;
+    }
+}
+
 .canvas-background {
     background-size: 14px 14px;
     z-index: 1;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
index 680c66fde1d..b4142e055c7 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
@@ -34,6 +34,7 @@ import { flowFeatureKey } from '../../state/flow';
 import { FlowAnalysisDrawerComponent } from 
'./header/flow-analysis-drawer/flow-analysis-drawer.component';
 import { CanvasActionsService } from '../../service/canvas-actions.service';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { OverlappingConnectionsBannerComponent } from 
'../../../../ui/common/overlapping-connections-banner/overlapping-connections-banner.component';
 import { CopyResponseEntity } from '../../../../state/copy';
 import { initialState as initialErrorState } from 
'../../../../state/error/error.reducer';
 import { errorFeatureKey } from '../../../../state/error';
@@ -70,6 +71,7 @@ describe('Canvas', () => {
                 MockComponent(GraphControls),
                 MockComponent(HeaderComponent),
                 MockComponent(FooterComponent),
+                MockComponent(OverlappingConnectionsBannerComponent),
                 FlowAnalysisDrawerComponent
             ],
             providers: [
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
index fbd875c5aef..7459bb90580 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
@@ -25,9 +25,11 @@ import {
     editComponent,
     editCurrentProcessGroup,
     loadProcessGroup,
+    navigateToComponents,
     paste,
     resetFlowState,
     selectComponents,
+    setAllowTransition,
     setSkipTransform,
     startProcessGroupPolling,
     stopProcessGroupPolling
@@ -57,6 +59,9 @@ import {
     selectRemoteProcessGroup,
     selectSingleEditedComponent,
     selectSingleSelectedComponent,
+    selectNavigationCollapsed,
+    selectOperationCollapsed,
+    selectOverlappingConnections,
     selectSkipTransform,
     selectViewStatusHistoryComponent,
     selectViewStatusHistoryCurrentProcessGroup
@@ -69,6 +74,7 @@ import { getStatusHistoryAndOpenDialog } from 
'../../../../state/status-history/
 import { concatLatestFrom } from '@ngrx/operators';
 import { ComponentType, isDefinedAndNotNull, NiFiCommon, selectUrl, Storage } 
from '@nifi/shared';
 import { CanvasUtils } from '../../service/canvas-utils.service';
+import { OverlappingConnectionGroup } from 
'../../../../ui/common/overlap-detection.utils';
 import { CanvasActionsService } from '../../service/canvas-actions.service';
 import { MatDialog } from '@angular/material/dialog';
 import { CopyResponseEntity } from '../../../../state/copy';
@@ -97,6 +103,14 @@ export class Canvas implements OnInit, OnDestroy {
     private canvasClicked = false;
 
     flowAnalysisOpen = this.store.selectSignal(selectFlowAnalysisOpen);
+    navigationCollapsed = this.store.selectSignal(selectNavigationCollapsed);
+    operationCollapsed = this.store.selectSignal(selectOperationCollapsed);
+    overlappingConnections$ = this.store.select(selectOverlappingConnections);
+
+    navigateToOverlappingConnections(group: OverlappingConnectionGroup): void {
+        this.store.dispatch(setAllowTransition({ allowTransition: true }));
+        this.store.dispatch(navigateToComponents({ request: { ids: 
group.connectionIds } }));
+    }
 
     constructor() {
         this.store
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
index b459f1e6653..e23d0e122a6 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
@@ -26,6 +26,7 @@ import { CanvasRoutingModule } from './canvas-routing.module';
 import { HeaderComponent } from './header/header.component';
 import { FooterComponent } from './footer/footer.component';
 import { FlowAnalysisDrawerComponent } from 
'./header/flow-analysis-drawer/flow-analysis-drawer.component';
+import { OverlappingConnectionsBannerComponent } from 
'../../../../ui/common/overlapping-connections-banner/overlapping-connections-banner.component';
 
 @NgModule({
     declarations: [Canvas],
@@ -42,7 +43,8 @@ import { FlowAnalysisDrawerComponent } from 
'./header/flow-analysis-drawer/flow-
         HeaderComponent,
         FooterComponent,
         MatSidenavModule,
-        FlowAnalysisDrawerComponent
+        FlowAnalysisDrawerComponent,
+        OverlappingConnectionsBannerComponent
     ]
 })
 export class CanvasModule {}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
index e400df89e17..4726e261aa9 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
@@ -18,6 +18,6 @@
 div.graph-controls {
     position: absolute;
     left: 0;
-    top: 22px;
+    top: 16px;
     z-index: 2;
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
index 1fbf087a6f5..b3e586bb58e 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
@@ -34,21 +34,46 @@
         <div class="w-72 px-2.5 pb-2.5 flex flex-col gap-y-2">
             <div class="flex justify-between">
                 <div class="flex gap-x-1">
-                    <button mat-icon-button class="primary-icon-button" 
type="button" matTooltip="Zoom In" (click)="zoomIn()">
+                    <button
+                        mat-icon-button
+                        class="primary-icon-button"
+                        type="button"
+                        matTooltip="Zoom In"
+                        (click)="zoomIn()">
                         <i class="fa fa-search-plus"></i>
                     </button>
-                    <button mat-icon-button class="primary-icon-button mr-2" 
type="button" matTooltip="Zoom Out" (click)="zoomOut()">
+                    <button
+                        mat-icon-button
+                        class="primary-icon-button mr-2"
+                        type="button"
+                        matTooltip="Zoom Out"
+                        (click)="zoomOut()">
                         <i class="fa fa-search-minus"></i>
                     </button>
-                    <button mat-icon-button class="primary-icon-button" 
type="button" matTooltip="Fit to Screen" (click)="zoomFit()">
+                    <button
+                        mat-icon-button
+                        class="primary-icon-button"
+                        type="button"
+                        matTooltip="Fit to Screen"
+                        (click)="zoomFit()">
                         <i class="ml-1 icon icon-zoom-fit"></i>
                     </button>
-                    <button mat-icon-button class="primary-icon-button" 
type="button" matTooltip="Zoom to Actual Size" (click)="zoomActual()">
+                    <button
+                        mat-icon-button
+                        class="primary-icon-button"
+                        type="button"
+                        matTooltip="Zoom to Actual Size"
+                        (click)="zoomActual()">
                         <i class="ml-1 icon icon-zoom-actual"></i>
                     </button>
                 </div>
                 @if (isNotRootGroup()) {
-                    <button mat-icon-button class="primary-icon-button" 
type="button" matTooltip="Leave Group" (click)="leaveProcessGroup()">
+                    <button
+                        mat-icon-button
+                        class="primary-icon-button"
+                        type="button"
+                        matTooltip="Leave Group"
+                        (click)="leaveProcessGroup()">
                         <i class="fa fa-level-up"></i>
                     </button>
                 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
index ff9511676e8..66eb8ce8bb6 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
@@ -32,7 +32,7 @@ import { MatInputModule } from '@angular/material/input';
 import { MatOptionModule } from '@angular/material/core';
 import { MatSelectModule } from '@angular/material/select';
 import { NifiSpinnerDirective } from 
'../../../../../../../ui/common/spinner/nifi-spinner.directive';
-import { ComponentType, CopyDirective, NifiTooltipDirective, TextTip } from 
'@nifi/shared';
+import { ComponentType, CopyDirective, NiFiCommon, NifiTooltipDirective, 
TextTip } from '@nifi/shared';
 import { MatTabsModule } from '@angular/material/tabs';
 import { NiFiState } from '../../../../../../../state';
 import { selectPrioritizerTypes } from 
'../../../../../../../state/extension-types/extension-types.selectors';
@@ -100,6 +100,7 @@ export class EditConnectionComponent extends TabbedDialog {
     private store = inject<Store<NiFiState>>(Store);
     private canvasUtils = inject(CanvasUtils);
     private client = inject(Client);
+    private nifiCommon = inject(NiFiCommon);
 
     @Input() set getChildOutputPorts(getChildOutputPorts: (groupId: string) => 
Observable<any>) {
         if (this.sourceType == ComponentType.ProcessGroup) {
@@ -419,6 +420,22 @@ export class EditConnectionComponent extends TabbedDialog {
             payload.component.loadBalanceCompression = 'DO_NOT_COMPRESS';
         }
 
+        if (this.previousDestination && this.nifiCommon.isEmpty(d.bends)) {
+            const sourceComponentId = 
this.canvasUtils.getConnectionSourceComponentId(d);
+            const newDestinationComponentId =
+                payload.component.destination?.groupId ?? 
payload.component.destination?.id;
+            if (newDestinationComponentId && newDestinationComponentId !== 
sourceComponentId) {
+                const collisionBends = 
this.canvasUtils.calculateBendPointsForCollisionAvoidanceByIds(
+                    sourceComponentId,
+                    newDestinationComponentId,
+                    d.id
+                );
+                if (collisionBends.length > 0) {
+                    payload.component.bends = collisionBends;
+                }
+            }
+        }
+
         this.store.dispatch(
             updateConnection({
                 request: {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.spec.ts
new file mode 100644
index 00000000000..8d762500b66
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.spec.ts
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+    detectOverlappingConnections,
+    OverlapDetectionConnection,
+    wouldRemovalCauseOverlap
+} from './overlap-detection.utils';
+
+function createConnection(overrides: Partial<OverlapDetectionConnection> = 
{}): OverlapDetectionConnection {
+    return {
+        id: 'conn-1',
+        sourceId: 'proc-a',
+        destinationId: 'proc-b',
+        sourceGroupId: 'root',
+        destinationGroupId: 'root',
+        bends: [],
+        ...overrides
+    };
+}
+
+describe('detectOverlappingConnections', () => {
+    const processGroupId = 'root';
+
+    it('should return empty array when no connections are provided', () => {
+        expect(detectOverlappingConnections([], processGroupId)).toEqual([]);
+    });
+
+    it('should return empty array for null/undefined connections', () => {
+        expect(detectOverlappingConnections(null as any, 
processGroupId)).toEqual([]);
+        expect(detectOverlappingConnections(undefined as any, 
processGroupId)).toEqual([]);
+    });
+
+    it('should return empty array when only one connection exists between a 
pair', () => {
+        const connections = [createConnection({ id: 'conn-1' })];
+        expect(detectOverlappingConnections(connections, 
processGroupId)).toEqual([]);
+    });
+
+    it('should detect two straight-line connections between the same pair', () 
=> {
+        const connections = [
+            createConnection({ id: 'conn-1', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-a', 
destinationId: 'proc-b' })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toHaveLength(1);
+        expect(result[0].connectionIds).toContain('conn-1');
+        expect(result[0].connectionIds).toContain('conn-2');
+    });
+
+    it('should not flag connections with bend points as overlapping', () => {
+        const connections = [
+            createConnection({ id: 'conn-1', sourceId: 'proc-a', 
destinationId: 'proc-b', bends: [] }),
+            createConnection({
+                id: 'conn-2',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 200 }]
+            })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toEqual([]);
+    });
+
+    it('should treat bidirectional connections as the same visual pair', () => 
{
+        const connections = [
+            createConnection({ id: 'conn-1', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-b', 
destinationId: 'proc-a' })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toHaveLength(1);
+        expect(result[0].connectionIds).toHaveLength(2);
+    });
+
+    it('should detect multiple overlap groups independently', () => {
+        const connections = [
+            createConnection({ id: 'conn-1', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-3', sourceId: 'proc-c', 
destinationId: 'proc-d' }),
+            createConnection({ id: 'conn-4', sourceId: 'proc-c', 
destinationId: 'proc-d' })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toHaveLength(2);
+    });
+
+    it('should resolve group IDs when source/destination are in different 
groups', () => {
+        const connections = [
+            createConnection({
+                id: 'conn-1',
+                sourceId: 'port-1',
+                destinationId: 'proc-b',
+                sourceGroupId: 'child-group'
+            }),
+            createConnection({
+                id: 'conn-2',
+                sourceId: 'port-2',
+                destinationId: 'proc-b',
+                sourceGroupId: 'child-group'
+            })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toHaveLength(1);
+        expect(result[0].sourceComponentId).toBeDefined();
+        expect(result[0].destinationComponentId).toBeDefined();
+    });
+
+    it('should not flag connections between different component pairs', () => {
+        const connections = [
+            createConnection({ id: 'conn-1', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-a', 
destinationId: 'proc-c' })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toEqual([]);
+    });
+
+    it('should handle three or more overlapping connections in the same 
group', () => {
+        const connections = [
+            createConnection({ id: 'conn-1', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-a', 
destinationId: 'proc-b' }),
+            createConnection({ id: 'conn-3', sourceId: 'proc-a', 
destinationId: 'proc-b' })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toHaveLength(1);
+        expect(result[0].connectionIds).toHaveLength(3);
+    });
+
+    it('should not flag a single connection with no bends', () => {
+        const connections = [createConnection({ id: 'conn-1', sourceId: 
'proc-a', destinationId: 'proc-b' })];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toEqual([]);
+    });
+
+    it('should ignore connections where both have bend points', () => {
+        const connections = [
+            createConnection({
+                id: 'conn-1',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 50 }]
+            }),
+            createConnection({
+                id: 'conn-2',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 50 }]
+            })
+        ];
+
+        const result = detectOverlappingConnections(connections, 
processGroupId);
+        expect(result).toEqual([]);
+    });
+});
+
+describe('wouldRemovalCauseOverlap', () => {
+    const processGroupId = 'root';
+
+    it('should return false when no other connections exist', () => {
+        const connections = [createConnection({ id: 'conn-1', sourceId: 
'proc-a', destinationId: 'proc-b' })];
+
+        expect(wouldRemovalCauseOverlap('conn-1', connections, 
processGroupId)).toBe(false);
+    });
+
+    it('should return true when another straight-line connection exists 
between same pair', () => {
+        const connections = [
+            createConnection({
+                id: 'conn-1',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 50 }]
+            }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-a', 
destinationId: 'proc-b' })
+        ];
+
+        expect(wouldRemovalCauseOverlap('conn-1', connections, 
processGroupId)).toBe(true);
+    });
+
+    it('should return false when other connection has bend points', () => {
+        const connections = [
+            createConnection({
+                id: 'conn-1',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 50 }]
+            }),
+            createConnection({
+                id: 'conn-2',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 200, y: 75 }]
+            })
+        ];
+
+        expect(wouldRemovalCauseOverlap('conn-1', connections, 
processGroupId)).toBe(false);
+    });
+
+    it('should return false when other connection is between different 
components', () => {
+        const connections = [
+            createConnection({
+                id: 'conn-1',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 50 }]
+            }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-a', 
destinationId: 'proc-c' })
+        ];
+
+        expect(wouldRemovalCauseOverlap('conn-1', connections, 
processGroupId)).toBe(false);
+    });
+
+    it('should return false for unknown connection ID', () => {
+        const connections = [createConnection({ id: 'conn-1', sourceId: 
'proc-a', destinationId: 'proc-b' })];
+
+        expect(wouldRemovalCauseOverlap('unknown', connections, 
processGroupId)).toBe(false);
+    });
+
+    it('should handle bidirectional connections', () => {
+        const connections = [
+            createConnection({
+                id: 'conn-1',
+                sourceId: 'proc-a',
+                destinationId: 'proc-b',
+                bends: [{ x: 100, y: 50 }]
+            }),
+            createConnection({ id: 'conn-2', sourceId: 'proc-b', 
destinationId: 'proc-a' })
+        ];
+
+        expect(wouldRemovalCauseOverlap('conn-1', connections, 
processGroupId)).toBe(true);
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.ts
new file mode 100644
index 00000000000..7139af3258c
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.ts
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface OverlappingConnectionGroup {
+    sourceComponentId: string;
+    destinationComponentId: string;
+    connectionIds: string[];
+}
+
+export interface OverlapDetectionConnection {
+    id: string;
+    sourceId: string;
+    destinationId: string;
+    sourceGroupId: string;
+    destinationGroupId: string;
+    bends: Array<{ x: number; y: number }>;
+}
+
+/**
+ * Pure function to detect groups of overlapping connections.
+ *
+ * Two connections overlap when they share the same effective source and 
destination
+ * components and both have no bend points (straight-line paths).
+ *
+ * @param connections       Array of connection entities
+ * @param currentProcessGroupId  The current process group context for 
resolving component IDs
+ * @returns Array of overlap groups, each containing the affected connection 
IDs
+ */
+export function detectOverlappingConnections(
+    connections: OverlapDetectionConnection[],
+    currentProcessGroupId: string
+): OverlappingConnectionGroup[] {
+    if (!connections || connections.length === 0) {
+        return [];
+    }
+
+    const pairMap = new Map<string, string[]>();
+
+    connections.forEach((connection) => {
+        if (connection.bends && connection.bends.length > 0) {
+            return;
+        }
+
+        const effectiveSourceId =
+            connection.sourceGroupId !== currentProcessGroupId ? 
connection.sourceGroupId : connection.sourceId;
+        const effectiveDestinationId =
+            connection.destinationGroupId !== currentProcessGroupId
+                ? connection.destinationGroupId
+                : connection.destinationId;
+
+        const pairKey = [effectiveSourceId, 
effectiveDestinationId].sort().join('::');
+
+        if (!pairMap.has(pairKey)) {
+            pairMap.set(pairKey, []);
+        }
+        pairMap.get(pairKey)!.push(connection.id);
+    });
+
+    const overlappingGroups: OverlappingConnectionGroup[] = [];
+
+    pairMap.forEach((connectionIds, pairKey) => {
+        if (connectionIds.length >= 2) {
+            const [componentA, componentB] = pairKey.split('::');
+            overlappingGroups.push({
+                sourceComponentId: componentA,
+                destinationComponentId: componentB,
+                connectionIds
+            });
+        }
+    });
+
+    return overlappingGroups;
+}
+
+/**
+ * Checks whether removing all bend points from a connection would cause it to
+ * visually overlap with another straight-line connection between the same 
components.
+ *
+ * @param connectionId          The connection whose bend points would be 
removed
+ * @param connections           All connections in the current view
+ * @param currentProcessGroupId The current process group context
+ * @returns true if removal would cause a visual overlap
+ */
+export function wouldRemovalCauseOverlap(
+    connectionId: string,
+    connections: OverlapDetectionConnection[],
+    currentProcessGroupId: string
+): boolean {
+    if (!connections || connections.length === 0) {
+        return false;
+    }
+
+    const target = connections.find((c) => c.id === connectionId);
+    if (!target) {
+        return false;
+    }
+
+    const effectiveSourceId = target.sourceGroupId !== currentProcessGroupId ? 
target.sourceGroupId : target.sourceId;
+    const effectiveDestinationId =
+        target.destinationGroupId !== currentProcessGroupId ? 
target.destinationGroupId : target.destinationId;
+    const pairKey = [effectiveSourceId, 
effectiveDestinationId].sort().join('::');
+
+    return connections.some((c) => {
+        if (c.id === connectionId) {
+            return false;
+        }
+        if (c.bends && c.bends.length > 0) {
+            return false;
+        }
+
+        const srcId = c.sourceGroupId !== currentProcessGroupId ? 
c.sourceGroupId : c.sourceId;
+        const dstId = c.destinationGroupId !== currentProcessGroupId ? 
c.destinationGroupId : c.destinationId;
+        const otherKey = [srcId, dstId].sort().join('::');
+
+        return otherKey === pairKey;
+    });
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/_overlapping-connections-banner.component-theme.scss
similarity index 56%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/_overlapping-connections-banner.component-theme.scss
index e400df89e17..5dc84a16eb8 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/_overlapping-connections-banner.component-theme.scss
@@ -15,9 +15,22 @@
  * limitations under the License.
  */
 
-div.graph-controls {
-    position: absolute;
-    left: 0;
-    top: 22px;
-    z-index: 2;
+@mixin generate-theme() {
+    .overlapping-connections-banner {
+        background-color: var(--mat-sys-surface-container);
+        border: 1px solid var(--mat-sys-outline-variant);
+        // !important needed to override the global *:not([class^=mat-])... 
border-color reset in _app.scss
+        border-left: 4px solid var(--nf-caution-default) !important;
+        color: var(--mat-sys-on-surface);
+
+        .overlap-link {
+            color: var(--mat-sys-primary);
+            cursor: pointer;
+            text-decoration: underline;
+
+            &:hover {
+                opacity: 0.6;
+            }
+        }
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.html
new file mode 100644
index 00000000000..a3d7df76781
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.html
@@ -0,0 +1,36 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+@if (overlappingGroups().length > 0) {
+    <div class="overlapping-connections-banner flex flex-col gap-1.5 px-4 py-3 
rounded text-sm">
+        <div class="flex items-center gap-2">
+            <i class="fa fa-warning caution-color"></i>
+            <span class="font-medium"> {{ overlappingGroups().length }} 
overlapping connection group(s) detected </span>
+        </div>
+        <span>
+            Overlapping connections with no bend points appear as a single 
connection. Add a bend point to separate
+            them.
+        </span>
+        <div class="flex flex-wrap gap-x-3">
+            @for (overlap of overlappingGroups(); track 
overlap.connectionIds[0]; let i = $index) {
+                <a class="overlap-link" (click)="showOverlap(overlap)">
+                    Overlap {{ i + 1 }} ({{ overlap.connectionIds.length }} 
connections)
+                </a>
+            }
+        </div>
+    </div>
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.scss
similarity index 90%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.scss
index e400df89e17..8305259f1a3 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.scss
@@ -15,9 +15,7 @@
  * limitations under the License.
  */
 
-div.graph-controls {
-    position: absolute;
-    left: 0;
-    top: 22px;
-    z-index: 2;
+:host {
+    display: block;
+    font-size: 13px;
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.spec.ts
new file mode 100644
index 00000000000..887019d9ebe
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.spec.ts
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { OverlappingConnectionsBannerComponent } from 
'./overlapping-connections-banner.component';
+import { OverlappingConnectionGroup } from '../overlap-detection.utils';
+
+interface SetupOptions {
+    overlappingGroups?: OverlappingConnectionGroup[];
+}
+
+async function setup(options: SetupOptions = {}) {
+    await TestBed.configureTestingModule({
+        imports: [OverlappingConnectionsBannerComponent]
+    }).compileComponents();
+
+    const fixture: ComponentFixture<OverlappingConnectionsBannerComponent> = 
TestBed.createComponent(
+        OverlappingConnectionsBannerComponent
+    );
+    const component = fixture.componentInstance;
+
+    if (options.overlappingGroups) {
+        fixture.componentRef.setInput('overlappingGroups', 
options.overlappingGroups);
+    }
+
+    fixture.detectChanges();
+
+    return { fixture, component };
+}
+
+describe('OverlappingConnectionsBannerComponent', () => {
+    beforeEach(() => {
+        jest.clearAllMocks();
+    });
+
+    describe('rendering', () => {
+        it('should not render when there are no overlapping groups', async () 
=> {
+            const { fixture } = await setup({ overlappingGroups: [] });
+            const banner = 
fixture.nativeElement.querySelector('.overlapping-connections-banner');
+            expect(banner).toBeNull();
+        });
+
+        it('should render when there are overlapping groups', async () => {
+            const groups: OverlappingConnectionGroup[] = [
+                { sourceComponentId: 'a', destinationComponentId: 'b', 
connectionIds: ['c1', 'c2'] }
+            ];
+            const { fixture } = await setup({ overlappingGroups: groups });
+            const banner = 
fixture.nativeElement.querySelector('.overlapping-connections-banner');
+            expect(banner).toBeTruthy();
+        });
+
+        it('should display the correct count of overlap groups', async () => {
+            const groups: OverlappingConnectionGroup[] = [
+                { sourceComponentId: 'a', destinationComponentId: 'b', 
connectionIds: ['c1', 'c2'] },
+                { sourceComponentId: 'c', destinationComponentId: 'd', 
connectionIds: ['c3', 'c4'] }
+            ];
+            const { fixture } = await setup({ overlappingGroups: groups });
+            const banner = 
fixture.nativeElement.querySelector('.overlapping-connections-banner');
+            expect(banner.textContent).toContain('2 overlapping connection 
group(s) detected');
+        });
+
+        it('should render a link for each overlap group', async () => {
+            const groups: OverlappingConnectionGroup[] = [
+                { sourceComponentId: 'a', destinationComponentId: 'b', 
connectionIds: ['c1', 'c2'] },
+                { sourceComponentId: 'c', destinationComponentId: 'd', 
connectionIds: ['c3', 'c4', 'c5'] }
+            ];
+            const { fixture } = await setup({ overlappingGroups: groups });
+            const links = 
fixture.nativeElement.querySelectorAll('.overlap-link');
+            expect(links.length).toBe(2);
+            expect(links[0].textContent).toContain('Overlap 1 (2 
connections)');
+            expect(links[1].textContent).toContain('Overlap 2 (3 
connections)');
+        });
+    });
+
+    describe('interaction', () => {
+        it('should emit navigateToGroup when a link is clicked', async () => {
+            const groups: OverlappingConnectionGroup[] = [
+                { sourceComponentId: 'a', destinationComponentId: 'b', 
connectionIds: ['c1', 'c2'] }
+            ];
+            const { fixture, component } = await setup({ overlappingGroups: 
groups });
+
+            const emitSpy = jest.spyOn(component.navigateToGroup, 'emit');
+            const link = fixture.nativeElement.querySelector('.overlap-link');
+            link.click();
+
+            expect(emitSpy).toHaveBeenCalledWith(groups[0]);
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.ts
similarity index 54%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.ts
index e400df89e17..ca7cae1d36d 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.ts
@@ -15,9 +15,21 @@
  * limitations under the License.
  */
 
-div.graph-controls {
-    position: absolute;
-    left: 0;
-    top: 22px;
-    z-index: 2;
+import { Component, input, output } from '@angular/core';
+import { OverlappingConnectionGroup } from '../overlap-detection.utils';
+
+@Component({
+    selector: 'overlapping-connections-banner',
+    standalone: true,
+    imports: [],
+    templateUrl: './overlapping-connections-banner.component.html',
+    styleUrls: ['./overlapping-connections-banner.component.scss']
+})
+export class OverlappingConnectionsBannerComponent {
+    overlappingGroups = input<OverlappingConnectionGroup[]>([]);
+    navigateToGroup = output<OverlappingConnectionGroup>();
+
+    showOverlap(overlap: OverlappingConnectionGroup): void {
+        this.navigateToGroup.emit(overlap);
+    }
 }
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss 
b/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
index fc277527522..45641f82aa7 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
@@ -55,6 +55,8 @@
     processor-status-table;
 @use 
'app/pages/flow-designer/ui/canvas/change-color-dialog/change-color-dialog.component-theme'
 as change-color-dialog;
 @use 
'libs/shared/src/components/map-table/editors/text-editor/text-editor.component-theme'
 as text-editor;
+@use 
'app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component-theme'
 as
+    overlapping-connections-banner;
 
 // Plus imports for other components in your app.
 @use 'libs/shared/src/assets/fonts/flowfont/flowfont.css';
@@ -101,6 +103,7 @@ html {
     @include change-color-dialog.generate-theme();
     @include text-editor.generate-theme();
     @include resizable.generate-theme();
+    @include overlapping-connections-banner.generate-theme();
 
     .darkMode {
         @include app.generate-material-theme();
@@ -133,5 +136,6 @@ html {
         @include change-color-dialog.generate-theme();
         @include text-editor.generate-theme();
         @include resizable.generate-theme();
+        @include overlapping-connections-banner.generate-theme();
     }
 }

Reply via email to