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 = ` `; 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 nodeTemplate = ""; for (const node of nodes) { const patched = GraphNode.patchModelPorts(node); 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(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; 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; } }