auspice
Version:
Web app for visualizing pathogen evolution
400 lines (372 loc) • 14.9 kB
JavaScript
/* eslint-disable no-multi-spaces */
/* eslint-disable space-infix-ops */
import { min, max, sum } from "d3-array";
import { addLeafCount } from "./helpers";
import { timerStart, timerEnd } from "../../../util/perf";
import { getTraitFromNode, getDivFromNode } from "../../../util/treeMiscHelpers";
/**
* assigns the attribute this.layout and calls the function that
* calculates the x,y coordinates for the respective layouts
* @param layout -- the layout to be used, has to be one of
* ["rect", "radial", "unrooted", "clock"]
*/
export const setLayout = function setLayout(layout) {
// 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;
}
if (this.layout === "rect") {
this.rectangularLayout();
} else if (this.layout === "clock") {
this.timeVsRootToTip();
} else if (this.layout === "radial") {
this.radialLayout();
} else if (this.layout === "unrooted") {
this.unrootedLayout();
}
timerEnd("setLayout");
};
/**
* assignes x,y coordinates for a rectancular layout
* @return {null}
*/
export const rectangularLayout = function rectangularLayout() {
this.nodes.forEach((d) => {
d.y = d.n.yvalue; // 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 fro the root-to-tip regression layout
* this requires a time tree with `num_date` info set
* in addition, this function calculates a regression between
* num_date and div which is saved as this.regression
* @return {null}
*/
export const timeVsRootToTip = function timeVsRootToTip() {
this.nodes.forEach((d) => {
d.y = getDivFromNode(d.n);
d.x = getTraitFromNode(d.n, "num_date");
d.px = getTraitFromNode(d.n.parent, "num_date");
d.py = getDivFromNode(d.n.parent);
});
if (this.vaccines) { /* overlay vaccine cross on tip */
this.vaccines.forEach((d) => {
d.xCross = d.x;
d.yCross = d.y;
});
}
const nTips = this.numberOfTips;
// REGRESSION WITH FREE INTERCEPT
// const meanDiv = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>d.y))/nTips;
// const meanTime = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>d.depth))/nTips;
// const covarTimeDiv = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.y-meanDiv)*(d.depth-meanTime)))/nTips;
// const varTime = d3.sum(this.nodes.filter((d)=>d.terminal).map((d)=>(d.depth-meanTime)*(d.depth-meanTime)))/nTips;
// const slope = covarTimeDiv/varTime;
// const intercept = meanDiv-meanTime*slope;
// REGRESSION THROUGH ROOT
const offset = this.nodes[0].depth;
const XY = sum(
this.nodes.filter((d) => d.terminal)
.map((d) => (d.y) * (d.depth - offset))
) / nTips;
const secondMomentTime = sum(
this.nodes.filter((d) => d.terminal)
.map((d) => (d.depth - offset) * (d.depth - offset))
) / nTips;
const slope = XY / secondMomentTime;
const intercept = -offset * slope;
this.regression = {slope: slope, intercept: intercept};
};
/*
* Utility function for the unrooted tree layout.
* assigns x,y coordinates to the subtree starting in node
* @params:
* node -- root of the subtree.
* nTips -- total number of tips in the tree.
*/
const unrootedPlaceSubtree = (node, nTips) => {
node.x = node.px + node.branchLength * Math.cos(node.tau + node.w * 0.5);
node.y = node.py + node.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.terminal) {
for (let i = 0; i < node.children.length; i++) {
const ch = node.children[i];
ch.w = 2 * Math.PI * ch.leafCount / nTips;
ch.tau = eta;
eta += ch.w;
ch.px = node.x;
ch.py = node.y;
unrootedPlaceSubtree(ch, nTips);
}
}
};
/**
* calculates x,y coordinates for the unrooted layout. this is
* done recursively via a the function unrootedPlaceSubtree
* @return {null}
*/
export const unrootedLayout = function unrootedLayout() {
// postorder iteration to determine leaf count of every node
addLeafCount(this.nodes[0]);
const nTips = this.nodes[0].leafCount;
// calculate branch length from depth
this.nodes.forEach((d) => {d.branchLength = d.depth - d.pDepth;});
// preorder iteration to layout nodes
this.nodes[0].x = 0;
this.nodes[0].y = 0;
this.nodes[0].px = 0;
this.nodes[0].py = 0;
this.nodes[0].w = 2 * Math.PI;
this.nodes[0].tau = 0;
let eta = 1.5 * Math.PI;
for (let i = 0; i < this.nodes[0].children.length; i++) {
this.nodes[0].children[i].px = 0;
this.nodes[0].children[i].py = 0;
this.nodes[0].children[i].w = 2.0 * Math.PI * this.nodes[0].children[i].leafCount / nTips;
this.nodes[0].children[i].tau = eta;
eta += this.nodes[0].children[i].w;
unrootedPlaceSubtree(this.nodes[0].children[i], nTips);
}
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);
});
}
};
/**
* 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
* @return {null}
*/
export const radialLayout = function radialLayout() {
const nTips = this.numberOfTips;
const offset = this.nodes[0].depth;
this.nodes.forEach((d) => {
const angleCBar1 = 2.0 * 0.95 * Math.PI * d.yRange[0] / nTips;
const angleCBar2 = 2.0 * 0.95 * Math.PI * d.yRange[1] / nTips;
d.angle = 2.0 * 0.95 * Math.PI * d.n.yvalue / nTips;
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(distanceAttribute) {
timerStart("setDistance");
this.nodes.forEach((d) => {d.update = true;});
this.distance = distanceAttribute || "div"; // div is default
// assign node and parent depth
if (this.distance === "div") {
this.nodes.forEach((d) => {
d.depth = getDivFromNode(d.n);
d.pDepth = getDivFromNode(d.n.parent);
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(d.n.parent, "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");
};
/**
* sets the range of the scales used to map the x,y coordinates to the screen
* @param {margins} -- object with "right, left, top, bottom" margins
*/
export const setScales = function setScales(margins) {
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 - (margins["left"] || 0) - (margins["right"] || 0);
const yExtend = height - (margins["top"] || 0) - (margins["top"] || 0);
const minExtend = min([xExtend, yExtend]);
const xSlack = xExtend - minExtend;
const ySlack = yExtend - minExtend;
this.xScale.range([0.5 * xSlack + margins["left"] || 0, width - 0.5 * xSlack - (margins["right"] || 0)]);
this.yScale.range([0.5 * ySlack + margins["top"] || 0, height - 0.5 * ySlack - (margins["bottom"] || 0)]);
} else {
// for rectancular layout, allow flipping orientation of left right and top/botton
if (this.params.orientation[0] > 0) {
this.xScale.range([margins["left"] || 0, width - (margins["right"] || 0)]);
} else {
this.xScale.range([width - (margins["right"] || 0), margins["left"] || 0]);
}
if (this.params.orientation[1] > 0) {
this.yScale.range([margins["top"] || 0, height - (margins["bottom"] || 0)]);
} else {
this.yScale.range([height - (margins["bottom"] || 0), margins["top"] || 0]);
}
}
};
/**
* this function sets the xScale, yScale domains and maps precalculated x,y
* coordinates to their places on the screen
* @return {null}
*/
export const mapToScreen = function mapToScreen() {
timerStart("mapToScreen");
/* pad margins if tip labels are visible */
/* padding width based on character count */
const tmpMargins = {
left: this.params.margins.left,
right: this.params.margins.right,
top: this.params.margins.top,
bottom: this.params.margins.bottom};
const inViewTerminalNodes = this.nodes.filter((d) => d.terminal).filter((d) => d.inView);
if (inViewTerminalNodes.length < this.params.tipLabelBreakL1) {
let fontSize = this.params.tipLabelFontSizeL1;
if (inViewTerminalNodes.length < this.params.tipLabelBreakL2) {
fontSize = this.params.tipLabelFontSizeL2;
}
if (inViewTerminalNodes.length < this.params.tipLabelBreakL3) {
fontSize = this.params.tipLabelFontSizeL3;
}
let padBy = 0;
inViewTerminalNodes.forEach((d) => {
if (padBy < d.n.name.length) {
padBy = 0.65 * d.n.name.length * fontSize;
}
});
tmpMargins.right += padBy;
}
/* set the range of the x & y scales */
this.setScales(tmpMargins);
/* find minimum & maximum x & y values */
let [minY, maxY, minX, maxX] = [1000000, 0, 1000000, 0];
this.nodes.filter((d) => d.inView).forEach((d) => {
if (d.x > maxX) maxX = d.x;
if (d.y > maxY) maxY = d.y;
if (d.x < minX) minX = d.x;
if (d.y < minY) minY = d.y;
});
/* fixes state of 0 length domain */
if (minX === maxX) {
minX -= 0.005;
maxX += 0.005;
}
/* 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;
}
/* set the domain of the x & y scales */
if (this.layout === "radial" || this.layout === "unrooted") {
// handle "radial and unrooted differently since they need to be square
// since branch length move in x and y direction
// TODO: should be tied to svg dimensions
const spanX = maxX-minX;
const spanY = maxY-minY;
const maxSpan = max([spanY, spanX]);
const ySlack = (spanX>spanY) ? (spanX-spanY)*0.5 : 0.0;
const xSlack = (spanX<spanY) ? (spanY-spanX)*0.5 : 0.0;
this.xScale.domain([minX-xSlack, minX+maxSpan-xSlack]);
this.yScale.domain([minY-ySlack, minY+maxSpan-ySlack]);
} else if (this.layout==="clock") {
// same as rectangular, but flipped yscale
this.xScale.domain([minX, maxX]);
this.yScale.domain([maxY, minY]);
} else { // rectangular
this.xScale.domain([minX, maxX]);
this.yScale.domain([minY, maxY]);
}
// 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);
});
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==="clock" || 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==="rect") {
this.nodes.forEach((d) => {
const stem_offset = 0.5*(d.parent["stroke-width"] - d["stroke-width"]) || 0.0;
const childrenY = [this.yScale(d.yRange[0]), this.yScale(d.yRange[1])];
d.branch =[` M ${d.xBase - stem_offset},${d.yBase} L ${d.xTip},${d.yTip} M ${d.xTip},${childrenY[0]} L ${d.xTip},${childrenY[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*(d.parent["stroke-width"] - d["stroke-width"]) || 0.0);});
this.nodes.forEach((d) => {d.cBarStart = this.yScale(d.yRange[0]);});
this.nodes.forEach((d) => {d.cBarEnd = this.yScale(d.yRange[1]);});
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.terminal) {
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()];
}
});
}
timerEnd("mapToScreen");
};