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 arrangeNecessary = false;
186 let nodeTemplate = "";
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));
193 if (missingX || missingY) {
194 arrangeNecessary = true;
197 nodeTemplate += GraphNode.makeTemplate(patched);
201 this.workflow.innerHTML += nodeTemplate;
205 Array.from(this.workflow.querySelectorAll(".node")).forEach(e => {
206 this.workflow.appendChild(e);
209 this.addEventListeners();
211 this.workflow.setAttribute("transform", oldTransform!);
213 this.scaleAtPoint(this.scale);
216 this.invokePlugins("afterRender");
219 findParent(el: Element, parentClass = "node"): SVGGElement | undefined {
220 let parentNode: Element | null = el;
222 if (parentNode.classList.contains(parentClass)) {
223 return parentNode as SVGGElement;
225 parentNode = parentNode.parentElement;
231 * Retrieves a plugin instance
232 * @param {{new(...args: any[]) => T}} plugin
235 getPlugin<T extends SVGPlugin>(plugin: { new(...args: any[]): T }): T {
236 return this.plugins.find(p => p instanceof plugin) as T;
239 on(event: string, handler: any) {
240 this.eventHub.on(event, handler);
243 off(event: string, handler: any) {
244 this.eventHub.off(event, handler);
248 * Scales the workflow to fit the available viewport
250 fitToViewport(ignoreScaleLimits = false): void {
252 this.scaleAtPoint(1);
254 Object.assign(this.workflow.transform.baseVal.getItem(0).matrix, {
259 const clientBounds = this.svgRoot.getBoundingClientRect();
260 const wfBounds = this.workflow.getBoundingClientRect();
263 if (clientBounds.width === 0 || clientBounds.height === 0) {
264 throw new Error("Cannot fit workflow to the area that has no visible viewport.");
267 const verticalScale = (wfBounds.height) / (clientBounds.height - padding);
268 const horizontalScale = (wfBounds.width) / (clientBounds.width - padding);
270 const scaleFactor = Math.max(verticalScale, horizontalScale);
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);
275 if (!ignoreScaleLimits) {
276 newScale = Math.max(newScale, this.minScale);
279 this.scaleAtPoint(newScale);
281 const scaledWFBounds = this.workflow.getBoundingClientRect();
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;
286 const matrix = this.workflow.transform.baseVal.getItem(0).matrix;
293 const highlightedEdges = new Set();
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);
304 const edgesTpl = this.model.connections
306 const edgeId = c.source.id + c.destination.id;
307 const edgeStates = highlightedEdges.has(edgeId) ? "highlighted" : "";
308 return GraphEdge.makeTemplate(c, this.workflow, edgeStates);
310 .reduce((acc, tpl) => acc! + tpl, "");
312 this.workflow.innerHTML = edgesTpl + this.workflow.innerHTML;
316 * Scale the workflow by the scaleCoefficient (not compounded) over given coordinates
318 scaleAtPoint(scale = 1, x = 0, y = 0): void {
320 this.docScale = scale;
321 this.labelScale = 1 + (1 - this.docScale) / (this.docScale * 2);
323 const transform = this.workflow.transform.baseVal;
324 const matrix: SVGMatrix = transform.getItem(0).matrix;
326 const coords = this.transformScreenCTMtoCanvas(x, y);
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;
334 const nodeLabels: any = this.workflow.querySelectorAll(".node .label") as NodeListOf<SVGPathElement>;
336 for (const el of nodeLabels) {
337 const matrix = el.transform.baseVal.getItem(0).matrix;
339 Object.assign(matrix, {
347 transformScreenCTMtoCanvas(x: any, y: any) {
348 const svg = this.svgRoot;
349 const ctm = this.workflow.getScreenCTM()!;
350 const point = svg.createSVGPoint();
354 const t = point.matrixTransform(ctm.inverse());
361 enableEditing(enabled: boolean): void {
362 this.invokePlugins("onEditableStateChange", enabled);
363 this.editingEnabled = enabled;
366 // noinspection JSUnusedGlobalSymbols
369 this.svgRoot.classList.remove(this.svgID);
372 this.eventHub.empty();
374 this.invokePlugins("destroy");
376 for (const dispose of this.disposers) {
380 this.isDestroyed = true;
384 this.workflow.setAttribute("transform", "matrix(1,0,0,1,0,0)");
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.");
399 private addEventListeners(): void {
403 * Attach canvas panning
406 let pane: SVGGElement | undefined;
409 let matrix: SVGMatrix | undefined;
410 this.domEvents.drag(".pan-handle", (dx, dy) => {
415 }, (ev, el, root) => {
416 pane = root!.querySelector(".workflow") as SVGGElement;
417 matrix = pane.transform.baseVal.getItem(0).matrix;
427 * On mouse over node, bring it to the front
429 this.domEvents.on("mouseover", ".node", (ev, target, root) => {
430 if (this.workflow.querySelector(".edge.dragged")) {
433 target!.parentElement!.appendChild(target!);
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");
445 private hookPlugins() {
447 this.plugins.forEach(plugin => {
449 plugin.registerOnBeforeChange!(event => {
450 this.eventHub.emit("beforeChange", event);
453 plugin.registerOnAfterChange!(event => {
454 this.eventHub.emit("afterChange", event);
457 plugin.registerOnAfterRender!(event => {
458 this.eventHub.emit("afterRender", event);
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);
472 * Listener for “connection.create” event on model that renders new edges on canvas
474 private onConnectionCreate(source: Connectable, destination: Connectable): void {
476 if (!source.isVisible || !destination.isVisible) {
480 const sourceID = source.connectionId;
481 const destinationID = destination.connectionId;
483 GraphEdge.spawnBetweenConnectionIDs(this.workflow, sourceID, destinationID);
487 * Listener for "connection.remove" event on the model that disconnects nodes
489 private onConnectionRemove(source: Connectable, destination: Connectable): void {
490 if (!source.isVisible || !destination.isVisible) {
494 const sourceID = source.connectionId;
495 const destinationID = destination.connectionId;
497 const edge = this.svgRoot.querySelector(`.edge[data-source-connection="${sourceID}"][data-destination-connection="${destinationID}"`);
502 * Listener for “input.create” event on model that renders workflow inputs
504 private onInputCreate(input: WorkflowInputParameterModel): void {
505 if (!input.isVisible) {
509 const patched = GraphNode.patchModelPorts(input);
510 const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
512 const el = TemplateParser.parse(graphTemplate)!;
513 this.workflow.appendChild(el);
518 * Listener for “output.create” event on model that renders workflow outputs
520 private onOutputCreate(output: WorkflowOutputParameterModel): void {
522 if (!output.isVisible) {
526 const patched = GraphNode.patchModelPorts(output);
527 const graphTemplate = GraphNode.makeTemplate(patched, this.labelScale);
529 const el = TemplateParser.parse(graphTemplate)!;
530 this.workflow.appendChild(el);
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"]) {
537 Object.assign(step.customProps, {
538 "sbg:x": step.run.customProps["sbg:x"],
539 "sbg:y": step.run.customProps["sbg:y"]
542 // remove them from the run property once finished
543 delete step.run.customProps["sbg:x"];
544 delete step.run.customProps["sbg:y"];
547 const template = GraphNode.makeTemplate(step, this.labelScale);
548 const element = TemplateParser.parse(template)!;
549 this.workflow.appendChild(element);
553 private onStepChange(change: StepModel) {
554 const title = this.workflow.querySelector(`.step[data-id="${change.connectionId}"] .title`) as SVGTextElement;
556 title.textContent = change.label;
560 private onInputPortShow(input: WorkflowStepInputModel) {
562 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${input.parentStep.connectionId}"]`) as SVGElement;
563 new StepNode(stepEl, input.parentStep).update();
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();
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();
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();
582 * Listener for "step.remove" event on model which removes steps
584 private onStepRemove(step: StepModel) {
585 const stepEl = this.svgRoot.querySelector(`.step[data-connection-id="${step.connectionId}"]`) as SVGElement;
590 * Listener for "input.remove" event on model which removes inputs
592 private onInputRemove(input: WorkflowInputParameterModel) {
593 if (!input.isVisible) {
596 const inputEl = this.svgRoot.querySelector(`.node.input[data-connection-id="${input.connectionId}"]`);
601 * Listener for "output.remove" event on model which removes outputs
603 private onOutputRemove(output: WorkflowOutputParameterModel) {
604 if (!output.isVisible) {
607 const outputEl = this.svgRoot.querySelector(`.node.output[data-connection-id="${output.connectionId}"]`);
611 private makeID(length = 6) {
613 const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
615 for (let i = 0; i < length; i++) {
616 output += charset.charAt(Math.floor(Math.random() * charset.length));