1 import {WorkflowStepInputModel} from "cwlts/models/generic";
2 import {StepModel} from "cwlts/models/generic/StepModel";
3 import {WorkflowInputParameterModel} from "cwlts/models/generic/WorkflowInputParameterModel";
4 import {WorkflowModel} from "cwlts/models/generic/WorkflowModel";
5 import {WorkflowOutputParameterModel} from "cwlts/models/generic/WorkflowOutputParameterModel";
6 import {SVGPlugin} from "../plugins/plugin";
7 import {DomEvents} from "../utils/dom-events";
8 import {EventHub} from "../utils/event-hub";
9 import {Connectable} from "./connectable";
10 import {Edge as GraphEdge} from "./edge";
11 import {GraphNode} from "./graph-node";
12 import {StepNode} from "./step-node";
13 import {TemplateParser} from "./template-parser";
14 import {WorkflowStepOutputModel} from "cwlts/models";
17 * @FIXME validation states of old and newly created edges
19 export class Workflow {
21 readonly eventHub: EventHub;
22 readonly svgID = this.makeID();
28 svgRoot: SVGSVGElement;
29 workflow: SVGGElement;
31 editingEnabled = true;
33 /** Scale of labels, they are different than scale of other elements in the workflow */
36 private workflowBoundingClientRect: any;
37 private plugins: SVGPlugin[] = [];
38 private disposers: Function[] = [];
40 private pendingFirstDraw = true;
42 /** Stored in order to ensure that once destroyed graph cannot be reused again */
43 private isDestroyed = false;
45 constructor(parameters: {
46 svgRoot: SVGSVGElement,
48 plugins?: SVGPlugin[],
49 editingEnabled?: boolean
51 this.svgRoot = parameters.svgRoot;
52 this.plugins = parameters.plugins || [];
53 this.domEvents = new DomEvents(this.svgRoot as any);
54 this.model = parameters.model;
55 this.editingEnabled = parameters.editingEnabled !== false; // default to true if undefined
57 this.svgRoot.classList.add(this.svgID);
59 this.svgRoot.innerHTML = `
60 <rect x="0" y="0" width="100%" height="100%" class="pan-handle" transform="matrix(1,0,0,1,0,0)"></rect>
61 <g class="workflow" transform="matrix(1,0,0,1,0,0)"></g>
64 this.workflow = this.svgRoot.querySelector(".workflow") as any;
66 this.invokePlugins("registerWorkflow", this);
68 this.eventHub = new EventHub([
80 this.draw(parameters.model);
83 this.eventHub.on("afterRender", () => this.invokePlugins("afterRender"));
86 /** Current scale of the document */
93 // noinspection JSUnusedGlobalSymbols
94 set scale(scale: number) {
95 this.workflowBoundingClientRect = this.svgRoot.getBoundingClientRect();
97 const x = (this.workflowBoundingClientRect.right + this.workflowBoundingClientRect.left) / 2;
98 const y = (this.workflowBoundingClientRect.top + this.workflowBoundingClientRect.bottom) / 2;
100 this.scaleAtPoint(scale, x, y);
103 static canDrawIn(element: SVGElement): boolean {
104 return element.getBoundingClientRect().width !== 0;
107 static makeConnectionPath(x1: any, y1: any, x2: any, y2: any, forceDirection: "right" | "left" | string = "right"): string | undefined {
109 if (!forceDirection) {
110 return `M ${x1} ${y1} C ${(x1 + x2) / 2} ${y1} ${(x1 + x2) / 2} ${y2} ${x2} ${y2}`;
111 } else if (forceDirection === "right") {
112 const outDir = x1 + Math.abs(x1 - x2) / 2;
113 const inDir = x2 - Math.abs(x1 - x2) / 2;
115 return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
116 } else if (forceDirection === "left") {
117 const outDir = x1 - Math.abs(x1 - x2) / 2;
118 const inDir = x2 + Math.abs(x1 - x2) / 2;
120 return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
125 draw(model: WorkflowModel = this.model) {
127 this.assertNotDestroyed("draw");
129 // We will need to restore the transformations when we redraw the model, so save the current state
130 const oldTransform = this.workflow.getAttribute("transform");
132 const modelChanged = this.model !== model;
134 if (modelChanged || this.pendingFirstDraw) {
135 this.pendingFirstDraw = false;
139 const stepChangeDisposer = this.model.on("step.change", this.onStepChange.bind(this));
140 const stepCreateDisposer = this.model.on("step.create", this.onStepCreate.bind(this));
141 const stepRemoveDisposer = this.model.on("step.remove", this.onStepRemove.bind(this));
142 const inputCreateDisposer = this.model.on("input.create", this.onInputCreate.bind(this));
143 const inputRemoveDisposer = this.model.on("input.remove", this.onInputRemove.bind(this));
144 const outputCreateDisposer = this.model.on("output.create", this.onOutputCreate.bind(this));
145 const outputRemoveDisposer = this.model.on("output.remove", this.onOutputRemove.bind(this));
146 const stepInPortShowDisposer = this.model.on("step.inPort.show", this.onInputPortShow.bind(this));
147 const stepInPortHideDisposer = this.model.on("step.inPort.hide", this.onInputPortHide.bind(this));
148 const connectionCreateDisposer = this.model.on("connection.create", this.onConnectionCreate.bind(this));
149 const connectionRemoveDisposer = this.model.on("connection.remove", this.onConnectionRemove.bind(this));
150 const stepOutPortCreateDisposer = this.model.on("step.outPort.create", this.onOutputPortCreate.bind(this));
151 const stepOutPortRemoveDisposer = this.model.on("step.outPort.remove", this.onOutputPortRemove.bind(this));
153 this.disposers.push(() => {
154 stepChangeDisposer.dispose();
155 stepCreateDisposer.dispose();
156 stepRemoveDisposer.dispose();
157 inputCreateDisposer.dispose();
158 inputRemoveDisposer.dispose();
159 outputCreateDisposer.dispose();
160 outputRemoveDisposer.dispose();
161 stepInPortShowDisposer.dispose();
162 stepInPortHideDisposer.dispose();
163 connectionCreateDisposer.dispose();
164 connectionRemoveDisposer.dispose();
165 stepOutPortCreateDisposer.dispose();
166 stepOutPortRemoveDisposer.dispose();
169 this.invokePlugins("afterModelChange");
176 ...this.model.inputs,
177 ...this.model.outputs
178 ].filter(n => n.isVisible);
181 * If there is a missing sbg:x or sbg:y property on any node model,
182 * graph should be arranged to avoid random placement.
184 let nodeTemplate = "";
186 for (const node of nodes) {
187 const patched = GraphNode.patchModelPorts(node);
188 nodeTemplate += GraphNode.makeTemplate(patched);
191 this.workflow.innerHTML += nodeTemplate;
195 Array.from(this.workflow.querySelectorAll(".node")).forEach(e => {
196 this.workflow.appendChild(e);
199 this.addEventListeners();
201 this.workflow.setAttribute("transform", oldTransform!);
203 this.scaleAtPoint(this.scale);
206 this.invokePlugins("afterRender");
209 findParent(el: Element, parentClass = "node"): SVGGElement | undefined {
210 let parentNode: Element | null = el;
212 if (parentNode.classList.contains(parentClass)) {
213 return parentNode as SVGGElement;
215 parentNode = parentNode.parentElement;
221 * Retrieves a plugin instance
222 * @param {{new(...args: any[]) => T}} plugin
225 getPlugin<T extends SVGPlugin>(plugin: { new(...args: any[]): T }): T {
226 return this.plugins.find(p => p instanceof plugin) as T;
229 on(event: string, handler: any) {
230 this.eventHub.on(event, handler);
233 off(event: string, handler: any) {
234 this.eventHub.off(event, handler);
238 * Scales the workflow to fit the available viewport
240 fitToViewport(ignoreScaleLimits = false): void {
242 this.scaleAtPoint(1);
244 Object.assign(this.workflow.transform.baseVal.getItem(0).matrix, {
249 const clientBounds = this.svgRoot.getBoundingClientRect();
250 const wfBounds = this.workflow.getBoundingClientRect();
253 if (clientBounds.width === 0 || clientBounds.height === 0) {
254 throw new Error("Cannot fit workflow to the area that has no visible viewport.");
257 const verticalScale = (wfBounds.height) / (clientBounds.height - padding);
258 const horizontalScale = (wfBounds.width) / (clientBounds.width - padding);
260 const scaleFactor = Math.max(verticalScale, horizontalScale);
262 // Cap the upscaling to 1, we don't want to zoom in workflows that would fit anyway
263 let newScale = Math.min(this.scale / scaleFactor, 1);
265 if (!ignoreScaleLimits) {
266 newScale = Math.max(newScale, this.minScale);
269 this.scaleAtPoint(newScale);
271 const scaledWFBounds = this.workflow.getBoundingClientRect();
273 const moveY = clientBounds.top - scaledWFBounds.top + Math.abs(clientBounds.height - scaledWFBounds.height) / 2;
274 const moveX = clientBounds.left - scaledWFBounds.left + Math.abs(clientBounds.width - scaledWFBounds.width) / 2;
276 const matrix = this.workflow.transform.baseVal.getItem(0).matrix;
283 const highlightedEdges = new Set();
285 Array.from(this.workflow.querySelectorAll(".edge")).forEach((el) => {
286 if (el.classList.contains("highlighted")) {
287 const edgeID = el.attributes["data-source-connection"].value + el.attributes["data-destination-connection"].value;
288 highlightedEdges.add(edgeID);
294 const edgesTpl = this.model.connections
296 const edgeId = c.source.id + c.destination.id;
297 const edgeStates = highlightedEdges.has(edgeId) ? "highlighted" : "";
298 return GraphEdge.makeTemplate(c, this.workflow, edgeStates);
300 .reduce((acc, tpl) => acc! + tpl, "");
302 this.workflow.innerHTML = edgesTpl + this.workflow.innerHTML;
306 * Scale the workflow by the scaleCoefficient (not compounded) over given coordinates
308 scaleAtPoint(scale = 1, x = 0, y = 0): void {
310 this.docScale = scale;
311 this.labelScale = 1 + (1 - this.docScale) / (this.docScale * 2);
313 const transform = this.workflow.transform.baseVal;
314 const matrix: SVGMatrix = transform.getItem(0).matrix;
316 const coords = this.transformScreenCTMtoCanvas(x, y);
318 matrix.e += matrix.a * coords.x;
319 matrix.f += matrix.a * coords.y;
320 matrix.a = matrix.d = scale;
321 matrix.e -= scale * coords.x;
322 matrix.f -= scale * coords.y;
324 const nodeLabels: any = this.workflow.querySelectorAll(".node .label") as NodeListOf<SVGPathElement>;
326 for (const el of nodeLabels) {
327 const matrix = el.transform.baseVal.getItem(0).matrix;
329 Object.assign(matrix, {
337 transformScreenCTMtoCanvas(x: any, y: any) {
338 const svg = this.svgRoot;
339 const ctm = this.workflow.getScreenCTM()!;
340 const point = svg.createSVGPoint();
344 const t = point.matrixTransform(ctm.inverse());
351 enableEditing(enabled: boolean): void {
352 this.invokePlugins("onEditableStateChange", enabled);
353 this.editingEnabled = enabled;
356 // noinspection JSUnusedGlobalSymbols
359 this.svgRoot.classList.remove(this.svgID);
362 this.eventHub.empty();
364 this.invokePlugins("destroy");
366 for (const dispose of this.disposers) {
370 this.isDestroyed = true;
374 this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
378 private assertNotDestroyed(method: string) {
379 if (this.isDestroyed) {
380 throw new Error("Cannot call the " + method + " method on a destroyed graph. " +
381 "Destroying this object removes DOM listeners, " +
382 "and reusing it would result in unexpected things not working. " +
383 "Instead, you can just call the “draw” method with a different model, " +
384 "or create a new Workflow object.");
389 private addEventListeners(): void {
393 * Attach canvas panning
396 let pane: SVGGElement | undefined;
399 let matrix: SVGMatrix | undefined;
400 this.domEvents.drag(".pan-handle", (dx, dy) => {
405 }, (ev, el, root) => {
406 pane = root!.querySelector(".workflow") as SVGGElement;
407 matrix = pane.transform.baseVal.getItem(0).matrix;
417 * On mouse over node, bring it to the front
419 this.domEvents.on("mouseover", ".node", (ev, target, root) => {
420 if (this.workflow.querySelector(".edge.dragged")) {
423 target!.parentElement!.appendChild(target!);
428 private clearCanvas() {
429 this.domEvents.detachAll();
430 this.workflow.innerHTML = "";
431 this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
432 this.workflow.setAttribute("class", "workflow");
435 private hookPlugins() {
437 this.plugins.forEach(plugin => {
439 plugin.registerOnBeforeChange!(event => {
440 this.eventHub.emit("beforeChange", event);
443 plugin.registerOnAfterChange!(event => {
444 this.eventHub.emit("afterChange", event);
447 plugin.registerOnAfterRender!(event => {
448 this.eventHub.emit("afterRender", event);
453 private invokePlugins(methodName: keyof SVGPlugin, ...args: any[]) {
454 this.plugins.forEach(plugin => {
455 if (typeof plugin[methodName] === "function") {
456 (plugin[methodName] as Function)(...args);
462 * Listener for “connection.create” event on model that renders new edges on canvas
464 private onConnectionCreate(source: Connectable, destination: Connectable): void {
466 if (!source.isVisible || !destination.isVisible) {
470 const sourceID = source.connectionId;
471 const destinationID = destination.connectionId;
473 GraphEdge.spawnBetweenConnectionIDs(this.workflow, sourceID, destinationID);
477 * Listener for "connection.remove" event on the model that disconnects nodes
479 private onConnectionRemove(source: Connectable, destination: Connectable): void {
480 if (!source.isVisible || !destination.isVisible) {
484 const sourceID = source.connectionId;
485 const destinationID = destination.connectionId;
487 const edge = this.svgRoot.querySelector(`.edge[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"`);
492 * Listener for “input.create” event on model that renders workflow inputs
494 private onInputCreate(input: WorkflowInputParameterModel): void {
495 if (!input.isVisible) {
499 const patched = GraphNode.patchModelPorts(input);
500 const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
502 const el = TemplateParser.parse(graphTemplate)!;
503 this.workflow.appendChild(el);
508 * Listener for “output.create” event on model that renders workflow outputs
510 private onOutputCreate(output: WorkflowOutputParameterModel): void {
512 if (!output.isVisible) {
516 const patched = GraphNode.patchModelPorts(output);
517 const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
519 const el = TemplateParser.parse(graphTemplate)!;
520 this.workflow.appendChild(el);
523 private onStepCreate(step: StepModel) {
524 // if the step doesn't have x & y coordinates, check if they are in the run property
525 if (!step.customProps["sbg:x"] && step.run.customProps && step.run.customProps["sbg:x"]) {
527 Object.assign(step.customProps, {
528 "sbg:x": step.run.customProps["sbg:x"],
529 "sbg:y": step.run.customProps["sbg:y"]
532 // remove them from the run property once finished
533 delete step.run.customProps["sbg:x"];
534 delete step.run.customProps["sbg:y"];
537 const template = GraphNode.makeTemplate(step, this.labelScale);
538 const element = TemplateParser.parse(template)!;
539 this.workflow.appendChild(element);
543 private onStepChange(change: StepModel) {
544 const title = this.workflow.querySelector(`.step[data-id="${change.connectionId}"] .title`) as SVGTextElement;
546 title.textContent = change.label;
550 private onInputPortShow(input: WorkflowStepInputModel) {
552 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
553 new StepNode(stepEl, input.parentStep).update();
556 private onInputPortHide(input: WorkflowStepInputModel) {
557 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
558 new StepNode(stepEl, input.parentStep).update();
561 private onOutputPortCreate(output: WorkflowStepOutputModel) {
562 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
563 new StepNode(stepEl, output.parentStep).update();
566 private onOutputPortRemove(output: WorkflowStepOutputModel) {
567 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
568 new StepNode(stepEl, output.parentStep).update();
572 * Listener for "step.remove" event on model which removes steps
574 private onStepRemove(step: StepModel) {
575 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${step.connectionId}"]`) as SVGElement;
580 * Listener for "input.remove" event on model which removes inputs
582 private onInputRemove(input: WorkflowInputParameterModel) {
583 if (!input.isVisible) {
586 const inputEl = this.svgRoot.querySelector(`.node.input[data-connection-id="${input.connectionId}"]`);
591 * Listener for "output.remove" event on model which removes outputs
593 private onOutputRemove(output: WorkflowOutputParameterModel) {
594 if (!output.isVisible) {
597 const outputEl = this.svgRoot.querySelector(`.node.output[data-connection-id="${output.connectionId}"]`);
601 private makeID(length = 6) {
603 const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
605 for (let i = 0; i < length; i++) {
606 output += charset.charAt(Math.floor(Math.random() * charset.length));