UNPKG

cytoscape-cola

Version:

The Cola.js physics simulation layout for Cytoscape.js

491 lines (391 loc) 12.6 kB
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;