Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / lib / cwl-svg / graph / workflow.ts
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";
15
16 /**
17  * @FIXME validation states of old and newly created edges
18  */
19 export class Workflow {
20
21     readonly eventHub: EventHub;
22     readonly svgID = this.makeID();
23
24     minScale = 0.2;
25     maxScale = 2;
26
27     domEvents: DomEvents;
28     svgRoot: SVGSVGElement;
29     workflow: SVGGElement;
30     model: WorkflowModel;
31     editingEnabled = true;
32
33     /** Scale of labels, they are different than scale of other elements in the workflow */
34     labelScale = 1;
35
36     private workflowBoundingClientRect: any;
37     private plugins: SVGPlugin[]  = [];
38     private disposers: Function[] = [];
39
40     private pendingFirstDraw = true;
41
42     /** Stored in order to ensure that once destroyed graph cannot be reused again */
43     private isDestroyed = false;
44
45     constructor(parameters: {
46         svgRoot: SVGSVGElement,
47         model: WorkflowModel,
48         plugins?: SVGPlugin[],
49         editingEnabled?: boolean
50     }) {
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
56
57         this.svgRoot.classList.add(this.svgID);
58
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>
62         `;
63
64         this.workflow = this.svgRoot.querySelector(".workflow") as any;
65
66         this.invokePlugins("registerWorkflow", this);
67
68         this.eventHub = new EventHub([
69             "connection.create",
70             "app.create.step",
71             "app.create.input",
72             "app.create.output",
73             "beforeChange",
74             "afterChange",
75             "afterRender",
76             "selectionChange"
77         ]);
78
79         this.hookPlugins();
80         this.draw(parameters.model);
81
82
83         this.eventHub.on("afterRender", () => this.invokePlugins("afterRender"));
84     }
85
86     /** Current scale of the document */
87     private docScale = 1;
88
89     get scale() {
90         return this.docScale;
91     }
92
93     // noinspection JSUnusedGlobalSymbols
94     set scale(scale: number) {
95         this.workflowBoundingClientRect = this.svgRoot.getBoundingClientRect();
96
97         const x = (this.workflowBoundingClientRect.right + this.workflowBoundingClientRect.left) / 2;
98         const y = (this.workflowBoundingClientRect.top + this.workflowBoundingClientRect.bottom) / 2;
99
100         this.scaleAtPoint(scale, x, y);
101     }
102
103     static canDrawIn(element: SVGElement): boolean {
104         return element.getBoundingClientRect().width !== 0;
105     }
106
107     static makeConnectionPath(x1: any, y1: any, x2: any, y2: any, forceDirection: "right" | "left" | string = "right"): string | undefined {
108
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;
114
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;
119
120             return `M ${x1} ${y1} C ${outDir} ${y1} ${inDir} ${y2} ${x2} ${y2}`;
121         }
122         return undefined;
123     }
124
125     draw(model: WorkflowModel = this.model) {
126
127         this.assertNotDestroyed("draw");
128
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");
131
132         const modelChanged = this.model !== model;
133
134         if (modelChanged || this.pendingFirstDraw) {
135             this.pendingFirstDraw = false;
136
137             this.model = model;
138
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));
152
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();
167             });
168
169             this.invokePlugins("afterModelChange");
170         }
171
172         this.clearCanvas();
173
174         const nodes = [
175             ...this.model.steps,
176             ...this.model.inputs,
177             ...this.model.outputs
178         ].filter(n => n.isVisible);
179
180         /**
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.
183          */
184         let nodeTemplate = "";
185
186         for (const node of nodes) {
187             const patched  = GraphNode.patchModelPorts(node);
188             nodeTemplate += GraphNode.makeTemplate(patched);
189         }
190
191         this.workflow.innerHTML += nodeTemplate;
192
193         this.redrawEdges();
194
195         Array.from(this.workflow.querySelectorAll(".node")).forEach(e => {
196             this.workflow.appendChild(e);
197         });
198
199         this.addEventListeners();
200
201         this.workflow.setAttribute("transform", oldTransform!);
202
203         this.scaleAtPoint(this.scale);
204
205
206         this.invokePlugins("afterRender");
207     }
208
209     findParent(el: Element, parentClass = "node"): SVGGElement | undefined {
210         let parentNode: Element | null = el;
211         while (parentNode) {
212             if (parentNode.classList.contains(parentClass)) {
213                 return parentNode as SVGGElement;
214             }
215             parentNode = parentNode.parentElement;
216         }
217         return undefined;
218     }
219
220     /**
221      * Retrieves a plugin instance
222      * @param {{new(...args: any[]) => T}} plugin
223      * @returns {T}
224      */
225     getPlugin<T extends SVGPlugin>(plugin: { new(...args: any[]): T }): T {
226         return this.plugins.find(p => p instanceof plugin) as T;
227     }
228
229     on(event: string, handler: any) {
230         this.eventHub.on(event, handler);
231     }
232
233     off(event: string, handler: any) {
234         this.eventHub.off(event, handler);
235     }
236
237     /**
238      * Scales the workflow to fit the available viewport
239      */
240     fitToViewport(ignoreScaleLimits = false): void {
241
242         this.scaleAtPoint(1);
243
244         Object.assign(this.workflow.transform.baseVal.getItem(0).matrix, {
245             e: 0,
246             f: 0
247         });
248
249         const clientBounds = this.svgRoot.getBoundingClientRect();
250         const wfBounds     = this.workflow.getBoundingClientRect();
251         const padding    = 100;
252
253         if (clientBounds.width === 0 || clientBounds.height === 0) {
254             throw new Error("Cannot fit workflow to the area that has no visible viewport.");
255         }
256
257         const verticalScale   = (wfBounds.height) / (clientBounds.height - padding);
258         const horizontalScale = (wfBounds.width) / (clientBounds.width - padding);
259
260         const scaleFactor = Math.max(verticalScale, horizontalScale);
261
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);
264
265         if (!ignoreScaleLimits) {
266             newScale = Math.max(newScale, this.minScale);
267         }
268
269         this.scaleAtPoint(newScale);
270
271         const scaledWFBounds = this.workflow.getBoundingClientRect();
272
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;
275
276         const matrix = this.workflow.transform.baseVal.getItem(0).matrix;
277         matrix.e += moveX;
278         matrix.f += moveY;
279     }
280
281     redrawEdges() {
282
283         const highlightedEdges = new Set();
284
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);
289             }
290             el.remove();
291         });
292
293
294         const edgesTpl = this.model.connections
295             .map(c => {
296                 const edgeId     = c.source.id + c.destination.id;
297                 const edgeStates = highlightedEdges.has(edgeId) ? "highlighted" : "";
298                 return GraphEdge.makeTemplate(c, this.workflow, edgeStates);
299             })
300             .reduce((acc, tpl) => acc! + tpl, "");
301
302         this.workflow.innerHTML = edgesTpl + this.workflow.innerHTML;
303     }
304
305     /**
306      * Scale the workflow by the scaleCoefficient (not compounded) over given coordinates
307      */
308     scaleAtPoint(scale = 1, x = 0, y = 0): void {
309
310         this.docScale     = scale;
311         this.labelScale = 1 + (1 - this.docScale) / (this.docScale * 2);
312
313         const transform         = this.workflow.transform.baseVal;
314         const matrix: SVGMatrix = transform.getItem(0).matrix;
315
316         const coords = this.transformScreenCTMtoCanvas(x, y);
317
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;
323
324         const nodeLabels: any = this.workflow.querySelectorAll(".node .label") as  NodeListOf<SVGPathElement>;
325
326         for (const el of nodeLabels) {
327             const matrix = el.transform.baseVal.getItem(0).matrix;
328
329             Object.assign(matrix, {
330                 a: this.labelScale,
331                 d: this.labelScale
332             });
333         }
334
335     }
336
337     transformScreenCTMtoCanvas(x: any, y: any) {
338         const svg   = this.svgRoot;
339         const ctm   = this.workflow.getScreenCTM()!;
340         const point = svg.createSVGPoint();
341         point.x     = x;
342         point.y     = y;
343
344         const t = point.matrixTransform(ctm.inverse());
345         return {
346             x: t.x,
347             y: t.y
348         };
349     }
350
351     enableEditing(enabled: boolean): void {
352         this.invokePlugins("onEditableStateChange", enabled);
353         this.editingEnabled = enabled;
354     }
355
356     // noinspection JSUnusedGlobalSymbols
357     destroy() {
358
359         this.svgRoot.classList.remove(this.svgID);
360
361         this.clearCanvas();
362         this.eventHub.empty();
363
364         this.invokePlugins("destroy");
365
366         for (const dispose of this.disposers) {
367             dispose();
368         }
369
370         this.isDestroyed = true;
371     }
372
373     resetTransform() {
374         this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
375         this.scaleAtPoint();
376     }
377
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.");
385
386         }
387     }
388
389     private addEventListeners(): void {
390
391
392         /**
393          * Attach canvas panning
394          */
395         {
396             let pane: SVGGElement | undefined;
397             let x = 0;
398             let y = 0;
399             let matrix: SVGMatrix | undefined;
400             this.domEvents.drag(".pan-handle", (dx, dy) => {
401
402                 matrix!.e = x + dx;
403                 matrix!.f = y + dy;
404
405             }, (ev, el, root) => {
406                 pane   = root!.querySelector(".workflow") as SVGGElement;
407                 matrix = pane.transform.baseVal.getItem(0).matrix;
408                 x      = matrix.e;
409                 y      = matrix.f;
410             }, () => {
411                 pane   = undefined;
412                 matrix = undefined;
413             });
414         }
415
416         /**
417          * On mouse over node, bring it to the front
418          */
419         this.domEvents.on("mouseover", ".node", (ev, target, root) => {
420             if (this.workflow.querySelector(".edge.dragged")) {
421                 return;
422             }
423             target!.parentElement!.appendChild(target!);
424         });
425
426     }
427
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");
433     }
434
435     private hookPlugins() {
436
437         this.plugins.forEach(plugin => {
438
439             plugin.registerOnBeforeChange!(event => {
440                 this.eventHub.emit("beforeChange", event);
441             });
442
443             plugin.registerOnAfterChange!(event => {
444                 this.eventHub.emit("afterChange", event);
445             });
446
447             plugin.registerOnAfterRender!(event => {
448                 this.eventHub.emit("afterRender", event);
449             });
450         });
451     }
452
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);
457             }
458         });
459     }
460
461     /**
462      * Listener for “connection.create” event on model that renders new edges on canvas
463      */
464     private onConnectionCreate(source: Connectable, destination: Connectable): void {
465
466         if (!source.isVisible || !destination.isVisible) {
467             return;
468         }
469
470         const sourceID      = source.connectionId;
471         const destinationID = destination.connectionId;
472
473         GraphEdge.spawnBetweenConnectionIDs(this.workflow, sourceID, destinationID);
474     }
475
476     /**
477      * Listener for "connection.remove" event on the model that disconnects nodes
478      */
479     private onConnectionRemove(source: Connectable, destination: Connectable): void {
480         if (!source.isVisible || !destination.isVisible) {
481             return;
482         }
483
484         const sourceID      = source.connectionId;
485         const destinationID = destination.connectionId;
486
487         const edge = this.svgRoot.querySelector(`.edge[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"`);
488         edge!.remove();
489     }
490
491     /**
492      * Listener for “input.create” event on model that renders workflow inputs
493      */
494     private onInputCreate(input: WorkflowInputParameterModel): void {
495         if (!input.isVisible) {
496             return;
497         }
498
499         const patched       = GraphNode.patchModelPorts(input);
500         const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
501
502         const el = TemplateParser.parse(graphTemplate)!;
503         this.workflow.appendChild(el);
504
505     }
506
507     /**
508      * Listener for “output.create” event on model that renders workflow outputs
509      */
510     private onOutputCreate(output: WorkflowOutputParameterModel): void {
511
512         if (!output.isVisible) {
513             return;
514         }
515
516         const patched       = GraphNode.patchModelPorts(output);
517         const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
518
519         const el = TemplateParser.parse(graphTemplate)!;
520         this.workflow.appendChild(el);
521     }
522
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"]) {
526
527             Object.assign(step.customProps, {
528                 "sbg:x": step.run.customProps["sbg:x"],
529                 "sbg:y": step.run.customProps["sbg:y"]
530             });
531
532             // remove them from the run property once finished
533             delete step.run.customProps["sbg:x"];
534             delete step.run.customProps["sbg:y"];
535         }
536
537         const template = GraphNode.makeTemplate(step, this.labelScale);
538         const element  = TemplateParser.parse(template)!;
539         this.workflow.appendChild(element);
540     }
541
542
543     private onStepChange(change: StepModel) {
544         const title = this.workflow.querySelector(`.step[data-id="${change.connectionId}"] .title`) as SVGTextElement;
545         if (title) {
546             title.textContent = change.label;
547         }
548     }
549
550     private onInputPortShow(input: WorkflowStepInputModel) {
551
552         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
553         new StepNode(stepEl, input.parentStep).update();
554     }
555
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();
559     }
560
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();
564     }
565
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();
569     }
570
571     /**
572      * Listener for "step.remove" event on model which removes steps
573      */
574     private onStepRemove(step: StepModel) {
575         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${step.connectionId}"]`) as SVGElement;
576         stepEl.remove();
577     }
578
579     /**
580      * Listener for "input.remove" event on model which removes inputs
581      */
582     private onInputRemove(input: WorkflowInputParameterModel) {
583         if (!input.isVisible) {
584             return;
585         }
586         const inputEl = this.svgRoot.querySelector(`.node.input[data-connection-id="${input.connectionId}"]`);
587         inputEl!.remove();
588     }
589
590     /**
591      * Listener for "output.remove" event on model which removes outputs
592      */
593     private onOutputRemove(output: WorkflowOutputParameterModel) {
594         if (!output.isVisible) {
595             return;
596         }
597         const outputEl = this.svgRoot.querySelector(`.node.output[data-connection-id="${output.connectionId}"]`);
598         outputEl!.remove();
599     }
600
601     private makeID(length = 6) {
602         let output    = "";
603         const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
604
605         for (let i = 0; i < length; i++) {
606             output += charset.charAt(Math.floor(Math.random() * charset.length));
607         }
608
609         return output;
610     }
611 }