cytoscape-tidytree
Version:
Cytoscape.js layout extension for positioning trees
146 lines (145 loc) • 6.67 kB
JavaScript
import { Layout } from "./alg/layout.js";
class DefaultOptions {
//** Needed to for the layout to be called from cytoscape */
name = "tidytree";
/**
* Specific layout options
*/
dataOnly = false; // when enabled, nodes' positions aren't set
horizontalSpacing = 20; // the width of the space between nodes in cytoscape units
verticalSpacing = 40; // the height of the space between parent and child in cytoscape units
direction = "TB"; // the direction of the tree, left to right, right to left, top to bottom, bottom to top
// a map from node's id to how much space should be added between it and its parent
extraVerticalSpacings = {};
// a map from node's id to how much space should be added for the node to have this y position
// overrides extraVerticalSpacings if both are set for a particular node
// if the y position would result in the child not being below the parent, the setting is ignored and a warning is printed
customYs = {};
// the width of the space left after a node is moved down
lineWidth = 5;
// forces nodes to be positioned on multiples of this value if set
layerHeight = undefined;
// a sorting function for the children array of the tree representation
// if undefined, the order is based on the order of the collection the layout was called on
edgeComparator = undefined;
// when not changed, the width and height of each node is read directly from the node
// this parameter allows to supply your own sizes
// if the h or w property is missing from the returned object, it is taken from the node
sizeGetter = () => ({});
/**
* Layout options passed to nodes.layoutPositions()
* https://js.cytoscape.org/#nodes.layoutPositions
*/
fit = true; // if true, fits the viewport to the graph
padding = 30; // the padding between the viewport and the graph on fit
pan = undefined; // pan to a specified position, ignored if fit is enabled
zoom = undefined; // how much to zoom the viewport, ignored if fit is enabled
// a positive value which adjusts spacing between nodes (>1 means greater than usual spacing)
spacingFactor = 1;
// allows to transform a given node's position before it is applied
transform = (n, p) => p;
animate = false; // animate the layout`s changes
animationDuration = 500; // duration of the animation in ms
animationEasing = undefined; // easing of animation
// returns true for nodes that should be animated, or false when the position should be set immediately
animateFilter = () => true;
ready = undefined; // callback for the start of the layout
stop = undefined; // callback for the layout`s finish
/**
* Layout options passed to nodes.node.layoutDimensions()
* https://js.cytoscape.org/#node.layoutDimensions
*/
nodeDimensionsIncludeLabels = true; // if overflowing labels shoud count in the width or height of the node
}
// using ES5 function "class" instead of ES6 class because of cytoscape calling CyLayout.call(this) internally
export function CyLayout(options) {
this.options = {
...new DefaultOptions(),
...options,
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
CyLayout.prototype.createTreeData = function () {
const includeLabels = this.options.nodeDimensionsIncludeLabels ?? true;
const eles = this.options.eles;
const ys = this.options.customYs;
const vertSpaces = this.options.extraVerticalSpacings;
const roots = new Set();
for (const node of eles.nodes()) {
const dims = {
...node.layoutDimensions({ nodeDimensionsIncludeLabels: includeLabels }),
...this.options.sizeGetter(node)
};
if (this.options.direction === "LR" || this.options.direction === "RL") {
[dims.w, dims.h] = [dims.h, dims.w];
}
const data = {
id: node.id(),
w: dims.w,
h: dims.h,
children: [],
extraVerticalSpacing: vertSpaces[node.id()],
customY: ys[node.id()] === undefined ? undefined : ys[node.id()] - dims.h / 2,
};
node.scratch("tidytree", data);
roots.add(data);
}
const comp = this.options.edgeComparator;
const edges = comp === undefined ? eles.edges() : eles.edges().sort(comp);
for (const edge of edges) {
const sourceData = edge.source().scratch("tidytree");
const targetData = edge.target().scratch("tidytree");
// ignore the edge if target already has a parent
if (roots.has(targetData)) {
sourceData.children.push(targetData);
roots.delete(targetData);
}
}
// if there are no roots, choose the first node as the root
if (roots.size === 0) {
const fakeRoot = eles.nodes().first();
const rootData = fakeRoot.scratch("tidytree");
roots.add(rootData);
// remove root from its parent's children list
for (const parent of fakeRoot.incomers("node")) {
const sourceData = parent.scratch("tidytree");
const i = sourceData.children.indexOf(rootData);
if (i !== -1) {
sourceData.children.splice(i, 1);
}
}
}
const newRoot = {
w: 0,
h: 0,
children: Array.from(roots),
customY: Math.min(-this.options.verticalSpacing, -(this.options.layerHeight ?? 0))
};
return newRoot;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
CyLayout.prototype.run = function () {
const treeData = this.createTreeData();
const tree = new Layout(this.options).run(treeData);
const nodes = this.options.eles.nodes();
if (!this.options.dataOnly) {
// casts because of wrong types in @types/cytoscape
// - first argument should be the layout object, not string
// - LayoutPositionOptions' "ready" and "stop" properties should be callback functions, not undefined
nodes.layoutPositions(this, this.options, (node) => {
const data = node.scratch("tidytree");
const pos = { x: data.x + data.w / 2, y: data.y + data.h / 2 };
if (this.options.direction === "LR" || this.options.direction === "RL") {
[pos.x, pos.y] = [pos.y, pos.x];
}
if (this.options.direction === "BT") {
pos.y = -pos.y;
}
if (this.options.direction === "RL") {
pos.x = -pos.x;
}
return pos;
});
}
return { treeData: treeData, tree: tree };
};