d3-flextree
Version:
Flexible tree layout algorithm that allows for variable node sizes.
352 lines (331 loc) • 11.2 kB
JavaScript
import {hierarchy} from 'd3-hierarchy';
import packageInfo from '../package.json';
const {version} = packageInfo;
const defaults = Object.freeze({
children: data => data.children,
nodeSize: node => node.data.size,
spacing: 0,
});
// Create a layout function with customizable options. Per D3-style, the
// options can be set at any time using setter methods. The layout function
// will compute the tree node positions based on the options in effect at the
// time it is called.
export default function flextree(options) {
const opts = Object.assign({}, defaults, options);
function accessor(name) {
const opt = opts[name];
return typeof opt === 'function' ? opt : () => opt;
}
function layout(tree) {
const wtree = wrap(getWrapper(), tree, node=>node.children);
wtree.update();
return wtree.data;
}
function getFlexNode() {
const nodeSize = accessor('nodeSize');
const spacing = accessor('spacing');
return class FlexNode extends hierarchy.prototype.constructor {
constructor(data) {
super(data);
}
copy() {
const c = wrap(this.constructor, this, node=>node.children);
c.each(node => node.data = node.data.data);
return c;
}
get size() { return nodeSize(this); }
spacing(oNode) { return spacing(this, oNode); }
get nodes() { return this.descendants(); }
get xSize() { return this.size[0]; }
get ySize() { return this.size[1]; }
get top() { return this.y; }
get bottom() { return this.y + this.ySize; }
get left() { return this.x - this.xSize / 2; }
get right() { return this.x + this.xSize / 2; }
get root() {
const ancs = this.ancestors();
return ancs[ancs.length - 1];
}
get numChildren() {
return this.hasChildren ? this.children.length : 0;
}
get hasChildren() { return !this.noChildren; }
get noChildren() { return this.children === null; }
get firstChild() {
return this.hasChildren ? this.children[0] : null;
}
get lastChild() {
return this.hasChildren ? this.children[this.numChildren - 1] : null;
}
get extents() {
return (this.children || []).reduce(
(acc, kid) => FlexNode.maxExtents(acc, kid.extents),
this.nodeExtents);
}
get nodeExtents() {
return {
top: this.top,
bottom: this.bottom,
left: this.left,
right: this.right,
};
}
static maxExtents(e0, e1) {
return {
top: Math.min(e0.top, e1.top),
bottom: Math.max(e0.bottom, e1.bottom),
left: Math.min(e0.left, e1.left),
right: Math.max(e0.right, e1.right),
};
}
};
}
function getWrapper() {
const FlexNode = getFlexNode();
const nodeSize = accessor('nodeSize');
const spacing = accessor('spacing');
return class extends FlexNode {
constructor(data) {
super(data);
Object.assign(this, {
x: 0, y: 0,
relX: 0, prelim: 0, shift: 0, change: 0,
lExt: this, lExtRelX: 0, lThr: null,
rExt: this, rExtRelX: 0, rThr: null,
});
}
get size() { return nodeSize(this.data); }
spacing(oNode) { return spacing(this.data, oNode.data); }
get x() { return this.data.x; }
set x(v) { this.data.x = v; }
get y() { return this.data.y; }
set y(v) { this.data.y = v; }
update() {
layoutChildren(this);
resolveX(this);
return this;
}
};
}
function wrap(FlexClass, treeData, children) {
const _wrap = (data, parent) => {
const node = new FlexClass(data);
Object.assign(node, {
parent,
depth: parent === null ? 0 : parent.depth + 1,
height: 0,
length: 1,
});
const kidsData = children(data) || [];
node.children = kidsData.length === 0 ? null
: kidsData.map(kd => _wrap(kd, node));
if (node.children) {
Object.assign(node, node.children.reduce(
(hl, kid) => ({
height: Math.max(hl.height, kid.height + 1),
length: hl.length + kid.length,
}), node
));
}
return node;
};
return _wrap(treeData, null);
}
Object.assign(layout, {
nodeSize(arg) {
return arguments.length ? (opts.nodeSize = arg, layout) : opts.nodeSize;
},
spacing(arg) {
return arguments.length ? (opts.spacing = arg, layout) : opts.spacing;
},
children(arg) {
return arguments.length ? (opts.children = arg, layout) : opts.children;
},
hierarchy(treeData, children) {
const kids = typeof children === 'undefined' ? opts.children : children;
return wrap(getFlexNode(), treeData, kids);
},
dump(tree) {
const nodeSize = accessor('nodeSize');
const _dump = i0 => node => {
const i1 = i0 + ' ';
const i2 = i0 + ' ';
const {x, y} = node;
const size = nodeSize(node);
const kids = (node.children || []);
const kdumps = (kids.length === 0) ? ' ' :
`,${i1}children: [${i2}${kids.map(_dump(i2)).join(i2)}${i1}],${i0}`;
return `{ size: [${size.join(', ')}],${i1}x: ${x}, y: ${y}${kdumps}},`;
};
return _dump('\n')(tree);
},
});
return layout;
}
flextree.version = version;
const layoutChildren = (w, y = 0) => {
w.y = y;
(w.children || []).reduce((acc, kid) => {
const [i, lastLows] = acc;
layoutChildren(kid, w.y + w.ySize);
// The lowest vertical coordinate while extreme nodes still point
// in current subtree.
const lowY = (i === 0 ? kid.lExt : kid.rExt).bottom;
if (i !== 0) separate(w, i, lastLows);
const lows = updateLows(lowY, i, lastLows);
return [i + 1, lows];
}, [0, null]);
shiftChange(w);
positionRoot(w);
return w;
};
// Resolves the relative coordinate properties - relX and prelim --
// to set the final, absolute x coordinate for each node. This also sets
// `prelim` to 0, so that `relX` for each node is its x-coordinate relative
// to its parent.
const resolveX = (w, prevSum, parentX) => {
// A call to resolveX without arguments is assumed to be for the root of
// the tree. This will set the root's x-coord to zero.
if (typeof prevSum === 'undefined') {
prevSum = -w.relX - w.prelim;
parentX = 0;
}
const sum = prevSum + w.relX;
w.relX = sum + w.prelim - parentX;
w.prelim = 0;
w.x = parentX + w.relX;
(w.children || []).forEach(k => resolveX(k, sum, w.x));
return w;
};
// Process shift and change for all children, to add intermediate spacing to
// each child's modifier.
const shiftChange = w => {
(w.children || []).reduce((acc, child) => {
const [lastShiftSum, lastChangeSum] = acc;
const shiftSum = lastShiftSum + child.shift;
const changeSum = lastChangeSum + shiftSum + child.change;
child.relX += changeSum;
return [shiftSum, changeSum];
}, [0, 0]);
};
// Separates the latest child from its previous sibling
/* eslint-disable complexity */
const separate = (w, i, lows) => {
const lSib = w.children[i - 1];
const curSubtree = w.children[i];
let rContour = lSib;
let rSumMods = lSib.relX;
let lContour = curSubtree;
let lSumMods = curSubtree.relX;
let isFirst = true;
while (rContour && lContour) {
if (rContour.bottom > lows.lowY) lows = lows.next;
// How far to the left of the right side of rContour is the left side
// of lContour? First compute the center-to-center distance, then add
// the "spacing"
const dist =
(rSumMods + rContour.prelim) - (lSumMods + lContour.prelim) +
rContour.xSize / 2 + lContour.xSize / 2 +
rContour.spacing(lContour);
if (dist > 0 || (dist < 0 && isFirst)) {
lSumMods += dist;
// Move subtree by changing relX.
moveSubtree(curSubtree, dist);
distributeExtra(w, i, lows.index, dist);
}
isFirst = false;
// Advance highest node(s) and sum(s) of modifiers
const rightBottom = rContour.bottom;
const leftBottom = lContour.bottom;
if (rightBottom <= leftBottom) {
rContour = nextRContour(rContour);
if (rContour) rSumMods += rContour.relX;
}
if (rightBottom >= leftBottom) {
lContour = nextLContour(lContour);
if (lContour) lSumMods += lContour.relX;
}
}
// Set threads and update extreme nodes. In the first case, the
// current subtree is taller than the left siblings.
if (!rContour && lContour) setLThr(w, i, lContour, lSumMods);
// In the next case, the left siblings are taller than the current subtree
else if (rContour && !lContour) setRThr(w, i, rContour, rSumMods);
};
/* eslint-enable complexity */
// Move subtree by changing relX.
const moveSubtree = (subtree, distance) => {
subtree.relX += distance;
subtree.lExtRelX += distance;
subtree.rExtRelX += distance;
};
const distributeExtra = (w, curSubtreeI, leftSibI, dist) => {
const curSubtree = w.children[curSubtreeI];
const n = curSubtreeI - leftSibI;
// Are there intermediate children?
if (n > 1) {
const delta = dist / n;
w.children[leftSibI + 1].shift += delta;
curSubtree.shift -= delta;
curSubtree.change -= dist - delta;
}
};
const nextLContour = w => {
return w.hasChildren ? w.firstChild : w.lThr;
};
const nextRContour = w => {
return w.hasChildren ? w.lastChild : w.rThr;
};
const setLThr = (w, i, lContour, lSumMods) => {
const firstChild = w.firstChild;
const lExt = firstChild.lExt;
const curSubtree = w.children[i];
lExt.lThr = lContour;
// Change relX so that the sum of modifier after following thread is correct.
const diff = lSumMods - lContour.relX - firstChild.lExtRelX;
lExt.relX += diff;
// Change preliminary x coordinate so that the node does not move.
lExt.prelim -= diff;
// Update extreme node and its sum of modifiers.
firstChild.lExt = curSubtree.lExt;
firstChild.lExtRelX = curSubtree.lExtRelX;
};
// Mirror image of setLThr.
const setRThr = (w, i, rContour, rSumMods) => {
const curSubtree = w.children[i];
const rExt = curSubtree.rExt;
const lSib = w.children[i - 1];
rExt.rThr = rContour;
const diff = rSumMods - rContour.relX - curSubtree.rExtRelX;
rExt.relX += diff;
rExt.prelim -= diff;
curSubtree.rExt = lSib.rExt;
curSubtree.rExtRelX = lSib.rExtRelX;
};
// Position root between children, taking into account their modifiers
const positionRoot = w => {
if (w.hasChildren) {
const k0 = w.firstChild;
const kf = w.lastChild;
const prelim = (k0.prelim + k0.relX - k0.xSize / 2 +
kf.relX + kf.prelim + kf.xSize / 2 ) / 2;
Object.assign(w, {
prelim,
lExt: k0.lExt, lExtRelX: k0.lExtRelX,
rExt: kf.rExt, rExtRelX: kf.rExtRelX,
});
}
};
// Make/maintain a linked list of the indexes of left siblings and their
// lowest vertical coordinate.
const updateLows = (lowY, index, lastLows) => {
// Remove siblings that are hidden by the new subtree.
while (lastLows !== null && lowY >= lastLows.lowY)
lastLows = lastLows.next;
// Prepend the new subtree.
return {
lowY,
index,
next: lastLows,
};
};