[NIFI-13162] horizontal and vertical canvas component alignment (#8762)

* [NIFI-13162] horizontal and vertical canvas component alignment

* review feedback

* rename to updatePositionRequestId

* use enum

* use the appropriate generic type and separate components and connections updates

* use enum

This closes #8762
This commit is contained in:
Scott Aslan 2024-05-08 15:07:42 -04:00 committed by GitHub
parent fc8f072e0a
commit 6950290c24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 234 additions and 15 deletions

View File

@ -40,7 +40,7 @@ export class DraggableBehavior {
private scale: number = INITIAL_SCALE;
private updateConnectionRequestId = 0;
private updatePositionRequestId = 0;
constructor(
private store: Store<CanvasState>,
@ -240,7 +240,7 @@ export class DraggableBehavior {
this.store.dispatch(
updatePositions({
request: {
requestId: this.updateConnectionRequestId++,
requestId: this.updatePositionRequestId++,
componentUpdates: Array.from(componentUpdates.values()),
connectionUpdates: Array.from(connectionUpdates.values())
}
@ -298,7 +298,7 @@ export class DraggableBehavior {
);
}
private updateComponentPosition(d: any, delta: Position): UpdateComponentRequest {
updateComponentPosition(d: any, delta: Position): UpdateComponentRequest {
const newPosition = {
x: this.snapEnabled
? Math.round((d.position.x + delta.x) / this.snapAlignmentPixels) * this.snapAlignmentPixels
@ -334,7 +334,7 @@ export class DraggableBehavior {
* @param delta The change in position
* @returns {*}
*/
private updateConnectionPosition(connection: any, delta: any): UpdateComponentRequest | null {
updateConnectionPosition(connection: any, delta: any): UpdateComponentRequest | null {
// only update if necessary
if (connection.bends.length === 0) {
return null;

View File

@ -47,14 +47,16 @@ import {
requestRefreshRemoteProcessGroup,
runOnce,
stopVersionControlRequest,
terminateThreads
terminateThreads,
updatePositions
} from '../state/flow/flow.actions';
import { ComponentType } from '../../../state/shared';
import {
ConfirmStopVersionControlRequest,
MoveComponentRequest,
OpenChangeVersionDialogRequest,
OpenLocalChangesDialogRequest
OpenLocalChangesDialogRequest,
UpdateComponentRequest
} from '../state/flow';
import {
ContextMenuDefinition,
@ -68,9 +70,13 @@ import * as d3 from 'd3';
import { Client } from '../../../service/client.service';
import { CanvasView } from './canvas-view.service';
import { CanvasActionsService } from './canvas-actions.service';
import * as FlowActions from '../state/flow/flow.actions';
import { DraggableBehavior } from './behavior/draggable-behavior.service';
@Injectable({ providedIn: 'root' })
export class CanvasContextMenu implements ContextMenuDefinitionProvider {
private updatePositionRequestId = 0;
readonly VERSION_MENU = {
id: 'version',
menuItems: [
@ -301,24 +307,195 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
menuItems: [
{
condition: (selection: any) => {
// TODO - canAlign
return false;
return this.canvasUtils.canAlign(selection);
},
clazz: 'fa fa-align-center fa-rotate-90',
text: 'Horizontally',
action: () => {
// TODO - alignHorizontal
action: (selection: any) => {
const componentUpdates: Map<string, UpdateComponentRequest> = new Map();
const connectionUpdates: Map<string, UpdateComponentRequest> = new Map();
// determine the extent
let minY: number = 0,
maxY: number = 0;
selection.each((d: any) => {
if (d.type !== ComponentType.Connection) {
if (minY === 0 || d.position.y < minY) {
minY = d.position.y;
}
const componentMaxY = d.position.y + d.dimensions.height;
if (maxY === 0 || componentMaxY > maxY) {
maxY = componentMaxY;
}
}
});
const center = (minY + maxY) / 2;
// align all components with top most component
selection.each((d: any) => {
if (d.type !== ComponentType.Connection) {
const delta = {
x: 0,
y: center - (d.position.y + d.dimensions.height / 2)
};
// if this component is already centered, no need to updated it
if (delta.y !== 0) {
// consider any connections
const connections = this.canvasUtils.getComponentConnections(d.id);
connections.forEach((connection: any) => {
const connectionSelection = d3.select('#id-' + connection.id);
if (
!connectionUpdates.has(connection.id) &&
this.canvasUtils.getConnectionSourceComponentId(connection) ===
this.canvasUtils.getConnectionDestinationComponentId(connection)
) {
// this connection is self looping and hasn't been updated by the delta yet
const connectionUpdate = this.draggableBehavior.updateConnectionPosition(
connectionSelection.datum(),
delta
);
if (connectionUpdate !== null) {
connectionUpdates.set(connection.id, connectionUpdate);
}
} else if (
!connectionUpdates.has(connection.id) &&
connectionSelection.classed('selected') &&
this.canvasUtils.canModify(connectionSelection)
) {
// this is a selected connection that hasn't been updated by the delta yet
if (
this.canvasUtils.getConnectionSourceComponentId(connection) === d.id ||
!this.canvasUtils.isSourceSelected(connection, selection)
) {
// the connection is either outgoing or incoming when the source of the connection is not part of the selection
const connectionUpdate = this.draggableBehavior.updateConnectionPosition(
connectionSelection.datum(),
delta
);
if (connectionUpdate !== null) {
connectionUpdates.set(connection.id, connectionUpdate);
}
}
}
});
componentUpdates.set(d.id, this.draggableBehavior.updateComponentPosition(d, delta));
}
}
});
if (connectionUpdates.size > 0 || componentUpdates.size > 0) {
// dispatch the position updates
this.store.dispatch(
updatePositions({
request: {
requestId: this.updatePositionRequestId++,
componentUpdates: Array.from(componentUpdates.values()),
connectionUpdates: Array.from(connectionUpdates.values())
}
})
);
}
}
},
{
condition: (selection: any) => {
// TODO - canAlign
return false;
return this.canvasUtils.canAlign(selection);
},
clazz: 'fa fa-align-center',
text: 'Vertically',
action: () => {
// TODO - alignVertical
action: (selection: any) => {
const componentUpdates: Map<string, UpdateComponentRequest> = new Map();
const connectionUpdates: Map<string, UpdateComponentRequest> = new Map();
// determine the extent
let minX = 0;
let maxX = 0;
selection.each((d: any) => {
if (d.type !== ComponentType.Connection) {
if (minX === 0 || d.position.x < minX) {
minX = d.position.x;
}
const componentMaxX = d.position.x + d.dimensions.width;
if (maxX === 0 || componentMaxX > maxX) {
maxX = componentMaxX;
}
}
});
const center = (minX + maxX) / 2;
// align all components with top most component
selection.each((d: any) => {
if (d.type !== ComponentType.Connection) {
const delta = {
x: center - (d.position.x + d.dimensions.width / 2),
y: 0
};
// if this component is already centered, no need to updated it
if (delta.x !== 0) {
// consider any connections
const connections = this.canvasUtils.getComponentConnections(d.id);
connections.forEach((connection: any) => {
const connectionSelection = d3.select('#id-' + connection.id);
if (
!connectionUpdates.has(connection.id) &&
this.canvasUtils.getConnectionSourceComponentId(connection) ===
this.canvasUtils.getConnectionDestinationComponentId(connection)
) {
// this connection is self looping and hasn't been updated by the delta yet
const connectionUpdate = this.draggableBehavior.updateConnectionPosition(
connectionSelection.datum(),
delta
);
if (connectionUpdate !== null) {
connectionUpdates.set(connection.id, connectionUpdate);
}
} else if (
!connectionUpdates.has(connection.id) &&
connectionSelection.classed('selected') &&
this.canvasUtils.canModify(connectionSelection)
) {
// this is a selected connection that hasn't been updated by the delta yet
if (
this.canvasUtils.getConnectionSourceComponentId(connection) === d.id ||
!this.canvasUtils.isSourceSelected(connection, selection)
) {
// the connection is either outgoing or incoming when the source of the connection is not part of the selection
const connectionUpdate = this.draggableBehavior.updateConnectionPosition(
connectionSelection.datum(),
delta
);
if (connectionUpdate !== null) {
connectionUpdates.set(connection.id, connectionUpdate);
}
}
}
});
componentUpdates.set(d.id, this.draggableBehavior.updateComponentPosition(d, delta));
}
}
});
if (connectionUpdates.size > 0 || componentUpdates.size > 0) {
// dispatch the position updates
this.store.dispatch(
updatePositions({
request: {
requestId: this.updatePositionRequestId++,
componentUpdates: Array.from(componentUpdates.values()),
connectionUpdates: Array.from(connectionUpdates.values())
}
})
);
}
}
}
]
@ -1145,7 +1322,8 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
private canvasUtils: CanvasUtils,
private client: Client,
private canvasView: CanvasView,
private canvasActionsService: CanvasActionsService
private canvasActionsService: CanvasActionsService,
private draggableBehavior: DraggableBehavior
) {
this.allMenus = new Map<string, ContextMenuDefinition>();
this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);

View File

@ -231,6 +231,33 @@ export class CanvasUtils {
return this.isNotRootGroup() && selection.empty();
}
/**
* Determines if the specified selection is alignable (in a single action).
*
* @param {selection} selection The selection
* @returns {boolean}
*/
public canAlign(selection: any) {
let canAlign = true;
// determine if the current selection is entirely connections
const selectedConnections = selection.filter((d: any) => {
return d.type == ComponentType.Connection;
});
// require multiple selections besides connections
if (selection.size() - selectedConnections.size() < 2) {
canAlign = false;
}
// require write permissions
if (!this.canModify(selection)) {
canAlign = false;
}
return canAlign;
}
/**
* Determines whether the components in the specified selection are writable.
*
@ -526,6 +553,17 @@ export class CanvasUtils {
return selection.size() === 1 && selection.classed('funnel');
}
// determine if the source of this connection is part of the selection
public isSourceSelected(connection: any, selection: any): boolean {
return (
selection
.filter((d: any) => {
return this.getConnectionSourceComponentId(connection) === d.id;
})
.size() > 0
);
}
/**
* Determines whether the current selection is a stateful processor.
*

View File

@ -1927,6 +1927,9 @@ export class FlowEffects {
updatePositionComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.updatePositionComplete),
tap(() => {
this.birdseyeView.refresh();
}),
map((action) => action.response),
switchMap((response) =>
of(FlowActions.renderConnectionsForComponent({ id: response.id, updatePath: true, updateLabel: false }))