cytoscape-cola
Version:
The Cola.js physics simulation layout for Cytoscape.js
491 lines (391 loc) • 12.6 kB
JavaScript
const assign = require('./assign');
const defaults = require('./defaults');
const cola = require('webcola') || ( typeof window !== 'undefined' ? window.cola : null );
const raf = require('./raf');
const isString = function(o){ return typeof o === typeof ''; };
const isNumber = function(o){ return typeof o === typeof 0; };
const isObject = function(o){ return o != null && typeof o === typeof {}; };
const isFunction = function(o){ return o != null && typeof o === typeof function(){}; };
const nop = function(){};
const getOptVal = function( val, ele ){
if( isFunction(val) ){
let fn = val;
return fn.apply( ele, [ ele ] );
} else {
return val;
}
};
// constructor
// options : object containing layout options
function ColaLayout( options ){
this.options = assign( {}, defaults, options );
}
// runs the layout
ColaLayout.prototype.run = function(){
let layout = this;
let options = this.options;
layout.manuallyStopped = false;
let cy = options.cy; // cy is automatically populated for us in the constructor
let eles = options.eles;
let nodes = eles.nodes();
let edges = eles.edges();
let ready = false;
let isParent = ele => ele.isParent();
let parentNodes = nodes.filter(isParent);
let nonparentNodes = nodes.subtract(parentNodes);
let bb = options.boundingBox || { x1: 0, y1: 0, w: cy.width(), h: cy.height() };
if( bb.x2 === undefined ){ bb.x2 = bb.x1 + bb.w; }
if( bb.w === undefined ){ bb.w = bb.x2 - bb.x1; }
if( bb.y2 === undefined ){ bb.y2 = bb.y1 + bb.h; }
if( bb.h === undefined ){ bb.h = bb.y2 - bb.y1; }
let updateNodePositions = function(){
for( let i = 0; i < nodes.length; i++ ){
let node = nodes[i];
let dimensions = node.layoutDimensions( options );
let scratch = node.scratch('cola');
// update node dims
if( !scratch.updatedDims ){
let padding = getOptVal( options.nodeSpacing, node );
scratch.width = dimensions.w + 2*padding;
scratch.height = dimensions.h + 2*padding;
}
}
nodes.positions(function(node){
let scratch = node.scratch().cola;
let retPos;
if( !node.grabbed() && nonparentNodes.contains(node) ){
retPos = {
x: bb.x1 + scratch.x,
y: bb.y1 + scratch.y
};
if( !isNumber(retPos.x) || !isNumber(retPos.y) ){
retPos = undefined;
}
}
return retPos;
});
nodes.updateCompoundBounds(); // because the way this layout sets positions is buggy for some reason; ref #878
if( !ready ){
onReady();
ready = true;
}
if( options.fit ){
cy.fit( options.padding );
}
};
let onDone = function(){
if( options.ungrabifyWhileSimulating ){
grabbableNodes.grabify();
}
cy.off('destroy', destroyHandler);
nodes.off('grab free position', grabHandler);
nodes.off('lock unlock', lockHandler);
// trigger layoutstop when the layout stops (e.g. finishes)
layout.one('layoutstop', options.stop);
layout.trigger({ type: 'layoutstop', layout: layout });
};
let onReady = function(){
// trigger layoutready when each node has had its position set at least once
layout.one('layoutready', options.ready);
layout.trigger({ type: 'layoutready', layout: layout });
};
let ticksPerFrame = options.refresh;
if( options.refresh < 0 ){
ticksPerFrame = 1;
} else {
ticksPerFrame = Math.max( 1, ticksPerFrame ); // at least 1
}
let adaptor = layout.adaptor = cola.adaptor({
trigger: function( e ){ // on sim event
let TICK = cola.EventType ? cola.EventType.tick : null;
let END = cola.EventType ? cola.EventType.end : null;
switch( e.type ){
case 'tick':
case TICK:
if( options.animate ){
updateNodePositions();
}
break;
case 'end':
case END:
updateNodePositions();
if( !options.infinite ){ onDone(); }
break;
}
},
kick: function(){ // kick off the simulation
//let skip = 0;
let firstTick = true;
let inftick = function(){
if( layout.manuallyStopped ){
onDone();
return true;
}
let ret = adaptor.tick();
if( !options.infinite && !firstTick ){
adaptor.convergenceThreshold(options.convergenceThreshold);
}
firstTick = false;
if( ret && options.infinite ){ // resume layout if done
adaptor.resume(); // resume => new kick
}
return ret; // allow regular finish b/c of new kick
};
let multitick = function(){ // multiple ticks in a row
let ret;
for( let i = 0; i < ticksPerFrame && !ret; i++ ){
ret = ret || inftick(); // pick up true ret vals => sim done
}
return ret;
};
if( options.animate ){
let frame = function(){
if( multitick() ){ return; }
raf( frame );
};
raf( frame );
} else {
while( !inftick() ){
// keep going...
}
}
},
on: nop, // dummy; not needed
drag: nop // not needed for our case
});
layout.adaptor = adaptor;
// if set no grabbing during layout
let grabbableNodes = nodes.filter(':grabbable');
if( options.ungrabifyWhileSimulating ){
grabbableNodes.ungrabify();
}
let destroyHandler;
cy.one('destroy', destroyHandler = function(){
layout.stop();
});
// handle node dragging
let grabHandler;
nodes.on('grab free position', grabHandler = function(e){
let node = this;
let scrCola = node.scratch().cola;
let pos = node.position();
let nodeIsTarget = e.cyTarget === node || e.target === node;
if( !nodeIsTarget ){ return; }
switch( e.type ){
case 'grab':
adaptor.dragstart( scrCola );
break;
case 'free':
adaptor.dragend( scrCola );
break;
case 'position':
// only update when different (i.e. manual .position() call or drag) so we don't loop needlessly
if( scrCola.px !== pos.x - bb.x1 || scrCola.py !== pos.y - bb.y1 ){
scrCola.px = pos.x - bb.x1;
scrCola.py = pos.y - bb.y1;
}
break;
}
});
let lockHandler;
nodes.on('lock unlock', lockHandler = function(){
let node = this;
let scrCola = node.scratch().cola;
scrCola.fixed = node.locked();
if( node.locked() ){
adaptor.dragstart( scrCola );
} else {
adaptor.dragend( scrCola );
}
});
// add nodes to cola
adaptor.nodes( nonparentNodes.map(function( node, i ){
let padding = getOptVal( options.nodeSpacing, node );
let pos = node.position();
let dimensions = node.layoutDimensions( options );
let struct = node.scratch().cola = {
x: options.randomize || pos.x === undefined ? Math.round( Math.random() * bb.w ) : pos.x,
y: options.randomize || pos.y === undefined ? Math.round( Math.random() * bb.h ) : pos.y,
width: dimensions.w + 2*padding,
height: dimensions.h + 2*padding,
index: i,
fixed: node.locked()
};
return struct;
}) );
// the constraints to be added on nodes
let constraints = [];
if( options.alignment ){ // then set alignment constraints
let offsetsX = [];
let offsetsY = [];
nonparentNodes.forEach(function( node ){
let align = getOptVal( options.alignment, node );
let scrCola = node.scratch().cola;
let index = scrCola.index;
if( !align ){ return; }
if( align.x != null ){
offsetsX.push({
node: index,
offset: align.x
});
}
if( align.y != null ){
offsetsY.push({
node: index,
offset: align.y
});
}
});
if( offsetsX.length > 0 ){
constraints.push({
type: 'alignment',
axis: 'x',
offsets: offsetsX
});
}
if( offsetsY.length > 0 ){
constraints.push({
type: 'alignment',
axis: 'y',
offsets: offsetsY
});
}
}
// if gapInequalities variable is set add each inequality constraint to list of constraints
if ( options.gapInequalities ) {
options.gapInequalities.forEach( inequality => {
// for the constraints to be passed to cola layout adaptor use indices of nodes,
// not the nodes themselves
let leftIndex = inequality.left.scratch().cola.index;
let rightIndex = inequality.right.scratch().cola.index;
constraints.push({
axis: inequality.axis,
left: leftIndex,
right: rightIndex,
gap: inequality.gap,
equality: inequality.equality
});
} );
}
// add constraints if any
if ( constraints.length > 0 ) {
adaptor.constraints( constraints );
}
// add compound nodes to cola
adaptor.groups( parentNodes.map(function( node, i ){ // add basic group incl leaf nodes
let optPadding = getOptVal( options.nodeSpacing, node );
let getPadding = function(d){
return parseFloat( node.style('padding-'+d) );
};
let pleft = getPadding('left') + optPadding;
let pright = getPadding('right') + optPadding;
let ptop = getPadding('top') + optPadding;
let pbottom = getPadding('bottom') + optPadding;
node.scratch().cola = {
index: i,
padding: Math.max( pleft, pright, ptop, pbottom ),
// leaves should only contain direct descendants (children),
// not the leaves of nested compound nodes or any nodes that are compounds themselves
leaves: node.children()
.intersection(nonparentNodes)
.map(function( child ){
return child[0].scratch().cola.index;
}),
fixed: node.locked()
};
return node;
}).map(function( node ){ // add subgroups
node.scratch().cola.groups = node.children()
.intersection(parentNodes)
.map(function( child ){
return child.scratch().cola.index;
});
return node.scratch().cola;
}) );
// get the edge length setting mechanism
let length;
let lengthFnName;
if( options.edgeLength != null ){
length = options.edgeLength;
lengthFnName = 'linkDistance';
} else if( options.edgeSymDiffLength != null ){
length = options.edgeSymDiffLength;
lengthFnName = 'symmetricDiffLinkLengths';
} else if( options.edgeJaccardLength != null ){
length = options.edgeJaccardLength;
lengthFnName = 'jaccardLinkLengths';
} else {
length = 100;
lengthFnName = 'linkDistance';
}
let lengthGetter = function( link ){
return link.calcLength;
};
// add the edges to cola
adaptor.links( edges.stdFilter(function( edge ){
return nonparentNodes.contains(edge.source()) && nonparentNodes.contains(edge.target());
}).map(function( edge ){
let c = edge.scratch().cola = {
source: edge.source()[0].scratch().cola.index,
target: edge.target()[0].scratch().cola.index
};
if( length != null ){
c.calcLength = getOptVal( length, edge );
}
return c;
}) );
adaptor.size([ bb.w, bb.h ]);
if( length != null ){
adaptor[ lengthFnName ]( lengthGetter );
}
// set the flow of cola
if( options.flow ){
let flow;
let defAxis = 'y';
let defMinSep = 50;
if( isString(options.flow) ){
flow = {
axis: options.flow,
minSeparation: defMinSep
};
} else if( isNumber(options.flow) ){
flow = {
axis: defAxis,
minSeparation: options.flow
};
} else if( isObject(options.flow) ){
flow = options.flow;
flow.axis = flow.axis || defAxis;
flow.minSeparation = flow.minSeparation != null ? flow.minSeparation : defMinSep;
} else { // e.g. options.flow: true
flow = {
axis: defAxis,
minSeparation: defMinSep
};
}
adaptor.flowLayout( flow.axis , flow.minSeparation );
}
layout.trigger({ type: 'layoutstart', layout: layout });
adaptor
.avoidOverlaps( options.avoidOverlap )
.handleDisconnected( options.handleDisconnected )
.start( options.unconstrIter, options.userConstIter, options.allConstIter)
;
if( !options.infinite ){
setTimeout(function(){
if( !layout.manuallyStopped ){
adaptor.stop();
}
}, options.maxSimulationTime);
}
return this; // chaining
};
// called on continuous layouts to stop them before they finish
ColaLayout.prototype.stop = function(){
if( this.adaptor ){
this.manuallyStopped = true;
this.adaptor.stop();
}
return this; // chaining
};
module.exports = ColaLayout;