From ba23a38d31aaa096601829c5f151626cc89b109e Mon Sep 17 00:00:00 2001
From: Mathis Neumann <mathis@simpletechs.net>
Date: Thu, 22 Sep 2016 17:50:02 +0200
Subject: [PATCH] prepare layout diffing, infinite mode for cola

---
 .../component.js                              | 172 ++++++++++++++----
 app/services/architecture-graphing-service.js |  14 +-
 app/services/deployment-graphing-service.js   |  21 ++-
 package.json                                  |   2 +-
 4 files changed, 150 insertions(+), 59 deletions(-)

diff --git a/app/components/architecture-visualisation-cytoscape/component.js b/app/components/architecture-visualisation-cytoscape/component.js
index b9e8b24..49002fd 100644
--- a/app/components/architecture-visualisation-cytoscape/component.js
+++ b/app/components/architecture-visualisation-cytoscape/component.js
@@ -5,7 +5,7 @@ import cytoscapeStyle from './style';
 import _ from 'npm:lodash';
 import coseBilkent from 'npm:cytoscape-cose-bilkent';
 
-const { Component, inject, observer, on } = Ember;
+const { Component, inject, observer, on, computed } = Ember;
 
 coseBilkent(cytoscape); // register
 
@@ -37,6 +37,37 @@ export default Component.extend({
             visualisationEvents.off('resizeEnd', resizeListener);
         });
     },
