Skip to content

Commit

Permalink
Merge pull request #1420 from nextstrain/node-click
Browse files Browse the repository at this point in the history
Improve info boxes on tree
  • Loading branch information
jameshadfield authored Oct 19, 2021
2 parents dbd40e4 + 9aafd61 commit 23f95a9
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 88 deletions.
2 changes: 0 additions & 2 deletions src/actions/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ export const NEW_COLORS = "NEW_COLORS";
export const LOAD_FREQUENCIES = "LOAD_FREQUENCIES";
export const FREQUENCY_MATRIX = "FREQUENCY_MATRIX";
export const BROWSER_DIMENSIONS = "BROWSER_DIMENSIONS";
export const BRANCH_MOUSEENTER = "BRANCH_MOUSEENTER";
export const BRANCH_MOUSELEAVE = "BRANCH_MOUSELEAVE";
export const NODE_MOUSEENTER = "NODE_MOUSEENTER";
export const NODE_MOUSELEAVE = "NODE_MOUSELEAVE";
export const SEARCH_INPUT_CHANGE = "SEARCH_INPUT_CHANGE";
Expand Down
117 changes: 81 additions & 36 deletions src/components/tree/infoPanels/click.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import styled from 'styled-components';
import { isValueValid } from "../../../util/globals";
import { infoPanelStyles } from "../../../globalStyles";
import { numericToCalendar } from "../../../util/dateHelpers";
Expand Down Expand Up @@ -51,35 +52,61 @@ const Link = ({url, title, value}) => (
</tr>
);

const Button = styled.button`
border: 0px;
background-color: inherit;
cursor: pointer;
outline: 0;
text-decoration: underline;
`;

