Add radix cwl graph visualization
[arvados-workbench2.git] / src / lib / cwl-svg / plugins / arrange / arrange.ts
diff --git a/src/lib/cwl-svg/plugins/arrange/arrange.ts b/src/lib/cwl-svg/plugins/arrange/arrange.ts
new file mode 100644 (file)
index 0000000..db5ac90
--- /dev/null
@@ -0,0 +1,472 @@
+import {GraphNode}                                                  from '../../graph/graph-node';
+import {Workflow}                                                   from '../../graph/workflow';
+import {SVGUtils}                                                   from '../../utils/svg-utils';
+import {GraphChange, SVGPlugin}                                     from '../plugin';
+import {
+    StepModel,
+    WorkflowInputParameterModel,
+    WorkflowOutputParameterModel,
+    WorkflowStepInputModel,
+    WorkflowStepOutputModel
+} from "cwlts/models";
+
+export class SVGArrangePlugin implements SVGPlugin {
+    private workflow: Workflow;
+    private svgRoot: SVGSVGElement;
+    private onBeforeChange: () => void;
+    private onAfterChange: (updates: NodePositionUpdates) => void;
+    private triggerAfterRender: () => void;
+
+    registerWorkflow(workflow: Workflow): void {
+        this.workflow = workflow;
+        this.svgRoot  = workflow.svgRoot;
+    }
+
+
+    registerOnBeforeChange(fn: (change: GraphChange) => void): void {
+        this.onBeforeChange = () => fn({type: "arrange"});
+    }
+
+    registerOnAfterChange(fn: (change: GraphChange) => void): void {
+        this.onAfterChange = () => fn({type: "arrange"});
+    }
+
+    registerOnAfterRender(fn: (change: GraphChange) => void): void {
+        this.triggerAfterRender = () => fn({type: "arrange"});
+    }
+
+    afterRender(): void {
+        const model     = this.workflow.model;
+        const arr = [] as Array<WorkflowInputParameterModel | WorkflowOutputParameterModel | StepModel>;
+        const drawables = arr.concat(
+            model.steps || [],
+            model.inputs || [],
+            model.outputs || []
+        );
+
+        for (const node of drawables) {
+            if (node.isVisible) {
+                const missingCoordinate = isNaN(parseInt(node.customProps["sbg:x"], 10));
+                if (missingCoordinate) {
+                    this.arrange();
+                    return;
+                }
+            }
+        }
+    }
+
+    arrange() {
+
+        this.onBeforeChange();
+
+        // We need to reset all transformations on the workflow for now.
+        // @TODO Make arranging work without this
+        this.workflow.resetTransform();
+
+        // We need main graph and dangling nodes separately, they will be distributed differently
+        const {mainGraph, danglingNodes} = this.makeNodeGraphs();
+
+        // Create an array of columns, each containing a list of NodeIOs
+        const columns = this.distributeNodesIntoColumns(mainGraph);
+
+        // Get total area in which we will fit the graph, and per-column dimensions
+        const {distributionArea, columnDimensions} = this.calculateColumnSizes(columns);
+
+        // This will be the vertical middle around which the graph should be centered
+        const verticalBaseline = distributionArea.height / 2;
+
+        let xOffset    = 0;
+        let maxYOffset = 0;
+
+        // Here we will store positions for each node that is to be updated.
+        // This should then be emitted as an afterChange event.
+        const nodePositionUpdates = {} as NodePositionUpdates;
+
+        columns.forEach((column, index) => {
+            const colSize = columnDimensions[index];
+            let yOffset   = verticalBaseline - (colSize.height / 2) - column[0].rect.height / 2;
+
+            column.forEach(node => {
+                yOffset += node.rect.height / 2;
+
+                const matrix = SVGUtils.createMatrix().translate(xOffset, yOffset);
+
+                yOffset += node.rect.height / 2;
+
+                if (yOffset > maxYOffset) {
+                    maxYOffset = yOffset;
+                }
+
+                node.el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
+
+                nodePositionUpdates[node.connectionID] = {
+                    x: matrix.e,
+                    y: matrix.f
+                };
+
+            });
+
+            xOffset += colSize.width;
+        });
+
+        const danglingNodeKeys = Object.keys(danglingNodes).sort((a, b) => {
+
+            const aIsInput  = a.startsWith("out/");
+            const aIsOutput = a.startsWith("in/");
+            const bIsInput  = b.startsWith("out/");
+            const bIsOutput = b.startsWith("in/");
+
+            const lowerA = a.toLowerCase();
+            const lowerB = b.toLowerCase();
+
+            if (aIsOutput) {
+
+                if (bIsOutput) {
+                    return lowerB.localeCompare(lowerA);
+                }
+                else {
+                    return 1;
+                }
+            } else if (aIsInput) {
+                if (bIsOutput) {
+                    return -1;
+                }
+                if (bIsInput) {
+                    return lowerB.localeCompare(lowerA);
+                }
+                else {
+                    return 1;
+                }
+            } else {
+                if (!bIsOutput && !bIsInput) {
+                    return lowerB.localeCompare(lowerA);
+                }
+                else {
+                    return -1;
+                }
+            }
+        });
+
+        const danglingNodeMarginOffset = 30;
+        const danglingNodeSideLength   = GraphNode.radius * 5;
+
+        let maxNodeHeightInRow = 0;
+        let row                = 0;
+        const indexWidthMap      = new Map<number, number>();
+        const rowMaxHeightMap    = new Map<number, number>();
+
+        xOffset = 0;
+
+        const danglingRowAreaWidth = Math.max(distributionArea.width, danglingNodeSideLength * 3);
+        danglingNodeKeys.forEach((connectionID, index) => {
+            const el   = danglingNodes[connectionID] as SVGGElement;
+            const rect = el.firstElementChild!.getBoundingClientRect();
+            indexWidthMap.set(index, rect.width);
+
+            if (xOffset === 0) {
+                xOffset -= rect.width / 2;
+            }
+            if (rect.height > maxNodeHeightInRow) {
+                maxNodeHeightInRow = rect.height;
+            }
+            xOffset += rect.width + danglingNodeMarginOffset + Math.max(150 - rect.width, 0);
+
+            if (xOffset >= danglingRowAreaWidth && index < danglingNodeKeys.length - 1) {
+                rowMaxHeightMap.set(row++, maxNodeHeightInRow);
+                maxNodeHeightInRow = 0;
+                xOffset            = 0;
+            }
+        });
+
+        rowMaxHeightMap.set(row, maxNodeHeightInRow);
+        let colYOffset = maxYOffset;
+        xOffset        = 0;
+        row            = 0;
+
+        danglingNodeKeys.forEach((connectionID, index) => {
+            const el        = danglingNodes[connectionID] as SVGGElement;
+            const width     = indexWidthMap.get(index)!;
+            const rowHeight = rowMaxHeightMap.get(row)!;
+            let left        = xOffset + width / 2;
+            const top       = colYOffset
+                + danglingNodeMarginOffset
+                + Math.ceil(rowHeight / 2)
+                + ((xOffset === 0 ? 0 : left) / danglingRowAreaWidth) * danglingNodeSideLength;
+
+            if (xOffset === 0) {
+                left -= width / 2;
+                xOffset -= width / 2;
+            }
+            xOffset += width + danglingNodeMarginOffset + Math.max(150 - width, 0);
+
+            const matrix = SVGUtils.createMatrix().translate(left, top);
+            el.setAttribute("transform", SVGUtils.matrixToTransformAttr(matrix));
+
+            nodePositionUpdates[connectionID] = {x: matrix.e, y: matrix.f};
+
+            if (xOffset >= danglingRowAreaWidth) {
+                colYOffset += Math.ceil(rowHeight) + danglingNodeMarginOffset;
+                xOffset            = 0;
+                maxNodeHeightInRow = 0;
+                row++;
+            }
+        });
+
+        this.workflow.redrawEdges();
+        this.workflow.fitToViewport();
+
+        this.onAfterChange(nodePositionUpdates);
+        this.triggerAfterRender();
+
+        for (const id in nodePositionUpdates) {
+            const pos       = nodePositionUpdates[id];
+            const nodeModel = this.workflow.model.findById(id);
+            if (!nodeModel.customProps) {
+                nodeModel.customProps = {};
+            }
+
+            Object.assign(nodeModel.customProps, {
+                "sbg:x": pos.x,
+                "sbg:y": pos.y
+            });
+        }
+
+        return nodePositionUpdates;
+    }
+
+    /**
+     * Calculates column dimensions and total graph area
+     * @param {NodeIO[][]} columns
+     */
+    private calculateColumnSizes(columns: NodeIO[][]): {
+        columnDimensions: {
+            width: number,
+            height: number
+        }[],
+        distributionArea: {
+            width: number,
+            height: number
+        }
+    } {
+        const distributionArea = {width: 0, height: 0};
+        const columnDimensions = [];
+
+        for (let i = 1; i < columns.length; i++) {
+
+            let width  = 0;
+            let height = 0;
+
+            for (let j = 0; j < columns[i].length; j++) {
+                const entry = columns[i][j];
+
+                height += entry.rect.height;
+
+                if (width < entry.rect.width) {
+                    width = entry.rect.width;
+                }
+            }
+
+            columnDimensions[i] = {height, width};
+
+            distributionArea.width += width;
+            if (height > distributionArea.height) {
+                distributionArea.height = height;
+            }
+        }
+
+        return {
+            columnDimensions,
+            distributionArea
+        };
+
+    }
+
+    /**
+     * Maps node's connectionID to a 1-indexed column number
+     */
+    private distributeNodesIntoColumns(graph: NodeMap): Array<NodeIO[]> {
+        const idToZoneMap   = {};
+        const sortedNodeIDs = Object.keys(graph).sort((a, b) => b.localeCompare(a));
+        const zones         = [] as any[];
+
+        for (let i = 0; i < sortedNodeIDs.length; i++) {
+            const nodeID = sortedNodeIDs[i];
+            const node   = graph[nodeID];
+
+            // For outputs and steps, we calculate the zone as a longest path you can take to them
+            if (node.type !== "input") {
+                idToZoneMap[nodeID] = this.traceLongestNodePathLength(node, graph);
+            } else {
+                //
+                // Longest trace methods would put all inputs in the first column,
+                // but we want it just behind the leftmost step that it is connected to
+                // So instead of:
+                //
+                // (input)<----------------->(step)---
+                // (input)<---------->(step)----------
+                //
+                // It should be:
+                //
+                // ---------------(input)<--->(step)---
+                // --------(input)<-->(step)-----------
+                //
+
+                let closestNodeZone = Infinity;
+                for (let i = 0; i < node.outputs.length; i++) {
+                    const successorNodeZone = idToZoneMap[node.outputs[i]];
+
+                    if (successorNodeZone < closestNodeZone) {
+                        closestNodeZone = successorNodeZone;
+                    }
+                }
+                if (closestNodeZone === Infinity) {
+                    idToZoneMap[nodeID] = 1;
+                } else {
+                    idToZoneMap[nodeID] = closestNodeZone - 1;
+                }
+
+            }
+
+            const zone = idToZoneMap[nodeID];
+            zones[zone] || (zones[zone] = []);
+
+            zones[zone].push(graph[nodeID]);
+        }
+
+        return zones;
+
+    }
+
+    /**
+     * Finds all nodes in the graph, and indexes them by their "data-connection-id" attribute
+     */
+    private indexNodesByID(): { [dataConnectionID: string]: SVGGElement } {
+        const indexed = {};
+        const nodes   = this.svgRoot.querySelectorAll(".node");
+
+        for (let i = 0; i < nodes.length; i++) {
+            indexed[nodes[i].getAttribute("data-connection-id")!] = nodes[i];
+        }
+
+        return indexed;
+    }
+
+    /**
+     * Finds length of the longest possible path from the graph root to a node.
+     * Lengths are 1-indexed. When a node has no predecessors, it will have length of 1.
+     */
+    private traceLongestNodePathLength(node: NodeIO, nodeGraph: any, visited = new Set<NodeIO>()): number {
+
+        visited.add(node);
+
+        if (node.inputs.length === 0) {
+            return 1;
+        }
+
+        const inputPathLengths = [];
+
+        for (let i = 0; i < node.inputs.length; i++) {
+            const el = nodeGraph[node.inputs[i]];
+
+            if (visited.has(el)) {
+                continue;
+            }
+
+            inputPathLengths.push(this.traceLongestNodePathLength(el, nodeGraph, visited));
+        }
+
+        return Math.max(...inputPathLengths) + 1;
+    }
+
+    private makeNodeGraphs(): {
+        mainGraph: NodeMap,
+        danglingNodes: { [nodeID: string]: SVGGElement }
+    } {
+
+        // We need all nodes in order to find the dangling ones, those will be sorted separately
+        const allNodes = this.indexNodesByID();
+
+        // Make a graph representation where you can trace inputs and outputs from/to connection ids
+        const nodeGraph = {} as NodeMap;
+
+        // Edges are the main source of information from which we will distribute nodes
+        const edges = this.svgRoot.querySelectorAll(".edge");
+
+        for (let i = 0; i < edges.length; i++) {
+
+            const edge = edges[i];
+
+            const sourceConnectionID      = edge.getAttribute("data-source-connection")!;
+            const destinationConnectionID = edge.getAttribute("data-destination-connection")!;
+
+            const [sourceSide, sourceNodeID, sourcePortID]                = sourceConnectionID.split("/");
+            const [destinationSide, destinationNodeID, destinationPortID] = destinationConnectionID.split("/");
+
+            // Both source and destination are considered to be steps by default
+            let sourceType      = "step";
+            let destinationType = "step";
+
+            // Ports have the same node and port ids
+            if (sourceNodeID === sourcePortID) {
+                sourceType = sourceSide === "in" ? "output" : "input";
+            }
+
+            if (destinationNodeID === destinationPortID) {
+                destinationType = destinationSide === "in" ? "output" : "input";
+            }
+
+            // Initialize keys on graph if they don't exist
+            const sourceNode      = this.svgRoot.querySelector(`.node[data-id="${sourceNodeID}"]`) as SVGGElement;
+            const destinationNode = this.svgRoot.querySelector(`.node[data-id="${destinationNodeID}"]`) as SVGGElement;
+
+            const sourceNodeConnectionID      = sourceNode.getAttribute("data-connection-id")!;
+            const destinationNodeConnectionID = destinationNode.getAttribute("data-connection-id")!;
+
+            // Source and destination of this edge are obviously not dangling, so we can remove them
+            // from the set of potentially dangling nodes
+            delete allNodes[sourceNodeConnectionID];
+            delete allNodes[destinationNodeConnectionID];
+
+            // Ensure that the source node has its entry in the node graph
+            (nodeGraph[sourceNodeID] || (nodeGraph[sourceNodeID] = {
+                inputs: [],
+                outputs: [],
+                type: sourceType,
+                connectionID: sourceNodeConnectionID,
+                el: sourceNode,
+                rect: sourceNode.getBoundingClientRect()
+            }));
+
+            // Ensure that the source node has its entry in the node graph
+            (nodeGraph[destinationNodeID] || (nodeGraph[destinationNodeID] = {
+                inputs: [],
+                outputs: [],
+                type: destinationType,
+                connectionID: destinationNodeConnectionID,
+                el: destinationNode,
+                rect: destinationNode.getBoundingClientRect()
+            }));
+
+            nodeGraph[sourceNodeID].outputs.push(destinationNodeID);
+            nodeGraph[destinationNodeID].inputs.push(sourceNodeID);
+        }
+
+        return {
+            mainGraph: nodeGraph,
+            danglingNodes: allNodes
+        };
+    }
+}
+
+
+export type NodeIO = {
+    inputs: string[],
+    outputs: string[],
+    connectionID: string,
+    el: SVGGElement,
+    rect: ClientRect,
+    type: "step" | "input" | "output" | string
+};
+export type NodeMap = { [connectionID: string]: NodeIO }
+
+export type NodePositionUpdates = { [connectionID: string]: { x: number, y: number } };