cytoscape-fcose
Version:
The fCoSE layout for Cytoscape.js by Bilkent with fast compound node placement
414 lines (384 loc) • 17.7 kB
JavaScript
/**
The implementation of the fcose layout algorithm
*/
const assign = require('../assign');
const aux = require('./auxiliary');
const { spectralLayout } = require('./spectral');
const { coseLayout } = require('./cose');
const defaults = Object.freeze({
// 'draft', 'default' or 'proof'
// - 'draft' only applies spectral layout
// - 'default' improves the quality with subsequent CoSE layout (fast cooling rate)
// - 'proof' improves the quality with subsequent CoSE layout (slow cooling rate)
quality: "default",
// Use random node positions at beginning of layout
// if this is set to false, then quality option must be "proof"
randomize: true,
// Whether or not to animate the layout
animate: true,
// Duration of animation in ms, if enabled
animationDuration: 1000,
// Easing of animation, if enabled
animationEasing: undefined,
// Fit the viewport to the repositioned nodes
fit: true,
// Padding around layout
padding: 30,
// Whether to include labels in node dimensions. Valid in "proof" quality
nodeDimensionsIncludeLabels: false,
// Whether or not simple nodes (non-compound nodes) are of uniform dimensions
uniformNodeDimensions: false,
// Whether to pack disconnected components - valid only if randomize: true
packComponents: true,
// Layout step - all, transformed, enforced, cose - for debug purpose only
step: "all",
/* spectral layout options */
// False for random, true for greedy
samplingType: true,
// Sample size to construct distance matrix
sampleSize: 25,
// Separation amount between nodes
nodeSeparation: 75,
// Power iteration tolerance
piTol: 0.0000001,
/* CoSE layout options */
// Node repulsion (non overlapping) multiplier
nodeRepulsion: node => 4500,
// Ideal edge (non nested) length
idealEdgeLength: edge => 50,
// Divisor to compute edge forces
edgeElasticity: edge => 0.45,
// Nesting factor (multiplier) to compute ideal edge length for nested edges
nestingFactor: 0.1,
// Gravity force (constant)
gravity: 0.25,
// Maximum number of iterations to perform
numIter: 2500,
// For enabling tiling
tile: true,
// The function that specifies the criteria for comparing nodes while sorting them during tiling operation.
// Takes the node id as a parameter and the default tiling operation is perfomed when this option is not set.
tilingCompareBy: undefined,
// Represents the amount of the vertical space to put between the zero degree members during the tiling operation(can also be a function)
tilingPaddingVertical: 10,
// Represents the amount of the horizontal space to put between the zero degree members during the tiling operation(can also be a function)
tilingPaddingHorizontal: 10,
// Gravity range (constant) for compounds
gravityRangeCompound: 1.5,
// Gravity force (constant) for compounds
gravityCompound: 1.0,
// Gravity range (constant)
gravityRange: 3.8,
// Initial cooling factor for incremental layout
initialEnergyOnIncremental: 0.3,
/* constraint options */
// Fix required nodes to predefined positions
// [{nodeId: 'n1', position: {x: 100, y: 200}, {...}]
fixedNodeConstraint: undefined,
// Align required nodes in vertical/horizontal direction
// {vertical: [['n1', 'n2')], ['n3', 'n4']], horizontal: ['n2', 'n4']}
alignmentConstraint: undefined,
// Place two nodes relatively in vertical/horizontal direction
// [{top: 'n1', bottom: 'n2', gap: 100}, {left: 'n3', right: 'n4', gap: 75}]
relativePlacementConstraint: undefined,
/* layout event callbacks */
ready: () => {}, // on layoutready
stop: () => {} // on layoutstop
});
class Layout {
constructor( options ){
this.options = assign( {}, defaults, options );
}
run(){
let layout = this;
let options = this.options;
let cy = options.cy;
let eles = options.eles;
let spectralResult = [];
let xCoords;
let yCoords;
let coseResult = [];
let components;
let componentCenters = [];
// basic validity check for constraint inputs
if(options.fixedNodeConstraint && (!Array.isArray(options.fixedNodeConstraint) || options.fixedNodeConstraint.length == 0)){
options.fixedNodeConstraint = undefined;
}
if(options.alignmentConstraint){
if(options.alignmentConstraint.vertical && (!Array.isArray(options.alignmentConstraint.vertical) || options.alignmentConstraint.vertical.length == 0)){
options.alignmentConstraint.vertical = undefined;
}
if(options.alignmentConstraint.horizontal && (!Array.isArray(options.alignmentConstraint.horizontal) || options.alignmentConstraint.horizontal.length == 0)){
options.alignmentConstraint.horizontal = undefined;
}
}
if(options.relativePlacementConstraint && (!Array.isArray(options.relativePlacementConstraint) || options.relativePlacementConstraint.length == 0)){
options.relativePlacementConstraint = undefined;
}
// if any constraint exists, set some options
let constraintExist = options.fixedNodeConstraint || options.alignmentConstraint || options.relativePlacementConstraint;
if(constraintExist){
// constraints work with these options
options.tile = false;
options.packComponents = false;
}
// decide component packing is enabled or not
let layUtil;
let packingEnabled = false;
if(cy.layoutUtilities && options.packComponents){
layUtil = cy.layoutUtilities("get");
if(!layUtil)
layUtil = cy.layoutUtilities();
packingEnabled = true;
}
if(eles.nodes().length > 0) {
// if packing is not enabled, perform layout on the whole graph
if(!packingEnabled){
// store component center
let boundingBox = options.eles.boundingBox();
componentCenters.push({x: boundingBox.x1 + boundingBox.w / 2, y: boundingBox.y1 + boundingBox.h / 2});
// apply spectral layout
if(options.randomize){
let result = spectralLayout(options);
spectralResult.push(result);
}
// apply cose layout as postprocessing
if(options.quality == "default" || options.quality == "proof"){
coseResult.push(coseLayout(options, spectralResult[0]));
aux.relocateComponent(componentCenters[0], coseResult[0], options); // relocate center to original position
}
else{
aux.relocateComponent(componentCenters[0], spectralResult[0], options); // relocate center to original position
}
}
else{ // packing is enabled
let topMostNodes = aux.getTopMostNodes(options.eles.nodes());
components = aux.connectComponents(cy, options.eles, topMostNodes);
// store component centers
components.forEach(function(component){
let boundingBox = component.boundingBox();
componentCenters.push({x: boundingBox.x1 + boundingBox.w / 2, y: boundingBox.y1 + boundingBox.h / 2});
});
//send each component to spectral layout if randomized
if(options.randomize){
components.forEach(function(component){
options.eles = component;
spectralResult.push(spectralLayout(options));
});
}
if(options.quality == "default" || options.quality == "proof"){
let toBeTiledNodes = cy.collection();
if(options.tile){ // behave nodes to be tiled as one component
let nodeIndexes = new Map();
let xCoords = [];
let yCoords = [];
let count = 0;
let tempSpectralResult = {nodeIndexes: nodeIndexes, xCoords: xCoords, yCoords: yCoords};
let indexesToBeDeleted = [];
components.forEach(function(component, index){
if(component.edges().length == 0){
component.nodes().forEach(function(node, i){
toBeTiledNodes.merge(component.nodes()[i]);
if(!node.isParent()){
tempSpectralResult.nodeIndexes.set(component.nodes()[i].id(), count++);
tempSpectralResult.xCoords.push(component.nodes()[0].position().x);
tempSpectralResult.yCoords.push(component.nodes()[0].position().y);
}
});
indexesToBeDeleted.push(index);
}
});
if(toBeTiledNodes.length > 1){
let boundingBox = toBeTiledNodes.boundingBox();
componentCenters.push({x: boundingBox.x1 + boundingBox.w / 2, y: boundingBox.y1 + boundingBox.h / 2});
components.push(toBeTiledNodes);
spectralResult.push(tempSpectralResult);
for(let i = indexesToBeDeleted.length-1; i >= 0; i--){
components.splice(indexesToBeDeleted[i], 1);
spectralResult.splice(indexesToBeDeleted[i], 1);
componentCenters.splice(indexesToBeDeleted[i], 1);
};
}
}
components.forEach(function(component, index){ // send each component to cose layout
options.eles = component;
coseResult.push(coseLayout(options, spectralResult[index]));
aux.relocateComponent(componentCenters[index], coseResult[index], options); // relocate center to original position
});
}
else {
components.forEach(function(component, index){
aux.relocateComponent(componentCenters[index], spectralResult[index], options); // relocate center to original position
});
}
// packing
let componentsEvaluated = new Set();
if(components.length > 1){
let subgraphs = [];
let hiddenEles = eles.filter((ele) => {return ele.css('display') == 'none'});
components.forEach(function(component, index){
let nodeIndexes;
if(options.quality == "draft"){
nodeIndexes = spectralResult[index].nodeIndexes;
}
if(component.nodes().not(hiddenEles).length > 0) {
let subgraph = {};
subgraph.edges = [];
subgraph.nodes = [];
let nodeIndex;
component.nodes().not(hiddenEles).forEach(function (node) {
if(options.quality == "draft"){
if(!node.isParent()){
nodeIndex = nodeIndexes.get(node.id());
subgraph.nodes.push({x: spectralResult[index].xCoords[nodeIndex] - node.boundingbox().w/2, y: spectralResult[index].yCoords[nodeIndex] - node.boundingbox().h/2, width: node.boundingbox().w, height: node.boundingbox().h});
}
else{
let parentInfo = aux.calcBoundingBox(node, spectralResult[index].xCoords, spectralResult[index].yCoords, nodeIndexes);
subgraph.nodes.push({x: parentInfo.topLeftX, y: parentInfo.topLeftY, width: parentInfo.width, height: parentInfo.height});
}
}
else{
if(coseResult[index][node.id()]) {
subgraph.nodes.push({x: coseResult[index][node.id()].getLeft(), y: coseResult[index][node.id()].getTop(), width: coseResult[index][node.id()].getWidth(), height: coseResult[index][node.id()].getHeight()});
}
}
});
component.edges().forEach(function (edge) {
let source = edge.source();
let target = edge.target();
if(source.css("display") != "none" && target.css("display") != "none") {
if(options.quality == "draft"){
let sourceNodeIndex = nodeIndexes.get(source.id());
let targetNodeIndex = nodeIndexes.get(target.id());
let sourceCenter = [];
let targetCenter = [];
if(source.isParent()){
let parentInfo = aux.calcBoundingBox(source, spectralResult[index].xCoords, spectralResult[index].yCoords, nodeIndexes);
sourceCenter.push(parentInfo.topLeftX + parentInfo.width / 2);
sourceCenter.push(parentInfo.topLeftY + parentInfo.height / 2);
}
else{
sourceCenter.push(spectralResult[index].xCoords[sourceNodeIndex]);
sourceCenter.push(spectralResult[index].yCoords[sourceNodeIndex]);
}
if(target.isParent()){
let parentInfo = aux.calcBoundingBox(target, spectralResult[index].xCoords, spectralResult[index].yCoords, nodeIndexes);
targetCenter.push(parentInfo.topLeftX + parentInfo.width / 2);
targetCenter.push(parentInfo.topLeftY + parentInfo.height / 2);
}
else{
targetCenter.push(spectralResult[index].xCoords[targetNodeIndex]);
targetCenter.push(spectralResult[index].yCoords[targetNodeIndex]);
}
subgraph.edges.push({startX: sourceCenter[0], startY: sourceCenter[1], endX: targetCenter[0], endY: targetCenter[1]});
}
else{
if(coseResult[index][source.id()] && coseResult[index][target.id()]) {
subgraph.edges.push({startX: coseResult[index][source.id()].getCenterX(), startY: coseResult[index][source.id()].getCenterY(), endX: coseResult[index][target.id()].getCenterX(), endY: coseResult[index][target.id()].getCenterY()});
}
}
}
});
if(subgraph.nodes.length > 0) {
subgraphs.push(subgraph);
componentsEvaluated.add(index);
}
}
});
let shiftResult = layUtil.packComponents(subgraphs, options.randomize).shifts;
if(options.quality == "draft"){
spectralResult.forEach(function(result, index){
let newXCoords = result.xCoords.map(x => x + shiftResult[index].dx);
let newYCoords = result.yCoords.map(y => y + shiftResult[index].dy);
result.xCoords = newXCoords;
result.yCoords = newYCoords;
});
}
else{
let count = 0;
componentsEvaluated.forEach((index) => {
Object.keys(coseResult[index]).forEach(function (item) {
let nodeRectangle = coseResult[index][item];
nodeRectangle.setCenter(nodeRectangle.getCenterX() + shiftResult[count].dx, nodeRectangle.getCenterY() + shiftResult[count].dy);
});
count++;
})
}
}
}
}
// get each element's calculated position
let getPositions = function(ele, i ){
if(options.quality == "default" || options.quality == "proof") {
if(typeof ele === "number") {
ele = i;
}
let pos;
let node;
let theId = ele.data('id');
coseResult.forEach(function(result){
if (theId in result){
pos = {x: result[theId].getRect().getCenterX(), y: result[theId].getRect().getCenterY()};
node = result[theId];
}
});
if(options.nodeDimensionsIncludeLabels){
if(node.labelWidth){
if(node.labelPosHorizontal == "left"){
pos.x += node.labelWidth/2;
}
else if(node.labelPosHorizontal == "right"){
pos.x -= node.labelWidth/2;
}
}
if(node.labelHeight){
if(node.labelPosVertical == "top"){
pos.y += node.labelHeight/2;
}
else if(node.labelPosVertical == "bottom"){
pos.y -= node.labelHeight/2;
}
}
}
if(pos == undefined)
pos = {x: ele.position("x"), y: ele.position("y")};
return {
x: pos.x,
y: pos.y
};
}
else{
let pos;
spectralResult.forEach(function(result){
let index = result.nodeIndexes.get(ele.id());
if(index != undefined){
pos = {x: result.xCoords[index], y: result.yCoords[index]};
}
});
if(pos == undefined)
pos = {x: ele.position("x"), y: ele.position("y")};
return {
x: pos.x,
y: pos.y
};
}
};
// quality = "draft" and randomize = false are contradictive so in that case positions don't change
if(options.quality == "default" || options.quality == "proof" || options.randomize) {
// transfer calculated positions to nodes (positions of only simple nodes are evaluated, compounds are positioned automatically)
let parentsWithoutChildren = aux.calcParentsWithoutChildren(cy, eles);
let hiddenEles = eles.filter((ele) => {return ele.css('display') == 'none'});
options.eles = eles.not(hiddenEles);
eles.nodes().not(":parent").not(hiddenEles).layoutPositions(layout, options, getPositions);
if(parentsWithoutChildren.length > 0){
parentsWithoutChildren.forEach((ele) => {
ele.position(getPositions(ele));
});
}
}
else{
console.log("If randomize option is set to false, then quality option must be 'default' or 'proof'.");
}
}
}
module.exports = Layout;