Merge branch '16659-copy-api-token-to-clipboard'
[arvados.git] / src / lib / cwl-svg / plugins / arrange / arrange.ts
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';
5 import {
6     StepModel,
7     WorkflowInputParameterModel,
8     WorkflowOutputParameterModel
9 } from "cwlts/models";
10
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;
17
18     registerWorkflow(workflow: Workflow): void {
19         this.workflow = workflow;
20         this.svgRoot  = workflow.svgRoot;
21     }
22
23
24     registerOnBeforeChange(fn: (change: GraphChange) => void): void {
25         this.onBeforeChange = () => fn({type: "arrange"});
26     }
27
28     registerOnAfterChange(fn: (change: GraphChange) => void): void {
29         this.onAfterChange = () => fn({type: "arrange"});
30     }
31
32     registerOnAfterRender(fn: (change: GraphChange) => void): void {
33         this.triggerAfterRender = () => fn({type: "arrange"});
34     }
35
36     afterRender(): void {
37         const model     = this.workflow.model;
38         const arr = [] as Array<WorkflowInputParameterModel | WorkflowOutputParameterModel | StepModel>;
39         const drawables = arr.concat(
40             model.steps || [],
41             model.inputs || [],
42             model.outputs || []
43         );
44
45         for (const node of drawables) {
46             if (node.isVisible) {
47                 const missingCoordinate = isNaN(parseInt(node.customProps["sbg:x"], 10));
48                 if (missingCoordinate) {
49                     this.arrange();
50                     return;
51                 }
52             }
53         }
54     }
55
56     arrange() {
57
58         this.onBeforeChange();
59
60         // We need to reset all transformations on the workflow for now.
61         // @TODO Make arranging work without this
62         this.workflow.resetTransform();
63
64         // We need main graph and dangling nodes separately, they will be distributed differently
65         const {mainGraph, danglingNodes} = this.makeNodeGraphs();
66
67         // Create an array of columns, each containing a list of NodeIOs
68         const columns = this.distributeNodesIntoColumns(mainGraph);
69
70         // Get total area in which we will fit the graph, and per-column dimensions
71         const {distributionArea, columnDimensions} = this.calculateColumnSizes(columns);
72
73         // This will be the vertical middle around which the graph should be centered
74         const verticalBaseline = distributionArea.height / 2;
75
76         let xOffset    = 0;
77         let maxYOffset = 0;
78
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;
82
83         columns.forEach((column, index) => {
84             const colSize = columnDimensions[index];
85             let yOffset   = verticalBaseline - (colSize.height / 2) - column[0].rect.height / 2;
86
87             column.forEach(node => {
88                 yOffset += node.rect.height / 2;
89
90                 const matrix = SVGUtils.createMatrix().translate(xOffset, yOffset);
91
92                 yOffset += node.rect.height / 2;
93
94                 if (yOffset > maxYOffset) {
95                     maxYOffset = yOffset;
96                 }
97
98                 node.el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
99
100                 nodePositionUpdates[node.connectionID] = {
101                     x: matrix.e,
102                     y: matrix.f
103                 };
104
105             });
106
107             xOffset += colSize.width;
108         });
109
110         const danglingNodeKeys = Object.keys(danglingNodes).sort((a, b) => {
111
112             const aIsInput  = a.startsWith("out/");
113             const aIsOutput = a.startsWith("in/");
114             const bIsInput  = b.startsWith("out/");
115             const bIsOutput = b.startsWith("in/");
116
117             const lowerA = a.toLowerCase();
118             const lowerB = b.toLowerCase();
119
120             if (aIsOutput) {
121
122                 if (bIsOutput) {
123                     return lowerB.localeCompare(lowerA);
124                 }
125                 else {
126                     return 1;
127                 }
128             } else if (aIsInput) {
129                 if (bIsOutput) {
130                     return -1;
131                 }
132                 if (bIsInput) {
133                     return lowerB.localeCompare(lowerA);
134                 }
135                 else {
136                     return 1;
137                 }
138             } else {
139                 if (!bIsOutput && !bIsInput) {
140                     return lowerB.localeCompare(lowerA);
141                 }
142                 else {
143                     return -1;
144                 }
145             }
146         });
147
148         const danglingNodeMarginOffset = 30;
149         const danglingNodeSideLength   = GraphNode.radius * 5;
150
151         let maxNodeHeightInRow = 0;
152         let row                = 0;
153         const indexWidthMap      = new Map<number, number>();
154         const rowMaxHeightMap    = new Map<number, number>();
155
156         xOffset = 0;
157
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);
163
164             if (xOffset === 0) {
165                 xOffset -= rect.width / 2;
166             }
167             if (rect.height > maxNodeHeightInRow) {
168                 maxNodeHeightInRow = rect.height;
169             }
170             xOffset += rect.width + danglingNodeMarginOffset + Math.max(150 - rect.width, 0);
171
172             if (xOffset >= danglingRowAreaWidth && index < danglingNodeKeys.length - 1) {
173                 rowMaxHeightMap.set(row++, maxNodeHeightInRow);
174                 maxNodeHeightInRow = 0;
175                 xOffset            = 0;
176             }
177         });
178
179         rowMaxHeightMap.set(row, maxNodeHeightInRow);
180         let colYOffset = maxYOffset;
181         xOffset        = 0;
182         row            = 0;
183
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;
193
194             if (xOffset === 0) {
195                 left -= width / 2;
196                 xOffset -= width / 2;
197             }
198             xOffset += width + danglingNodeMarginOffset + Math.max(150 - width, 0);
199
200             const matrix = SVGUtils.createMatrix().translate(left, top);
201             el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
202
203             nodePositionUpdates[connectionID] = {x: matrix.e, y: matrix.f};
204
205             if (xOffset >= danglingRowAreaWidth) {
206                 colYOffset += Math.ceil(rowHeight) + danglingNodeMarginOffset;
207                 xOffset            = 0;
208                 maxNodeHeightInRow = 0;
209                 row++;
210             }
211         });
212
213         this.workflow.redrawEdges();
214         this.workflow.fitToViewport();
215
216         this.onAfterChange(nodePositionUpdates);
217         this.triggerAfterRender();
218
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 = {};
224             }
225
226             Object.assign(nodeModel.customProps, {
227                 "sbg:x": pos.x,
228                 "sbg:y": pos.y
229             });
230         }
231
232         return nodePositionUpdates;
233     }
234
235     /**
236      * Calculates column dimensions and total graph area
237      * @param {NodeIO[][]} columns
238      */
239     private calculateColumnSizes(columns: NodeIO[][]): {
240         columnDimensions: {
241             width: number,
242             height: number
243         }[],
244         distributionArea: {
245             width: number,
246             height: number
247         }
248     } {
249         const distributionArea = {width: 0, height: 0};
250         const columnDimensions = [];
251
252         for (let i = 1; i < columns.length; i++) {
253
254             let width  = 0;
255             let height = 0;
256
257             for (let j = 0; j < columns[i].length; j++) {
258                 const entry = columns[i][j];
259
260                 height += entry.rect.height;
261
262                 if (width < entry.rect.width) {
263                     width = entry.rect.width;
264                 }
265             }
266
267             columnDimensions[i] = {height, width};
268
269             distributionArea.width += width;
270             if (height > distributionArea.height) {
271                 distributionArea.height = height;
272             }
273         }
274
275         return {
276             columnDimensions,
277             distributionArea
278         };
279
280     }
281
282     /**
283      * Maps node's connectionID to a 1-indexed column number
284      */
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[];
289
290         for (let i = 0; i < sortedNodeIDs.length; i++) {
291             const nodeID = sortedNodeIDs[i];
292             const node   = graph[nodeID];
293
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);
297             } else {
298                 //
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
301                 // So instead of:
302                 //
303                 // (input)<----------------->(step)---
304                 // (input)<---------->(step)----------
305                 //
306                 // It should be:
307                 //
308                 // ---------------(input)<--->(step)---
309                 // --------(input)<-->(step)-----------
310                 //
311
312                 let closestNodeZone = Infinity;
313                 for (let i = 0; i < node.outputs.length; i++) {
314                     const successorNodeZone = idToZoneMap[node.outputs[i]];
315
316                     if (successorNodeZone < closestNodeZone) {
317                         closestNodeZone = successorNodeZone;
318                     }
319                 }
320                 if (closestNodeZone === Infinity) {
321                     idToZoneMap[nodeID] = 1;
322                 } else {
323                     idToZoneMap[nodeID] = closestNodeZone - 1;
324                 }
325
326             }
327
328             const zone = idToZoneMap[nodeID];
329             zones[zone] || (zones[zone] = []);
330
331             zones[zone].push(graph[nodeID]);
332         }
333
334         return zones;
335
336     }
337
338     /**
339      * Finds all nodes in the graph, and indexes them by their "data-connection-id" attribute
340      */
341     private indexNodesByID(): { [dataConnectionID: string]: SVGGElement } {
342         const indexed = {};
343         const nodes   = this.svgRoot.querySelectorAll(".node");
344
345         for (let i = 0; i < nodes.length; i++) {
346             indexed[nodes[i].getAttribute("data-connection-id")!] = nodes[i];
347         }
348
349         return indexed;
350     }
351
352     /**
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.
355      */
356     private traceLongestNodePathLength(node: NodeIO, nodeGraph: any, visited = new Set<NodeIO>()): number {
357
358         visited.add(node);
359
360         if (node.inputs.length === 0) {
361             return 1;
362         }
363
364         const inputPathLengths = [];
365
366         for (let i = 0; i < node.inputs.length; i++) {
367             const el = nodeGraph[node.inputs[i]];
368
369             if (visited.has(el)) {
370                 continue;
371             }
372
373             inputPathLengths.push(this.traceLongestNodePathLength(el, nodeGraph, visited));
374         }
375
376         return Math.max(...inputPathLengths) + 1;
377     }
378
379     private makeNodeGraphs(): {
380         mainGraph: NodeMap,
381         danglingNodes: { [nodeID: string]: SVGGElement }
382     } {
383
384         // We need all nodes in order to find the dangling ones, those will be sorted separately
385         const allNodes = this.indexNodesByID();
386
387         // Make a graph representation where you can trace inputs and outputs from/to connection ids
388         const nodeGraph = {} as NodeMap;
389
390         // Edges are the main source of information from which we will distribute nodes
391         const edges = this.svgRoot.querySelectorAll(".edge");
392
393         for (let i = 0; i < edges.length; i++) {
394
395             const edge = edges[i];
396
397             const sourceConnectionID      = edge.getAttribute("data-source-connection")!;
398             const destinationConnectionID = edge.getAttribute("data-destination-connection")!;
399
400             const [sourceSide, sourceNodeID, sourcePortID]                = sourceConnectionID.split("/");
401             const [destinationSide, destinationNodeID, destinationPortID] = destinationConnectionID.split("/");
402
403             // Both source and destination are considered to be steps by default
404             let sourceType      = "step";
405             let destinationType = "step";
406
407             // Ports have the same node and port ids
408             if (sourceNodeID === sourcePortID) {
409                 sourceType = sourceSide === "in" ? "output" : "input";
410             }
411
412             if (destinationNodeID === destinationPortID) {
413                 destinationType = destinationSide === "in" ? "output" : "input";
414             }
415
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;
419
420             const sourceNodeConnectionID      = sourceNode.getAttribute("data-connection-id")!;
421             const destinationNodeConnectionID = destinationNode.getAttribute("data-connection-id")!;
422
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];
427
428             // Ensure that the source node has its entry in the node graph
429             (nodeGraph[sourceNodeID] || (nodeGraph[sourceNodeID] = {
430                 inputs: [],
431                 outputs: [],
432                 type: sourceType,
433                 connectionID: sourceNodeConnectionID,
434                 el: sourceNode,
435                 rect: sourceNode.getBoundingClientRect()
436             }));
437
438             // Ensure that the source node has its entry in the node graph
439             (nodeGraph[destinationNodeID] || (nodeGraph[destinationNodeID] = {
440                 inputs: [],
441                 outputs: [],
442                 type: destinationType,
443                 connectionID: destinationNodeConnectionID,
444                 el: destinationNode,
445                 rect: destinationNode.getBoundingClientRect()
446             }));
447
448             nodeGraph[sourceNodeID].outputs.push(destinationNodeID);
449             nodeGraph[destinationNodeID].inputs.push(sourceNodeID);
450         }
451
452         return {
453             mainGraph: nodeGraph,
454             danglingNodes: allNodes
455         };
456     }
457 }
458
459
460 export type NodeIO = {
461     inputs: string[],
462     outputs: string[],
463     connectionID: string,
464     el: SVGGElement,
465     rect: ClientRect,
466     type: "step" | "input" | "output" | string
467 };
468 export type NodeMap = { [connectionID: string]: NodeIO }
469
470 export type NodePositionUpdates = { [connectionID: string]: { x: number, y: number } };