NIFI-13065: Adding initial bend points for self looping connections and connections that collide with existing connections (#8671)

* NIFI-13065:
- Adding initial bend points for self looping connections and connections that collide with existing connections.
- Merging two actions into one for opening the new connection dialog.

* NIFI-13065:
- Only considering self looping connections when automatically moving bends when the source component moves.

* NIFI-13065:
- Making collision check for more lenient.
- Setting initial label index to 0.

This closes #8671
This commit is contained in:
Matt Gilman 2024-04-19 17:58:26 -04:00 committed by GitHub
parent fe5ef39ff2
commit d9e48f8645
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 214 additions and 59 deletions

View File

@ -20,9 +20,11 @@ import * as d3 from 'd3';
import { CanvasUtils } from '../canvas-utils.service';
import { Store } from '@ngrx/store';
import { CanvasState } from '../../state';
import { getDefaultsAndOpenNewConnectionDialog, selectComponents } from '../../state/flow/flow.actions';
import { openNewConnectionDialog, selectComponents } from '../../state/flow/flow.actions';
import { ConnectionManager } from '../manager/connection-manager.service';
import { Position } from '../../state/shared';
import { CreateConnectionRequest } from '../../state/flow';
import { NiFiCommon } from '../../../../service/nifi-common.service';
@Injectable({
providedIn: 'root'
@ -33,7 +35,8 @@ export class ConnectableBehavior {
constructor(
private store: Store<CanvasState>,
private canvasUtils: CanvasUtils
private canvasUtils: CanvasUtils,
private nifiCommon: NiFiCommon
) {
const self: ConnectableBehavior = this;
@ -222,26 +225,174 @@ export class ConnectableBehavior {
// create the connection
const destinationData = destination.datum();
// prepare the connection request
const request: CreateConnectionRequest = {
source: {
id: sourceData.id,
componentType: sourceData.type,
entity: sourceData
},
destination: {
id: destinationData.id,
componentType: destinationData.type,
entity: destinationData
}
};
// add initial bend points if necessary
const bends: Position[] = self.calculateInitialBendPoints(sourceData, destinationData);
if (bends) {
request.bends = bends;
}
self.store.dispatch(
getDefaultsAndOpenNewConnectionDialog({
request: {
source: {
id: sourceData.id,
componentType: sourceData.type,
entity: sourceData
},
destination: {
id: destinationData.id,
componentType: destinationData.type,
entity: destinationData
}
}
openNewConnectionDialog({
request
})
);
}
});
}
/**
* 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;
});
}
/**
* Determines if we want to allow adding connections in the current state:
*

View File

@ -176,7 +176,6 @@ export class DraggableBehavior {
* @param {selection} dragSelection The current drag selection
*/
private updateComponentsPosition(dragSelection: any): void {
const self: DraggableBehavior = this;
const componentUpdates: Map<string, UpdateComponentRequest> = new Map();
const connectionUpdates: Map<string, UpdateComponentRequest> = new Map();
@ -192,8 +191,8 @@ export class DraggableBehavior {
return;
}
const selectedConnections: any = d3.selectAll('g.connection.selected');
const selectedComponents: any = d3.selectAll('g.component.selected');
const selectedConnections: d3.Selection<any, any, any, any> = d3.selectAll('g.connection.selected');
const selectedComponents: d3.Selection<any, any, any, any> = d3.selectAll('g.component.selected');
// ensure every component is writable
if (!this.canvasUtils.canModify(selectedConnections) || !this.canvasUtils.canModify(selectedComponents)) {
@ -207,29 +206,34 @@ export class DraggableBehavior {
}
// go through each selected connection
selectedConnections.each(function (d: any) {
const connectionUpdate = self.updateConnectionPosition(d, delta);
if (connectionUpdate !== null) {
selectedConnections.each((d) => {
const connectionUpdate = this.updateConnectionPosition(d, delta);
if (connectionUpdate) {
connectionUpdates.set(d.id, connectionUpdate);
}
});
// go through each selected component
selectedComponents.each(function (d: any) {
selectedComponents.each((d) => {
// consider any self looping connections
const componentConnections = self.canvasUtils.getComponentConnections(d.id);
const componentConnections = this.canvasUtils.getComponentConnections(d.id);
componentConnections.forEach((componentConnection) => {
if (!connectionUpdates.has(componentConnection.id)) {
const connectionUpdate = self.updateConnectionPosition(componentConnection, delta);
if (connectionUpdate !== null) {
connectionUpdates.set(componentConnection.id, connectionUpdate);
componentConnections.forEach((connection) => {
if (!connectionUpdates.has(connection.id)) {
const sourceId = this.canvasUtils.getConnectionSourceComponentId(connection);
const destinationId = this.canvasUtils.getConnectionDestinationComponentId(connection);
if (sourceId === destinationId) {
const connectionUpdate = this.updateConnectionPosition(connection, delta);
if (connectionUpdate) {
connectionUpdates.set(connection.id, connectionUpdate);
}
}
}
});
// consider the component itself
componentUpdates.set(d.id, self.updateComponentPosition(d, delta));
componentUpdates.set(d.id, this.updateComponentPosition(d, delta));
});
// dispatch the position updates
@ -247,7 +251,7 @@ export class DraggableBehavior {
/**
* Updates the parent group of all selected components.
*
* @param {selection} the destination group
* @param group
*/
private updateComponentsGroup(group: any): void {
// get the selection and deselect the components being moved

View File

@ -72,6 +72,9 @@ export class ConnectionManager {
public static readonly SELF_LOOP_X_OFFSET: number = ConnectionManager.DIMENSIONS.width / 2 + 5;
public static readonly SELF_LOOP_Y_OFFSET: number = 25;
public static readonly CONNECTION_OFFSET_Y_INCREMENT: number = 75;
public static readonly CONNECTION_OFFSET_X_INCREMENT: number = 250;
private static readonly HEIGHT_FOR_BACKPRESSURE: number = 3;
private static readonly SNAP_ALIGNMENT_PIXELS: number = 8;

View File

@ -26,7 +26,6 @@ import {
CreateComponentRequest,
CreateComponentResponse,
CreateConnection,
CreateConnectionDialogRequest,
CreateConnectionRequest,
CreatePortRequest,
CreateProcessGroupDialogRequest,
@ -305,14 +304,9 @@ export const createProcessor = createAction(
props<{ request: CreateProcessorRequest }>()
);
export const getDefaultsAndOpenNewConnectionDialog = createAction(
`${CANVAS_PREFIX} Get Defaults And Open New Connection Dialog`,
props<{ request: CreateConnectionRequest }>()
);
export const openNewConnectionDialog = createAction(
`${CANVAS_PREFIX} Open New Connection Dialog`,
props<{ request: CreateConnectionDialogRequest }>()
props<{ request: CreateConnectionRequest }>()
);
export const createConnection = createAction(

View File

@ -41,6 +41,7 @@ import {
} from 'rxjs';
import {
CopyComponentRequest,
CreateConnectionDialogRequest,
CreateProcessGroupDialogRequest,
DeleteComponentResponse,
GroupComponentsDialogRequest,
@ -610,36 +611,32 @@ export class FlowEffects {
)
);
getDefaultsAndOpenNewConnectionDialog$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.getDefaultsAndOpenNewConnectionDialog),
map((action) => action.request),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
switchMap(([request, currentProcessGroupId]) =>
from(this.flowService.getProcessGroup(currentProcessGroupId)).pipe(
map((response) =>
FlowActions.openNewConnectionDialog({
request: {
openNewConnectionDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(FlowActions.openNewConnectionDialog),
map((action) => action.request),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
switchMap(([request, currentProcessGroupId]) =>
from(this.flowService.getProcessGroup(currentProcessGroupId)).pipe(
map((response) => {
return {
request,
defaults: {
flowfileExpiration: response.component.defaultFlowFileExpiration,
objectThreshold: response.component.defaultBackPressureObjectThreshold,
dataSizeThreshold: response.component.defaultBackPressureDataSizeThreshold
}
} as CreateConnectionDialogRequest;
}),
tap({
error: (errorResponse: HttpErrorResponse) => {
this.canvasUtils.removeTempEdge();
this.store.dispatch(FlowActions.flowSnackbarError({ error: errorResponse.error }));
}
})
),
catchError((error) => of(FlowActions.flowApiError({ error: error.error })))
)
)
)
);
openNewConnectionDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(FlowActions.openNewConnectionDialog),
map((action) => action.request),
)
),
tap((request) => {
const dialogReference = this.dialog.open(CreateConnection, {
...LARGE_DIALOG,

View File

@ -101,6 +101,7 @@ export interface CreateComponentRequest {
export interface CreateConnectionRequest {
source: SelectedComponent;
destination: SelectedComponent;
bends?: Position[];
}
export const loadBalanceStrategies: SelectOption[] = [

View File

@ -241,6 +241,7 @@ export class CreateConnection {
flowFileExpiration: this.createConnectionForm.get('flowFileExpiration')?.value,
loadBalanceStrategy: this.createConnectionForm.get('loadBalanceStrategy')?.value,
name: this.createConnectionForm.get('name')?.value,
labelIndex: 0,
prioritizers: this.createConnectionForm.get('prioritizers')?.value
}
};
@ -296,6 +297,10 @@ export class CreateConnection {
payload.component.loadBalanceCompression = 'DO_NOT_COMPRESS';
}
if (this.dialogRequest.request.bends) {
payload.component.bends = this.dialogRequest.request.bends;
}
this.store.dispatch(
createConnection({
request: {