auspice
Version:
Web app for visualizing pathogen evolution
454 lines (406 loc) • 17.6 kB
text/typescript
/* eslint-disable no-param-reassign */
import { max } from "d3-array";
import {getTraitFromNode, getDivFromNode, getBranchMutations} from "../../../util/treeMiscHelpers";
import { NODE_VISIBLE } from "../../../util/globals";
import { timerStart, timerEnd } from "../../../util/perf";
import { ReduxNode, weightToDisplayOrderScaleFactor, Streams } from "../../../reducers/tree/types";
import { Focus } from "../../../reducers/controls";
import { Distance, PhyloNode } from "./types";
import { ScaleContinuousNumeric } from "d3-scale";
/** get a string to be used as the DOM element ID
* Note that this cannot have any "special" characters
*/
export const getDomId = (
type: string,
strain: string,
): string => {
// Replace non-alphanumeric characters with dashes (probably unnecessary)
const name = `${type}_${strain}`.replace(/(\W+)/g, '-');
return CSS.escape(name);
};
/**
* this function takes a call back and applies it recursively
* to all child nodes, including internal nodes
*/
export const applyToChildren = (
phyloNode: PhyloNode,
/** function to apply to each child. Is passed a single argument, the <PhyloNode> of the children. */
func: (node: PhyloNode) => void,
): void => {
func(phyloNode);
const node = phyloNode.n;
if ((!node.hasChildren) || (node.children === undefined)) { // in case clade set by URL, terminal hasn't been set yet!
return;
}
for (const child of node.children) {
applyToChildren(child.shell, func);
}
};
/**
* Traverse through a set of connected streams (which originate from the `streamStartNode`),
* each time calculating the total display order space each stream occupies. (Note:
* the actual transform of ribbons into display order space is not done here.)
* Returns the `yCounter` incremented by the space needed for the stream(s).
*/
function traverseConnectedStreams(
yCounter: number,
/**
* streamStartNode
* Node representing the start of a (possibly connected) stream, but not a
* stream which is the child of another stream
*/
streamStartNode: ReduxNode,
streamInfo:StreamInfo
): number {
if (!(streamStartNode.streamName in streamInfo.streams)) {
console.error(`BUG! - found (old?) stream name ${streamStartNode.streamName} on nodes but no longer in (new?) 'streams'`);
return yCounter;
}
/** Setting displayorders to undefined allows us to skip rendering, however a more performant
* solution would be to skip the computation of all unneeded properties for nodes in streamtrees
* whilst also skipping rendering. Leaving this as a future performance improvement.
*/
_setDisplayOrderToUndefined(streamStartNode.shell);
for (const streamName of streamInfo.streams[streamStartNode.streamName].renderingOrder) {
yCounter = setDisplayOrdersForStream(yCounter, streamName, streamInfo)
}
return yCounter;
}
/**
* Calculates the (maximum) display order occupied by this stream and places this above
* (in display order terms) the provided `yCounter`. Two properties are set on the stream
* start node: `displayOrder` (the midpoint of the stream) and `displayOrderRange`.
*/
function setDisplayOrdersForStream(yCounter: number, streamName: string, streamInfo: StreamInfo): number {
const spaceAround = 1; // 1 display order unit
const n = streamInfo.startNodes[streamName];
const totalStreamHeight = spaceAround * 2 + n.streamMaxHeight*streamInfo.streams[weightToDisplayOrderScaleFactor];
const displayOrderMidpoint = yCounter + totalStreamHeight/2;
n.shell.displayOrder = displayOrderMidpoint;
n.shell.displayOrderRange = [yCounter, yCounter + totalStreamHeight];
yCounter += totalStreamHeight;
return yCounter;
}
/** setDisplayOrderRecursively
* Postorder traversal to calculate the display order of nodes
* @sideeffect modifies node.displayOrder and node.displayOrderRange
* Returns the current yCounter after assignment to the tree originating from `node`
*/
export const setDisplayOrderRecursively = (
node: PhyloNode,
incrementer: (node: PhyloNode) => number,
yCounter: number,
streamInfo: false|StreamInfo
): number => {
const children = node.n.children;
const showStreamTrees = !!streamInfo;
if (children && children.length) {
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
yCounter = (showStreamTrees && child.streamName) ?
traverseConnectedStreams(yCounter, child, streamInfo) :
setDisplayOrderRecursively(children[i].shell, incrementer, yCounter, streamInfo);
}
} else {
// terminal node
if (node.n.fullTipCount !== 0) {
yCounter += incrementer(node);
}
node.displayOrder = yCounter;
node.displayOrderRange = [yCounter, yCounter];
return yCounter;
}
/* if here, then all children have displayOrders, but we don't. */
node.displayOrder = children.reduce((acc, d) => acc + d.shell.displayOrder, 0) / children.length;
node.displayOrderRange = [children[0].shell.displayOrder, children[children.length - 1].shell.displayOrder];
return yCounter;
};
/**
* heuristic function to return the appropriate spacing between subtrees for a given tree
* the returned value is to be interpreted as a count of the number of tips that would
* otherwise fit in the gap
*/
function _getSpaceBetweenSubtrees(
numSubtrees: number,
numTips: number,
): number {
if (numSubtrees===1 || numTips<10) {
return 0;
}
if (numSubtrees*2 > numTips) {
return 0;
}
return numTips/20; /* note that it's not actually 5% of vertical space,
as the final max yCount = numTips + numSubtrees*numTips/20 */
}
interface StreamInfo {
streams: Streams,
startNodes: Record<string, ReduxNode>,
}
/**
* Sets the `displayOrder` and `displayOrderRange` for each node by traversing the (ladderized) tree.
* For internal nodes the range represents the range of the children display orders.
*
* For streams we set these properties on the stream start node. The values correspond to the
* midpoint of the stream and the range of the stream at its maximum (i.e. at some pivot).
* We then (separately) compute the display orders for each ripple (at each pivot) via
* `rippleDisplayOrders()`.
*
* Nodes must have parent child links established (via createChildrenAndParents) before running this.
*/
export const setDisplayOrder = ({
nodes,
focus,
streams,
}: {
nodes: PhyloNode[]
focus: Focus
streams: false|Streams,
}): void => {
timerStart("setDisplayOrder");
const numSubtrees = nodes[0].n.children.filter((n) => n.fullTipCount!==0).length;
const numTips = focus ? nodes[0].n.tipCount : nodes[0].n.fullTipCount;
const spaceBetweenSubtrees = _getSpaceBetweenSubtrees(numSubtrees, numTips);
// No focus: 1 unit per node
let incrementer = (_node): number => 1;
if (focus === "selected") {
const nVisible = nodes[0].n.tipCount;
const nTotal = nodes[0].n.fullTipCount;
let yProportionFocused = 0.8;
// Adjust for a small number of visible tips (n<4)
yProportionFocused = Math.min(yProportionFocused, nVisible / 5);
// Adjust for a large number of visible tips (>80% of all tips)
yProportionFocused = Math.max(yProportionFocused, nVisible / nTotal);
const yPerFocused = (yProportionFocused * nTotal) / nVisible;
const yPerUnfocused = ((1 - yProportionFocused) * nTotal) / (nTotal - nVisible);
incrementer = ((): ((_node) => number) => {
let previousWasVisible = false;
return (node) => {
// Focus if the current node is visible or if the previous node was visible (for symmetric padding)
const y = (node.visibility === NODE_VISIBLE || previousWasVisible) ? yPerFocused : yPerUnfocused;
// Update for the next node
previousWasVisible = node.visibility === NODE_VISIBLE;
return y;
}
})();
}
let yCounter = 0;
let streamInfo: StreamInfo|false = false;
if (nodes[0].that.params.showStreamTrees) {
if (streams===false) {
console.error("collectStreamInfo. Attempting to show streams but no stream data defined")
} else {
streamInfo = {
streams,
startNodes: Object.fromEntries(Object.entries(streams)
.map(([streamName, stream]) => [streamName, nodes[stream.startNode].n])
)
}
}
}
/* iterate through each subtree, and add padding between each */
for (const subtree of nodes[0].n.children) {
if (subtree.fullTipCount===0) { // don't use screen space for this subtree
_setDisplayOrderToUndefined(nodes[subtree.arrayIdx]) // see note above in `traverseConnectedStreams`
} else if (!!streamInfo && subtree.streamName) {
/* Special case where the entire (sub)tree is a series of connected streams */
yCounter = traverseConnectedStreams(yCounter, subtree, streamInfo) + spaceBetweenSubtrees;
} else {
yCounter = setDisplayOrderRecursively(nodes[subtree.arrayIdx], incrementer, yCounter, streamInfo);
yCounter+=spaceBetweenSubtrees;
}
}
/* note that nodes[0] is a dummy node holding each subtree */
nodes[0].displayOrder = undefined;
nodes[0].displayOrderRange = [undefined, undefined];
/**
* The above didn't compute the display orders for the ripples themselves (just the overall stream dimensions)
* so do this now
*/
if (nodes[0].that.params.showStreamTrees) {
if (!streams) {
console.error("setDisplayOrder bug - streams toggled on but no stream data")
} else {
setRippleDisplayOrders(nodes, streams)
}
}
timerEnd("setDisplayOrder");
};
export const formatDivergence = (divergence: number): string | number => {
return divergence > 1 ?
Math.round((divergence + Number.EPSILON) * 1000) / 1000 :
divergence > 0.01 ?
Math.round((divergence + Number.EPSILON) * 10000) / 10000 :
divergence.toExponential(3);
};
/** get the idx of the zoom node (i.e. the in-view root node).
* This differs depending on which tree is in view so it's helpful to access it
* by reaching into phyotree to get it
*/
export const getIdxOfInViewRootNode = (node: ReduxNode): number => {
return node.shell.that.zoomNode.n.arrayIdx;
};
/**
* Are the provided nodes within some divergence / time of each other?
* NOTE: `otherNode` is always closer to the root in the tree than `node`
*/
function isWithinBranchTolerance(
node: ReduxNode,
otherNode: ReduxNode,
distanceMeasure: Distance,
): boolean {
if (distanceMeasure === "num_date") {
/* We calculate the threshold by reaching into phylotree to extract the date range of the dataset
and then split the data into ~50 slices. This could be refactored to not reach into phylotree. */
const tolerance = (node.shell.that.dateRange[1]-node.shell.that.dateRange[0])/50;
return (getTraitFromNode(node, "num_date") - tolerance < getTraitFromNode(otherNode, "num_date"));
}
/* If mutations aren't defined, we fallback to calculating divergence tolerance similarly to temporal.
This uses the approach used to compute the x-axis grid within phyotree, and could be refactored into a
helper function. Note that we don't store the maximum divergence on the tree so we use the in-view max instead */
const tolerance = (node.shell.that.xScale.domain()[1] - node.shell.that.nodes[0].depth)/50;
return (getDivFromNode(node) - tolerance < getDivFromNode(otherNode));
}
/**
* Walk up the tree from node until we find either a node which has a nucleotide mutation or we
* reach the root of the (sub)tree. Gaps, deletions and undeletions do not count as mutations here.
*/
function findFirstBranchWithAMutation(node: ReduxNode): ReduxNode {
if (node.parent === node) {
return node;
}
const categorisedMutations = getBranchMutations(node, {}); // 2nd param of `{}` means we skip homoplasy detection
if (categorisedMutations?.nuc?.unique?.length>0) {
return node.parent;
}
return findFirstBranchWithAMutation(node.parent);
}
/**
* Given a `node`, get the parent, grandparent etc node which is beyond some
* branch length threshold (either divergence or time). This is useful for finding the node
* beyond a polytomy, or polytomy-like structure. If nucleotide mutations are defined on
* the tree (and distanceMeasure=div) then we find the first branch with a mutation.
*/
export const getParentBeyondPolytomy = (
node: ReduxNode,
distanceMeasure: Distance,
observedMutations: Record<string, number>,
): ReduxNode => {
let potentialNode = node.parent;
if (distanceMeasure==="div" && areNucleotideMutationsPresent(observedMutations)) {
return findFirstBranchWithAMutation(node);
}
while (isWithinBranchTolerance(node, potentialNode, distanceMeasure)) {
if (potentialNode === potentialNode.parent) break; // root node of tree
potentialNode = potentialNode.parent;
}
return potentialNode;
};
export function getParentStream(node: ReduxNode): ReduxNode {
let n = node.parent;
while (true) { // eslint-disable-line no-constant-condition
if (n.streamName) return n;
if (n.parent===n) throw new Error("getParentStream failed to find parent stream before reaching the tree root");
n = n.parent;
}
}
function areNucleotideMutationsPresent(observedMutations): boolean {
const mutList = Object.keys(observedMutations);
for (let idx=mutList.length-1; idx>=0; idx--) { // start at end, as nucs come last in the key-insertion order
if (mutList[idx].startsWith("nuc:")) {
return true;
}
}
return false;
}
/**
* Prior to Jan 2020, the divergence measure was always "subs per site per year"
* however certain datasets changed this to "subs per year" across entire sequence.
* This distinction is not set in the JSON, so in order to correctly display the rate
* we will "guess" this here. A future augur update will export this in a JSON key,
* removing the need to guess.
*/
export function guessAreMutationsPerSite(
scale: ScaleContinuousNumeric<number, number>,
): boolean {
const maxDivergence = max(scale.domain());
return maxDivergence <= 5;
}
/**
* Is the node a subtree root node? (implies that we have either exploded trees or
* the dataset has multiple subtrees to display)
*/
const isSubtreeRoot = (n: ReduxNode): boolean => (n.parent.name === "__ROOT" && n.parentInfo.original !== undefined);
/**
* Gets the parent node to be used for stem / branch calculation.
* Most of the time this is the same as `d.n.parent` however it is not in the
* case of the root nodes for subtrees (e.g. exploded trees).
*/
export const stemParent = (n: ReduxNode): ReduxNode => {
return isSubtreeRoot(n) ? n.parentInfo.original : n.parent;
};
/**
* Returns a function which itself gets the order of the node and that of its parent.
* This is not strictly the same as the `displayOrder` the scatterplot axis
* renders increasing values going upwards (i.e. from the bottom to top of screen)
* whereas the rectangular tree renders zero at the top and goes downwards
*/
export const nodeOrdering = (
nodes: PhyloNode[],
): ((d: PhyloNode) => [number, number]) => {
const maxVal = nodes.map((d) => d.displayOrder)
.reduce((acc, val) => ((val ?? 0) > acc ? val : acc), 0);
return (d: PhyloNode) => ([
maxVal - d.displayOrder,
isSubtreeRoot(d.n) ? undefined : (maxVal - d.n.parent.shell.displayOrder)
]);
};
/**
* Sets the `displayOrder` and `displayOrderRange` to undefined for this *node*
* and all descendants.
*/
function _setDisplayOrderToUndefined(node: PhyloNode):void {
node.displayOrder = undefined;
node.displayOrderRange = [undefined, undefined];
for (const child of node.n.children || []) {
_setDisplayOrderToUndefined(child.shell)
}
}
/**
* Sets `rippleDisplayOrders` on the start node of each stream by converting `streamDimensions`
* (KDE-weights independently for each ribbon) into stacked coordinates in displayOrder space
* each representing a ripple. The set of ripples are centred around the (previously set)
* `displayOrder` (stream midpoint).
*
* NOTE: This function depends on the (stream start node's) `displayOrder` being up to date
* (via `setDisplayOrder`)
*/
export function setRippleDisplayOrders(nodes: PhyloNode[], streams: Streams): void {
for (const stream of Object.values(streams)) {
const startingNode = nodes[stream.startNode];
/* Convert KDE weights to ribbons which start at display order zero */
const rippleDisplayOrders = startingNode.n.streamDimensions
.reduce((acc: [number,number][][], weightsAcrossPivot, categoryIdx) => {
acc.push(
weightsAcrossPivot.map((weight, pivotIdx) => {
const base: number = categoryIdx===0 ? 0 : acc[categoryIdx-1][pivotIdx][1];
// We don't need to scale `weight` as it's already ~normalised to display-order units
return [base, base + weight*streams[weightToDisplayOrderScaleFactor]];
})
);
return acc;
}, []);
const displayOrderMidpoint = startingNode.displayOrder;
/* Center the ribbons */
for (let pivotIdx=0; pivotIdx<startingNode.n.streamPivots.length; pivotIdx++) {
if (!rippleDisplayOrders.length) continue;
const range = [rippleDisplayOrders.at(0).at(pivotIdx).at(0), rippleDisplayOrders.at(-1).at(pivotIdx).at(1)];
const shift = displayOrderMidpoint - (range[0] + (range[1]-range[0])/2);
for (const displayOrderAcrossPivots of rippleDisplayOrders) {
displayOrderAcrossPivots[pivotIdx][0]+=shift;
displayOrderAcrossPivots[pivotIdx][1]+=shift;
}
}
startingNode.rippleDisplayOrders = rippleDisplayOrders;
}
}