+    layoutOptions: computed('visualisationSettings.layoutAlgorithm', function() {
+        return {
+            name: this.get('visualisationSettings.layoutAlgorithm'),
+            randomize: false, // kose-bilkent will randomize node positions
+            // maxSimulationTime: 1000,
+            // padding: 6,
+            // ungrabifyWhileSimulating: false,
+
+            // webcola options
+            // infinite: false // blocks all interaction
+            refresh: 4, // fast animation
+            avoidOverlap: true,
+            edgeLength: 250, // should be at least two times the diagonal of a block, blocks are 100x60, therefore around 2*116
+            unconstrIter: 1, // unconstrained initial layout iterations
+            userConstIter: 0, // initial layout iterations with user-specified constraints - we don't have any user constraints
+            allConstIter: 1, // initial layout iterations with all constraints including non-overlap
+            infinite: true,
+            fit: false
+        };
+    }),
+
+    /**
+     * Since not every layout algorthim supports the reuse of the same cytoscape instance, we need to filter them.
+     * Currently cose-bilkent and cose require a complete rerender
+     * @property
+     * @return {Boolean}
+     * @readonly
+     */
+    layoutSupportsReuse: computed('visualisationSettings.layoutAlgorithm', function() {
+        return ['cose-bilkent', 'cose'].indexOf(this.get('visualisationSettings.layoutAlgorithm')) < 0;
+    }),
     /**
      * Observer method that renders the visualisation in a canvas using Cytoscape
      * @method renderGraph
@@ -44,58 +75,119 @@ export default Component.extend({
     renderGraph: on('didInsertElement', observer('visualisationSettings.{layoutAlgorithm,theme}', 'graph', function() {
         this.debug('graph', this.get('visualisationSettings.theme'), this.get('visualisationSettings.layoutAlgorithm'), this.get('graph'));
 
-        // do not use this.set('rendering') since it would trigger rendering updates within didInsertElement
-        this._rendering = cytoscape({
-            container: this.element,
-
-            boxSelectionEnabled: false,
-            autounselectify: true,
+        // if(!this._rendering) { // FIXME: does not handle layout/theme changes
+            // do not use this.set('rendering') since it would trigger rendering updates within didInsertElement
+            const elements = _.cloneDeep(this.get('graph'));
+            const stylesheet = cytoscapeStyle(this.get('visualisationSettings.themeStyle'));
+            if(!this._rendering || !this.get('layoutSupportsReuse')) {
+                if(this._rendering) {
+                    // this._rendering.destroy(); // remove previous listeners TODO: requires https://github.com/cytoscape/cytoscape.js-cola/issues/15
+                }
+                this._rendering = cytoscape({
+                    container: this.element,
 
-            style: cytoscapeStyle(this.get('visualisationSettings.themeStyle')),
+                    boxSelectionEnabled: false,
+                    autounselectify: true,
 
-            elements: _.cloneDeep(this.get('graph')),
+                    style: stylesheet,
 
-            layout: {
-                name: this.get('visualisationSettings.layoutAlgorithm'),
-                randomize: false, // kose-bilkent will randomize node positions
-                // maxSimulationTime: 1000,
-                // padding: 6,
-                // ungrabifyWhileSimulating: true,
-                // infinite: false
+                    elements: elements,
 
-                // webcola options
-                refresh: 5, // fast animation
-                avoidOverlap: true,
-                edgeLength: 250, // should be at least two times the diagonal of a block, blocks are 100x60, therefore around 2*116
-                unconstrIter: 100, // unconstrained initial layout iterations
-                userConstIter: 0, // initial layout iterations with user-specified constraints - we don't have any user constraints
-                allConstIter: 10 // initial layout iterations with all constraints including non-overlap
-            }
-        });
+                    layout: this.get('layoutOptions')
+                });
+                const isCola = this.get('layoutOptions.name') === 'cola';
 
-        this._rendering.on('click', (event) => {
-            const target = event.cyTarget;
-            const data = target && target.data && target.data();
-
-            this.set('_clickedElement', target);
-            if(data && data.id) {
-                this.debug('clicked on element in graph', data, event);
-                const action = this.get('select');
-                if(action) {
-                    action(data);
-                } else {
-                    this.debug('select action not set, ignoring click');
+                if(isCola) {
+                    this._rendering.on('layoutready', () => {
+                        this.debug('layoutready, using fit');
+                        this._rendering.fit();
+                    });
                 }
+
+                this._rendering.on('click', (event) => {
+                    const target = event.cyTarget;
+                    const data = target && target.data && target.data();
+
+                    this.set('_clickedElement', target);
+                    if(data && data.id) {
+                        this.debug('clicked on element in graph', data, event);
+                        const action = this.get('select');
+                        if(action) {
+                            action(data);
+                        } else {
+                            this.debug('select action not set, ignoring click');
+                        }
+                    } else {
+                        this.debug('clicked on non-selectable entity', event);
+                    }
+                });
             } else {
-                this.debug('clicked on non-selectable entity', event);
+                const cy = this._rendering;
+                cy.batch(() => {
+                    cy.style(stylesheet);
+                    this.applyDiff(cy, elements);
+                });
+                if(this._layout) {
+                    this._layout.stop(); // may still be running, e.g. cola uses infinite mode
+                }
+                const layout = cy.makeLayout(this.get('layoutOptions'));
+                this._layout = layout;
+                layout.run();
             }
 
-        });
-
         // just for development purposes - TODO: remove
         window.cy = cytoscape;
+        window.layout = this._layout;
         window.cytoscape = this._rendering;
     })),
+
+    applyDiff(cytoscapeInstance, newElements) {
+        cytoscapeInstance.batch(() => {
+            const renderedElementsById = {}; // stores cytoscape node/edge instances by id
+            const updatedElementsById = {}; // stores all elements that will have to be updated/added
+
+            newElements.forEach((element) => {
+                const id = element.data.id;
+                updatedElementsById[id] = element;
+            });
+
+            const renderedElements = newElements
+                .map((element) => {
+                    const id = element.data.id;
+                    const existingElement = cytoscapeInstance.getElementById(id);
+                    return existingElement;
+                })
+                .filter((renderedElement) => !!renderedElement); // null if not in cytoscape
+
+            renderedElements.forEach((renderedElement) => renderedElementsById[renderedElement.id()] = renderedElement);
+
+            // existing update existing nodes/edges
+            renderedElements
+                .map((renderedElement) => {
+                    const id = renderedElement.id();
+                    const updatedElement = renderedElementsById[id];
+                    renderedElement.data(updatedElement.data);
+                });
+
+
+            // remove unnecessary. Do this before adding new ones, otherwise they would new elements will be directly removed
+            cytoscapeInstance.elements()
+                .filterFn((el) => !renderedElementsById[el.id()]) // elements that are no longer in source data
+                .forEach((el) => el.remove());
+
+            // add new
+            newElements
+                .filter((element) => !renderedElementsById[element.data.id]) // should not have rendered counterpart
+                .forEach((element) => {
+                    element.position = { // new nodes require an initial position
+                        x: Math.random()* 10,
+                        y: Math.random()* 10
+                    };
+                    cytoscapeInstance.add(element);
+                });
+        });
+    },
+
     resize() {
         if(this._rendering) {
             this._rendering.resize();
diff --git a/app/services/architecture-graphing-service.js b/app/services/architecture-graphing-service.js
index 3d0ae8b..f8a022e 100644
--- a/app/services/architecture-graphing-service.js
+++ b/app/services/architecture-graphing-service.js
@@ -20,15 +20,12 @@ export default Ember.Service.extend({
 
     // services not used in current view
     const {services, /* serviceInstances,*/ communications} = prepared;
