auspice
Version:
Web app for visualizing pathogen evolution
657 lines (606 loc) • 25.3 kB
text/typescript
/* eslint-disable no-multi-spaces */
/* eslint-disable space-infix-ops */
import { scaleLinear, ScalePoint, scalePoint } from "d3-scale";
import { timerStart, timerEnd } from "../../../util/perf";
import { getTraitFromNode, getDivFromNode } from "../../../util/treeMiscHelpers";
import { stemParent, nodeOrdering } from "./helpers";
import { numDate } from "../../../util/colorHelpers";
import { Layout, ScatterVariables } from "../../../reducers/controls";
import { ReduxNode, colorBySymbol } from "../../../reducers/tree/types";
import { Distance, Params, PhyloNode, PhyloTreeType, Ripple } from "./types";
/**
* assigns the attribute this.layout and calls the function that
* calculates the x,y coordinates for the respective layouts
*/
export const setLayout = function setLayout(
this: PhyloTreeType,
layout?: Layout,
scatterVariables?: ScatterVariables,
): void {
// console.log("set layout");
timerStart("setLayout");
if (typeof layout === "undefined" || layout !== this.layout) {
this.nodes.forEach((d) => {d.update = true;});
}
if (typeof layout === "undefined") {
this.layout = "rect";
} else {
this.layout = layout;
}
// remove any regression. This will be recalculated if required.
this.regression = undefined;
// assign scatterVariables, needed for clock / scatter layouts.
// P.S. we overwrite the x & y axis for clock views _only_ within PhyloTree. This allows
// the scatterplot variables to be remembered while viewing other layouts
if (scatterVariables) this.scatterVariables = {...scatterVariables};
if (this.layout === "clock") {
this.scatterVariables.x="num_date";
this.scatterVariables.y="div";
}
if (this.layout === "rect") {
this.rectangularLayout();
} else if (this.layout === "clock" || this.layout === "scatter") {
this.scatterplotLayout();
} else if (this.layout === "radial") {
this.radialLayout();
} else if (this.layout === "unrooted") {
this.unrootedLayout();
}
timerEnd("setLayout");
};
/**
* assignes x,y coordinates for a rectangular layout
*/
export const rectangularLayout = function rectangularLayout(this: PhyloTreeType): void {
this.nodes.forEach((d) => {
d.y = d.displayOrder; // precomputed y-values
d.x = d.depth; // depth according to current distance
d.px = d.pDepth; // parent positions
d.py = d.y;
// d.x_conf = d.conf; // assign confidence intervals
});
if (this.vaccines) {
this.vaccines.forEach((d) => {
d.xCross = d.crossDepth;
d.yCross = d.y;
});
}
};
/**
* assign x,y coordinates for nodes based upon user-selected variables
* TODO: timeVsRootToTip is a specific instance of this
*/
export const scatterplotLayout = function scatterplotLayout(this: PhyloTreeType): void {
if (!this.scatterVariables) {
console.error("Scatterplot called without variables");
return;
}
const getDisplayOrderPair = (this.scatterVariables.x==="displayOrder" || this.scatterVariables.y==="displayOrder") ?
nodeOrdering(this.nodes) :
undefined;
for (const d of this.nodes) {
// set x and parent X values
if (this.scatterVariables.x==="div") {
d.x = getDivFromNode(d.n);
d.px = getDivFromNode(stemParent(d.n));
} else if (this.scatterVariables.x==="gt") {
d.x = d.n.currentGt;
d.px = stemParent(d.n).currentGt;
} else if (this.scatterVariables.x==="displayOrder") {
[d.x, d.px] = getDisplayOrderPair(d);
} else {
d.x = getTraitFromNode(d.n, this.scatterVariables.x);
d.px = getTraitFromNode(stemParent(d.n), this.scatterVariables.x);
if (this.scatterVariables.xTemporal) {
[d.x, d.px] = [numDate(d.x), numDate(d.px)]
}
}
// set y and parent values
if (this.scatterVariables.y==="div") {
d.y = getDivFromNode(d.n);
d.py = getDivFromNode(stemParent(d.n));
} else if (this.scatterVariables.y==="gt") {
d.y = d.n.currentGt;
d.py = stemParent(d.n).currentGt;
} else if (this.scatterVariables.y==="displayOrder") {
[d.y, d.py] = getDisplayOrderPair(d);
} else {
d.y = getTraitFromNode(d.n, this.scatterVariables.y);
d.py = getTraitFromNode(stemParent(d.n), this.scatterVariables.y);
if (this.scatterVariables.yTemporal) {
[d.y, d.py] = [numDate(d.y), numDate(d.py)]
}
}
}
if (this.vaccines) { /* overlay vaccine cross on tip */
this.vaccines.forEach((d) => {
d.xCross = d.x;
d.yCross = d.y;
});
}
if (this.scatterVariables.showRegression) {
this.calculateRegression(); // sets this.regression
}
};
/**
* Utility function for the unrooted tree layout. See `unrootedLayout` for details.
*/
const unrootedPlaceSubtree = (
node: PhyloNode,
totalLeafWeight: number,
): void => {
const branchLength = node.depth - node.pDepth;
node.x = node.px + branchLength * Math.cos(node.tau + node.w * 0.5);
node.y = node.py + branchLength * Math.sin(node.tau + node.w * 0.5);
let eta = node.tau; // eta is the cumulative angle for the wedges in the layout
if (node.n.hasChildren) {
for (let i = 0; i < node.n.children.length; i++) {
const ch = node.n.children[i].shell;
ch.w = 2 * Math.PI * leafWeight(ch.n) / totalLeafWeight;
ch.tau = eta;
eta += ch.w;
ch.px = node.x;
ch.py = node.y;
unrootedPlaceSubtree(ch, totalLeafWeight);
}
}
};
// TODO
// can't use the .child approach, must use parent stem function
// check internal nodes with 1 child don't increase .w
/**
* calculates x,y coordinates for the unrooted layout. this is
* done recursively via a the function unrootedPlaceSubtree
*/
export const unrootedLayout = function unrootedLayout(this: PhyloTreeType): void {
/* the angle of a branch (i.e. the line leading to the node) is `tau + 0.5*w`
`tau` stores the previous angle which has been used
`w` is a measurement of the angle occupied by the clade defined by this node
`eta` is a temporary variable of `tau` + the `w` of each child visited thus far
Note 1: we don't consider this.nodes[0] as that's the (unrendered)
root which holds the subtrees. We instead start by defining the values
for each subtree's root, which will be used by the children of that root
Note 2: Angles will start from `eta` as defined below, and then cover ~2*Pi radians
*/
const totalLeafWeight = leafWeight(this.nodes[0].n);
let eta = 1.5 * Math.PI;
const children = this.nodes[0].n.children; // <Node>
this.nodes.forEach((d) => { // this shouldn't be necessary
d.x = undefined;
d.y = undefined;
d.px = undefined;
d.py = undefined;
});
for (let i = 0; i < children.length; i++) {
const d = children[i].shell; // <PhyloNode>
d.w = 2.0 * Math.PI * leafWeight(d.n) / totalLeafWeight; // angle occupied by entire subtree
if (d.w>0) { // i.e. subtree has tips which should be drawn
const distFromOrigin = d.depth - this.nodes[0].depth;
d.px = distFromOrigin * Math.cos(eta + d.w * 0.5);
d.py = distFromOrigin * Math.sin(eta + d.w * 0.5);
d.tau = eta;
unrootedPlaceSubtree(d, totalLeafWeight);
eta += d.w;
}
}
if (this.vaccines) {
this.vaccines.forEach((d) => {
const bL = d.crossDepth - d.depth;
d.xCross = d.px + bL * Math.cos(d.tau + d.w * 0.5);
d.yCross = d.py + bL * Math.sin(d.tau + d.w * 0.5);
});
}
this.nodes.forEach((d) => { // remove properties which otherwise build up over time
delete d.tau;
delete d.w;
});
};
/**
* calculates and assigns x,y coordinates for the radial layout.
* in addition to x,y, this calculates the end-points of the radial
* arcs and whether that arc is more than pi or not
*/
export const radialLayout = function radialLayout(this: PhyloTreeType): void {
const maxDisplayOrder = Math.max(...this.nodes.map((d) => d.displayOrder).filter((val) => val));
const offset = this.nodes[0].depth;
this.nodes.forEach((d) => {
const angleCBar1 = 2.0 * 0.95 * Math.PI * d.displayOrderRange[0] / maxDisplayOrder;
const angleCBar2 = 2.0 * 0.95 * Math.PI * d.displayOrderRange[1] / maxDisplayOrder;
d.angle = 2.0 * 0.95 * Math.PI * d.displayOrder / maxDisplayOrder;
d.y = (d.depth - offset) * Math.cos(d.angle);
d.x = (d.depth - offset) * Math.sin(d.angle);
d.py = d.y * (d.pDepth - offset) / (d.depth - offset + 1e-15);
d.px = d.x * (d.pDepth - offset) / (d.depth - offset + 1e-15);
d.yCBarStart = (d.depth - offset) * Math.cos(angleCBar1);
d.xCBarStart = (d.depth - offset) * Math.sin(angleCBar1);
d.yCBarEnd = (d.depth - offset) * Math.cos(angleCBar2);
d.xCBarEnd = (d.depth - offset) * Math.sin(angleCBar2);
d.smallBigArc = Math.abs(angleCBar2 - angleCBar1) > Math.PI * 1.0;
});
if (this.vaccines) {
this.vaccines.forEach((d) => {
if (this.distance === "div") {
d.xCross = d.x;
d.yCross = d.y;
} else {
d.xCross = (d.crossDepth - offset) * Math.sin(d.angle);
d.yCross = (d.crossDepth - offset) * Math.cos(d.angle);
}
});
}
};
/**
* set the property that is used as distance along branches
* this is set to "depth" of each node. depth is later used to
* calculate coordinates. Parent depth is assigned as well.
* @sideEffect sets this.distance -> "div" or "num_date"
*/
export const setDistance = function setDistance(
this: PhyloTreeType,
distanceAttribute?: Distance,
): void {
timerStart("setDistance");
this.nodes.forEach((d) => {d.update = true;});
if (distanceAttribute) {
this.distance = distanceAttribute;
}
if (!["div", "num_date"].includes(this.distance)) {
console.error("Tree distance measure not set or invalid. Using `div`.");
this.distance = "div"; // fallback to div
}
// todo - can the following loops be skipped for scatterplots?
// assign node and parent depth
if (this.distance === "div") {
this.nodes.forEach((d) => {
d.depth = getDivFromNode(d.n);
d.pDepth = getDivFromNode(stemParent(d.n));
d.conf = [d.depth, d.depth]; // TO DO - shouldn't be needed, never have div confidence...
});
} else {
this.nodes.forEach((d) => {
d.depth = getTraitFromNode(d.n, "num_date");
d.pDepth = getTraitFromNode(stemParent(d.n), "num_date");
d.conf = getTraitFromNode(d.n, "num_date", {confidence: true}) || [d.depth, d.depth];
});
}
if (this.vaccines) {
this.vaccines.forEach((d) => {
d.crossDepth = d.depth;
});
}
timerEnd("setDistance");
};
/**
* Initializes and sets the range of the scales (this.xScale, this.yScale)
* which are used to map the x,y coordinates to the screen
*/
export const setScales = function setScales(this: PhyloTreeType): void {
if (this.layout==="scatter" && !this.scatterVariables.xContinuous) {
this.xScale = scalePoint().round(false).align(0.5).padding(0.5);
} else {
this.xScale = scaleLinear();
}
if (this.layout==="scatter" && !this.scatterVariables.yContinuous) {
this.yScale = scalePoint().round(false).align(0.5).padding(0.5);
} else {
this.yScale = scaleLinear();
}
// TODO: access these from d3treeParent so they don't have to be set twice
const width = parseInt(this.svg.attr("width"), 10);
const height = parseInt(this.svg.attr("height"), 10);
if (this.layout === "radial" || this.layout === "unrooted") {
// Force Square: TODO, harmonize with the map to screen
const xExtend = width - this.margins.left - this.margins.right;
const yExtend = height - this.margins.bottom - this.margins.top;
const minExtend = Math.min(xExtend, yExtend);
const xSlack = xExtend - minExtend;
const ySlack = yExtend - minExtend;
this.xScale.range([0.5 * xSlack + this.margins.left, width - 0.5 * xSlack - this.margins.right]);
this.yScale.range([0.5 * ySlack + this.margins.top, height - 0.5 * ySlack - this.margins.bottom]);
} else {
// for rectangular layout, allow flipping orientation of left/right and top/bottom
if (this.params.orientation[0] > 0) {
this.xScale.range([this.margins.left, width - this.margins.right]);
} else {
this.xScale.range([width - this.margins.right, this.margins.left]);
}
if (this.params.orientation[1] > 0) {
this.yScale.range([this.margins.top, height - this.margins.bottom]);
} else {
this.yScale.range([height - this.margins.bottom, this.margins.top]);
}
}
};
/**
* this function sets the xScale, yScale domains and maps precalculated x,y
* coordinates to their places on the screen
*/
export const mapToScreen = function mapToScreen(this: PhyloTreeType): void {
timerStart("mapToScreen");
const inViewTerminalNodes = this.nodes.filter((d) => !d.n.hasChildren).filter((d) => d.inView);
/* set up space (padding) for axes etc, as we don't want the branches & tips to occupy the entire SVG! */
this.margins = {
left: (this.layout==="scatter" || this.layout==="clock") ? 40 : 5, // space for y-axis label
right: 5 + getTipLabelPadding(this.params, inViewTerminalNodes),
top: this.layout==="radial" ? 10 : 15, // avoid tips rendering behind legend
bottom: 35 // space for x-axis labels
};
/* construct & set the range of the x & y scales */
this.setScales();
/* update the clip mask accordingly */
this.setClipMask();
/**
* Select the nodes that we'll use to define the domain of the scales - essentially
* select which nodes we want to use to define the viewport.
*/
let nodesInDomain;
const focusOn = this.focus==='selected' && (this.layout==='rect' || this.layout==='radial');
const focusNodes = this.focusNodes; // these are calculated by redux thunks / reducers
/**
* "focus on selected" limits the axis domains to nodes Auspice will actually render
* Note: nodes marked as `inView` may be off-screen in this mode
*/
if (focusOn && focusNodes) {
nodesInDomain = focusNodes.nodes.map((idx) => this.nodes[idx])
.filter((d) => d.y !== undefined && d.x !== undefined);
} else {
/**
* `inView` nodes are every node which descends from the inViewRootNode - so they include
* nodes which are filtered out, e.g. because they're beyond the selected date range
* This is the "normal" auspice viewport behaviour, or maybe the "old fashioned" behaviour...
*/
nodesInDomain = this.nodes.filter((d) => d.inView && d.y!==undefined && d.x!==undefined);
}
if (this.layout === "scatter" && this.scatterVariables.showBranches === false) {
nodesInDomain = nodesInDomain.filter((d) => !d.n.hasChildren);
}
/* Compute the domains to pass to the d3 scales for the x & y axes */
let xDomain, yDomain, spanX, spanY;
if (this.layout!=="scatter" || this.scatterVariables.xContinuous) {
let [minX, maxX] = [1000000, -100000];
nodesInDomain.forEach((d) => {
if (d.x < minX) minX = d.x;
if (d.x > maxX) maxX = d.x;
});
/* fixes state of 0 length domain */
if (minX === maxX && !(this.params.showStreamTrees && nodesInDomain[0].n.streamName)) {
minX -= 0.005;
maxX += 0.005;
}
/* Don't allow tiny x-axis domains -- e.g. if zoomed into a polytomy where the
divergence values are all tiny, then we don't want to display the tree topology */
const minimumXAxisSpan = 1E-8;
spanX = maxX-minX;
if (spanX < minimumXAxisSpan) {
maxX = minimumXAxisSpan - minX;
spanX = minimumXAxisSpan;
}
/* In rectangular mode, if the tree has been zoomed, leave some room to display the (clade's) root branch */
if (this.layout==="rect" && this.zoomNode.n.arrayIdx!==0) {
minX -= (maxX-minX)/20; // 5%
}
xDomain = [minX, maxX];
} else {
const seenValues = new Set(nodesInDomain.map((d) => d.x));
xDomain = this.scatterVariables.xDomain.filter((v) => seenValues.has(v));
padCategoricalScales(xDomain, this.xScale);
}
if (this.layout!=="scatter" || this.scatterVariables.yContinuous) {
let [minY, maxY] = [1000000, -100000];
nodesInDomain.forEach((d) => {
if (d.y < minY) minY = d.y;
if (d.y > maxY) maxY = d.y;
});
/* slightly pad min and max y to account for small clades */
if (inViewTerminalNodes.length < 30) {
const delta = 0.05 * (maxY - minY);
minY -= delta;
maxY += delta;
}
spanY = maxY-minY;
yDomain = [minY, maxY];
} else {
const seenValues = new Set(nodesInDomain.map((d) => d.y));
yDomain = this.scatterVariables.yDomain.filter((v) => seenValues.has(v));
padCategoricalScales(yDomain, this.yScale);
}
/* Radial / Unrooted layouts need to be square since branch lengths
depend on this */
if (this.layout === "radial" || this.layout === "unrooted") {
const maxSpan = Math.max(spanY, spanX);
const ySlack = (spanX>spanY) ? (spanX-spanY)*0.5 : 0.0;
const xSlack = (spanX<spanY) ? (spanY-spanX)*0.5 : 0.0;
xDomain = [xDomain[0]-xSlack, xDomain[0]+maxSpan-xSlack];
yDomain = [yDomain[0]-ySlack, yDomain[0]+maxSpan-ySlack];
}
/* Clock & Scatter plots flip the yDomain */
if (this.layout === "clock" || this.layout === "scatter") {
yDomain.reverse();
}
/**
* The above approach doesn't include nodes which are in streams and so
* streams may currently be outside the x/y domains
*/
if (this.params.showStreamTrees) {
for (const stream of Object.values(this.streams)) {
const node = this.nodes[stream.startNode];
if (!node.inView) continue;
const pivots = node.n.streamPivots;
if (pivots.at(0) < xDomain[0]) xDomain[0] = pivots.at(0);
if (pivots.at(-1) > xDomain[1]) xDomain[1] = pivots.at(-1);
if (node.displayOrderRange[0] < yDomain[0]) yDomain[0] = node.displayOrderRange[0];
if (node.displayOrderRange[1] > yDomain[1]) yDomain[1] = node.displayOrderRange[1];
}
}
this.xScale.domain(xDomain);
this.yScale.domain(yDomain);
const hiddenYPosition = this.yScale.range()[1] + 100;
const hiddenXPosition = this.xScale.range()[0] - 100;
// pass all x,y through scales and assign to xTip, xBase
this.nodes.forEach((d) => {
d.xTip = this.xScale(d.x);
d.yTip = this.yScale(d.y);
d.xBase = this.xScale(d.px);
d.yBase = this.yScale(d.py);
d.rot = Math.atan2(d.yTip-d.yBase, d.xTip-d.xBase) * 180/Math.PI;
});
// for scatterplots we do an additional iteration as some values may be missing
// & we want to avoid rendering these
if (this.layout==="scatter") {
if (!this.scatterVariables.yContinuous) jitter("y", this.yScale, this.nodes);
if (!this.scatterVariables.xContinuous) jitter("x", this.xScale, this.nodes);
this.nodes.forEach((d) => {
if (isNaN(d.xTip)) d.xTip = hiddenXPosition;
if (isNaN(d.yTip)) d.yTip=hiddenYPosition;
if (isNaN(d.xBase)) d.xBase=hiddenXPosition;
if (isNaN(d.yBase)) d.yBase=hiddenYPosition;
});
}
if (this.vaccines) {
this.vaccines.forEach((d) => {
const n = 6; /* half the number of pixels that the cross will take up */
const xTipCross = this.xScale(d.xCross); /* x position of the center of the cross */
const yTipCross = this.yScale(d.yCross); /* x position of the center of the cross */
d.vaccineCross = ` M ${xTipCross-n},${yTipCross-n} L ${xTipCross+n},${yTipCross+n} M ${xTipCross-n},${yTipCross+n} L ${xTipCross+n},${yTipCross-n}`;
});
}
// assign the branches as path to each node for the different layouts
if (this.layout==="unrooted") {
this.nodes.forEach((d) => {
d.branch = [" M "+d.xBase.toString()+","+d.yBase.toString()+" L "+d.xTip.toString()+","+d.yTip.toString(), ""];
});
} else if (this.layout==="clock" || this.layout==="scatter") {
// if nodes are deliberately obscured (as traits may not be set for some nodes), we don't want to render branches joining that node
if (this.scatterVariables.showBranches) {
this.nodes.forEach((d) => {
d.branch = d.xBase===hiddenXPosition || d.xTip===hiddenXPosition || d.yBase===hiddenYPosition || d.yTip===hiddenYPosition ?
["", ""] :
[" M "+d.xBase.toString()+","+d.yBase.toString()+" L "+d.xTip.toString()+","+d.yTip.toString(), ""];
});
} else {
this.nodes.forEach((d) => {d.branch=["", ""];});
}
} else if (this.layout==="rect") {
this.nodes.forEach((d) => { // d is a <PhyloNode>
const stem_offset = 0.5*(stemParent(d.n).shell["stroke-width"] - d["stroke-width"]) || 0.0;
const stemRange = [this.yScale(d.displayOrderRange[0]), this.yScale(d.displayOrderRange[1])];
// Note that a branch cannot be perfectly horizontal and also have a (linear) gradient applied to it
// So we add a tiny amount of jitter (e.g 1/1000px) to the horizontal line (d.branch[0])
// see https://stackoverflow.com/questions/13223636/svg-gradient-for-perfectly-horizontal-path
d.branch =[
` M ${d.xBase - stem_offset},${d.yBase} L ${d.xTip},${d.yTip+0.01}`,
` M ${d.xTip},${stemRange[0]} L ${d.xTip},${stemRange[1]}`
];
if (this.params.confidence) {
d.confLine =` M ${this.xScale(d.conf[0])},${d.yBase} L ${this.xScale(d.conf[1])},${d.yTip}`;
}
});
} else if (this.layout==="radial") {
const offset = this.nodes[0].depth;
const stem_offset_radial = this.nodes.map((d) => {return (0.5*(stemParent(d.n).shell["stroke-width"] - d["stroke-width"]) || 0.0);});
this.nodes.forEach((d, i) => {
d.branch =[
" M "+(d.xBase-stem_offset_radial[i]*Math.sin(d.angle)).toString() +
" "+(d.yBase-stem_offset_radial[i]*Math.cos(d.angle)).toString() +
" L "+d.xTip.toString()+" "+d.yTip.toString(), ""
];
if (d.n.hasChildren) {
d.branch[1] = " M "+this.xScale(d.xCBarStart).toString()+" "+this.yScale(d.yCBarStart).toString()+
" A "+(this.xScale(d.depth)-this.xScale(offset)).toString()+" "+
(this.yScale(d.depth)-this.yScale(offset)).toString()+
" 0 "+(d.smallBigArc?"1 ":"0 ") +" 1 "+
" "+this.xScale(d.xCBarEnd).toString()+","+this.yScale(d.yCBarEnd).toString();
}
});
}
/* map any streams to pixel space */
if (this.params.showStreamTrees) {
this.mapStreamsToScreen()
}
};
/**
* Maps the pivot space (x) and displayOrderSpace (y) into pixel space for each stream.
*
* Creates `node.streamRipples` on the start node of each stream by transforming the node's `rippleDisplayOrders`
* and `streamPivots` by the d3 scales.
*/
export function mapStreamsToScreen(this: PhyloTreeType): void {
for (const stream of Object.values(this.streams)) {
const node = this.nodes[stream.startNode];
node.streamRipples = node.rippleDisplayOrders.map((displayOrderByPivot, categoryIdx) => {
const datum: Ripple = Object.assign(
[],
displayOrderByPivot.map(([min,max], pivotIdx) => {
return {
x: this.xScale(node.n.streamPivots[pivotIdx]),
y0: this.yScale(min),
y1: this.yScale(max),
}
}),
/* we define a key for d3 to use which allows ribbons to morph as needed and be created/destroyed as needed */
{key: node.n.streamCategories[categoryIdx].name+"_"+this.streams[colorBySymbol]} // aka the name of the ripple
);
return datum;
});
}
}
const JITTER_MIN_STEP_SIZE = 50; // pixels
function padCategoricalScales(
domain: string[],
scale: ScalePoint<string>,
): ScalePoint<string> {
if (scale.step() > JITTER_MIN_STEP_SIZE) return scale.padding(0.5); // balanced padding when we can jitter
if (domain.length<=4) return scale.padding(0.4);
if (domain.length<=6) return scale.padding(0.3);
if (domain.length<=10) return scale.padding(0.2);
return scale.padding(0.1);
}
/**
* Add jitter to the already-computed node positions.
*/
function jitter(
axis: "x" | "y",
scale: ScalePoint<string>,
nodes: PhyloNode[],
): void {
const step = scale.step();
if (scale.step() <= JITTER_MIN_STEP_SIZE) return;
const rand: number[] = []; // pre-compute a small set of pseudo random numbers for speed
for (let i=1e2; i--;) {
rand.push((Math.random()-0.5)*step*0.5); // occupy 50%
}
const [base, tip, randLen] = [`${axis}Base`, `${axis}Tip`, rand.length];
let j = 0;
function recurse(phyloNode: PhyloNode): void {
phyloNode[base] = stemParent(phyloNode.n).shell[tip];
phyloNode[tip] += rand[j++];
if (j>=randLen) j=0;
if (!phyloNode.n.hasChildren) return;
for (const child of phyloNode.n.children) recurse(child.shell);
}
recurse(nodes[0]);
}
function getTipLabelPadding(
params: Params,
inViewTerminalNodes: PhyloNode[],
): number {
let padBy = 0;
if (inViewTerminalNodes.length < params.tipLabelBreakL1) {
let fontSize = params.tipLabelFontSizeL1;
if (inViewTerminalNodes.length < params.tipLabelBreakL2) {
fontSize = params.tipLabelFontSizeL2;
}
if (inViewTerminalNodes.length < params.tipLabelBreakL3) {
fontSize = params.tipLabelFontSizeL3;
}
inViewTerminalNodes.forEach((d) => {
if (padBy < d.n.name.length) {
padBy = 0.65 * d.n.name.length * fontSize;
}
});
}
return padBy;
}
function leafWeight(node: ReduxNode): number {
return node.tipCount + 0.15*(node.fullTipCount-node.tipCount);
}