auspice
Version:
Web app for visualizing pathogen evolution
543 lines (506 loc) • 21.7 kB
text/typescript
import { AnyAction } from "@reduxjs/toolkit";
import { calcTipRadii } from "../util/tipRadiusHelpers";
import { strainNameToIdx, calculateVisiblityAndBranchThickness } from "../util/treeVisibilityHelpers";
import * as types from "./types";
import { updateEntropyVisibility } from "./entropy";
import { updateFrequencyDataDebounced } from "./frequencies";
import { calendarToNumeric } from "../util/dateHelpers";
import { applyToChildren } from "../components/tree/phyloTree/helpers";
import { constructVisibleTipLookupBetweenTrees } from "../util/treeTangleHelpers";
import { createVisibleLegendValues, getLegendOrder } from "../util/colorScale";
import { getTraitFromNode } from "../util/treeMiscHelpers";
import { warningNotification } from "./notifications";
import { calcFullTipCounts, calcTipCounts } from "../util/treeCountingHelpers";
import { PhyloNode } from "../components/tree/phyloTree/types";
import { Metadata } from "../metadata";
import { ThunkFunction } from "../store";
import { ReduxNode, TreeState, FocusNodes } from "../reducers/tree/types";
import { processStreams } from "../util/treeStreams";
import { NODE_VISIBLE } from "../util/globals";
type RootIndex = number | undefined
/** [root idx tree1, root idx tree2] */
export type Root = [RootIndex, RootIndex]
/**
* Updates the `inView` property of nodes which depends on the currently selected
* root index (i.e. what node the tree is zoomed into).
* Note that this property is historically the remit of PhyloTree, however this function
* may be called before those objects are created; in this case we store the property on
* the tree node itself.
*/
export const applyInViewNodesToTree = (
/** index of displayed root node */
idx: RootIndex,
tree: TreeState,
): number => {
const validIdxRoot = idx !== undefined ? idx : tree.idxOfInViewRootNode;
if (tree.nodes[0].shell) {
tree.nodes.forEach((d) => {
d.shell.inView = false;
d.shell.update = true;
});
if (tree.nodes[validIdxRoot].hasChildren) {
applyToChildren(tree.nodes[validIdxRoot].shell, (d: PhyloNode) => {d.inView = true;});
} else if (tree.nodes[validIdxRoot].parent.arrayIdx===0) {
// subtree with n=1 tips => don't make the parent in-view as this will cover the entire tree!
tree.nodes[validIdxRoot].shell.inView = true;
} else {
applyToChildren(tree.nodes[validIdxRoot].parent.shell, (d: PhyloNode) => {d.inView = true;});
}
} else {
/* FYI applyInViewNodesToTree is now setting inView on the redux nodes */
tree.nodes.forEach((d) => {
d.inView = false;
});
/* note that we cannot use `applyToChildren` as that operates on PhyloNodes */
const _markChildrenInView = (node: ReduxNode): void => {
node.inView = true;
if (node.children) {
for (const child of node.children) _markChildrenInView(child);
}
};
const startingNode = tree.nodes[validIdxRoot].hasChildren ? tree.nodes[validIdxRoot] : tree.nodes[validIdxRoot].parent;
_markChildrenInView(startingNode);
}
return validIdxRoot;
};
/**
* define the visible branches and their thicknesses. This could be a path to a single tip or a selected clade.
* filtering etc will "turn off" branches, etc etc
* this fn relies on the "inView" attr of nodes
* note that this function checks to see if the tree has been defined (different to if it's ready / loaded!)
* for arg destructuring see https://simonsmith.io/destructuring-objects-as-function-parameters-in-es6/
*/
export const updateVisibleTipsAndBranchThicknesses = ({
root = [undefined, undefined],
}: {
/**
* Change the in-view part of the tree.
*
* [0, 0]: reset. [undefined, undefined]: do nothing
*/
root?: Root
} = {}
): ThunkFunction => {
return (dispatch, getState) => {
const { tree, treeToo, controls, frequencies } = getState();
if (!tree.nodes) {return;}
// console.log("ROOT SETTING TO", root)
/* mark nodes as "in view" as applicable */
const rootIdxTree1 = applyInViewNodesToTree(root[0], tree);
const data = calculateVisiblityAndBranchThickness(
tree,
controls,
{dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric}
);
const dispatchObj: AnyAction = {
type: types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS,
visibility: data.visibility,
visibilityVersion: data.visibilityVersion,
branchThickness: data.branchThickness,
branchThicknessVersion: data.branchThicknessVersion,
idxOfInViewRootNode: rootIdxTree1,
idxOfFilteredRoot: data.idxOfFilteredRoot,
focusNodes: data.focusNodes,
};
if (controls.showTreeToo) {
const rootIdxTree2 = applyInViewNodesToTree(root[1], treeToo);
const dataToo = calculateVisiblityAndBranchThickness(
treeToo,
controls,
{dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric},
// {tipSelectedIdx: tipIdx2}
);
dispatchObj.tangleTipLookup = constructVisibleTipLookupBetweenTrees(tree.nodes, treeToo.nodes, data.visibility, dataToo.visibility);
dispatchObj.visibilityToo = dataToo.visibility;
dispatchObj.visibilityVersionToo = dataToo.visibilityVersion;
dispatchObj.branchThicknessToo = dataToo.branchThickness;
dispatchObj.branchThicknessVersionToo = dataToo.branchThicknessVersion;
dispatchObj.idxOfInViewRootNodeToo = rootIdxTree2;
dispatchObj.idxOfFilteredRootToo = dataToo.idxOfFilteredRoot;
dispatchObj.focusNodesTreeToo = dataToo.focusNodes;
/* tip selected is the same as the first tree - the reducer uses that */
}
if (Object.keys(tree.streams).length) {
// recomputes them even if they're toggled off
processStreams(tree.streams, tree.nodes, dispatchObj.visibility, controls.distanceMeasure, controls.colorScale, {skipPivots: true, skipCategories: true});
}
/* Changes in visibility require a recomputation of which legend items we wish to display */
dispatchObj.visibleLegendValues = createVisibleLegendValues({
colorBy: controls.colorBy,
genotype: controls.colorScale.genotype,
scaleType: controls.colorScale.scaleType,
legendValues: controls.colorScale.legendValues,
treeNodes: tree.nodes,
treeTooNodes: treeToo ? treeToo.nodes : undefined,
visibility: dispatchObj.visibility,
visibilityToo: dispatchObj.visibilityToo
});
/* D I S P A T C H */
dispatch(dispatchObj);
updateEntropyVisibility(dispatch, getState);
if (frequencies.loaded) {
updateFrequencyDataDebounced(dispatch, getState);
}
};
};
/**
* date changes need to update tip visibility & branch thicknesses
* this can be done in a single action
* NB calling this without specifying newMin OR newMax is a no-op
* side-effects: a single action
*/
export const changeDateFilter = ({
newMin = false,
newMax = false,
quickdraw = false,
}: {
newMin?: string | false
newMax?: string | false
quickdraw?: boolean
}): ThunkFunction => {
return (dispatch, getState) => {
const { tree, treeToo, controls, frequencies } = getState();
if (!tree.nodes) {return;}
const dates = {
dateMinNumeric: newMin ? calendarToNumeric(newMin) : controls.dateMinNumeric,
dateMaxNumeric: newMax ? calendarToNumeric(newMax) : controls.dateMaxNumeric
};
const data = calculateVisiblityAndBranchThickness(tree, controls, dates);
const dispatchObj: AnyAction = {
type: types.CHANGE_DATES_VISIBILITY_THICKNESS,
quickdraw,
dateMin: newMin ? newMin : controls.dateMin,
dateMax: newMax ? newMax : controls.dateMax,
dateMinNumeric: dates.dateMinNumeric,
dateMaxNumeric: dates.dateMaxNumeric,
visibility: data.visibility,
visibilityVersion: data.visibilityVersion,
branchThickness: data.branchThickness,
branchThicknessVersion: data.branchThicknessVersion,
idxOfInViewRootNode: tree.idxOfInViewRootNode,
idxOfFilteredRoot: tree.idxOfFilteredRoot,
focusNodes: data.focusNodes,
};
if (controls.showTreeToo) {
const dataToo = calculateVisiblityAndBranchThickness(treeToo, controls, dates);
dispatchObj.tangleTipLookup = constructVisibleTipLookupBetweenTrees(tree.nodes, treeToo.nodes, data.visibility, dataToo.visibility);
dispatchObj.visibilityToo = dataToo.visibility;
dispatchObj.visibilityVersionToo = dataToo.visibilityVersion;
dispatchObj.branchThicknessToo = dataToo.branchThickness;
dispatchObj.branchThicknessVersionToo = dataToo.branchThicknessVersion;
dispatchObj.idxOfInViewRootNodeToo = treeToo.idxOfInViewRootNode;
dispatchObj.idxOfFilteredRootToo = treeToo.idxOfFilteredRoot;
dispatchObj.focusNodesTreeToo = dataToo.focusNodes;
}
/* Changes in visibility require a recomputation of which legend items we wish to display */
dispatchObj.visibleLegendValues = createVisibleLegendValues({
colorBy: controls.colorBy,
scaleType: controls.colorScale.scaleType,
genotype: controls.colorScale.genotype,
legendValues: controls.colorScale.legendValues,
treeNodes: tree.nodes,
treeTooNodes: treeToo ? treeToo.nodes : undefined,
visibility: dispatchObj.visibility,
visibilityToo: dispatchObj.visibilityToo
});
if (Object.keys(tree.streams).length) {
// recomputes them even if they're toggled off
processStreams(tree.streams, tree.nodes, dispatchObj.visibility, controls.distanceMeasure, controls.colorScale, {skipPivots: true, skipCategories: true});
}
/* D I S P A T C H */
dispatch(dispatchObj);
updateEntropyVisibility(dispatch, getState);
if (frequencies.loaded) {
updateFrequencyDataDebounced(dispatch, getState);
}
};
};
/**
* NB all params are optional - supplying none resets the tip radii to defaults
* side-effects: a single action
*/
export const updateTipRadii = (
{
tipSelectedIdx = false,
selectedLegendItem = false,
geoFilter = [],
}: {
/** the strain to highlight (always tree 1) */
tipSelectedIdx?: number | false,
/** value of the attr. if scale is continuous a bound will be used. Boolean attrs are strings, e.g. 'False' */
selectedLegendItem?: string | number | false,
/** a filter to apply to the strains. Empty array or array of len 2. [0]: geoResolution, [1]: value to filter to */
geoFilter?: [string, string] | [],
} = {}
): ThunkFunction => {
return (dispatch, getState) => {
const { controls, tree, treeToo } = getState();
const colorScale = controls.colorScale;
const d: AnyAction = {
type: types.UPDATE_TIP_RADII,
version: tree.tipRadiiVersion + 1,
hoveredLegendSwatch: selectedLegendItem,
};
const tt = controls.showTreeToo;
if (tipSelectedIdx) {
d.data = calcTipRadii({tipSelectedIdx, colorScale, tree});
if (tt) {
const idx = strainNameToIdx(treeToo.nodes, tree.nodes[tipSelectedIdx].name);
d.dataToo = calcTipRadii({tipSelectedIdx: idx, colorScale, tree: treeToo});
}
} else {
d.data = calcTipRadii({selectedLegendItem, geoFilter, colorScale, tree});
if (tt) d.dataToo = calcTipRadii({selectedLegendItem, geoFilter, colorScale, tree: treeToo});
}
dispatch(d);
};
};
/**
* Apply a filter to the current selection (i.e. filtered / "on" values associated with this trait)
*/
export const applyFilter = (
/** Explanation of the modes:
* - "add" -> add the values to the current selection (if any exists).
* - "inactivate" -> inactivate values (i.e. change active prop to false). To activate just use "add".
* - "remove" -> remove the values from the current selection
* - "set" -> set the values of the filter to be those provided. All disabled filters will be removed. XXX TODO.
* - "focus" -> similar to "set" except other existing filter values are inactivated not removed.
*/
mode: "add" | "inactivate" | "remove" | "set" | "focus",
/** the trait name of the filter ("authors", "country" etcetera) */
trait: string | symbol,
/** the values (see above) */
values: string[],
): ThunkFunction => {
return (dispatch, getState) => {
const { controls } = getState();
const currentlyFilteredTraits = Reflect.ownKeys(controls.filters);
let newValues;
switch (mode) {
case "set":
newValues = values.map((value) => ({value, active: true}));
break;
case "focus": {
const valuesToActivate = new Set(values);
const existingValues = (controls.filters[trait] || []).slice()
.map((f) => {
if (valuesToActivate.has(f.value)) {
valuesToActivate.delete(f.value);
return {value: f.value, active: true};
} else {
return {value: f.value, active: false};
}
});
newValues = ([
...existingValues,
[...valuesToActivate].map((value) => ({value, active: true})),
]).flat(); // flat to remove empty array if valuesToActivate empty
break;
}
case "add":
if (currentlyFilteredTraits.indexOf(trait) === -1) {
newValues = values.map((value) => ({value, active: true}));
} else {
newValues = controls.filters[trait].slice();
const currentItemNames = newValues.map((i) => i.value);
values.forEach((valueToAdd) => {
const idx = currentItemNames.indexOf(valueToAdd);
if (idx === -1) {
newValues.push({value: valueToAdd, active: true});
} else {
/* it's already there, ensure it's active */
newValues[idx].active = true;
}
});
}
break;
case "remove": // fallthrough
case "inactivate": {
if (currentlyFilteredTraits.indexOf(trait) === -1) {
console.error(`trying to ${mode} values from an un-initialised filter!`);
return;
}
newValues = controls.filters[trait].map((f) => ({...f}));
const currentItemNames = newValues.map((i) => i.value);
for (const item of values) {
const idx = currentItemNames.indexOf(item);
if (idx !== -1) {
if (mode==="remove") {
newValues.splice(idx, 1);
} else {
newValues[idx].active = false;
}
} else {
console.error(`trying to ${mode} filter value ${item} which was not part of the filter selection`);
}
}
break;
}
default:
console.error(`applyFilter called with invalid mode: ${mode}`);
return; // don't dispatch
}
dispatch({type: types.APPLY_FILTER, trait, values: newValues});
dispatch(updateVisibleTipsAndBranchThicknesses());
};
};
export const toggleTemporalConfidence = (): AnyAction => ({
type: types.TOGGLE_TEMPORAL_CONF
});
/**
* restore original state by iterating over all nodes and restoring children to unexplodedChildren (as necessary)
*/
const _resetExpodedTree = (nodes: ReduxNode[]): void => {
nodes.forEach((n) => {
if (Object.prototype.hasOwnProperty.call(n, 'unexplodedChildren')) {
n.children = n.unexplodedChildren;
n.hasChildren = true;
delete n.unexplodedChildren;
for (const child of n.children) {
child.parent = n;
}
}
});
};
/**
* Recursive function which traverses the tree modifying parent -> child links in order to
* create subtrees where branches have different attrs.
* Note: because the children of a node may change, we store the previous (unexploded) children
* as `unexplodedChildren` so we can return to the original tree.
*/
const _traverseAndCreateSubtrees = (
/** root node of entire tree */
root: ReduxNode,
/** current node being traversed */
node: ReduxNode,
/** trait name to determine if a child should become subtree */
attr: string,
): void => {
// store original children so we traverse the entire tree
const originalChildren = node.hasChildren ? [...node.children] : [];
if (node.arrayIdx === 0) { // __ROOT will hold all (exploded) subtrees
node.unexplodedChildren = originalChildren;
} else if (node.hasChildren) {
const parentTrait = getTraitFromNode(node, attr);
const childrenToPrune = node.children
.map((c) => getTraitFromNode(c, attr))
.map((childTrait, idx) => (childTrait!==parentTrait) ? idx : undefined)
.filter((v) => v!==undefined);
if (childrenToPrune.length) {
childrenToPrune.forEach((idx) => {
const subtreeRootNode = node.children[idx];
root.children.push(subtreeRootNode);
subtreeRootNode.parent = root;
});
node.unexplodedChildren = originalChildren;
node.children = node.children.filter((_c, idx) => {
return !childrenToPrune.includes(idx);
});
/* it may be the case that the node now has no children (they're all subtrees!) */
if (node.children.length===0) {
node.hasChildren = false;
}
}
}
for (const originalChild of originalChildren) { // this may jump into subtrees
_traverseAndCreateSubtrees(root, originalChild, attr);
}
};
/**
* sort the subtrees by the order the trait would appear in the legend
*/
const _orderSubtrees = (
metadata: Metadata,
nodes: ReduxNode[],
attr: string,
): void => {
const attrValueOrder = getLegendOrder(attr, metadata.colorings[attr], nodes, undefined);
nodes[0].children.sort((childA, childB) => {
const [attrA, attrB] = [getTraitFromNode(childA, attr), getTraitFromNode(childB, attr)];
if (attrValueOrder.length) {
const [idxA, idxB] = [attrValueOrder.indexOf(attrA), attrValueOrder.indexOf(attrB)];
if (idxA===-1 && idxB===-1) return -1; // neither in legend => preserve order
if (idxB===-1) return -1; // childA in legend, childB not => sort A before B
if (idxA < idxB) return -1; // childA before childB => sort a before b
if (idxA > idxB) return 1; // and vice versa
return 0;
}
// fallthrough, if there's no available legend order, is to simply sort alphabetically
return attrA > attrB ? -1 : 1;
});
};
export const explodeTree = (
attr: string | undefined,
): ThunkFunction => {
return (dispatch, getState) => {
const {tree, metadata, controls} = getState();
_resetExpodedTree(tree.nodes); // ensure we start with an unexploded tree
if (attr) {
const root = tree.nodes[0];
_traverseAndCreateSubtrees(root, root, attr);
if (root.unexplodedChildren.length === root.children.length) {
dispatch(warningNotification({message: "Cannot explode tree on this trait - is it defined on internal nodes?"}));
return;
}
_orderSubtrees(metadata, tree.nodes, attr);
}
/* tree splitting necessitates recalculation of tip counts */
calcFullTipCounts(tree.nodes[0]);
calcTipCounts(tree.nodes[0], tree.visibility);
/* we default to zooming out completely whenever we explode the tree. There are nicer behaviours here,
such as re-calculating the MRCA of visible nodes, but this comes at the cost of increased complexity.
Note that the functions called here involve a lot of code duplication and are good targets for refactoring */
applyInViewNodesToTree(0, tree);
const visData = calculateVisiblityAndBranchThickness(
tree,
controls,
{dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric}
);
visData.idxOfInViewRootNode = 0;
/* Changes in visibility require a recomputation of which legend items we wish to display */
visData.visibleLegendValues = createVisibleLegendValues({
colorBy: controls.colorBy,
genotype: controls.colorScale.genotype,
scaleType: controls.colorScale.scaleType,
legendValues: controls.colorScale.legendValues,
treeNodes: tree.nodes,
visibility: visData.visibility
});
dispatch({
type: types.CHANGE_EXPLODE_ATTR,
explodeAttr: attr,
...visData
});
};
};
export function changeDistanceMeasure(metric: "num_date"|"div"): ThunkFunction {
return function(dispatch, getState) {
const {controls, tree} = getState();
if (Object.keys(tree.streams).length) {
processStreams(tree.streams, tree.nodes, tree.visibility, metric, controls.colorScale, {skipCategories: true})
}
dispatch({type: types.CHANGE_DISTANCE_MEASURE, data: metric})
}
}
/**
* The "focus" zoom mode limits the nodes displayed in order to zoom the
* temporal/divergence axis (x-axis for rectangular trees). This function
* returns the nodes which are focused "on", as well as the root nodes for the
* focused subtree(s).
*
* Note that this approach differs from the typical approach auspice uses to
* show subtrees: in all other modes we show a single subtree and thus nodes
* on screen are defined by the `inView` property, root node via
* `idxOfInViewRootNode` etc. Focus doesn't conform to this design as nodes
* marked as `inView` may be off-screen in this mode. We should spend some
* time thinking about how to unify these concepts in the future.
*/
export function getFocusedNodes(nodes: ReduxNode[], visibility: any[]): FocusNodes {
let focusNodes = nodes.filter((_n, i) => visibility[i] === NODE_VISIBLE).map((n) => n.arrayIdx)
if (focusNodes.length===0) {
focusNodes = nodes.filter((n) => n.shell.inView).map((n) => n.arrayIdx);
}
const focusNodesSet = new Set(focusNodes);
const focusRoots = focusNodes.filter((idx) => !focusNodesSet.has(nodes[idx].parent.arrayIdx));
return {nodes: focusNodes, roots: focusRoots}
}