auspice
Version:
Web app for visualizing pathogen evolution
201 lines (174 loc) • 8.97 kB
text/typescript
import { Selection, select, event as d3event } from "d3-selection";
import { updateVisibleTipsAndBranchThicknesses, applyFilter, Root } from "../../../actions/tree";
import { NODE_VISIBLE, strainSymbol } from "../../../util/globals";
import { getDomId, getParentBeyondPolytomy, getIdxOfInViewRootNode } from "../phyloTree/helpers";
import { branchStrokeForHover, branchStrokeForLeave, LabelDatum, nonHoveredRippleOpacity } from "../phyloTree/renderers";
import { PhyloNode } from "../phyloTree/types";
import { SELECT_NODE, DESELECT_NODE } from "../../../actions/types";
import { SelectedNode } from "../../../reducers/controls";
import { TreeComponent } from "../tree";
import { getEmphasizedColor } from "../../../util/colorHelpers";
/* Callbacks used by the tips / branches when hovered / selected */
export const onTipHover = function onTipHover(this: TreeComponent, d: PhyloNode): void {
if (d.visibility !== NODE_VISIBLE) return;
const phylotree = d.that.params.orientation[0] === 1 ?
this.state.tree :
this.state.treeToo;
phylotree.svg.select("#"+getDomId("tip", d.n.name))
.attr("r", (e) => e["r"] + 4);
this.setState({
hoveredNode: {node: d, isBranch: false}
});
};
export const onTipClick = function onTipClick(this: TreeComponent, d: PhyloNode): void {
if (d.visibility !== NODE_VISIBLE) return;
if (this.props.narrativeMode) return;
/* The order of these two dispatches is important: the reducer handling
`SELECT_NODE` must have access to the filtering state _prior_ to these filters
being applied */
this.props.dispatch({type: SELECT_NODE, name: d.n.name, idx: d.n.arrayIdx, isBranch: false, treeId: d.that.id});
this.props.dispatch(applyFilter("add", strainSymbol, [d.n.name]));
};
export const onBranchHover = function onBranchHover(this: TreeComponent, d: PhyloNode): void {
if (d.visibility !== NODE_VISIBLE) return;
branchStrokeForHover(d);
/* if temporal confidence bounds are defined for this branch, then display them on hover */
if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) {
const tree = d.that.params.orientation[0] === 1 ? this.state.tree : this.state.treeToo;
if (!("confidenceIntervals" in tree.groups)) {
tree.groups.confidenceIntervals = tree.svg.append("g").attr("id", "confidenceIntervals");
}
tree.groups.confidenceIntervals
.selectAll(".conf")
.data([d])
.enter()
.call((sel) => tree.drawSingleCI(sel, 0.5));
}
/* Set the hovered state so that an info box can be displayed */
this.setState({
hoveredNode: {node: d, isBranch: true}
});
};
export const onBranchClick = function onBranchClick(this: TreeComponent, d: PhyloNode): void {
if (d.visibility !== NODE_VISIBLE) return;
if (this.props.narrativeMode) return;
/* if a branch was clicked while holding the shift key, we instead display a node-clicked modal */
/* NOTE: window.event is deprecated, however the version of d3-selection we're using doesn't supply
the event as an argument */
if (window.event instanceof PointerEvent && window.event.shiftKey) {
// no need to dispatch a filter action
this.props.dispatch({type: SELECT_NODE, name: d.n.name, idx: d.n.arrayIdx, isBranch: true, treeId: d.that.id})
return;
}
const LHSTree = d.that.params.orientation[0] === 1;
const zoomBackwards = getIdxOfInViewRootNode(d.n) === d.n.arrayIdx;
/** Handle the case where we are clicking on the in-view root which is also a stream, i.e.
* we want to zoom back to the parent stream
*/
if (zoomBackwards && this.props.showStreamTrees) { // Note: streamtrees only (currently) work for single trees
const parentStreamName = this.props.tree.streams[d.n.streamName].parentStreamName;
if (parentStreamName) { // if this is false we are zooming back into the "normal" tree, so use the non-stream-tree code path
const parentStreamIndex = this.props.tree.streams[parentStreamName].startNode
return this.props.dispatch(updateVisibleTipsAndBranchThicknesses({
root: [parentStreamIndex, undefined],
}));
}
}
/* Clicking on a branch means we want to zoom into the clade defined by that branch
_except_ when it's the "in-view" root branch, in which case we want to zoom out */
const arrayIdxToZoomTo = zoomBackwards ?
getParentBeyondPolytomy(d.n, this.props.distanceMeasure, LHSTree ? this.props.tree.observedMutations : this.props.treeToo.observedMutations).arrayIdx :
d.n.arrayIdx;
const root: Root = LHSTree ? [arrayIdxToZoomTo, undefined] : [undefined, arrayIdxToZoomTo];
/* clade selected (as used in the URL query) is only designed to work for the main tree, not the RHS tree */
this.props.dispatch(updateVisibleTipsAndBranchThicknesses({root}));
};
/* onBranchLeave called when mouse-off, i.e. anti-hover */
export const onBranchLeave = function onBranchLeave(this: TreeComponent, d: PhyloNode): void {
/* Reset the stroke back to what it was before */
branchStrokeForLeave(d);
/* Remove the temporal confidence bar unless it's meant to be displayed */
if (this.props.temporalConfidence.exists && this.props.temporalConfidence.display && !this.props.temporalConfidence.on) {
const tree = d.that.params.orientation[0] === 1 ? this.state.tree : this.state.treeToo;
tree.removeConfidence();
}
/* Set selectedNode state to an empty object, which will remove the info box */
this.setState({hoveredNode: null});
};
export const onTipLeave = function onTipLeave(this: TreeComponent, d: PhyloNode): void {
const phylotree = d.that.params.orientation[0] === 1 ?
this.state.tree :
this.state.treeToo;
if (this.state.hoveredNode) {
phylotree.svg.select("#"+getDomId("tip", d.n.name))
.attr("r", (dd) => dd["r"]);
}
this.setState({hoveredNode: null});
};
/* clearSelectedNode when clicking to remove the node-selected modal */
export const clearSelectedNode = function clearSelectedNode(this: TreeComponent, selectedNode: SelectedNode): void {
if (!selectedNode.isBranch) {
/* perform the filtering action (if necessary) that will restore the
filtering state of the node prior to the selection */
if (!selectedNode.existingFilterState) {
this.props.dispatch(applyFilter("remove", strainSymbol, [selectedNode.name]));
} else if (selectedNode.existingFilterState==='inactive') {
this.props.dispatch(applyFilter("inactivate", strainSymbol, [selectedNode.name]));
}
/* else the filter was already active, so leave it unchanged */
}
this.props.dispatch({type: DESELECT_NODE});
};
export function onStreamHover(this: TreeComponent, node: PhyloNode, categoryIndex: number, paths: SVGPathElement[], isBranch: boolean): void {
/** For each ripple (SVGPathElement) _not_ hovered, lower the opacity so that we focus attention on the hovered ribbon */
if (isBranch) {
select(paths[0]).style("stroke", getEmphasizedColor(node.branchStroke))
} else {
paths.forEach((path, i) => {
if (i===categoryIndex) {
select(path).attr("fill", getEmphasizedColor(node.n.streamCategories[categoryIndex].color))
} else {
select(path).style('opacity', nonHoveredRippleOpacity)
}
})
}
/* Ensure the label is visible & enlarged */
const selection = selectStreamLabel(node);
if (selection.data()?.at(0)?.visibility==='hidden') {
selection.attr("visibility", "visible")
selection.attr("font-size", 16)
}
this.setState({hoveredNode: {
node,
isBranch,
streamDetails: {x: d3event.layerX, y: d3event.layerY, categoryIndex}
}});
}
export function onStreamLeave(this: TreeComponent, node: PhyloNode, categoryIndex: number, paths: SVGPathElement[], isBranch): void {
if (isBranch) {
/* return branch colour back to normal */
select(paths[0]).style("stroke", node.branchStroke)
} else {
/** ensure each ripple's opacity is reset back to 1 */
paths.forEach((path, i) => {
if (i===categoryIndex) {
select(path).attr("fill", node.n.streamCategories[categoryIndex].color)
} else {
select(path).style('opacity', 1)
}
})
}
/* Ensure the label goes back to its previous state */
const selection = selectStreamLabel(node);
if (selection.data()?.at(0)?.visibility==='hidden') {
selection.attr("visibility", "hidden")
}
this.setState({hoveredNode: null});
}
function selectStreamLabel(node: PhyloNode): Selection<SVGTextElement, LabelDatum, null, unknown> {
// When `groups.streamsLabels` is created we haven't bound any data so it's datum type is `unknown`
// We subsequently bind `LabelDatum` elements, but we can't change the underlying type without an assertion
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return node.that.groups.streamsLabels
.select(`#${CSS.escape(`label${node.n.streamName}`)}`) as Selection<SVGTextElement, LabelDatum, null, unknown>;
}