/**
* Render a 2-column table of gene -> mutations.
* Rows are sorted by gene name, alphabetically, with "nuc" last.
* Mutations are sorted by genomic position.
* todo: sort genes by position in genome
* todo: provide in-app links from mutations to color-bys? filters?
*/
const MutationTable = ({mutations}) => {
const geneSortFn = (a, b) => {
if (a[0]==="nuc") return 1;
if (b[0]==="nuc") return -1;
return a[0]<b[0] ? -1 : 1;
};
const MutationTable = ({node, geneSortFn, isTip}) => {
const mutSortFn = (a, b) => {
const [aa, bb] = [parseInt(a.slice(1, -1), 10), parseInt(b.slice(1, -1), 10)];
return aa<bb ? -1 : 1;
};
const displayGeneMutations = (gene, muts) => {
if (gene==="nuc" && isTip && muts.length>10) {
return (
<div key={gene} style={{...infoPanelStyles.item, ...{fontWeight: 300}}}>
<Button onClick={() => {navigator.clipboard.writeText(muts.sort(mutSortFn).join(", "));}}>
{`${muts.length} nucleotide mutations, click to copy to clipboard`}
</Button>
</div>
);
}
return (
<div key={gene} style={{...infoPanelStyles.item, ...{fontWeight: 300}}}>
{gene}: {muts.sort(mutSortFn).join(", ")}
</div>
);
};

let mutations;
if (isTip) {
mutations = collectMutations(node, true);
} else if (node.branch_attrs && node.branch_attrs.mutations && Object.keys(node.branch_attrs.mutations).length) {
mutations = node.branch_attrs.mutations;
}
if (!mutations) return null;

const title = isTip ? "Mutations from root" : "Mutations on branch";

// we encode the table here (rather than via `item()`) to set component keys appropriately
return (
<tr key={"Mutations"}>
<th style={infoPanelStyles.item}>{"Mutations from root"}</th>
<th style={infoPanelStyles.item}>{title}</th>
<td style={infoPanelStyles.item}>{
Object.entries(mutations)
Object.keys(mutations)
.sort(geneSortFn)
.map(([gene, muts]) => (
<div style={{...infoPanelStyles.item, ...{fontWeight: 300}}}>
{gene}: {muts.sort(mutSortFn).join(", ")}
</div>
))
.map((gene) => displayGeneMutations(gene, mutations[gene]))
}</td>
</tr>
);
Expand Down Expand Up @@ -204,21 +231,21 @@ const StrainName = ({children}) => (
<p style={infoPanelStyles.modalHeading}>{children}</p>
);

const SampleDate = ({node, t}) => {
const SampleDate = ({isTerminal, node, t}) => {
const date = getTraitFromNode(node, "num_date");
if (!date) return null;

const dateUncertainty = getTraitFromNode(node, "num_date", {confidence: true});
if (date && dateUncertainty && dateUncertainty[0] !== dateUncertainty[1]) {
return (
<>
{item(t("Inferred collection date"), numericToCalendar(date))}
{item(t(isTerminal ? "Inferred collection date" : "Inferred date"), numericToCalendar(date))}
{item(t("Date Confidence Interval"), `(${numericToCalendar(dateUncertainty[0])}, ${numericToCalendar(dateUncertainty[1])})`)}
</>
);
}

return item(t("Collection date"), numericToCalendar(date));
/* internal nodes are always inferred, regardless of whether uncertainty bounds are present */
return item(t(isTerminal ? "Collection date" : "Inferred date"), numericToCalendar(date));
};

const getTraitsToDisplay = (node) => {
Expand All @@ -228,16 +255,24 @@ const getTraitsToDisplay = (node) => {
return Object.keys(node.node_attrs).filter((k) => !ignore.includes(k));
};

const Trait = ({node, trait, colorings}) => {
const value_tmp = getTraitFromNode(node, trait);
let value = value_tmp;
if (typeof value_tmp === "number") {
if (!Number.isInteger(value_tmp)) {
value = Number.parseFloat(value_tmp).toPrecision(3);
const Trait = ({node, trait, colorings, isTerminal}) => {
let value = getTraitFromNode(node, trait);
const confidence = getTraitFromNode(node, trait, {confidence: true});

if (typeof value === "number") {
if (!Number.isInteger(value)) {
value = Number.parseFloat(value).toPrecision(3);
}
}
if (!isValueValid(value)) return null;

if (confidence && value in confidence) {
/* if it's a tip with one confidence value > 0.99 then we interpret this as a known (i.e. not inferred) state */
if (!isTerminal || confidence[value]<0.99) {
value = `${value} (${(100 * confidence[value]).toFixed(0)}%)`;
}
}

const name = (colorings && colorings[trait] && colorings[trait].title) ?
colorings[trait].title :
trait;
Expand All @@ -255,27 +290,37 @@ const Trait = ({node, trait, colorings}) => {
* @param {function} props.goAwayCallback
* @param {object} props.colorings
*/
const TipClickedPanel = ({tip, goAwayCallback, colorings, t}) => {
if (!tip) {return null;}
const NodeClickedPanel = ({selectedNode, clearSelectedNode, colorings, geneSortFn, t}) => {
if (selectedNode.event!=="click") {return null;}
const panelStyle = { ...infoPanelStyles.panel};
panelStyle.maxHeight = "70%";
const node = tip.n;
const mutationsToRoot = collectMutations(node);
const node = selectedNode.node.n;
const isTip = selectedNode.type === "tip";
const isTerminal = node.fullTipCount===1;

const title = isTip ?
node.name :
isTerminal ?
`Branch leading to ${node.name}` :
"Internal branch";

return (
<div style={infoPanelStyles.modalContainer} onClick={() => goAwayCallback(tip)}>
<div style={infoPanelStyles.modalContainer} onClick={() => clearSelectedNode(selectedNode)}>
<div className={"panel"} style={panelStyle} onClick={(e) => stopProp(e)}>
<StrainName>{node.name}</StrainName>
<StrainName>{title}</StrainName>
<table>
<tbody>
<VaccineInfo node={node} t={t}/>
<SampleDate node={node} t={t}/>
<PublicationInfo node={node} t={t}/>
{!isTip && item(t("Number of terminal tips"), node.fullTipCount)}
{isTip && <VaccineInfo node={node} t={t}/>}
<SampleDate isTerminal={isTerminal} node={node} t={t}/>
{!isTip && item("Node name", node.name)}
{isTip && <PublicationInfo node={node} t={t}/>}
{getTraitsToDisplay(node).map((trait) => (
<Trait node={node} trait={trait} colorings={colorings} key={trait}/>
<Trait node={node} trait={trait} colorings={colorings} key={trait} isTerminal={isTerminal}/>
))}
<AccessionAndUrl node={node}/>
{isTip && <AccessionAndUrl node={node}/>}
{item("", "")}
<MutationTable mutations={mutationsToRoot}/>
<MutationTable node={node} geneSortFn={geneSortFn} isTip={isTip}/>
</tbody>
</table>
<p style={infoPanelStyles.comment}>
Expand All @@ -286,4 +331,4 @@ const TipClickedPanel = ({tip, goAwayCallback, colorings, t}) => {
);
};

export default TipClickedPanel;
export default NodeClickedPanel;
21 changes: 13 additions & 8 deletions src/components/tree/infoPanels/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ const ColorBy = ({node, colorBy, colorByConfidence, colorScale, colorings}) => {
* A React Component to Display AA / NT mutations, if present.
* @param {Object} props
* @param {Object} props.node branch node which is currently highlighted
* @param {Object} props.geneSortFn function to sort a list of genes
*/
const Mutations = ({node, t}) => {
const Mutations = ({node, geneSortFn, t}) => {
if (!node.branch_attrs || !node.branch_attrs.mutations) return null;
const elements = []; // elements to render
const mutations = node.branch_attrs.mutations;
Expand Down Expand Up @@ -183,7 +184,9 @@ const Mutations = ({node, t}) => {

/* --------- AMINO ACID MUTATIONS --------------- */
/* AA mutations are found at `mutations[prot_name]` -> Array of strings */
const prots = Object.keys(mutations).filter((v) => v !== "nuc");
const prots = Object.keys(mutations)
.sort(geneSortFn)
.filter((v) => v !== "nuc");

const mutationsToDisplay = {};
let shouldDisplay = false;
Expand Down Expand Up @@ -349,25 +352,26 @@ const Comment = ({children}) => (
);

const HoverInfoPanel = ({
hovered,
selectedNode,
colorBy,
colorByConfidence,
colorScale,
panelDims,
colorings,
geneSortFn,
t
}) => {
if (!hovered) return null;
const node = hovered.d.n;
if (selectedNode.event !== "hover") return null;
const node = selectedNode.node.n;
const idxOfInViewRootNode = getIdxOfInViewRootNode(node);

return (
<Container node={node} panelDims={panelDims}>
{hovered.type === ".tip" ? (
{selectedNode.type === "tip" ? (
<>
<StrainName name={node.name}/>
<VaccineInfo node={node} t={t}/>
<Mutations node={node} t={t}/>
<Mutations node={node} geneSortFn={geneSortFn} t={t}/>
<BranchLength node={node} t={t}/>
<ColorBy node={node} colorBy={colorBy} colorByConfidence={colorByConfidence} colorScale={colorScale} colorings={colorings}/>
<AttributionInfo node={node}/>
Expand All @@ -376,12 +380,13 @@ const HoverInfoPanel = ({
) : (
<>
<BranchDescendents node={node} t={t}/>
<Mutations node={node} t={t}/>
<Mutations node={node} geneSortFn={geneSortFn} t={t}/>
<BranchLength node={node} t={t}/>
<ColorBy node={node} colorBy={colorBy} colorByConfidence={colorByConfidence} colorScale={colorScale} colorings={colorings}/>
<Comment>
{idxOfInViewRootNode === node.arrayIdx ? t('Click to zoom out to parent clade') : t('Click to zoom into clade')}
</Comment>
<Comment>{t("Shift + Click to display more info")}</Comment>
</>
)}
</Container>
Expand Down
63 changes: 44 additions & 19 deletions src/components/tree/reactD3Interface/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,23 @@ export const onTipHover = function onTipHover(d) {
phylotree.svg.select(getDomId("#tip", d.n.name))
.attr("r", (e) => e["r"] + 4);
this.setState({
hovered: {d, type: ".tip"}
selectedNode: {
node: d,
type: "tip",
event: "hover"
}
});
};

export const onTipClick = function onTipClick(d) {
if (d.visibility !== NODE_VISIBLE) return;
if (this.props.narrativeMode) return;
this.setState({
hovered: null,
selectedTip: d
selectedNode: {
node: d,
type: "tip",
event: "click"
}
});
this.props.dispatch(applyFilter("add", strainSymbol, [d.n.name]));
};
Expand All @@ -48,13 +55,30 @@ export const onBranchHover = function onBranchHover(d) {

/* Set the hovered state so that an info box can be displayed */
this.setState({
hovered: {d, type: ".branch"}
selectedNode: {
node: d,
type: "branch",
event: "hover"
}
});
};

export const onBranchClick = function onBranchClick(d) {
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 */
if (window.event.shiftKey) {
this.setState({
selectedNode: {
node: d,
type: "branch",
event: "click"
}
});
return;
}

const root = [undefined, undefined];
let cladeSelected;
// Branches with multiple labels will be used in the order specified by this.props.tree.availableBranchLabels
Expand Down Expand Up @@ -89,6 +113,8 @@ export const onBranchClick = function onBranchClick(d) {

/* onBranchLeave called when mouse-off, i.e. anti-hover */
export const onBranchLeave = function onBranchLeave(d) {
if (this.state.selectedNode.event!=="hover") return;

/* Reset the stroke back to what it was before */
branchStrokeForLeave(d);

Expand All @@ -97,33 +123,32 @@ export const onBranchLeave = function onBranchLeave(d) {
const tree = d.that.params.orientation[0] === 1 ? this.state.tree : this.state.treeToo;
tree.removeConfidence();
}
/* Set hovered state to `null`, which will remove the info box */
if (this.state.hovered) {
this.setState({hovered: null});
}
/* Set selectedNode state to an empty object, which will remove the info box */
this.setState({selectedNode: {}});
};

export const onTipLeave = function onTipLeave(d) {
if (this.state.selectedNode.event!=="hover") return;
const phylotree = d.that.params.orientation[0] === 1 ?
this.state.tree :
this.state.treeToo;
if (!this.state.selectedTip) {
if (!this.state.selectedNode) {
phylotree.svg.select(getDomId("#tip", d.n.name))
.attr("r", (dd) => dd["r"]);
}
if (this.state.hovered) {
this.setState({hovered: null});
}
this.setState({selectedNode: {}});
};

/* clearSelectedTip when clicking to go away */
export const clearSelectedTip = function clearSelectedTip(d) {
const phylotree = d.that.params.orientation[0] === 1 ?
/* clearSelectedNode when clicking to remove the node-selected modal */
export const clearSelectedNode = function clearSelectedNode(selectedNode) {
const phylotree = selectedNode.node.that.params.orientation[0] === 1 ?
this.state.tree :
this.state.treeToo;
phylotree.svg.select(getDomId("#tip", d.n.name))
phylotree.svg.select(getDomId("#tip", selectedNode.node.n.name))
.attr("r", (dd) => dd["r"]);
this.setState({selectedTip: null, hovered: null});
/* restore the tip visibility! */
this.props.dispatch(applyFilter("inactivate", strainSymbol, [d.n.name]));
this.setState({selectedNode: {}});
if (selectedNode.type==="tip") {
/* restore the tip visibility! */
this.props.dispatch(applyFilter("inactivate", strainSymbol, [selectedNode.node.n.name]));
}
};
Loading

0 comments on commit 23f95a9

Please sign in to comment.