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

View File

@ -47,14 +47,16 @@ import {
requestRefreshRemoteProcessGroup, requestRefreshRemoteProcessGroup,
runOnce, runOnce,
stopVersionControlRequest, stopVersionControlRequest,
terminateThreads terminateThreads,
updatePositions
} from '../state/flow/flow.actions'; } from '../state/flow/flow.actions';
import { ComponentType } from '../../../state/shared'; import { ComponentType } from '../../../state/shared';
import { import {
ConfirmStopVersionControlRequest, ConfirmStopVersionControlRequest,
MoveComponentRequest, MoveComponentRequest,
OpenChangeVersionDialogRequest, OpenChangeVersionDialogRequest,
OpenLocalChangesDialogRequest OpenLocalChangesDialogRequest,
UpdateComponentRequest
} from '../state/flow'; } from '../state/flow';
import { import {
ContextMenuDefinition, ContextMenuDefinition,
@ -68,9 +70,13 @@ import * as d3 from 'd3';
import { Client } from '../../../service/client.service'; import { Client } from '../../../service/client.service';
import { CanvasView } from './canvas-view.service'; import { CanvasView } from './canvas-view.service';
import { CanvasActionsService } from './canvas-actions.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' }) @Injectable({ providedIn: 'root' })
export class CanvasContextMenu implements ContextMenuDefinitionProvider { export class CanvasContextMenu implements ContextMenuDefinitionProvider {
private updatePositionRequestId = 0;
readonly VERSION_MENU = { readonly VERSION_MENU = {
id: 'version', id: 'version',
menuItems: [ menuItems: [
@ -301,24 +307,195 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
menuItems: [ menuItems: [
{ {
condition: (selection: any) => { condition: (selection: any) => {
// TODO - canAlign return this.canvasUtils.canAlign(selection);
return false;
}, },
clazz: 'fa fa-align-center fa-rotate-90', clazz: 'fa fa-align-center fa-rotate-90',
text: 'Horizontally', text: 'Horizontally',
action: () => { action: (selection: any) => {
// TODO - alignHorizontal 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) => { condition: (selection: any) => {
// TODO - canAlign return this.canvasUtils.canAlign(selection);
return false;
}, },
clazz: 'fa fa-align-center', clazz: 'fa fa-align-center',
text: 'Vertically', text: 'Vertically',
action: () => { action: (selection: any) => {
// TODO - alignVertical 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 canvasUtils: CanvasUtils,
private client: Client, private client: Client,
private canvasView: CanvasView, private canvasView: CanvasView,
private canvasActionsService: CanvasActionsService private canvasActionsService: CanvasActionsService,
private draggableBehavior: DraggableBehavior
) { ) {
this.allMenus = new Map<string, ContextMenuDefinition>(); this.allMenus = new Map<string, ContextMenuDefinition>();
this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU); this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);

View File

@ -231,6 +231,33 @@ export class CanvasUtils {
return this.isNotRootGroup() && selection.empty(); 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. * Determines whether the components in the specified selection are writable.
* *
@ -526,6 +553,17 @@ export class CanvasUtils {
return selection.size() === 1 && selection.classed('funnel'); 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. * Determines whether the current selection is a stateful processor.
* *

View File

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