1 import {GraphNode} from '../../graph/graph-node';
2 import {Workflow} from '../../graph/workflow';
3 import {SVGUtils} from '../../utils/svg-utils';
4 import {GraphChange, SVGPlugin} from '../plugin';
7 WorkflowInputParameterModel,
8 WorkflowOutputParameterModel
11 export class SVGArrangePlugin implements SVGPlugin {
12 private workflow: Workflow;
13 private svgRoot: SVGSVGElement;
14 private onBeforeChange: () => void;
15 private onAfterChange: (updates: NodePositionUpdates) => void;
16 private triggerAfterRender: () => void;
18 registerWorkflow(workflow: Workflow): void {
19 this.workflow = workflow;
20 this.svgRoot = workflow.svgRoot;
24 registerOnBeforeChange(fn: (change: GraphChange) => void): void {
25 this.onBeforeChange = () => fn({type: "arrange"});
28 registerOnAfterChange(fn: (change: GraphChange) => void): void {
29 this.onAfterChange = () => fn({type: "arrange"});
32 registerOnAfterRender(fn: (change: GraphChange) => void): void {
33 this.triggerAfterRender = () => fn({type: "arrange"});
37 const model = this.workflow.model;
38 const arr = [] as Array<WorkflowInputParameterModel | WorkflowOutputParameterModel | StepModel>;
39 const drawables = arr.concat(
45 for (const node of drawables) {
47 const missingCoordinate = isNaN(parseInt(node.customProps["sbg:x"], 10));
48 if (missingCoordinate) {
58 this.onBeforeChange();
60 // We need to reset all transformations on the workflow for now.
61 // @TODO Make arranging work without this
62 this.workflow.resetTransform();
64 // We need main graph and dangling nodes separately, they will be distributed differently
65 const {mainGraph, danglingNodes} = this.makeNodeGraphs();
67 // Create an array of columns, each containing a list of NodeIOs
68 const columns = this.distributeNodesIntoColumns(mainGraph);
70 // Get total area in which we will fit the graph, and per-column dimensions
71 const {distributionArea, columnDimensions} = this.calculateColumnSizes(columns);
73 // This will be the vertical middle around which the graph should be centered
74 const verticalBaseline = distributionArea.height / 2;
79 // Here we will store positions for each node that is to be updated.
80 // This should then be emitted as an afterChange event.
81 const nodePositionUpdates = {} as NodePositionUpdates;
83 columns.forEach((column, index) => {
84 const colSize = columnDimensions[index];
85 let yOffset = verticalBaseline - (colSize.height / 2) - column[0].rect.height / 2;
87 column.forEach(node => {
88 yOffset += node.rect.height / 2;
90 const matrix = SVGUtils.createMatrix().translate(xOffset, yOffset);
92 yOffset += node.rect.height / 2;
94 if (yOffset > maxYOffset) {
98 node.el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
100 nodePositionUpdates[node.connectionID] = {
107 xOffset += colSize.width;
110 const danglingNodeKeys = Object.keys(danglingNodes).sort((a, b) => {
112 const aIsInput = a.startsWith("out/");
113 const aIsOutput = a.startsWith("in/");
114 const bIsInput = b.startsWith("out/");
115 const bIsOutput = b.startsWith("in/");
117 const lowerA = a.toLowerCase();
118 const lowerB = b.toLowerCase();
123 return lowerB.localeCompare(lowerA);
128 } else if (aIsInput) {
133 return lowerB.localeCompare(lowerA);
139 if (!bIsOutput && !bIsInput) {
140 return lowerB.localeCompare(lowerA);
148 const danglingNodeMarginOffset = 30;
149 const danglingNodeSideLength = GraphNode.radius * 5;
151 let maxNodeHeightInRow = 0;
153 const indexWidthMap = new Map<number, number>();
154 const rowMaxHeightMap = new Map<number, number>();
158 const danglingRowAreaWidth = Math.max(distributionArea.width, danglingNodeSideLength * 3);
159 danglingNodeKeys.forEach((connectionID, index) => {
160 const el = danglingNodes[connectionID] as SVGGElement;
161 const rect = el.firstElementChild!.getBoundingClientRect();
162 indexWidthMap.set(index, rect.width);
165 xOffset -= rect.width / 2;
167 if (rect.height > maxNodeHeightInRow) {
168 maxNodeHeightInRow = rect.height;
170 xOffset += rect.width + danglingNodeMarginOffset + Math.max(150 - rect.width, 0);
172 if (xOffset >= danglingRowAreaWidth && index < danglingNodeKeys.length - 1) {
173 rowMaxHeightMap.set(row++, maxNodeHeightInRow);
174 maxNodeHeightInRow = 0;
179 rowMaxHeightMap.set(row, maxNodeHeightInRow);
180 let colYOffset = maxYOffset;
184 danglingNodeKeys.forEach((connectionID, index) => {
185 const el = danglingNodes[connectionID] as SVGGElement;
186 const width = indexWidthMap.get(index)!;
187 const rowHeight = rowMaxHeightMap.get(row)!;
188 let left = xOffset + width / 2;
189 const top = colYOffset
190 + danglingNodeMarginOffset
191 + Math.ceil(rowHeight / 2)
192 + ((xOffset === 0 ? 0 : left) / danglingRowAreaWidth) * danglingNodeSideLength;
196 xOffset -= width / 2;
198 xOffset += width + danglingNodeMarginOffset + Math.max(150 - width, 0);
200 const matrix = SVGUtils.createMatrix().translate(left, top);
201 el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
203 nodePositionUpdates[connectionID] = {x: matrix.e, y: matrix.f};
205 if (xOffset >= danglingRowAreaWidth) {
206 colYOffset += Math.ceil(rowHeight) + danglingNodeMarginOffset;
208 maxNodeHeightInRow = 0;
213 this.workflow.redrawEdges();
214 this.workflow.fitToViewport();
216 this.onAfterChange(nodePositionUpdates);
217 this.triggerAfterRender();
219 for (const id in nodePositionUpdates) {
220 const pos = nodePositionUpdates[id];
221 const nodeModel = this.workflow.model.findById(id);
222 if (!nodeModel.customProps) {
223 nodeModel.customProps = {};
226 Object.assign(nodeModel.customProps, {
232 return nodePositionUpdates;
236 * Calculates column dimensions and total graph area
237 * @param {NodeIO[][]} columns
239 private calculateColumnSizes(columns: NodeIO[][]): {
249 const distributionArea = {width: 0, height: 0};
250 const columnDimensions = [];
252 for (let i = 1; i < columns.length; i++) {
257 for (let j = 0; j < columns[i].length; j++) {
258 const entry = columns[i][j];
260 height += entry.rect.height;
262 if (width < entry.rect.width) {
263 width = entry.rect.width;
267 columnDimensions[i] = {height, width};
269 distributionArea.width += width;
270 if (height > distributionArea.height) {
271 distributionArea.height = height;
283 * Maps node's connectionID to a 1-indexed column number
285 private distributeNodesIntoColumns(graph: NodeMap): Array<NodeIO[]> {
286 const idToZoneMap = {};
287 const sortedNodeIDs = Object.keys(graph).sort((a, b) => b.localeCompare(a));
288 const zones = [] as any[];
290 for (let i = 0; i < sortedNodeIDs.length; i++) {
291 const nodeID = sortedNodeIDs[i];
292 const node = graph[nodeID];
294 // For outputs and steps, we calculate the zone as a longest path you can take to them
295 if (node.type !== "input") {
296 idToZoneMap[nodeID] = this.traceLongestNodePathLength(node, graph);
299 // Longest trace methods would put all inputs in the first column,
300 // but we want it just behind the leftmost step that it is connected to
303 // (input)<----------------->(step)---
304 // (input)<---------->(step)----------
308 // ---------------(input)<--->(step)---
309 // --------(input)<-->(step)-----------
312 let closestNodeZone = Infinity;
313 for (let i = 0; i < node.outputs.length; i++) {
314 const successorNodeZone = idToZoneMap[node.outputs[i]];
316 if (successorNodeZone < closestNodeZone) {
317 closestNodeZone = successorNodeZone;
320 if (closestNodeZone === Infinity) {
321 idToZoneMap[nodeID] = 1;
323 idToZoneMap[nodeID] = closestNodeZone - 1;
328 const zone = idToZoneMap[nodeID];
329 zones[zone] || (zones[zone] = []);
331 zones[zone].push(graph[nodeID]);
339 * Finds all nodes in the graph, and indexes them by their "data-connection-id" attribute
341 private indexNodesByID(): { [dataConnectionID: string]: SVGGElement } {
343 const nodes = this.svgRoot.querySelectorAll(".node");
345 for (let i = 0; i < nodes.length; i++) {
346 indexed[nodes[i].getAttribute("data-connection-id")!] = nodes[i];
353 * Finds length of the longest possible path from the graph root to a node.
354 * Lengths are 1-indexed. When a node has no predecessors, it will have length of 1.
356 private traceLongestNodePathLength(node: NodeIO, nodeGraph: any, visited = new Set<NodeIO>()): number {
360 if (node.inputs.length === 0) {
364 const inputPathLengths = [];
366 for (let i = 0; i < node.inputs.length; i++) {
367 const el = nodeGraph[node.inputs[i]];
369 if (visited.has(el)) {
373 inputPathLengths.push(this.traceLongestNodePathLength(el, nodeGraph, visited));
376 return Math.max(...inputPathLengths) + 1;
379 private makeNodeGraphs(): {
381 danglingNodes: { [nodeID: string]: SVGGElement }
384 // We need all nodes in order to find the dangling ones, those will be sorted separately
385 const allNodes = this.indexNodesByID();
387 // Make a graph representation where you can trace inputs and outputs from/to connection ids
388 const nodeGraph = {} as NodeMap;
390 // Edges are the main source of information from which we will distribute nodes
391 const edges = this.svgRoot.querySelectorAll(".edge");
393 for (let i = 0; i < edges.length; i++) {
395 const edge = edges[i];
397 const sourceConnectionID = edge.getAttribute("data-source-connection")!;
398 const destinationConnectionID = edge.getAttribute("data-destination-connection")!;
400 const [sourceSide, sourceNodeID, sourcePortID] = sourceConnectionID.split("/");
401 const [destinationSide, destinationNodeID, destinationPortID] = destinationConnectionID.split("/");
403 // Both source and destination are considered to be steps by default
404 let sourceType = "step";
405 let destinationType = "step";
407 // Ports have the same node and port ids
408 if (sourceNodeID === sourcePortID) {
409 sourceType = sourceSide === "in" ? "output" : "input";
412 if (destinationNodeID === destinationPortID) {
413 destinationType = destinationSide === "in" ? "output" : "input";
416 // Initialize keys on graph if they don't exist
417 const sourceNode = this.svgRoot.querySelector(`.node[data-id="${sourceNodeID}"]`) as SVGGElement;
418 const destinationNode = this.svgRoot.querySelector(`.node[data-id="${destinationNodeID}"]`) as SVGGElement;
420 const sourceNodeConnectionID = sourceNode.getAttribute("data-connection-id")!;
421 const destinationNodeConnectionID = destinationNode.getAttribute("data-connection-id")!;
423 // Source and destination of this edge are obviously not dangling, so we can remove them
424 // from the set of potentially dangling nodes
425 delete allNodes[sourceNodeConnectionID];
426 delete allNodes[destinationNodeConnectionID];
428 // Ensure that the source node has its entry in the node graph
429 (nodeGraph[sourceNodeID] || (nodeGraph[sourceNodeID] = {
433 connectionID: sourceNodeConnectionID,
435 rect: sourceNode.getBoundingClientRect()
438 // Ensure that the source node has its entry in the node graph
439 (nodeGraph[destinationNodeID] || (nodeGraph[destinationNodeID] = {
442 type: destinationType,
443 connectionID: destinationNodeConnectionID,
445 rect: destinationNode.getBoundingClientRect()
448 nodeGraph[sourceNodeID].outputs.push(destinationNodeID);
449 nodeGraph[destinationNodeID].inputs.push(sourceNodeID);
453 mainGraph: nodeGraph,
454 danglingNodes: allNodes
460 export type NodeIO = {
463 connectionID: string,
466 type: "step" | "input" | "output" | string
468 export type NodeMap = { [connectionID: string]: NodeIO }
470 export type NodePositionUpdates = { [connectionID: string]: { x: number, y: number } };