15610: Code cleanup, styiling additions. (WIP)
[arvados.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 arrangeNecessary = false;
185
186         let nodeTemplate = "";
187
188         for (const node of nodes) {
189             const patched  = GraphNode.patchModelPorts(node);
190             const missingX = isNaN(parseInt(patched.customProps["sbg:x"], 10));
191             const missingY = isNaN(parseInt(patched.customProps["sbg:y"], 10));
192
193             if (missingX || missingY) {
194                 arrangeNecessary = true;
195             }
196
197             nodeTemplate += GraphNode.makeTemplate(patched);
198
199         }
200
201         this.workflow.innerHTML += nodeTemplate;
202
203         this.redrawEdges();
204
205         Array.from(this.workflow.querySelectorAll(".node")).forEach(e => {
206             this.workflow.appendChild(e);
207         });
208
209         this.addEventListeners();
210
211         this.workflow.setAttribute("transform", oldTransform!);
212
213         this.scaleAtPoint(this.scale);
214
215
216         this.invokePlugins("afterRender");
217     }
218
219     findParent(el: Element, parentClass = "node"): SVGGElement | undefined {
220         let parentNode: Element | null = el;
221         while (parentNode) {
222             if (parentNode.classList.contains(parentClass)) {
223                 return parentNode as SVGGElement;
224             }
225             parentNode = parentNode.parentElement;
226         }
227         return undefined;
228     }
229
230     /**
231      * Retrieves a plugin instance
232      * @param {{new(...args: any[]) => T}} plugin
233      * @returns {T}
234      */
235     getPlugin<T extends SVGPlugin>(plugin: { new(...args: any[]): T }): T {
236         return this.plugins.find(p => p instanceof plugin) as T;
237     }
238
239     on(event: string, handler: any) {
240         this.eventHub.on(event, handler);
241     }
242
243     off(event: string, handler: any) {
244         this.eventHub.off(event, handler);
245     }
246
247     /**
248      * Scales the workflow to fit the available viewport
249      */
250     fitToViewport(ignoreScaleLimits = false): void {
251
252         this.scaleAtPoint(1);
253
254         Object.assign(this.workflow.transform.baseVal.getItem(0).matrix, {
255             e: 0,
256             f: 0
257         });
258
259         const clientBounds = this.svgRoot.getBoundingClientRect();
260         const wfBounds     = this.workflow.getBoundingClientRect();
261         const padding    = 100;
262
263         if (clientBounds.width === 0 || clientBounds.height === 0) {
264             throw new Error("Cannot fit workflow to the area that has no visible viewport.");
265         }
266
267         const verticalScale   = (wfBounds.height) / (clientBounds.height - padding);
268         const horizontalScale = (wfBounds.width) / (clientBounds.width - padding);
269
270         const scaleFactor = Math.max(verticalScale, horizontalScale);
271
272         // Cap the upscaling to 1, we don't want to zoom in workflows that would fit anyway
273         let newScale = Math.min(this.scale / scaleFactor, 1);
274
275         if (!ignoreScaleLimits) {
276             newScale = Math.max(newScale, this.minScale);
277         }
278
279         this.scaleAtPoint(newScale);
280
281         const scaledWFBounds = this.workflow.getBoundingClientRect();
282
283         const moveY = clientBounds.top - scaledWFBounds.top + Math.abs(clientBounds.height - scaledWFBounds.height) / 2;
284         const moveX = clientBounds.left - scaledWFBounds.left + Math.abs(clientBounds.width - scaledWFBounds.width) / 2;
285
286         const matrix = this.workflow.transform.baseVal.getItem(0).matrix;
287         matrix.e += moveX;
288         matrix.f += moveY;
289     }
290
291     redrawEdges() {
292
293         const highlightedEdges = new Set();
294
295         Array.from(this.workflow.querySelectorAll(".edge")).forEach((el) => {
296             if (el.classList.contains("highlighted")) {
297                 const edgeID = el.attributes["data-source-connection"].value + el.attributes["data-destination-connection"].value;
298                 highlightedEdges.add(edgeID);
299             }
300             el.remove();
301         });
302
303
304         const edgesTpl = this.model.connections
305             .map(c => {
306                 const edgeId     = c.source.id + c.destination.id;
307                 const edgeStates = highlightedEdges.has(edgeId) ? "highlighted" : "";
308                 return GraphEdge.makeTemplate(c, this.workflow, edgeStates);
309             })
310             .reduce((acc, tpl) => acc! + tpl, "");
311
312         this.workflow.innerHTML = edgesTpl + this.workflow.innerHTML;
313     }
314
315     /**
316      * Scale the workflow by the scaleCoefficient (not compounded) over given coordinates
317      */
318     scaleAtPoint(scale = 1, x = 0, y = 0): void {
319
320         this.docScale     = scale;
321         this.labelScale = 1 + (1 - this.docScale) / (this.docScale * 2);
322
323         const transform         = this.workflow.transform.baseVal;
324         const matrix: SVGMatrix = transform.getItem(0).matrix;
325
326         const coords = this.transformScreenCTMtoCanvas(x, y);
327
328         matrix.e += matrix.a * coords.x;
329         matrix.f += matrix.a * coords.y;
330         matrix.a = matrix.d = scale;
331         matrix.e -= scale * coords.x;
332         matrix.f -= scale * coords.y;
333
334         const nodeLabels: any = this.workflow.querySelectorAll(".node .label") as  NodeListOf<SVGPathElement>;
335
336         for (const el of nodeLabels) {
337             const matrix = el.transform.baseVal.getItem(0).matrix;
338
339             Object.assign(matrix, {
340                 a: this.labelScale,
341                 d: this.labelScale
342             });
343         }
344
345     }
346
347     transformScreenCTMtoCanvas(x: any, y: any) {
348         const svg   = this.svgRoot;
349         const ctm   = this.workflow.getScreenCTM()!;
350         const point = svg.createSVGPoint();
351         point.x     = x;
352         point.y     = y;
353
354         const t = point.matrixTransform(ctm.inverse());
355         return {
356             x: t.x,
357             y: t.y
358         };
359     }
360
361     enableEditing(enabled: boolean): void {
362         this.invokePlugins("onEditableStateChange", enabled);
363         this.editingEnabled = enabled;
364     }
365
366     // noinspection JSUnusedGlobalSymbols
367     destroy() {
368
369         this.svgRoot.classList.remove(this.svgID);
370
371         this.clearCanvas();
372         this.eventHub.empty();
373
374         this.invokePlugins("destroy");
375
376         for (const dispose of this.disposers) {
377             dispose();
378         }
379
380         this.isDestroyed = true;
381     }
382
383     resetTransform() {
384         this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
385         this.scaleAtPoint();
386     }
387
388     private assertNotDestroyed(method: string) {
389         if (this.isDestroyed) {
390             throw new Error("Cannot call the " + method + " method on a destroyed graph. " +
391                 "Destroying this object removes DOM listeners, " +
392                 "and reusing it would result in unexpected things not working. " +
393                 "Instead, you can just call the “draw” method with a different model, " +
394                 "or create a new Workflow object.");
395
396         }
397     }
398
399     private addEventListeners(): void {
400
401
402         /**
403          * Attach canvas panning
404          */
405         {
406             let pane: SVGGElement | undefined;
407             let x = 0;
408             let y = 0;
409             let matrix: SVGMatrix | undefined;
410             this.domEvents.drag(".pan-handle", (dx, dy) => {
411
412                 matrix!.e = x + dx;
413                 matrix!.f = y + dy;
414
415             }, (ev, el, root) => {
416                 pane   = root!.querySelector(".workflow") as SVGGElement;
417                 matrix = pane.transform.baseVal.getItem(0).matrix;
418                 x      = matrix.e;
419                 y      = matrix.f;
420             }, () => {
421                 pane   = undefined;
422                 matrix = undefined;
423             });
424         }
425
426         /**
427          * On mouse over node, bring it to the front
428          */
429         this.domEvents.on("mouseover", ".node", (ev, target, root) => {
430             if (this.workflow.querySelector(".edge.dragged")) {
431                 return;
432             }
433             target!.parentElement!.appendChild(target!);
434         });
435
436     }
437
438     private clearCanvas() {
439         this.domEvents.detachAll();
440         this.workflow.innerHTML = "";
441         this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
442         this.workflow.setAttribute("class", "workflow");
443     }
444
445     private hookPlugins() {
446
447         this.plugins.forEach(plugin => {
448
449             plugin.registerOnBeforeChange!(event => {
450                 this.eventHub.emit("beforeChange", event);
451             });
452
453             plugin.registerOnAfterChange!(event => {
454                 this.eventHub.emit("afterChange", event);
455             });
456
457             plugin.registerOnAfterRender!(event => {
458                 this.eventHub.emit("afterRender", event);
459             });
460         });
461     }
462
463     private invokePlugins(methodName: keyof SVGPlugin, ...args: any[]) {
464         this.plugins.forEach(plugin => {
465             if (typeof plugin[methodName] === "function") {
466                 (plugin[methodName] as Function)(...args);
467             }
468         });
469     }
470
471     /**
472      * Listener for “connection.create” event on model that renders new edges on canvas
473      */
474     private onConnectionCreate(source: Connectable, destination: Connectable): void {
475
476         if (!source.isVisible || !destination.isVisible) {
477             return;
478         }
479
480         const sourceID      = source.connectionId;
481         const destinationID = destination.connectionId;
482
483         GraphEdge.spawnBetweenConnectionIDs(this.workflow, sourceID, destinationID);
484     }
485
486     /**
487      * Listener for "connection.remove" event on the model that disconnects nodes
488      */
489     private onConnectionRemove(source: Connectable, destination: Connectable): void {
490         if (!source.isVisible || !destination.isVisible) {
491             return;
492         }
493
494         const sourceID      = source.connectionId;
495         const destinationID = destination.connectionId;
496
497         const edge = this.svgRoot.querySelector(`.edge[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"`);
498         edge!.remove();
499     }
500
501     /**
502      * Listener for “input.create” event on model that renders workflow inputs
503      */
504     private onInputCreate(input: WorkflowInputParameterModel): void {
505         if (!input.isVisible) {
506             return;
507         }
508
509         const patched       = GraphNode.patchModelPorts(input);
510         const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
511
512         const el = TemplateParser.parse(graphTemplate)!;
513         this.workflow.appendChild(el);
514
515     }
516
517     /**
518      * Listener for “output.create” event on model that renders workflow outputs
519      */
520     private onOutputCreate(output: WorkflowOutputParameterModel): void {
521
522         if (!output.isVisible) {
523             return;
524         }
525
526         const patched       = GraphNode.patchModelPorts(output);
527         const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
528
529         const el = TemplateParser.parse(graphTemplate)!;
530         this.workflow.appendChild(el);
531     }
532
533     private onStepCreate(step: StepModel) {
534         // if the step doesn't have x & y coordinates, check if they are in the run property
535         if (!step.customProps["sbg:x"] && step.run.customProps && step.run.customProps["sbg:x"]) {
536
537             Object.assign(step.customProps, {
538                 "sbg:x": step.run.customProps["sbg:x"],
539                 "sbg:y": step.run.customProps["sbg:y"]
540             });
541
542             // remove them from the run property once finished
543             delete step.run.customProps["sbg:x"];
544             delete step.run.customProps["sbg:y"];
545         }
546
547         const template = GraphNode.makeTemplate(step, this.labelScale);
548         const element  = TemplateParser.parse(template)!;
549         this.workflow.appendChild(element);
550     }
551
552
553     private onStepChange(change: StepModel) {
554         const title = this.workflow.querySelector(`.step[data-id="${change.connectionId}"] .title`) as SVGTextElement;
555         if (title) {
556             title.textContent = change.label;
557         }
558     }
559
560     private onInputPortShow(input: WorkflowStepInputModel) {
561
562         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
563         new StepNode(stepEl, input.parentStep).update();
564     }
565
566     private onInputPortHide(input: WorkflowStepInputModel) {
567         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
568         new StepNode(stepEl, input.parentStep).update();
569     }
570
571     private onOutputPortCreate(output: WorkflowStepOutputModel) {
572         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
573         new StepNode(stepEl, output.parentStep).update();
574     }
575
576     private onOutputPortRemove(output: WorkflowStepOutputModel) {
577         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${output.parentStep.connectionId}"]`) as SVGElement;
578         new StepNode(stepEl, output.parentStep).update();
579     }
580
581     /**
582      * Listener for "step.remove" event on model which removes steps
583      */
584     private onStepRemove(step: StepModel) {
585         const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${step.connectionId}"]`) as SVGElement;
586         stepEl.remove();
587     }
588
589     /**
590      * Listener for "input.remove" event on model which removes inputs
591      */
592     private onInputRemove(input: WorkflowInputParameterModel) {
593         if (!input.isVisible) {
594             return;
595         }
596         const inputEl = this.svgRoot.querySelector(`.node.input[data-connection-id="${input.connectionId}"]`);
597         inputEl!.remove();
598     }
599
600     /**
601      * Listener for "output.remove" event on model which removes outputs
602      */
603     private onOutputRemove(output: WorkflowOutputParameterModel) {
604         if (!output.isVisible) {
605             return;
606         }
607         const outputEl = this.svgRoot.querySelector(`.node.output[data-connection-id="${output.connectionId}"]`);
608         outputEl!.remove();
609     }
610
611     private makeID(length = 6) {
612         let output    = "";
613         const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
614
615         for (let i = 0; i < length; i++) {
616             output += charset.charAt(Math.floor(Math.random() * charset.length));
617         }
618
619         return output;
620     }
621 }