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