auspice
Version:
Web app for visualizing pathogen evolution
533 lines (482 loc) • 20.7 kB
JavaScript
import React from "react";
import { infoPanelStyles } from "../../../globalStyles";
import { numericToCalendar } from "../../../util/dateHelpers";
import { getTipColorAttribute } from "../../../util/colorHelpers";
import { isColorByGenotype, decodeColorByGenotype } from "../../../util/getGenotype";
import { getTraitFromNode, getDivFromNode, getVaccineFromNode,
getFullAuthorInfoFromNode, getTipChanges, getBranchMutations } from "../../../util/treeMiscHelpers";
import { isValueValid, strainSymbol, NODE_VISIBLE } from "../../../util/globals";
import { formatDivergence, getIdxOfInViewRootNode } from "../phyloTree/helpers";
import { parseIntervalsOfNsOrGaps } from "./MutationTable";
import { nodeDisplayName, dateInfo } from "./helpers";
import { streamLabelSymbol } from "../../../reducers/tree/types";
export const InfoLine = ({name, value, padBelow=false}) => {
const renderValues = () => {
if (!value && value !== 0) return null;
if (Array.isArray(value)) {
return value.map((v) => (
<div key={v} style={{fontWeight: "300", marginLeft: "0em"}}>
{v}
</div>
));
}
return (<span style={{fontWeight: "300"}}>{value}</span>);
};
return (
<div style={{paddingBottom: padBelow ? "15px" : "10px"}} key={name}>
<span style={{fontWeight: "500"}}>
{name + " "}
</span>
{renderValues()}
</div>
);
};
/**
* A React component to display information about the branch's time & divergence (where applicable)
* @param {Object} props
* @param {Object} props.node branch node currently highlighted
* @param {boolean} props.isTerminal
*/
const BranchLength = ({node, t, isTerminal}) => {
const elements = []; // elements to render
const divergence = getDivFromNode(node);
if (divergence) {
elements.push(<InfoLine name={t("Divergence")+":"} value={formatDivergence(divergence)} key="div"/>);
}
const {date, dateRange, inferred, ambiguousDate} = dateInfo(node, isTerminal);
if (date) {
const dateDescription = inferred ? 'Inferred Date' : 'Date';
elements.push(<InfoLine name={t(dateDescription)+":"} value={date} key="date"/>);
if (inferred && dateRange) {
elements.push(<InfoLine name={t("Date Confidence Interval")+":"} value={`(${dateRange.join(', ')})`} key="dateConf"/>);
}
if (ambiguousDate) {
elements.push(<InfoLine name={t("Provided Date")+":"} value={ambiguousDate} key="date"/>);
}
}
return elements;
};
/**
* A React component to display information about the colorBy for a tip/branch,
* potentially including a table with confidences.
* @param {Object} props
* @param {Object} props.node branch / tip node currently highlighted
* @param {string} props.colorBy
* @param {bool} props.colorByConfidence should these (colorBy conf) be displayed, if applicable?
* @param {func} props.colorScale
* @param {object} props.colorings
*/
const ColorBy = ({node, colorBy, colorByConfidence, colorScale, colorings}) => {
if (colorBy === "num_date") {
return null; /* date has already been displayed via <BranchLength> */
}
/* handle genotype as a special case */
if (isColorByGenotype(colorBy)) {
const genotype = decodeColorByGenotype(colorBy);
const name = genotype.aa ?
`Amino Acid at ${genotype.gene} site ${genotype.positions.join(", ")}:` :
`Nucleotide at pos ${genotype.positions.join(", ")}:`;
return <InfoLine name={name} value={getTipColorAttribute(node, colorScale)}/>;
}
/* handle author as a special case */
if (colorBy === "author") {
const authorInfo = getFullAuthorInfoFromNode(node);
if (!authorInfo) return null;
// <InfoLine name="Author:" value={authorInfo.value}/> This is already displayed by AttributionInfo
return (
<>
{authorInfo.title ? <InfoLine name="Title:" value={authorInfo.title}/> : null}
{authorInfo.journal ? <InfoLine name="Journal:" value={authorInfo.journal}/> : null}
</>
);
}
/* general case */
const name = (colorings && colorings[colorBy] && colorings[colorBy].title) ?
colorings[colorBy].title :
colorBy;
const value = getTraitFromNode(node, colorBy);
/* case where the colorScale is temporal */
if (colorScale.scaleType==="temporal" && typeof value === "number") {
return <InfoLine name={`${name}:`} value={numericToCalendar(value)}/>;
}
/* helper function to avoid code duplication */
const showCurrentColorByWithoutConfidence = () => {
return isValueValid(value) ?
<InfoLine name={`${name}:`} value={value}/> :
null;
};
/* handle trait confidences with lots of edge cases.
This can be much improved upon resolution of https://github.com/nextstrain/augur/issues/386 */
if (colorByConfidence === true) {
const confidenceData = getTraitFromNode(node, colorBy, {confidence: true});
if (!confidenceData) {
console.error("couldn't find confidence vals for ", colorBy);
return null;
}
/* if it's a tip with one confidence value > 0.99 then we interpret this as a known (i.e. not inferred) state */
if (!node.hasChildren && Object.keys(confidenceData).length === 1 && Object.values(confidenceData)[0] > 0.99) {
return showCurrentColorByWithoutConfidence();
}
const vals = Object.keys(confidenceData)
.filter((v) => isValueValid(v))
.sort((a, b) => confidenceData[a] > confidenceData[b] ? -1 : 1)
.slice(0, 4)
.map((v) => `${v} (${(100 * confidenceData[v]).toFixed(0)}%)`);
if (!vals.length) return null; // can happen if values are invalid
return <InfoLine name={`${name} (confidence):`} value={vals}/>;
}
return showCurrentColorByWithoutConfidence();
};
/**
* A React Component to display summary counts of changes between a tip node & the root
* @param {Object} props
* @param {Object} props.node branch node which is currently highlighted
*/
const TipMutations = ({node, t}) => {
const changes = getTipChanges(node);
if (!changes.nuc) return null; // can happen on trees with no mutations defined
const nucCounts = {changes: 0, gaps: 0, reversionsToRoot: 0, ns: 0};
const aaCounts = {changes: 0, gaps: 0, reversionsToRoot: 0};
Object.keys(changes)
.forEach((gene) => {
Object.entries(changes[gene]).forEach(([key, muts]) => {
if (gene==="nuc") {
nucCounts[key] += muts.length;
} else {
aaCounts[key] += muts.length;
}
});
});
let ntSummary = `${nucCounts.changes}${nucCounts.reversionsToRoot ? ` + ${nucCounts.reversionsToRoot} reversions to root`: ''}`;
ntSummary += `${nucCounts.gaps ? ` + ${nucCounts.gaps} gaps`: ''}${nucCounts.nt ? ` + ${nucCounts.nt} Ns`: ''}`;
let aaSummary = `${aaCounts.changes}${aaCounts.reversionsToRoot ? ` + ${aaCounts.reversionsToRoot} reversions to root`: ''}`;
aaSummary += `${aaCounts.gaps ? ` + ${aaCounts.gaps} gaps`: ''}`;
return [
<InfoLine name={t("Nucleotide changes")+":"} value={ntSummary} key="nuc"/>,
<InfoLine name={t("Amino Acid changes")+":"} value={aaSummary} key="aa"/>
];
};
/**
* 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
* @param {Object} props.observedMutations counts of all observed mutations across the tree
*/
const BranchMutations = ({node, geneSortFn, observedMutations, t}) => {
if (!node.branch_attrs || !node.branch_attrs.mutations) return null;
const elements = []; // elements to render
const mutations = node.branch_attrs.mutations;
const categorisedMutations = getBranchMutations(node, observedMutations);
const subset = (muts, maxNum) =>
muts.slice(0, Math.min(maxNum, muts.length)).join(", ") +
(muts.length > maxNum ? ` + ${muts.length-maxNum} more` : '');
/* --------- NUCLEOTIDE MUTATIONS --------------- */
if (categorisedMutations.nuc) {
const nDisplay = 5; // max number of mutations to display per category
if (categorisedMutations.nuc.unique.length) {
elements.push(<InfoLine name='Unique Nucleotide mutations:' value={subset(categorisedMutations.nuc.unique, nDisplay)} key="nuc_unique"/>);
}
if (categorisedMutations.nuc.homoplasies.length) {
elements.push(<InfoLine name='Homoplasic mutations:' value={subset(categorisedMutations.nuc.homoplasies, nDisplay)} key="nuc_homoplasies"/>);
}
if (categorisedMutations.nuc.reversionsToRoot.length) {
elements.push(<InfoLine name='Reversions to Root:' value={categorisedMutations.nuc.reversionsToRoot.length} key="nuc_rtr"/>);
}
if (categorisedMutations.nuc.gaps.length) {
const value = `${parseIntervalsOfNsOrGaps(categorisedMutations.nuc.gaps).length} regions, ${categorisedMutations.nuc.gaps.length}bp.`;
elements.push(<InfoLine name='Gaps:' value={value} key="nuc_gaps"/>);
}
if (categorisedMutations.nuc.undeletions.length) {
const value = `${parseIntervalsOfNsOrGaps(categorisedMutations.nuc.undeletions).length} regions, ${categorisedMutations.nuc.undeletions.length}bp.`;
elements.push(<InfoLine name='Undeletions:' value={value} key="nuc_undeletions"/>);
}
if (categorisedMutations.nuc.ns.length) {
const value = `${parseIntervalsOfNsOrGaps(categorisedMutations.nuc.ns).length} regions, ${categorisedMutations.nuc.ns.length}bp.`;
elements.push(<InfoLine name='Ns:' value={value} key="nuc_ns"/>);
}
} else {
elements.push(<InfoLine name={t("No nucleotide mutations")} value="" key="nuc"/>);
}
/* --------- AMINO ACID MUTATIONS --------------- */
/* AA mutations are found at `mutations[prot_name]` -> Array of strings */
const prots = Object.keys(mutations)
.sort(geneSortFn)
.filter((v) => v !== "nuc");
const mutationsToDisplay = {};
let shouldDisplay = false;
for (const prot of prots) {
const muts = mutations[prot].filter((mut) => !mut.endsWith("X"));
if (muts.length) {
mutationsToDisplay[prot] = muts;
shouldDisplay = true;
}
}
if (shouldDisplay) {
const nDisplay = 3; // number of mutations to display per protein
const nProtsToDisplay = 7; // max number of proteins to display
const mutationsToRender = [];
Object.keys(mutationsToDisplay).forEach((prot, idx) => {
if (idx < nProtsToDisplay) {
let x = prot + ":\u00A0\u00A0" + mutationsToDisplay[prot].slice(0, Math.min(nDisplay, mutationsToDisplay[prot].length)).join(", ");
if (mutationsToDisplay[prot].length > nDisplay) {
x += " + " + t("{{x}} more", {x: mutationsToDisplay[prot].length - nDisplay});
}
mutationsToRender.push(x);
} else if (idx === nProtsToDisplay) {
mutationsToRender.push(`(${t("protein mutations truncated")})`);
}
});
elements.push(<InfoLine name={t("AA mutations")+":"} value={mutationsToRender} key="aa"/>);
} else if (mutations.nuc && mutations.nuc.length) {
/* we only print "No amino acid mutations" if we didn't already print
"No nucleotide mutations" above */
elements.push(<InfoLine name={t("No amino acid mutations")} key="aa"/>);
}
return elements;
};
/**
* A React component to render the descendent(s) of the current branch
* @param {Object} props
* @param {Object} props.node branch node which is currently highlighted
*/
const BranchDescendants = ({node, t, tipLabelKey}) => {
const [name, value] = node.fullTipCount === 1 ?
[nodeDisplayName(t, node, tipLabelKey, true), ""] :
[t("Number of descendants")+":", node.fullTipCount];
return <InfoLine name={name} value={value} padBelow/>;
};
/**
* A React component to show vaccine information, if present
* @param {Object} props
* @param {Object} props.node branch node which is currently highlighted
*/
const VaccineInfo = ({node, t}) => {
const vaccineInfo = getVaccineFromNode(node);
if (!vaccineInfo) return null;
const renderElements = [];
if (vaccineInfo.selection_date) {
renderElements.push(<InfoLine name={t("Vaccine selected")+":"} value={vaccineInfo.selection_date} key="seldate"/>);
}
if (vaccineInfo.start_date) {
renderElements.push(<InfoLine name={t("Vaccine start date")+":"} value={vaccineInfo.start_date} key="startdate"/>);
}
if (vaccineInfo.end_date) {
renderElements.push(<InfoLine name={t("Vaccine end date")+":"} value={vaccineInfo.end_date} key="enddate"/>);
}
if (vaccineInfo.serum) {
renderElements.push(<InfoLine name={t("Serum strain")} key="serum"/>);
}
return renderElements;
};
/**
* A React component to show attribution information, if present
* @param {Object} props
* @param {Object} props.node branch node which is currently highlighted
*/
const AttributionInfo = ({node}) => {
const renderElements = [];
const authorInfo = getFullAuthorInfoFromNode(node);
if (authorInfo) {
renderElements.push(<InfoLine name="Author:" value={authorInfo.value} key="author"/>);
}
/* The `gisaid_epi_isl` is a special value attached to nodes introduced during the 2019 nCoV outbreak.
If set, we display this extra piece of information on hover */
const gisaid_epi_isl = getTraitFromNode(node, "gisaid_epi_isl");
if (isValueValid(gisaid_epi_isl)) {
const epi_isl = gisaid_epi_isl.split("_")[2];
renderElements.push(<InfoLine name="GISAID EPI ISL:" value={epi_isl} key="gisaid_epi_isl"/>);
}
return renderElements;
};
const Container = ({node, panelDims, children, xy=undefined}) => {
const xOffset = 10;
const yOffset = 10;
let width = 200;
if (panelDims.width < 420) {
width = 200;
} else if (panelDims.width < 460) {
width = 220;
} else if (panelDims.width < 500) {
width = 240;
} else if (panelDims.width < 540) {
width = 260;
} else {
width = 280;
}
let xPos,yPos;
if (xy) {
[xPos,yPos] = xy;
} else {
/* this adjusts the x-axis for the right tree in dual tree view */
xPos = node.shell.that.params.orientation[0] === -1 ?
panelDims.width / 2 + panelDims.spaceBetweenTrees + node.shell.xTip :
node.shell.xTip;
yPos = node.shell.yTip;
}
const styles = {
container: {
position: "absolute",
width,
padding: "10px",
borderRadius: 10,
zIndex: 1000,
pointerEvents: "none",
fontFamily: infoPanelStyles.panel.fontFamily,
fontSize: 14,
lineHeight: 1,
fontWeight: infoPanelStyles.panel.fontWeight,
color: infoPanelStyles.panel.color,
backgroundColor: infoPanelStyles.panel.backgroundColor,
wordWrap: "break-word",
wordBreak: "break-word"
}
};
if (xPos < panelDims.width * 0.5) {
styles.container.left = xPos + xOffset;
} else {
styles.container.right = panelDims.width - xPos + xOffset;
}
if (yPos < panelDims.height * 0.55) {
styles.container.top = yPos + 4 * yOffset;
} else {
styles.container.bottom = panelDims.height - yPos + yOffset;
}
return (
<div style={styles.container}>
<div className={"tooltip"} style={infoPanelStyles.tooltip}>
{children}
</div>
</div>
);
};
const Comment = ({children}) => (
<div style={infoPanelStyles.comment}>
{children}
</div>
);
/**
* Information to show when hovering over an individual ribbon within a stream
*/
function StreamRibbonInfo({node, streamDetails, colorBy}) {
const idxOfInViewRootNode = getIdxOfInViewRootNode(node);
const streamLabel = node.shell.that.streams[streamLabelSymbol];
const streamCounts = node.streamNodeCounts; /* for entire stream */
const streamCountsSummary = streamCounts.total === streamCounts.visible ?
`${streamCounts.total} (all visible)` :
`${streamCounts.visible}/${streamCounts.total}`;
const category = node.streamCategories[streamDetails.categoryIndex].name;
const rippleNodeIdxs = node.streamCategories.filter((s) => s.name===category).at(0).nodes;
const nVisible = rippleNodeIdxs.filter((idx) => node.shell.that.nodes[idx].visibility===NODE_VISIBLE).length;
const rippleCountsSummary = nVisible===rippleNodeIdxs.length ?
`${nVisible} (all visible)` :
`${nVisible}/${rippleNodeIdxs.length}`;
return (
<>
<div style={infoPanelStyles.tooltipHeading}>
{`Streamtree for ${streamLabel} ${node.streamName}`}
</div>
<InfoLine name="Visible tips (entire streamtree):" value={streamCountsSummary}/>
<div style={infoPanelStyles.tooltipHeading}>
{`Ripple (${colorBy}): ${category ?? '(missing data)'}`}
</div>
<InfoLine name="Visible tips (this category):" value={rippleCountsSummary}/>
<div style={{paddingTop: '5px'}}>
{idxOfInViewRootNode===node.arrayIdx ? 'Click to zoom out' : 'Click to zoom into stream'}
</div>
</>
);
}
/**
* Information to show when hovering over the connector (branch) to a stream
*/
function StreamConnectorInfo({node, lineType}) {
/* Work out how many streams descend from this one */
const streams = node.shell.that.streams;
let nDescendentStreams = 0; // don't include this stream!
const stack = [...streams[node.streamName].streamChildren];
while (stack.length) {
const streamName = stack.pop();
nDescendentStreams++;
for (const childName of streams[streamName].streamChildren) stack.push(childName);
}
const totalTipCounts = node.fullTipCount === node.tipCount ?
`${node.tipCount} tips (all visible)` :
`${node.tipCount} visible tips (out of ${node.fullTipCount})`;
const counts = node.streamNodeCounts;
const thisStreamCounts = counts.total === counts.visible ?
`${counts.total} tips (all visible)` :
`${counts.visible} visible tips (out of ${counts.total})`;
const title = lineType==='joiner' ?
`Connection to stream: ${node.streamName}` :
`Stream: ${node.streamName}`;
return (
<>
<div style={infoPanelStyles.tooltipHeading}>
{title}
</div>
<InfoLine name={`Stream ${node.streamName} comprises`} value={thisStreamCounts}/>
{nDescendentStreams > 0 ?
<>
<InfoLine name="Further streams originate from this one" value={`(n=${nDescendentStreams})`}/>
<InfoLine name="All streams (together) summarise" value={totalTipCounts}/>
</> :
<InfoLine name="No further streams originate from this one" value=""/>}
</>
);
}
const HoverInfoPanel = ({
selectedNode,
colorBy,
colorByConfidence,
colorScale,
panelDims,
colorings,
geneSortFn,
observedMutations,
tipLabelKey,
t
}) => {
if (!selectedNode) return null
const node = selectedNode.node.n; // want the redux node, not the phylo node
const idxOfInViewRootNode = getIdxOfInViewRootNode(node);
if (selectedNode.streamDetails) {
return (
<Container node={node} panelDims={panelDims} xy={[selectedNode.streamDetails.x, selectedNode.streamDetails.y]}>
{selectedNode.isBranch ?
<StreamConnectorInfo node={node} lineType={['joiner', 'backbone'][selectedNode.streamDetails.categoryIndex]}/> :
<StreamRibbonInfo node={node} streamDetails={selectedNode.streamDetails} colorBy={colorBy}/>}
</Container>
);
}
return (
<Container node={node} panelDims={panelDims}>
{selectedNode.isBranch===false ? (
<>
<div style={infoPanelStyles.tooltipHeading}>
{nodeDisplayName(t, node, tipLabelKey, false)}
</div>
{tipLabelKey!==strainSymbol && <InfoLine name="Node name:" value={node.name}/>}
<VaccineInfo node={node} t={t}/>
<TipMutations node={node} t={t}/>
<BranchLength node={node} t={t} isTerminal={true}/>
<ColorBy node={node} colorBy={colorBy} colorByConfidence={colorByConfidence} colorScale={colorScale} colorings={colorings}/>
<AttributionInfo node={node}/>
<Comment>{t("Click on tip to display more info")}</Comment>
</>
) : (
<>
<BranchDescendants node={node} t={t} tipLabelKey={tipLabelKey}/>
<BranchMutations node={node} geneSortFn={geneSortFn} observedMutations={observedMutations} t={t}/>
<BranchLength node={node} t={t} isTerminal={false}/>
<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>
);
};
export default HoverInfoPanel;