-
-    var network = {
-      nodes: [],
-      edges: []
-    };
+    const elements = [];
 
     services.forEach(data => {
         data.label = data.name;
-        network.nodes.push({
+        elements.push({
+            group: 'nodes',
             data: data
         });
     });
@@ -55,12 +52,13 @@ export default Ember.Service.extend({
 
         //TODO Normalize
 
-        network.edges.push({
+        elements.push({
+            group: 'edges',
             data,
             classes: data.status || ''
         });
     });
 
-    return network;
+    return elements;
   }
 });
diff --git a/app/services/deployment-graphing-service.js b/app/services/deployment-graphing-service.js
index 60335c3..ec649c4 100644
--- a/app/services/deployment-graphing-service.js
+++ b/app/services/deployment-graphing-service.js
@@ -22,16 +22,13 @@ export default Ember.Service.extend({
 
     // services not used in current view
     const {serviceInstances, communicationInstances, nodeGroups, nodes} = prepared;
-
-    var network = {
-      nodes: [],
-      edges: []
-    };
+    const elements = [];
 
     nodeGroups.forEach(data => {
         data.label = data.name;
 
-        network.nodes.push({
+        elements.push({
+            group: 'nodes',
             data: data
         });
     });
@@ -40,7 +37,8 @@ export default Ember.Service.extend({
         data.label = data.name;
         data.parent = data.nodeGroupId;
 
-        network.nodes.push({
+        elements.push({
+            group: 'nodes',
             data: data,
             classes: data.status || ''
         });
@@ -50,8 +48,10 @@ export default Ember.Service.extend({
         data.label = data.name;
         data.parent = data.nodeId;
 
-        network.nodes.push({
+        elements.push({
+            group: 'nodes',
             data: data,
+            parent: data.parent,
             classes: data.status || ''
         });
     });
@@ -62,12 +62,13 @@ export default Ember.Service.extend({
         data.technology = this.get('store').peekRecord('communication', data.communicationId).get('technology');
         data.label = data.technology;
 
-        network.edges.push({
+        elements.push({
+            group: 'edges',
             data,
             classes: data.status || ''
         });
     });
 
-    return network;
+    return elements;
   }
 });
diff --git a/package.json b/package.json
index bf6298b..307e195 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,7 @@
   },
   "dependencies": {
     "cytoscape": "^2.7.3",
-    "cytoscape-cola": "git+https://github.com/EyMaddis/cytoscape.js-cola.git#5c0957e60726def6d7e849956939079c5a491ba2",
+    "cytoscape-cola": "https://github.com/cytoscape/cytoscape.js-cola.git#8436961cd4ac1899a2a429d6c99dc2a140adcf0e",
     "cytoscape-cose-bilkent": "^1.3.6",
     "ember-cli": "^2.7.0",
     "yuidocjs": "^0.10.1"
-- 
GitLab