Add radix cwl graph visualization
[arvados-workbench2.git] / src / lib / cwl-svg / graph / workflow.ts
diff --git a/src/lib/cwl-svg/graph/workflow.ts b/src/lib/cwl-svg/graph/workflow.ts
new file mode 100644 (file)
index 0000000..cb6f228
--- /dev/null
@@ -0,0 +1,621 @@
+import {WorkflowStepInputModel}       from "cwlts/models/generic";
+import {StepModel}                    from "cwlts/models/generic/StepModel";
+import {WorkflowInputParameterModel}  from "cwlts/models/generic/WorkflowInputParameterModel";
+import {WorkflowModel}                from "cwlts/models/generic/WorkflowModel";
+import {WorkflowOutputParameterModel} from "cwlts/models/generic/WorkflowOutputParameterModel";
+import {SVGPlugin}                    from "../plugins/plugin";
+import {DomEvents}                    from "../utils/dom-events";
+import {EventHub}                     from "../utils/event-hub";
+import {Connectable}                  from "./connectable";
+import {Edge as GraphEdge}            from "./edge";
+import {GraphNode}                    from "./graph-node";
+import {StepNode}                     from "./step-node";
+import {TemplateParser}               from "./template-parser";
+import {WorkflowStepOutputModel}      from "cwlts/models";
+
+/**
+ * @FIXME validation states of old and newly created edges
+ */
+export class Workflow {
+
+    readonly eventHub: EventHub;
+    readonly svgID = this.makeID();
+
+    minScale = 0.2;
+    maxScale = 2;
+
+    domEvents: DomEvents;
+    svgRoot: SVGSVGElement;
+    workflow: SVGGElement;
+    model: WorkflowModel;
+    editingEnabled = true;
+
+    /** Scale of labels, they are different than scale of other elements in the workflow */
+    labelScale = 1;
+
+    private workflowBoundingClientRect: any;
+    private plugins: SVGPlugin[]  = [];
+    private disposers: Function[] = [];
+
+    private pendingFirstDraw = true;
+
+    /** Stored in order to ensure that once destroyed graph cannot be reused again */
+    private isDestroyed = false;
+
+    constructor(parameters: {
+        svgRoot: SVGSVGElement,
+        model: WorkflowModel,
+        plugins?: SVGPlugin[],
+        editingEnabled?: boolean
+    }) {
+        this.svgRoot        = parameters.svgRoot;
+        this.plugins        = parameters.plugins || [];
+        this.domEvents      = new DomEvents(this.svgRoot as any);
+        this.model          = parameters.model;
+        this.editingEnabled = parameters.editingEnabled !== false; // default to true if undefined
+
+        this.svgRoot.classList.add(this.svgID);
+
+        this.svgRoot.innerHTML = `
+            <rect x="0" y="0" width="100%" height="100%" class="pan-handle" transform="matrix(1,0,0,1,0,0)"></rect>
+            <g class="workflow" transform="matrix(1,0,0,1,0,0)"></g>
+        `;
+
+        this.workflow = this.svgRoot.querySelector(".workflow") as any;
+
+        this.invokePlugins("registerWorkflow", this);
+
+        this.eventHub = new EventHub([
+            "connection.create",
+            "app.create.step",
+            "app.create.input",
+            "app.create.output",
+            "beforeChange",
+            "afterChange",
+            "afterRender",
+            "selectionChange"
+        ]);
+
+        this.hookPlugins();
+        this.draw(parameters.model);
+
+
+        this.eventHub.on("afterRender", () => this.invokePlugins("afterRender"));
+    }
+
+    /** Current scale of the document */
+    private docScale = 1;
+
+    get scale() {
+        return this.docScale;
+    }
+
+    // noinspection JSUnusedGlobalSymbols
+    set scale(scale: number) {
+        this.workflowBoundingClientRect = this.svgRoot.getBoundingClientRect();
+
+        const x = (this.workflowBoundingClientRect.right + this.workflowBoundingClientRect.left) / 2;
+        const y = (this.workflowBoundingClientRect.top + this.workflowBoundingClientRect.bottom) / 2;
+
+        this.scaleAtPoint(scale, x, y);
+    }
+
+    static canDrawIn(element: SVGElement): boolean {
+        return element.getBoundingClientRect().width !== 0;
+    }
+
+    static makeConnectionPath(x1: any, y1: any, x2: any, y2: any, forceDirection: "right" | "left" | string = "right"): string | undefined {
+
+        if (!forceDirection) {
+            return `M ${x1} ${y1} C ${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2} ${x2} ${y2}`;
+        } else if (forceDirection === "right") {
+            const outDir = x1 + Math.abs(x1 - x2) / 2;
+            const inDir  = x2 - Math.abs(x1 - x2) / 2;
+
+            return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
+        } else if (forceDirection === "left") {
+            const outDir = x1 - Math.abs(x1 - x2) / 2;
+            const inDir  = x2 + Math.abs(x1 - x2) / 2;
+
+            return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
+        }
+        return undefined;
+    }
+
+    draw(model: WorkflowModel = this.model) {
+
+        this.assertNotDestroyed("draw");
+
+        // We will need to restore the transformations when we redraw the model, so save the current state
+        const oldTransform = this.workflow.getAttribute("transform");
+
+        const modelChanged = this.model !== model;
+
+        if (modelChanged || this.pendingFirstDraw) {
+            this.pendingFirstDraw = false;
+
+            this.model = model;
+
+            const stepChangeDisposer        = this.model.on("step.change", this.onStepChange.bind(this));
+            const stepCreateDisposer        = this.model.on("step.create", this.onStepCreate.bind(this));
+            const stepRemoveDisposer        = this.model.on("step.remove", this.onStepRemove.bind(this));
+            const inputCreateDisposer       = this.model.on("input.create", this.onInputCreate.bind(this));
+            const inputRemoveDisposer       = this.model.on("input.remove", this.onInputRemove.bind(this));
+            const outputCreateDisposer      = this.model.on("output.create", this.onOutputCreate.bind(this));
+            const outputRemoveDisposer      = this.model.on("output.remove", this.onOutputRemove.bind(this));
+            const stepInPortShowDisposer    = this.model.on("step.inPort.show", this.onInputPortShow.bind(this));
+            const stepInPortHideDisposer    = this.model.on("step.inPort.hide", this.onInputPortHide.bind(this));
+            const connectionCreateDisposer  = this.model.on("connection.create", this.onConnectionCreate.bind(this));
+            const connectionRemoveDisposer  = this.model.on("connection.remove", this.onConnectionRemove.bind(this));
+            const stepOutPortCreateDisposer = this.model.on("step.outPort.create", this.onOutputPortCreate.bind(this));
+            const stepOutPortRemoveDisposer = this.model.on("step.outPort.remove", this.onOutputPortRemove.bind(this));
+
+            this.disposers.push(() => {
+                stepChangeDisposer.dispose();
+                stepCreateDisposer.dispose();
+                stepRemoveDisposer.dispose();
+                inputCreateDisposer.dispose();
+                inputRemoveDisposer.dispose();
+                outputCreateDisposer.dispose();
+                outputRemoveDisposer.dispose();
+                stepInPortShowDisposer.dispose();
+                stepInPortHideDisposer.dispose();
+                connectionCreateDisposer.dispose();
+                connectionRemoveDisposer.dispose();
+                stepOutPortCreateDisposer.dispose();
+                stepOutPortRemoveDisposer.dispose();
+            });
+
+            this.invokePlugins("afterModelChange");
+        }
+
+        this.clearCanvas();
+
+        const nodes = [
+            ...this.model.steps,
+            ...this.model.inputs,
+            ...this.model.outputs
+        ].filter(n => n.isVisible);
+
+        /**
+         * If there is a missing sbg:x or sbg:y property on any node model,
+         * graph should be arranged to avoid random placement.
+         */
+        let arrangeNecessary = false;
+
+        let nodeTemplate = "";
+
+        for (const node of nodes) {
+            const patched  = GraphNode.patchModelPorts(node);
+            const missingX = isNaN(parseInt(patched.customProps["sbg:x"], 10));
+            const missingY = isNaN(parseInt(patched.customProps["sbg:y"], 10));
+
+            if (missingX || missingY) {
+                arrangeNecessary = true;
+            }
+
+            nodeTemplate += GraphNode.makeTemplate(patched);
+
+        }
+
+        this.workflow.innerHTML += nodeTemplate;
+
+        this.redrawEdges();
+
+        Array.from(this.workflow.querySelectorAll(".node")).forEach(e => {
+            this.workflow.appendChild(e);
+        });
+
+        this.addEventListeners();
+
+        this.workflow.setAttribute("transform", oldTransform!);
+
+        this.scaleAtPoint(this.scale);
+
+
+        this.invokePlugins("afterRender");
+    }
+
+    findParent(el: Element, parentClass = "node"): SVGGElement | undefined {
+        let parentNode: Element | null = el;
+        while (parentNode) {
+            if (parentNode.classList.contains(parentClass)) {
+                return parentNode as SVGGElement;
+            }
+            parentNode = parentNode.parentElement;
+        }
+        return undefined;
+    }
+
+    /**
+     * Retrieves a plugin instance
+     * @param {{new(...args: any[]) => T}} plugin
+     * @returns {T}
+     */
+    getPlugin<T extends SVGPlugin>(plugin: { new(...args: any[]): T }): T {
+        return this.plugins.find(p => p instanceof plugin) as T;
+    }
+
+    on(event: string, handler: any) {
+        this.eventHub.on(event, handler);
+    }
+
+    off(event: string, handler: any) {
+        this.eventHub.off(event, handler);
+    }
+
+    /**
+     * Scales the workflow to fit the available viewport
+     */
+    fitToViewport(ignoreScaleLimits = false): void {
+
+        this.scaleAtPoint(1);
+
+        Object.assign(this.workflow.transform.baseVal.getItem(0).matrix, {
+            e: 0,
+            f: 0
+        });
+
+        const clientBounds = this.svgRoot.getBoundingClientRect();
+        const wfBounds     = this.workflow.getBoundingClientRect();
+        const padding    = 100;
+
+        if (clientBounds.width === 0 || clientBounds.height === 0) {
+            throw new Error("Cannot fit workflow to the area that has no visible viewport.");
+        }
+
+        const verticalScale   = (wfBounds.height) / (clientBounds.height - padding);
+        const horizontalScale = (wfBounds.width) / (clientBounds.width - padding);
+
+        const scaleFactor = Math.max(verticalScale, horizontalScale);
+
+        // Cap the upscaling to 1, we don't want to zoom in workflows that would fit anyway
+        let newScale = Math.min(this.scale / scaleFactor, 1);
+
+        if (!ignoreScaleLimits) {
+            newScale = Math.max(newScale, this.minScale);
+        }
+
+        this.scaleAtPoint(newScale);
+
+        const scaledWFBounds = this.workflow.getBoundingClientRect();
+
+        const moveY = clientBounds.top - scaledWFBounds.top + Math.abs(clientBounds.height - scaledWFBounds.height) / 2;
+        const moveX = clientBounds.left - scaledWFBounds.left + Math.abs(clientBounds.width - scaledWFBounds.width) / 2;
+
+        const matrix = this.workflow.transform.baseVal.getItem(0).matrix;
+        matrix.e += moveX;
+        matrix.f += moveY;
+    }
+
+    redrawEdges() {
+
+        const highlightedEdges = new Set();
+
+        Array.from(this.workflow.querySelectorAll(".edge")).forEach((el) => {
+            if (el.classList.contains("highlighted")) {
+                const edgeID = el.attributes["data-source-connection"].value + el.attributes["data-destination-connection"].value;
+                highlightedEdges.add(edgeID);
+            }
+            el.remove();
+        });
+
+
+        const edgesTpl = this.model.connections
+            .map(c => {
+                const edgeId     = c.source.id + c.destination.id;
+                const edgeStates = highlightedEdges.has(edgeId) ? "highlighted" : "";
+                return GraphEdge.makeTemplate(c, this.workflow, edgeStates);
+            })
+            .reduce((acc, tpl) => acc! + tpl, "");
+
+        this.workflow.innerHTML = edgesTpl + this.workflow.innerHTML;
+    }
+
+    /**
+     * Scale the workflow by the scaleCoefficient (not compounded) over given coordinates
+     */
+    scaleAtPoint(scale = 1, x = 0, y = 0): void {
+
+        this.docScale     = scale;
+        this.labelScale = 1 + (1 - this.docScale) / (this.docScale * 2);
+
+        const transform         = this.workflow.transform.baseVal;
+        const matrix: SVGMatrix = transform.getItem(0).matrix;
+
+        const coords = this.transformScreenCTMtoCanvas(x, y);
+
+        matrix.e += matrix.a * coords.x;
+        matrix.f += matrix.a * coords.y;
+        matrix.a = matrix.d = scale;
+        matrix.e -= scale * coords.x;
+        matrix.f -= scale * coords.y;
+
+        const nodeLabels: any = this.workflow.querySelectorAll(".node .label") as  NodeListOf<SVGPathElement>;
+
+        for (const el of nodeLabels) {
+            const matrix = el.transform.baseVal.getItem(0).matrix;
+
+            Object.assign(matrix, {
+                a: this.labelScale,
+                d: this.labelScale
+            });
+        }
+
+    }
+
+    transformScreenCTMtoCanvas(x: any, y: any) {
+        const svg   = this.svgRoot;
+        const ctm   = this.workflow.getScreenCTM()!;
+        const point = svg.createSVGPoint();
+        point.x     = x;
+        point.y     = y;
+
+        const t = point.matrixTransform(ctm.inverse());
+        return {
+            x: t.x,
+            y: t.y
+        };
+    }
+
+    enableEditing(enabled: boolean): void {
+        this.invokePlugins("onEditableStateChange", enabled);
+        this.editingEnabled = enabled;
+    }
+
+    // noinspection JSUnusedGlobalSymbols
+    destroy() {
+
+        this.svgRoot.classList.remove(this.svgID);
+
+        this.clearCanvas();
+        this.eventHub.empty();
+
+        this.invokePlugins("destroy");
+
+        for (const dispose of this.disposers) {
+            dispose();
+        }
+
+        this.isDestroyed = true;
+    }
+
+    resetTransform() {
+        this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
+        this.scaleAtPoint();
+    }
+
+    private assertNotDestroyed(method: string) {
+        if (this.isDestroyed) {
+            throw new Error("Cannot call the " + method + " method on a destroyed graph. " +
+                "Destroying this object removes DOM listeners, " +
+                "and reusing it would result in unexpected things not working. " +
+                "Instead, you can just call the “draw” method with a different model, " +
+                "or create a new Workflow object.");
+
+        }
+    }
+
+    private addEventListeners(): void {
+
+
+        /**
+         * Attach canvas panning
+         */
+        {
+            let pane: SVGGElement | undefined;
+            let x = 0;
+            let y = 0;
+            let matrix: SVGMatrix | undefined;
+            this.domEvents.drag(".pan-handle", (dx, dy) => {
+
+                matrix!.e = x + dx;
+                matrix!.f = y + dy;
+
+            }, (ev, el, root) => {
+                pane   = root!.querySelector(".workflow") as SVGGElement;
+                matrix = pane.transform.baseVal.getItem(0).matrix;
+                x      = matrix.e;
+                y      = matrix.f;
+            }, () => {
+                pane   = undefined;
+                matrix = undefined;
+            });
+        }
+
+        /**
+         * On mouse over node, bring it to the front
+         */
+        this.domEvents.on("mouseover", ".node", (ev, target, root) => {
+            if (this.workflow.querySelector(".edge.dragged")) {
+                return;
+            }
+            target!.parentElement!.appendChild(target!);
+        });
+
+    }
+
+    private clearCanvas() {
+        this.domEvents.detachAll();
+        this.workflow.innerHTML = "";
+        this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
+        this.workflow.setAttribute("class", "workflow");
+    }
+
+    private hookPlugins() {
+
+        this.plugins.forEach(plugin => {
+
+            plugin.registerOnBeforeChange!(event => {
+                this.eventHub.emit("beforeChange", event);
+            });
+
+            plugin.registerOnAfterChange!(event => {
+                this.eventHub.emit("afterChange", event);
+            });
+
+            plugin.registerOnAfterRender!(event => {
+                this.eventHub.emit("afterRender", event);
+            });
+        });
+    }
+
+    private invokePlugins(methodName: keyof SVGPlugin, ...args: any[]) {
+        this.plugins.forEach(plugin => {
+            if (typeof plugin[methodName] === "function") {
+                (plugin[methodName] as Function)(...args);
+            }
+        });
+    }
+
+    /**
+     * Listener for “connection.create” event on model that renders new edges on canvas
+     */
+    private onConnectionCreate(source: Connectable, destination: Connectable): void {
+
+        if (!source.isVisible || !destination.isVisible) {
+            return;
+        }
+
+        const sourceID      = source.connectionId;
+        const destinationID = destination.connectionId;
+
+        GraphEdge.spawnBetweenConnectionIDs(this.workflow, sourceID, destinationID);
+    }
+
+    /**
+     * Listener for "connection.remove" event on the model that disconnects nodes
+     */
+    private onConnectionRemove(source: Connectable, destination: Connectable): void {
+        if (!source.isVisible || !destination.isVisible) {
+            return;
+        }
+
+        const sourceID      = source.connectionId;
+        const destinationID = destination.connectionId;
+
+        const edge = this.svgRoot.querySelector(`.edge[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"`);
+        edge!.remove();
+    }
+
+    /**
+     * Listener for “input.create” event on model that renders workflow inputs
+     */
+    private onInputCreate(input: WorkflowInputParameterModel): void {
+        if (!input.isVisible) {
+            return;
+        }
+
+        const patched       = GraphNode.patchModelPorts(input);
+        const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
+
+        const el = TemplateParser.parse(graphTemplate)!;
+        this.workflow.appendChild(el);
+
+    }
+
+    /**
+     * Listener for “output.create” event on model that renders workflow outputs
+     */
+    private onOutputCreate(output: WorkflowOutputParameterModel): void {
+
+        if (!output.isVisible) {
+            return;
+        }
+
+        const patched       = GraphNode.patchModelPorts(output);
+        const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
+
+        const el = TemplateParser.parse(graphTemplate)!;
+        this.workflow.appendChild(el);
+    }
+
+    private onStepCreate(step: StepModel) {
+        // if the step doesn't have x & y coordinates, check if they are in the run property
+        if (!step.customProps["sbg:x"] && step.run.customProps && step.run.customProps["sbg:x"]) {
+
+            Object.assign(step.customProps, {
+                "sbg:x": step.run.customProps["sbg:x"],
+                "sbg:y": step.run.customProps["sbg:y"]
+            });
+
+            // remove them from the run property once finished
+            delete step.run.customProps["sbg:x"];
+            delete step.run.customProps["sbg:y"];
+        }
+
+        const template = GraphNode.makeTemplate(step, this.labelScale);
+        const element  = TemplateParser.parse(template)!;
+        this.workflow.appendChild(element);
+    }
+
+
+    private onStepChange(change: StepModel) {
+        const title = this.workflow.querySelector(`.step[data-id="${change.connectionId}"] .title`) as SVGTextElement;
+        if (title) {
+            title.textContent = change.label;
+        }
+    }
+
+    private onInputPortShow(input: WorkflowStepInputModel) {
+
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, input.parentStep).update();
+    }
+
+    private onInputPortHide(input: WorkflowStepInputModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, input.parentStep).update();
+    }
+
+    private onOutputPortCreate(output: WorkflowStepOutputModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, output.parentStep).update();
+    }
+
+    private onOutputPortRemove(output: WorkflowStepOutputModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
+        new StepNode(stepEl, output.parentStep).update();
+    }
+
+    /**
+     * Listener for "step.remove" event on model which removes steps
+     */
+    private onStepRemove(step: StepModel) {
+        const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${step.connectionId}"]`) as SVGElement;
+        stepEl.remove();
+    }
+
+    /**
+     * Listener for "input.remove" event on model which removes inputs
+     */
+    private onInputRemove(input: WorkflowInputParameterModel) {
+        if (!input.isVisible) {
+            return;
+        }
+        const inputEl = this.svgRoot.querySelector(`.node.input[data-connection-id="${input.connectionId}"]`);
+        inputEl!.remove();
+    }
+
+    /**
+     * Listener for "output.remove" event on model which removes outputs
+     */
+    private onOutputRemove(output: WorkflowOutputParameterModel) {
+        if (!output.isVisible) {
+            return;
+        }
+        const outputEl = this.svgRoot.querySelector(`.node.output[data-connection-id="${output.connectionId}"]`);
+        outputEl!.remove();
+    }
+
+    private makeID(length = 6) {
+        let output    = "";
+        const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+
+        for (let i = 0; i < length; i++) {
+            output += charset.charAt(Math.floor(Math.random() * charset.length));
+        }
+
+        return output;
+    }
+}