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,
9 WorkflowStepInputModel,
10 WorkflowStepOutputModel
11 } from "cwlts/models";
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;
20 registerWorkflow(workflow: Workflow): void {
21 this.workflow = workflow;
22 this.svgRoot = workflow.svgRoot;
26 registerOnBeforeChange(fn: (change: GraphChange) => void): void {
27 this.onBeforeChange = () => fn({type: "arrange"});
30 registerOnAfterChange(fn: (change: GraphChange) => void): void {
31 this.onAfterChange = () => fn({type: "arrange"});
34 registerOnAfterRender(fn: (change: GraphChange) => void): void {
35 this.triggerAfterRender = () => fn({type: "arrange"});
39 const model = this.workflow.model;
40 const arr = [] as Array<WorkflowInputParameterModel | WorkflowOutputParameterModel | StepModel>;
41 const drawables = arr.concat(
47 for (const node of drawables) {
49 const missingCoordinate = isNaN(parseInt(node.customProps["sbg:x"], 10));
50 if (missingCoordinate) {
60 this.onBeforeChange();
62 // We need to reset all transformations on the workflow for now.
63 // @TODO Make arranging work without this
64 this.workflow.resetTransform();
66 // We need main graph and dangling nodes separately, they will be distributed differently
67 const {mainGraph, danglingNodes} = this.makeNodeGraphs();
69 // Create an array of columns, each containing a list of NodeIOs
70 const columns = this.distributeNodesIntoColumns(mainGraph);
72 // Get total area in which we will fit the graph, and per-column dimensions
73 const {distributionArea, columnDimensions} = this.calculateColumnSizes(columns);
75 // This will be the vertical middle around which the graph should be centered
76 const verticalBaseline = distributionArea.height / 2;
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;
85 columns.forEach((column, index) => {
86 const colSize = columnDimensions[index];
87 let yOffset = verticalBaseline - (colSize.height / 2) - column[0].rect.height / 2;
89 column.forEach(node => {
90 yOffset += node.rect.height / 2;
92 const matrix = SVGUtils.createMatrix().translate(xOffset, yOffset);
94 yOffset += node.rect.height / 2;
96 if (yOffset > maxYOffset) {
100 node.el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
102 nodePositionUpdates[node.connectionID] = {
109 xOffset += colSize.width;
112 const danglingNodeKeys = Object.keys(danglingNodes).sort((a, b) => {
114 const aIsInput = a.startsWith("out/");
115 const aIsOutput = a.startsWith("in/");
116 const bIsInput = b.startsWith("out/");
117 const bIsOutput = b.startsWith("in/");
119 const lowerA = a.toLowerCase();
120 const lowerB = b.toLowerCase();
125 return lowerB.localeCompare(lowerA);
130 } else if (aIsInput) {
135 return lowerB.localeCompare(lowerA);
141 if (!bIsOutput && !bIsInput) {
142 return lowerB.localeCompare(lowerA);
150 const danglingNodeMarginOffset = 30;
151 const danglingNodeSideLength = GraphNode.radius * 5;
153 let maxNodeHeightInRow = 0;
155 const indexWidthMap = new Map<number, number>();
156 const rowMaxHeightMap = new Map<number, number>();
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);
167 xOffset -= rect.width / 2;
169 if (rect.height > maxNodeHeightInRow) {
170 maxNodeHeightInRow = rect.height;
172 xOffset += rect.width + danglingNodeMarginOffset + Math.max(150 - rect.width, 0);
174 if (xOffset >= danglingRowAreaWidth && index < danglingNodeKeys.length - 1) {
175 rowMaxHeightMap.set(row++, maxNodeHeightInRow);
176 maxNodeHeightInRow = 0;
181 rowMaxHeightMap.set(row, maxNodeHeightInRow);
182 let colYOffset = maxYOffset;
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;
198 xOffset -= width / 2;
200 xOffset += width + danglingNodeMarginOffset + Math.max(150 - width, 0);
202 const matrix = SVGUtils.createMatrix().translate(left, top);
203 el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
205 nodePositionUpdates[connectionID] = {x: matrix.e, y: matrix.f};
207 if (xOffset >= danglingRowAreaWidth) {
208 colYOffset += Math.ceil(rowHeight) + danglingNodeMarginOffset;
210 maxNodeHeightInRow = 0;
215 this.workflow.redrawEdges();
216 this.workflow.fitToViewport();
218 this.onAfterChange(nodePositionUpdates);
219 this.triggerAfterRender();
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 = {};
228 Object.assign(nodeModel.customProps, {
234 return nodePositionUpdates;
238 * Calculates column dimensions and total graph area
239 * @param {NodeIO[][]} columns
241 private calculateColumnSizes(columns: NodeIO[][]): {
251 const distributionArea = {width: 0, height: 0};
252 const columnDimensions = [];
254 for (let i = 1; i < columns.length; i++) {
259 for (let j = 0; j < columns[i].length; j++) {
260 const entry = columns[i][j];
262 height += entry.rect.height;
264 if (width < entry.rect.width) {
265 width = entry.rect.width;
269 columnDimensions[i] = {height, width};
271 distributionArea.width += width;
272 if (height > distributionArea.height) {
273 distributionArea.height = height;
285 * Maps node's connectionID to a 1-indexed column number
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[];
292 for (let i = 0; i < sortedNodeIDs.length; i++) {
293 const nodeID = sortedNodeIDs[i];
294 const node = graph[nodeID];
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);
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
305 // (input)<----------------->(step)---
306 // (input)<---------->(step)----------
310 // ---------------(input)<--->(step)---
311 // --------(input)<-->(step)-----------
314 let closestNodeZone = Infinity;
315 for (let i = 0; i < node.outputs.length; i++) {
316 const successorNodeZone = idToZoneMap[node.outputs[i]];
318 if (successorNodeZone < closestNodeZone) {
319 closestNodeZone = successorNodeZone;
322 if (closestNodeZone === Infinity) {
323 idToZoneMap[nodeID] = 1;
325 idToZoneMap[nodeID] = closestNodeZone - 1;
330 const zone = idToZoneMap[nodeID];
331 zones[zone] || (zones[zone] = []);
333 zones[zone].push(graph[nodeID]);
341 * Finds all nodes in the graph, and indexes them by their "data-connection-id" attribute
343 private indexNodesByID(): { [dataConnectionID: string]: SVGGElement } {
345 const nodes = this.svgRoot.querySelectorAll(".node");
347 for (let i = 0; i < nodes.length; i++) {
348 indexed[nodes[i].getAttribute("data-connection-id")!] = nodes[i];
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.
358 private traceLongestNodePathLength(node: NodeIO, nodeGraph: any, visited = new Set<NodeIO>()): number {
362 if (node.inputs.length === 0) {
366 const inputPathLengths = [];
368 for (let i = 0; i < node.inputs.length; i++) {
369 const el = nodeGraph[node.inputs[i]];
371 if (visited.has(el)) {
375 inputPathLengths.push(this.traceLongestNodePathLength(el, nodeGraph, visited));
378 return Math.max(...inputPathLengths) + 1;
381 private makeNodeGraphs(): {
383 danglingNodes: { [nodeID: string]: SVGGElement }
386 // We need all nodes in order to find the dangling ones, those will be sorted separately
387 const allNodes = this.indexNodesByID();
389 // Make a graph representation where you can trace inputs and outputs from/to connection ids
390 const nodeGraph = {} as NodeMap;
392 // Edges are the main source of information from which we will distribute nodes
393 const edges = this.svgRoot.querySelectorAll(".edge");
395 for (let i = 0; i < edges.length; i++) {
397 const edge = edges[i];
399 const sourceConnectionID = edge.getAttribute("data-source-connection")!;
400 const destinationConnectionID = edge.getAttribute("data-destination-connection")!;
402 const [sourceSide, sourceNodeID, sourcePortID] = sourceConnectionID.split("/");
403 const [destinationSide, destinationNodeID, destinationPortID] = destinationConnectionID.split("/");
405 // Both source and destination are considered to be steps by default
406 let sourceType = "step";
407 let destinationType = "step";
409 // Ports have the same node and port ids
410 if (sourceNodeID === sourcePortID) {
411 sourceType = sourceSide === "in" ? "output" : "input";
414 if (destinationNodeID === destinationPortID) {
415 destinationType = destinationSide === "in" ? "output" : "input";
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;
422 const sourceNodeConnectionID = sourceNode.getAttribute("data-connection-id")!;
423 const destinationNodeConnectionID = destinationNode.getAttribute("data-connection-id")!;
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];
430 // Ensure that the source node has its entry in the node graph
431 (nodeGraph[sourceNodeID] || (nodeGraph[sourceNodeID] = {
435 connectionID: sourceNodeConnectionID,
437 rect: sourceNode.getBoundingClientRect()
440 // Ensure that the source node has its entry in the node graph
441 (nodeGraph[destinationNodeID] || (nodeGraph[destinationNodeID] = {
444 type: destinationType,
445 connectionID: destinationNodeConnectionID,
447 rect: destinationNode.getBoundingClientRect()
450 nodeGraph[sourceNodeID].outputs.push(destinationNodeID);
451 nodeGraph[destinationNodeID].inputs.push(sourceNodeID);
455 mainGraph: nodeGraph,
456 danglingNodes: allNodes
462 export type NodeIO = {
465 connectionID: string,
468 type: "step" | "input" | "output" | string
470 export type NodeMap = { [connectionID: string]: NodeIO }
472 export type NodePositionUpdates = { [connectionID: string]: { x: number, y: number } };