diff --git a/Gruntfile.js b/Gruntfile.js index 8b55bcd4..4a6d0800 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -345,6 +345,7 @@ module.exports.jsFiles = [ 'src/graphviz_layout.js', 'src/d3_force_layout.js', 'src/d3v4_force_layout.js', + 'src/nested_layout.js', 'src/flexbox_layout.js', 'src/manual_layout.js', 'src/place_ports.js', diff --git a/src/cola_layout.js b/src/cola_layout.js index dc5fac55..c13b257a 100644 --- a/src/cola_layout.js +++ b/src/cola_layout.js @@ -20,6 +20,7 @@ dc_graph.cola_layout = function(id) { .avoidOverlaps(true) .size([options.width, options.height]) .handleDisconnected(options.handleDisconnected); + if(_d3cola.tickSize) // non-standard _d3cola.tickSize(options.tickSize); @@ -51,6 +52,7 @@ dc_graph.cola_layout = function(id) { v1.width = v.width; v1.height = v.height; v1.fixed = !!v.dcg_nodeFixed; + v1.attrs = v.attrs; if(v1.fixed && typeof v.dcg_nodeFixed === 'object') { v1.x = v.dcg_nodeFixed.x; @@ -73,11 +75,14 @@ dc_graph.cola_layout = function(id) { e1.source = _nodes[e.dcg_edgeSource]; e1.target = _nodes[e.dcg_edgeTarget]; e1.dcg_edgeLength = e.dcg_edgeLength; + e1.attrs = e.attrs; }); // cola needs each node object to have an index property wnodes.forEach(function(v, i) { v.index = i; + //use user defined attribute extractor to get needed attributes + engine.extractNodeAttrs(v, v.attrs); }); var groups = null; @@ -103,10 +108,29 @@ dc_graph.cola_layout = function(id) { }).on('end', /* _done = */ function() { dispatchState('end'); }); - _d3cola.nodes(wnodes) - .links(wedges) - .constraints(constraints) - .groups(groups); + + if(engine.setcolaSpec !== undefined) { + var setcola_result = setcola + .nodes(wnodes) // Set the graph nodes + .links(wedges) // Set the graph links + .guides(engine.setcolaGuides) + .constraints(engine.setcolaSpec) // Set the constraints + .gap(10) //default value is 10, can be customized in setcolaSpec + .layout(); + + console.log('applying setcola constrains'); + + _d3cola.nodes(setcola_result.nodes) + .links(setcola_result.links) + .constraints(setcola_result.constraints) + .groups(groups); + } else { + _d3cola.nodes(wnodes) + .links(wedges) + .constraints(constraints) + .groups(groups); + } + } function start() { @@ -240,9 +264,13 @@ dc_graph.cola_layout = function(id) { allConstraintsIterations: property(20), gridSnapIterations: property(0), tickSize: property(1), - groupConnected: property(false) + groupConnected: property(false), + setcolaSpec: undefined, + setcolaGuides: undefined, + extractNodeAttrs: function(_node, _attrs) {}, //add new attributes to _node from _attrs + extractEdgeAttrs: function(_edge, _attrs) {}, }); return engine; }; -dc_graph.cola_layout.scripts = ['d3.js', 'cola.js']; +dc_graph.cola_layout.scripts = ['d3.js', 'cola.js', 'setcola.js']; diff --git a/src/d3v4_force_layout.js b/src/d3v4_force_layout.js index 13975f19..ce4d24e7 100644 --- a/src/d3v4_force_layout.js +++ b/src/d3v4_force_layout.js @@ -19,12 +19,17 @@ dc_graph.d3v4_force_layout = function(id) { function init(options) { _options = options; + var collideFunc = d3v4.forceCollide(_options.collisionRadius); + if(_options.radiusAccessor) { + collideFunc = d3v4.forceCollide().radius(_options.radiusAccessor); + } + _simulation = d3v4.forceSimulation() .force('link', d3v4.forceLink()) .force('center', d3v4.forceCenter(options.width / 2, options.height / 2)) .force('gravityX', d3v4.forceX(options.width / 2).strength(_options.gravityStrength)) .force('gravityY', d3v4.forceY(options.height / 2).strength(_options.gravityStrength)) - .force('collision', d3v4.forceCollide(_options.collisionRadius)) + .force('collision', collideFunc) .force('charge', d3v4.forceManyBody()) .stop(); } @@ -51,6 +56,8 @@ dc_graph.d3v4_force_layout = function(id) { v1.width = v.width; v1.height = v.height; v1.id = v.dcg_nodeKey; + v1.r = v.r; + v1.attrs = v.attrs; if(v.dcg_nodeFixed) { v1.fx = v.dcg_nodeFixed.x; v1.fy = v.dcg_nodeFixed.y; diff --git a/src/diagram.js b/src/diagram.js index 34685271..a5b9926b 100644 --- a/src/diagram.js +++ b/src/diagram.js @@ -1792,8 +1792,16 @@ dc_graph.diagram = function (parent, chartGroup) { _dispatch.start(); // cola doesn't seem to fire this itself? _diagram.layoutEngine().data( { width: _diagram.width(), height: _diagram.height() }, - wnodes.map(function(v) { return v.cola; }), - layout_edges.map(function(v) { return v.cola; }), + wnodes.map(function(v) { + var _v = v.cola; + _v.attrs = v.orig; + return _v; + }), + layout_edges.map(function(v) { + var _v = v.cola; + _v.attrs = v.orig; + return _v; + }), constraints ); _diagram.layoutEngine().start(); diff --git a/src/engine.js b/src/engine.js index eaed8b5a..e6456c57 100644 --- a/src/engine.js +++ b/src/engine.js @@ -46,7 +46,13 @@ dc_graph._engines = [ instantiate: function() { return dc_graph.cola_layout(); } - } + }, + { + name: 'nested', + instantiate: function() { + return dc_graph.nested_layout(); + } + }, ]; dc_graph._default_engine = 'cola'; diff --git a/src/nested_layout.js b/src/nested_layout.js new file mode 100644 index 00000000..72db682b --- /dev/null +++ b/src/nested_layout.js @@ -0,0 +1,467 @@ +dc_graph.nested_layout = function(id) { + var _layoutId = id || uuid(); + var _dispatch = d3.dispatch('tick', 'start', 'end'); + var _flowLayout; + var _nodes = {}, _edges = {}; + var _options = null; + var _engines_l1 = {}; // level1 engines + var _engines_l2 = []; // level2 engines + var _engines_l1_p2 = {}; // level1 engines + var _engines_l2_p2 = []; // level2 engines + var _level1 = []; // level1 promises + var _level2 = []; // level2 promises + var _level1_p2 = []; // level1 promises + var _level2_p2 = []; // level2 promises + + function init(options) { + _options = options; + console.log('applying nested layout'); + } + + function createEngines(subgroups, constraints) { + // create layout engine for each subgroups in level1 + for(var type in subgroups) { + //var _e = dc_graph.d3v4_force_layout(); + var current_engine = engine.nestedSpec.level1.default_engine; + if(engine.nestedSpec.level1.engines && type in engine.nestedSpec.level1.engines) { + current_engine = engine.nestedSpec.level1.engines[type]; + } + var _e = dc_graph.spawn_engine(current_engine.engine, {}, false); + if(current_engine.engine == 'cola') { + _e.setcolaSpec = current_engine.setcolaSpec || undefined; + _e.setcolaGuides = current_engine.setcolaGuides || []; + } + _e.init(_options); + _engines_l1[type] = _e; + _engines_l1_p2[type] = Object.assign(_e); + } + + // create layout engine for level2 + var _l2e = dc_graph.spawn_engine(engine.nestedSpec.level2.engine, {}, false); + if(engine.nestedSpec.level2.engine === 'cola') { + // TODO generate secolaSpec + _l2e.setcolaSpec = engine.nestedSpec.level2.setcolaSpec; + _l2e.setcolaGuides = engine.nestedSpec.level2.setcolaGuides || []; + _l2e.getNodeType = engine.nestedSpec.level2.getNodeType; + //_l2e.lengthStrategy = engine.lengthStrategy; + } + _engines_l2.push(_l2e); + _engines_l2_p2.push(Object.assign(_l2e)); + } + + function runLayout(nodes, edges, constraints) { + + var subgroups = {}; + var nodeTypeMap = {}; + var superEdges = []; + + for(var i = 0; i < nodes.length; i ++) { + var tp = engine.getNodeType(nodes[i]); + nodeTypeMap[nodes[i].dcg_nodeKey] = tp; + if( !(tp in subgroups)) { + subgroups[tp] = {'nodes':[], 'edges':[]}; + } + subgroups[tp].nodes.push(nodes[i]); + } + + for(var i = 0; i < edges.length; i ++) { + var sourceType = nodeTypeMap[edges[i].dcg_edgeSource]; + var targetType = nodeTypeMap[edges[i].dcg_edgeTarget]; + if( sourceType === targetType ) { + subgroups[sourceType].edges.push(edges[i]); + } else { + superEdges.push({ + dcg_edgeKey: edges[i].dcg_edgeKey, + dcg_edgeSource: sourceType, + dcg_edgeTarget: targetType, + dcg_edgeLength: edges[i].dcg_edgeLength, + }); + } + } + + var createOnEndPromise = function(_e, _key) { + var onEnd = new Promise(function(resolve){ + _e.on('end', function(nodes, edges) { + resolve([nodes, edges, _key]); + }); + }); + return onEnd; + }; + + for(var type in subgroups) { + _engines_l1[type].data(null, subgroups[type].nodes, subgroups[type].edges, constraints); + _level1.push(createOnEndPromise(_engines_l1[type], type)); + _level1_p2.push(createOnEndPromise(_engines_l1_p2[type], type)); + } + + for(var i = 0; i < _engines_l2.length; i ++) { + _level2.push(createOnEndPromise(_engines_l2[i], 'level2')); + _level2_p2.push(createOnEndPromise(_engines_l2_p2[i], 'level2')); + } + + Promise.all(_level1).then(function(results){ + var superNodes = []; + var maxRadius = 0; + for(var i = 0; i < results.length; i ++) { + subgroups[results[i][2]].nodes = results[i][0]; + //subgroups[results[i][2]].edges = results[i][1]; + var sn = calSuperNode(results[i][0]); + sn.dcg_nodeKey = results[i][2]; + superNodes.push(sn); + maxRadius = Math.max(maxRadius, sn.r); + } + if(engine.nestedSpec.level2.engine === 'd3v4force') { + // set accessor for each super nodes + _options.radiusAccessor = function(e){ + return e.r + engine.nestedSpec.level2.collisionMargin || 0; + }; + } + + _engines_l2[0].init(_options); + // now we have data for higher level layouts + _engines_l2[0].data(null, superNodes, superEdges, constraints); + + for(var i = 0; i < _engines_l2.length; i ++) { + _engines_l2[i].start(); + } + + }); + + Promise.all(_level2).then(function(results){ + // add offsets to subgroups + // only support one higher level + for(var level = 0; level < results.length; level++) { + for(var i = 0; i < results[level][0].length; i ++) { + var sn = results[level][0][i]; + var groupName = sn.dcg_nodeKey; + var offX = sn.x; + var offY = sn.y; + + for(var j = 0; j < subgroups[groupName].nodes.length; j ++) { + subgroups[groupName].nodes[j].x += offX; + subgroups[groupName].nodes[j].y += offY; + } + } + } + + // assemble all nodes and edges + var allNodes = []; + for(var key in subgroups) { + allNodes = allNodes.concat(subgroups[key].nodes); + } + + secondPass(allNodes, edges, constraints); + + for(var key in _engines_l1_p2) { + _engines_l1_p2[key].start(); + } + //_dispatch['end'](allNodes, edges); + }); + + Promise.all(_level1_p2).then(function(results) { + console.log('level1 p2 finished'); + console.log(results); + var superNodes = []; + var maxRadius = 0; + for(var i = 0; i < results.length; i ++) { + subgroups[results[i][2]].nodes = results[i][0]; + //subgroups[results[i][2]].edges = results[i][1]; + var sn = calSuperNode(results[i][0]); + sn.dcg_nodeKey = results[i][2]; + superNodes.push(sn); + maxRadius = Math.max(maxRadius, sn.r); + } + if(engine.nestedSpec.level2.engine === 'd3v4force') { + // set accessor for each super nodes + _options.radiusAccessor = function(e){ + return e.r + engine.nestedSpec.level2.collisionMargin || 0; + }; + } + + _engines_l2_p2[0].init(_options); + // now we have data for higher level layouts + _engines_l2_p2[0].data(null, superNodes, superEdges, constraints); + + for(var i = 0; i < _engines_l2.length; i ++) { + _engines_l2_p2[i].start(); + } + + }); + + Promise.all(_level2_p2).then(function(results){ + console.log('level2 p2 finished'); + for(var level = 0; level < results.length; level++) { + for(var i = 0; i < results[level][0].length; i ++) { + var sn = results[level][0][i]; + var groupName = sn.dcg_nodeKey; + var offX = sn.x; + var offY = sn.y; + + for(var j = 0; j < subgroups[groupName].nodes.length; j ++) { + subgroups[groupName].nodes[j].x += offX; + subgroups[groupName].nodes[j].y += offY; + } + } + } + + // assemble all nodes and edges + var allNodes = []; + for(var key in subgroups) { + allNodes = allNodes.concat(subgroups[key].nodes); + } + + _dispatch['end'](allNodes, edges); + }); + } + + function secondPass(nodes, edges, constraints) { + _level1_p2 = []; // level1 promises + _level2_p2 = []; // level2 promises + + var subgroups = {}; + var nodeTypeMap = {}; + var nodeMap = {}; + var superEdges = []; + + for(var i = 0; i < nodes.length; i ++) { + var tp = engine.getNodeType(nodes[i]); + nodeTypeMap[nodes[i].dcg_nodeKey] = tp; + nodeMap[nodes[i].dcg_nodeKey] = nodes[i]; + if( !(tp in subgroups)) { + subgroups[tp] = {'nodes':[], 'edges':[]}; + } + subgroups[tp].nodes.push(nodes[i]); + } + + for(var i = 0; i < edges.length; i ++) { + var sourceType = nodeTypeMap[edges[i].dcg_edgeSource]; + var targetType = nodeTypeMap[edges[i].dcg_edgeTarget]; + if( sourceType === targetType ) { + subgroups[sourceType].edges.push(edges[i]); + } else { + // insert virtual nodes + var sourceNode = nodeMap[edges[i].dcg_edgeSource]; + var targetNode = nodeMap[edges[i].dcg_edgeTarget]; + + var sourceVirtualNode = Object.assign( + sourceNode, + { + 'virtual': true, + 'dcg_nodeFixed': {'x': sourceNode.x, 'y': sourceNode.y} + } + ); + + var targetVirtualNode = Object.assign( + targetNode, + { + 'virtual': true, + 'dcg_nodeFixed': {'x': targetNode.x, 'y': targetNode.y} + } + ); + + subgroups[sourceType].nodes.push(targetVirtualNode); + subgroups[sourceType].edges.push(edges[i]); + + subgroups[targetType].nodes.push(sourceVirtualNode); + subgroups[targetType].edges.push(edges[i]); + + superEdges.push({ + dcg_edgeKey: edges[i].dcg_edgeKey, + dcg_edgeSource: sourceType, + dcg_edgeTarget: targetType, + dcg_edgeLength: edges[i].dcg_edgeLength, + }); + } + } + + for(var type in subgroups) { + _engines_l1_p2[type].data(null, subgroups[type].nodes, subgroups[type].edges, constraints); + } + } + + function data(nodes, edges, constraints) { + // reset engines + _engines_l1 = {}; // level1 engines + _engines_l2 = []; // level2 engines + _level1 = []; // level1 promises + _level2 = []; // level2 promises + + var groups = {}; + for(var i = 0; i < nodes.length; i ++) { + var tp = engine.getNodeType(nodes[i]); + if( !(tp in groups)) { + groups[tp] = true; + } + } + createEngines(groups, constraints); + runLayout(nodes, edges, constraints); + } + + function calSuperNode(nodes) { + var minX = Math.min.apply(null, nodes.filter(function(d){return d.virtual !== true}).map(function(e){return e.x})); + var maxX = Math.max.apply(null, nodes.filter(function(d){return d.virtual !== true}).map(function(e){return e.x})); + var minY = Math.min.apply(null, nodes.filter(function(d){return d.virtual !== true}).map(function(e){return e.y})); + var maxY = Math.max.apply(null, nodes.filter(function(d){return d.virtual !== true}).map(function(e){return e.y})); + // center nodes + var centerX = (maxX+minX)/2; + var centerY = (maxY+minY)/2; + for(var i = 0; i < nodes.length; i ++) { + nodes[i].x -= centerX; + nodes[i].y -= centerY; + } + + var n = {r: Math.max((maxX-minX)/2, (maxY-minY)/2)}; + //var n = {}; + return n; + } + + function start() { + // execute the layout algorithms + for(var key in _engines_l1) { + _engines_l1[key].start(); + } + + } + + function stop() { + var stopEngines = function(_engines) { + for(var i = 0; i < _engines.length; i ++) { + if(_engines[i]) + _engines[i].stop(); + } + } + stopEngines(_engines_l1); + stopEngines(_engines_l2); + } + + var graphviz = dc_graph.graphviz_attrs(), graphviz_keys = Object.keys(graphviz); + graphviz.rankdir(null); + + var engine = Object.assign(graphviz, { + layoutAlgorithm: function() { + return 'multi'; + }, + layoutId: function() { + return _layoutId; + }, + supportsWebworker: function() { + return true; + }, + needsStage: function(stage) { // stopgap until we have engine chaining + return stage === 'ports' || stage === 'edgepos'; + }, + parent: property(null), + on: function(event, f) { + if(arguments.length === 1) + return _dispatch.on(event); + _dispatch.on(event, f); + return this; + }, + init: function(options) { + this.optionNames().forEach(function(option) { + options[option] = options[option] || this[option](); + }.bind(this)); + init(options); + return this; + }, + data: function(graph, nodes, edges, constraints) { + data(nodes, edges, constraints); + }, + start: function() { + start(); + }, + stop: function() { + stop(); + }, + optionNames: function() { + return ['handleDisconnected', 'lengthStrategy', 'baseLength', 'flowLayout', 'tickSize', 'groupConnected'] + .concat(graphviz_keys); + }, + populateLayoutNode: function() {}, + populateLayoutEdge: function() {}, + /** + * Instructs cola.js to fit the connected components. + * @method handleDisconnected + * @memberof dc_graph.cola_layout + * @instance + * @param {Boolean} [handleDisconnected=true] + * @return {Boolean} + * @return {dc_graph.cola_layout} + **/ + handleDisconnected: property(true), + /** + * Currently, three strategies are supported for specifying the lengths of edges: + * * 'individual' - uses the `edgeLength` for each edge. If it returns falsy, uses the + * `baseLength` + * * 'symmetric', 'jaccard' - compute the edge length based on the graph structure around + * the edge. See + * {@link https://github.com/tgdwyer/WebCola/wiki/link-lengths the cola.js wiki} + * for more details. + * 'none' - no edge lengths will be specified + * @method lengthStrategy + * @memberof dc_graph.cola_layout + * @instance + * @param {Function|String} [lengthStrategy='symmetric'] + * @return {Function|String} + * @return {dc_graph.cola_layout} + **/ + lengthStrategy: property('symmetric'), + /** + * Gets or sets the default edge length (in pixels) when the `.lengthStrategy` is + * 'individual', and the base value to be multiplied for 'symmetric' and 'jaccard' edge + * lengths. + * @method baseLength + * @memberof dc_graph.cola_layout + * @instance + * @param {Number} [baseLength=30] + * @return {Number} + * @return {dc_graph.cola_layout} + **/ + baseLength: property(30), + /** + * If `flowLayout` is set, it determines the axis and separation for + * {@link http://marvl.infotech.monash.edu/webcola/doc/classes/cola.layout.html#flowlayout cola flow layout}. + * If it is not set, `flowLayout` will be calculated from the {@link dc_graph.graphviz_attrs#rankdir rankdir} + * and {@link dc_graph.graphviz_attrs#ranksep ranksep}; if `rankdir` is also null (the + * default for cola layout), then there will be no flow. + * @method flowLayout + * @memberof dc_graph.cola_layout + * @instance + * @param {Object} [flowLayout=null] + * @example + * // No flow (default) + * diagram.flowLayout(null) + * // flow in x with min separation 200 + * diagram.flowLayout({axis: 'x', minSeparation: 200}) + **/ + flowLayout: function(flow) { + if(!arguments.length) { + if(_flowLayout) + return _flowLayout; + var dir = engine.rankdir(); + switch(dir) { + case 'LR': return {axis: 'x', minSeparation: engine.ranksep() + engine.parent().nodeRadius()*2}; + case 'TB': return {axis: 'y', minSeparation: engine.ranksep() + engine.parent().nodeRadius()*2}; + default: return null; // RL, BT do not appear to be possible (negative separation) (?) + } + } + _flowLayout = flow; + return this; + }, + unconstrainedIterations: property(10), + userConstraintIterations: property(20), + allConstraintsIterations: property(20), + gridSnapIterations: property(0), + tickSize: property(1), + groupConnected: property(false), + setcolaSpec: undefined, + setcolaGuides: undefined, + extractNodeAttrs: function(_node, _attrs) {}, //add new attributes to _node from _attrs + extractEdgeAttrs: function(_edge, _attrs) {}, + getNodeType: function(_node) {}, + nestedSpec: undefined, + }); + return engine; +}; + +dc_graph.nested_layout.scripts = ['d3.js', 'cola.js', 'setcola.js', 'd3v4-force.js'];