From 68f57f6093e64101993f4760ade970e2d0551724 Mon Sep 17 00:00:00 2001 From: James Hadfield Date: Wed, 26 Jan 2022 12:09:22 +1300 Subject: [PATCH 1/5] Collect all observed mutations This is in preparation for a forthcoming commit which will need this information to decide whether a mutation is homoplasic. --- src/reducers/tree.js | 1 + src/util/treeJsonProcessing.js | 26 +++++++++++++++++++++++++- test/treeHelpers.test.js | 30 ++++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/reducers/tree.js b/src/reducers/tree.js index 156b31834..e5255d5b1 100644 --- a/src/reducers/tree.js +++ b/src/reducers/tree.js @@ -22,6 +22,7 @@ export const getDefaultTreeState = () => { idxOfInViewRootNode: 0, visibleStateCounts: {}, totalStateCounts: {}, + observedMutations: {}, availableBranchLabels: [], selectedStrain: undefined, selectedClade: undefined diff --git a/src/util/treeJsonProcessing.js b/src/util/treeJsonProcessing.js index b65f7a754..31ad1e311 100644 --- a/src/util/treeJsonProcessing.js +++ b/src/util/treeJsonProcessing.js @@ -117,6 +117,29 @@ const appendParentsToTree = (root) => { } }; +/** + * Collects all mutations on the tree + * @param {Node[]} nodesArray + * @return {Object} + * keys are mutations in gene:fromPosTo format (e.g. nuc:A123T) + * values are integers representing occurrences on tree + * @todo The original remit of this function was for homoplasy detection. + * If storing all the mutations becomes an issue, we may be able use an array + * of mutations observed more than once. + */ +const collectObservedMutations = (nodesArray) => { + const mutations = {}; + nodesArray.forEach((n) => { + if (!n.branch_attrs || !n.branch_attrs.mutations) return; + Object.entries(n.branch_attrs.mutations).forEach(([gene, muts]) => { + muts.forEach((mut) => { + mutations[`${gene}:${mut}`] ? mutations[`${gene}:${mut}`]++ : (mutations[`${gene}:${mut}`] = 1); + }); + }); + }); + return mutations; +}; + export const treeJsonToState = (treeJSON) => { appendParentsToTree(treeJSON); const nodesArray = flattenTree(treeJSON); @@ -126,7 +149,8 @@ export const treeJsonToState = (treeJSON) => { return (v && (Object.keys(v).length > 1 || Object.keys(v)[0] !== "serum")); }); const availableBranchLabels = processBranchLabelsInPlace(nodesArray); + const observedMutations = collectObservedMutations(nodesArray); return Object.assign({}, getDefaultTreeState(), { - nodes, vaccines, availableBranchLabels, loaded: true + nodes, vaccines, observedMutations, availableBranchLabels, loaded: true }); }; diff --git a/test/treeHelpers.test.js b/test/treeHelpers.test.js index 362e820a6..0d5adb172 100644 --- a/test/treeHelpers.test.js +++ b/test/treeHelpers.test.js @@ -2,13 +2,15 @@ import { collectMutations, getUrlFromNode, getAccessionFromNode } from "../src/u import { treeJsonToState } from "../src/util/treeJsonProcessing"; /** - * `dummyTree` is a simple tree with two tips: tipX and tipY + * `dummyTree` is a simple tree with three tips: tipX-Z * root to tipX mutations: * single mutation at position 100 in gene "GENE" of A->B * a reversion of C->D->C at position 200 * multiple mutations at 300 E->F->G * root to tipY mutations: * ["A100B", "C200D", "E300F"] + * root to tipZ mutations: + * the three root mutations + a duplicated mutation present on tipX ("F300G") */ const dummyTree = treeJsonToState({ name: "ROOT", @@ -28,22 +30,42 @@ const dummyTree = treeJsonToState({ }, { // start 2nd child of node1 name: "tipY" + }, + { // 3rd child of node1 + name: "tipZ", + branch_attrs: {mutations: {GENE: ["F300G"]}} } ] } ] -}).nodes; +}); test("Tip->root mutations are correctly parsed", () => { - const tipXMutations = collectMutations(getNodeByName(dummyTree, "tipX")).GENE; - const tipYMutations = collectMutations(getNodeByName(dummyTree, "tipY")).GENE; + const tipXMutations = collectMutations(getNodeByName(dummyTree.nodes, "tipX")).GENE; + const tipYMutations = collectMutations(getNodeByName(dummyTree.nodes, "tipY")).GENE; expect(tipXMutations.sort()) .toEqual(["A100B", "E300G"].sort()); // note that pos 200 (reversion) has no mutations here expect(tipYMutations.sort()) .toEqual(["A100B", "C200D", "E300F"].sort()); }); + +describe('Parse and summarise mutations', () => { + test("Collection of all mutations", () => { + // note that the function we are testing, collectObservedMutations, + // is part of treeJsonToState so we are testing it indirectly + expect(dummyTree.observedMutations) + .toEqual({ // exactly equal all Obj keys and values + "GENE:A100B": 1, + "GENE:C200D": 1, + "GENE:E300F": 1, + "GENE:D200C": 1, + "GENE:F300G": 2, + }); + }); +}); + function getNodeByName(tree, name) { let namedNode; const recurse = (node) => { From 5a9d59f022eb38dd5549235296a6352ea0d73037 Mon Sep 17 00:00:00 2001 From: James Hadfield Date: Fri, 21 Jan 2022 14:45:49 +1300 Subject: [PATCH 2/5] Categorise mutations in tip-clicked panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These changes were motivated by issue #1444 [1] where separating mutations into categories can aid both QC and biological interpretation. I chose to use "mutations" to refer to mutations observed on a branch and "changes" to refer to the collection of mutations between a tip and the root. The categories are not necessarily disjoint, as a mutation back to the root will also be a homoplasy or a unique mutation. Note that changes between a tip sequence and the root aren't grouped into homoplasies, as a single change (A→C) may be the result of multiple mutations (e.g. A→B→C) and thus we would need to check the tip state of each position which is difficult with the current code. On-hover panels are left unchanged in this commit. [1] https://github.com/nextstrain/auspice/issues/1444 --- .../tree/infoPanels/MutationTable.js | 131 ++++++++++++++++++ src/components/tree/infoPanels/click.js | 69 +-------- src/components/tree/tree.js | 1 + src/util/treeMiscHelpers.js | 123 +++++++++++++--- test/treeHelpers.test.js | 48 +++++-- 5 files changed, 272 insertions(+), 100 deletions(-) create mode 100644 src/components/tree/infoPanels/MutationTable.js diff --git a/src/components/tree/infoPanels/MutationTable.js b/src/components/tree/infoPanels/MutationTable.js new file mode 100644 index 000000000..f2845dffb --- /dev/null +++ b/src/components/tree/infoPanels/MutationTable.js @@ -0,0 +1,131 @@ +import React from "react"; +import styled from 'styled-components'; +import { getBranchMutations, getTipChanges } from "../../../util/treeMiscHelpers"; + + +const Button = styled.button` + border: 0px; + background-color: inherit; + cursor: pointer; + outline: 0; + text-decoration: underline; + font-size: 16px; + padding: 0px 0px; +`; + +const mutSortFn = (a, b) => { + const [aa, bb] = [parseInt(a.slice(1, -1), 10), parseInt(b.slice(1, -1), 10)]; + return aa ( + + {title} + {muts.sort(mutSortFn).join(", ")} + +); + +const mutCategoryLookup = { + unique: "Unique", + changes: "Changes", + homoplasies: "Homoplasies", + reversionsToRoot: "Reversions to root", + gaps: "Gaps", + ns: "Ns " +}; + +/** + * Returns a TSV-style string of all mutations / changes + */ +const mutationsToTsv = (categorisedMutations, geneSortFn) => + Object.keys(categorisedMutations).sort(geneSortFn).map((gene) => + Object.keys(mutCategoryLookup) + .filter((key) => (key in categorisedMutations[gene] && categorisedMutations[gene][key].length)) + .map((key) => + `${gene}\t${key}\t${categorisedMutations[gene][key].sort(mutSortFn).join(", ")}` + ) + ).flat() + .join("\n"); + +/** + * Returns a table row element for the (categorised) mutations for the given gene + * @returns {(ReactComponent|null)} + */ +const displayGeneMutations = (gene, mutsPerCat) => { + /* check if any categories have entries for us to display */ + if (Object.values(mutsPerCat).filter((lst) => lst.length).length === 0) { + return null; + } + return ( + + {gene==="nuc" ? "Nt" : gene} + + {Object.entries(mutCategoryLookup).map(([key, name]) => ( + (key in mutsPerCat && mutsPerCat[key].length) ? + () : + null + ))} + + + ); +}; + +export const MutationTable = ({node, geneSortFn, isTip, observedMutations}) => { + const categorisedMutations = isTip ? + getTipChanges(node) : + getBranchMutations(node, observedMutations); + + if (Object.keys(categorisedMutations).length===0) { + return ( + + {isTip ? `No sequence changes observed` : `No mutations observed on branch`} + + ); + } + + return ( + <> + + {isTip ? `Sequence changes observed (from root):` : `Mutations observed on branch:`} + + + + {Object.keys(categorisedMutations).sort(geneSortFn).map( + (gene) => displayGeneMutations(gene, categorisedMutations[gene], isTip) + )} + + + + +
+ + +
+ + ); +}; diff --git a/src/components/tree/infoPanels/click.js b/src/components/tree/infoPanels/click.js index 5cd339297..78f9fe052 100644 --- a/src/components/tree/infoPanels/click.js +++ b/src/components/tree/infoPanels/click.js @@ -1,10 +1,10 @@ import React from "react"; -import styled from 'styled-components'; import { isValueValid } from "../../../util/globals"; import { infoPanelStyles } from "../../../globalStyles"; import { numericToCalendar } from "../../../util/dateHelpers"; import { getTraitFromNode, getFullAuthorInfoFromNode, getVaccineFromNode, - getAccessionFromNode, getUrlFromNode, collectMutations } from "../../../util/treeMiscHelpers"; + getAccessionFromNode, getUrlFromNode } from "../../../util/treeMiscHelpers"; +import { MutationTable } from "./MutationTable"; export const styles = { container: { @@ -52,67 +52,6 @@ const Link = ({url, title, value}) => ( ); -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 = ({node, geneSortFn, isTip}) => { - const mutSortFn = (a, b) => { - const [aa, bb] = [parseInt(a.slice(1, -1), 10), parseInt(b.slice(1, -1), 10)]; - return aa { - if (gene==="nuc" && isTip && muts.length>10) { - return ( -
- -
- ); - } - return ( -
- {gene}: {muts.sort(mutSortFn).join(", ")} -
- ); - }; - - 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 ( - - {title} - { - Object.keys(mutations) - .sort(geneSortFn) - .map((gene) => displayGeneMutations(gene, mutations[gene])) - } - - ); -}; - - const AccessionAndUrl = ({node}) => { /* If `gisaid_epi_isl` or `genbank_accession` exist as node attrs, these preempt normal use of `accession` and `url`. These special values were introduced during the SARS-CoV-2 pandemic. */ @@ -290,7 +229,7 @@ const Trait = ({node, trait, colorings, isTerminal}) => { * @param {function} props.goAwayCallback * @param {object} props.colorings */ -const NodeClickedPanel = ({selectedNode, clearSelectedNode, colorings, geneSortFn, t}) => { +const NodeClickedPanel = ({selectedNode, clearSelectedNode, colorings, observedMutations, geneSortFn, t}) => { if (selectedNode.event!=="click") {return null;} const panelStyle = { ...infoPanelStyles.panel}; panelStyle.maxHeight = "70%"; @@ -320,9 +259,9 @@ const NodeClickedPanel = ({selectedNode, clearSelectedNode, colorings, geneSortF ))} {isTip && } {item("", "")} - +

{t("Click outside this box to go back to the tree")}

diff --git a/src/components/tree/tree.js b/src/components/tree/tree.js index a0011d201..01333cdc0 100644 --- a/src/components/tree/tree.js +++ b/src/components/tree/tree.js @@ -197,6 +197,7 @@ class Tree extends React.Component { AB -> BA -> fromNode) will not be reported - * Multiple mutations (e.g. root -> AB -> BC -> fromNode) will be represented as AC - * We may want to expand this function to take a second argument as the "stopping node" - * @param {TreeNode} fromNode + * Walk from the proivided node back to the root, collecting all mutations as we go. + * Multiple mutations (e.g. root -> AB -> BC -> fromNode) will be collapsed to as AC + * Reversions to root (e.g. root -> AB -> BA -> fromNode) will be reported as AA + * Returned structure is .. = [, ] */ -export const collectMutations = (fromNode, include_nuc=false) => { +export const getSeqChanges = (fromNode) => { const mutations = {}; const walk = (n) => { if (n.branch_attrs && n.branch_attrs.mutations && Object.keys(n.branch_attrs.mutations).length) { Object.entries(n.branch_attrs.mutations).forEach(([gene, muts]) => { - if ((gene === "nuc" && include_nuc) || gene !== "nuc") { + if ((gene === "nuc") || gene !== "nuc") { if (!mutations[gene]) mutations[gene] = {}; muts.forEach((m) => { const [from, pos, to] = [m.slice(0, 1), m.slice(1, -1), m.slice(-1)]; // note: `pos` is a string @@ -160,34 +159,118 @@ export const collectMutations = (fromNode, include_nuc=false) => { } }; walk(fromNode); - // update structure to be returned - Object.keys(mutations).forEach((gene) => { - mutations[gene] = Object.entries(mutations[gene]) - .map(([pos, [from, to]]) => { - if (from===to) return undefined; // reversion to ancestral (root) state - return `${from}${pos}${to}`; - }) - .filter((value) => !!value); - }); return mutations; }; +/** + * Categorise each mutation into one or more of the following categories: + * (i) unique mutations (those which are only observed once) + * (ii) homoplasies (mutation observed elsewhere on the tree) + * (iii) gaps + * (iv) Ns (only applicable for nucleotides) + * (v) reversions to root (these will also be in (i) or (ii)) + */ +export const categoriseMutations = (mutations, observedMutations, seqChangesToRoot) => { + const categorisedMutations = {}; + for (const gene of Object.keys(mutations)) { + const categories = { unique: [], homoplasies: [], gaps: [], reversionsToRoot: []}; + const isNuc = gene==="nuc"; + if (isNuc) categories.ns = []; + mutations[gene].forEach((mut) => { + const newChar = mut.slice(-1); + if (newChar==="-") { + categories.gaps.push(mut); + } else if (isNuc && newChar==="N") { + categories.ns.push(mut); + } else if (observedMutations[`${gene}:${mut}`] > 1) { + categories.homoplasies.push(mut); + } else { + categories.unique.push(mut); + } + // check to see if this mutation is a reversion to root + const pos = mut.slice(1, -1); + if (newChar!=="-" && newChar!=="N" && seqChangesToRoot[gene] && + seqChangesToRoot[gene][pos] && seqChangesToRoot[gene][pos][0]===seqChangesToRoot[gene][pos][1]) { + categories.reversionsToRoot.push(mut); + } + }); + categorisedMutations[gene]=categories; + } + return categorisedMutations; +}; + +/** + * Categorise each seq change into one or more of the following categories: + * (i) changes mutations (those which are only observed once) + * (ii) reversions to root (these will _not_ be in (i) because they're not technically a change) + * (iii) gaps + * (iv) Ns (only applicable for nucleotides) + */ +export const categoriseSeqChanges = (seqChangesToRoot) => { + const categorisedSeqChanges = {}; + for (const gene of Object.keys(seqChangesToRoot)) { + const categories = { changes: [], gaps: [], reversionsToRoot: []}; + const isNuc = gene==="nuc"; + if (isNuc) categories.ns = []; + for (const [pos, fromTo] of Object.entries(seqChangesToRoot[gene])) { + const mut = `${fromTo[0]}${pos}${fromTo[1]}`; + if (fromTo[1]==="-") { + categories.gaps.push(mut); + } else if (isNuc && fromTo[1]==="N") { + categories.ns.push(mut); + } else if (fromTo[0]===fromTo[1]) { + categories.reversionsToRoot.push(mut); + } else { + categories.changes.push(mut); + } + } + categorisedSeqChanges[gene]=categories; + } + return categorisedSeqChanges; +}; + + +/** + * Return the mutations on the branch split into (potentially overlapping) categories + * @param {Object} branchNode + * @param {Object} observedMutations all observed mutations on the tree + * @returns {Object} + */ +export const getBranchMutations = (branchNode, observedMutations) => { + const mutations = branchNode.branch_attrs && branchNode.branch_attrs.mutations; + const seqChangesToRoot = branchNode.parent===branchNode ? {} : getSeqChanges(branchNode, mutations); + const categorisedMutations = categoriseMutations(mutations, observedMutations, seqChangesToRoot); + return categorisedMutations; +}; + +/** + * Return the changes between the terminal node and the root, split into (potentially overlapping) categories + * @param {Object} tipNode + * @returns {Object} + */ +export const getTipChanges = (tipNode) => { + const mutations = tipNode.branch_attrs && tipNode.branch_attrs.mutations; + const seqChanges = getSeqChanges(tipNode, mutations); + const categorisedSeqChanges = categoriseSeqChanges(seqChanges); + return categorisedSeqChanges; +}; + /** * Returns a function which will sort a list, where each element in the list - * is a gene name. Sorted by start position of the gene, with "nuc" last. + * is a gene name. Sorted by start position of the gene, with "nuc" first. */ export const sortByGeneOrder = (genomeAnnotations) => { if (!(genomeAnnotations instanceof Object)) { return (a, b) => { - if (a==="nuc") return 1; - if (b==="nuc") return -1; + if (a==="nuc") return -1; + if (b==="nuc") return 1; return 0; }; } const geneOrder = Object.entries(genomeAnnotations) .sort((a, b) => { - if (b[0]==="nuc") return -1; // show nucleotide "gene" last + if (b[0]==="nuc") return 1; // show nucleotide "gene" first if (a[1].start < b[1].start) return -1; if (a[1].start > b[1].start) return 1; return 0; diff --git a/test/treeHelpers.test.js b/test/treeHelpers.test.js index 0d5adb172..764f779a6 100644 --- a/test/treeHelpers.test.js +++ b/test/treeHelpers.test.js @@ -1,4 +1,4 @@ -import { collectMutations, getUrlFromNode, getAccessionFromNode } from "../src/util/treeMiscHelpers"; +import { getUrlFromNode, getAccessionFromNode, getBranchMutations } from "../src/util/treeMiscHelpers"; import { treeJsonToState } from "../src/util/treeJsonProcessing"; /** @@ -6,11 +6,11 @@ import { treeJsonToState } from "../src/util/treeJsonProcessing"; * root to tipX mutations: * single mutation at position 100 in gene "GENE" of A->B * a reversion of C->D->C at position 200 - * multiple mutations at 300 E->F->G + * multiple mutations at 300 E->F->G (F300G is a homoplasy) * root to tipY mutations: * ["A100B", "C200D", "E300F"] * root to tipZ mutations: - * the three root mutations + a duplicated mutation present on tipX ("F300G") + * the three root mutations + F300G (homoplasy) */ const dummyTree = treeJsonToState({ name: "ROOT", @@ -33,24 +33,13 @@ const dummyTree = treeJsonToState({ }, { // 3rd child of node1 name: "tipZ", - branch_attrs: {mutations: {GENE: ["F300G"]}} + branch_attrs: {mutations: {GENE: ["F300G", "B100A"]}} } ] } ] }); - -test("Tip->root mutations are correctly parsed", () => { - const tipXMutations = collectMutations(getNodeByName(dummyTree.nodes, "tipX")).GENE; - const tipYMutations = collectMutations(getNodeByName(dummyTree.nodes, "tipY")).GENE; - expect(tipXMutations.sort()) - .toEqual(["A100B", "E300G"].sort()); // note that pos 200 (reversion) has no mutations here - expect(tipYMutations.sort()) - .toEqual(["A100B", "C200D", "E300F"].sort()); -}); - - describe('Parse and summarise mutations', () => { test("Collection of all mutations", () => { // note that the function we are testing, collectObservedMutations, @@ -62,10 +51,39 @@ describe('Parse and summarise mutations', () => { "GENE:E300F": 1, "GENE:D200C": 1, "GENE:F300G": 2, + "GENE:B100A": 1, }); }); + + test("Branch mutations are correctly interpreted", () => { + const node_1 = getBranchMutations( + getNodeByName(dummyTree.nodes, "node1"), + dummyTree.observedMutations + ); + expect(node_1).toEqual({ + GENE: { + unique: ["A100B", "C200D", "E300F"], + homoplasies: [], + gaps: [], + reversionsToRoot: [] + } + }); + const node_1_1 = getBranchMutations( + getNodeByName(dummyTree.nodes, "node1.1"), + dummyTree.observedMutations + ); + expect(node_1_1).toEqual({ + GENE: { + unique: ["D200C"], + homoplasies: ["F300G"], + gaps: [], + reversionsToRoot: ["D200C"] + } + }); + }); }); + function getNodeByName(tree, name) { let namedNode; const recurse = (node) => { From a18bb64090bc91347d8fa8b72582767d23750651 Mon Sep 17 00:00:00 2001 From: James Hadfield Date: Thu, 27 Jan 2022 19:26:41 +1300 Subject: [PATCH 3/5] Tip hover shows counts of changes to root Previously we displayed the mutations on the branch leading to the tip, which was inconsistent with the information shown when clicking on a tip. Showing a summary of counts is a good compromise between visual space and conveying all the information. Note that hovering on the branch leading to the tip still shows the mutations leading to the tip, if that info is desired. --- src/components/tree/infoPanels/hover.js | 39 ++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/components/tree/infoPanels/hover.js b/src/components/tree/infoPanels/hover.js index 52d34bc64..efb61a12e 100644 --- a/src/components/tree/infoPanels/hover.js +++ b/src/components/tree/infoPanels/hover.js @@ -3,7 +3,8 @@ 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 } from "../../../util/treeMiscHelpers"; +import { getTraitFromNode, getDivFromNode, getVaccineFromNode, + getFullAuthorInfoFromNode, getTipChanges } from "../../../util/treeMiscHelpers"; import { isValueValid } from "../../../util/globals"; import { formatDivergence, getIdxOfInViewRootNode } from "../phyloTree/helpers"; @@ -137,13 +138,43 @@ const ColorBy = ({node, colorBy, colorByConfidence, colorScale, colorings}) => { 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 [ + , + + ]; +}; + /** * 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, geneSortFn, t}) => { +const BranchMutations = ({node, geneSortFn, t}) => { if (!node.branch_attrs || !node.branch_attrs.mutations) return null; const elements = []; // elements to render const mutations = node.branch_attrs.mutations; @@ -371,7 +402,7 @@ const HoverInfoPanel = ({ <> - + @@ -380,7 +411,7 @@ const HoverInfoPanel = ({ ) : ( <> - + From 9971471b327eeb4d04b498c6351d828c5f5b2363 Mon Sep 17 00:00:00 2001 From: James Hadfield Date: Fri, 28 Jan 2022 18:03:03 +1300 Subject: [PATCH 4/5] Group gaps and Ns into intervals --- .../tree/infoPanels/MutationTable.js | 52 ++++++++++++++++--- test/treeHelpers.test.js | 16 ++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/components/tree/infoPanels/MutationTable.js b/src/components/tree/infoPanels/MutationTable.js index f2845dffb..3f56d0888 100644 --- a/src/components/tree/infoPanels/MutationTable.js +++ b/src/components/tree/infoPanels/MutationTable.js @@ -18,6 +18,27 @@ const mutSortFn = (a, b) => { return aa { + const runs = []; + muts.sort(mutSortFn).forEach((m, idx) => { + const pos = parseInt(m.slice(1, -1), 10); + if (idx===0 || pos!==runs[runs.length-1].end+1) { + runs.push({start: pos, end: pos, count: 1, char: m.slice(-1)}); + } else { + runs[runs.length-1].end = pos; + runs[runs.length-1].count+=1; + } + }); + return runs; +}; + const Heading = styled.p` margin-top: 12px; margin-bottom: 4px; @@ -37,12 +58,27 @@ const TableFirstColumn = styled.td` white-space: nowrap; vertical-align: baseline; `; -const ListOfMutations = ({title, muts}) => ( - - {title} - {muts.sort(mutSortFn).join(", ")} - -); +const ListOfMutations = ({name, muts, displayAsIntervals, isNuc}) => { + let mutString, title; + if (displayAsIntervals) { + const intervals = parseIntervalsOfNsOrGaps(muts); + title = `${name} (${intervals.length} regions, ${muts.length}${isNuc?'bp':' codons'}):`; + mutString = intervals.map((interval) => + interval.count===1 ? + `${interval.start}` : + `${interval.start}..${interval.end} (${interval.count} ${isNuc?'bp':'codons'})` + ).join(", "); + } else { + title = `${name} (${muts.length}):`; + mutString = muts.sort(mutSortFn).join(", "); + } + return ( + + {title} + {mutString} + + ); +}; const mutCategoryLookup = { unique: "Unique", @@ -83,8 +119,10 @@ const displayGeneMutations = (gene, mutsPerCat) => { (key in mutsPerCat && mutsPerCat[key].length) ? () : null ))} diff --git a/test/treeHelpers.test.js b/test/treeHelpers.test.js index 764f779a6..371f9cf70 100644 --- a/test/treeHelpers.test.js +++ b/test/treeHelpers.test.js @@ -1,5 +1,6 @@ import { getUrlFromNode, getAccessionFromNode, getBranchMutations } from "../src/util/treeMiscHelpers"; import { treeJsonToState } from "../src/util/treeJsonProcessing"; +import { parseIntervalsOfNsOrGaps } from "../src/components/tree/infoPanels/MutationTable"; /** * `dummyTree` is a simple tree with three tips: tipX-Z @@ -127,3 +128,18 @@ describe('Extract various values from node_attrs', () => { }); }); + +test("Parse intervals of gaps or Ns", () => { + expect(parseIntervalsOfNsOrGaps(["T200N", "T100N", "C101N", "G102N"])) + .toStrictEqual( + [{start: 100, end: 102, count: 3, char: 'N'}, {start: 200, end: 200, count: 1, char: 'N'}] + ); + expect(parseIntervalsOfNsOrGaps(["T5N", "T3N", "C1N", "G7N"])) + .toStrictEqual( + [{start: 1, end: 1, count: 1, char: 'N'}, {start: 3, end: 3, count: 1, char: 'N'}, {start: 5, end: 5, count: 1, char: 'N'}, {start: 7, end: 7, count: 1, char: 'N'}] + ); + expect(parseIntervalsOfNsOrGaps(["T5-", "T3-", "C4-", "G7-", "G8-"])) + .toStrictEqual( + [{start: 3, end: 5, count: 3, char: '-'}, {start: 7, end: 8, count: 2, char: '-'}] + ); +}); From 69b53a0e93edd4f86e971e2298276ee32e65bcd7 Mon Sep 17 00:00:00 2001 From: James Hadfield Date: Fri, 28 Jan 2022 19:03:49 +1300 Subject: [PATCH 5/5] Group nuc mutations on-hover Grouping nucleotide mutations (as per previous commits) while hovering on branches is really helpful for understanding the tree. The penalty is the use of more space, and so I've chosen to not group AA mutations here; one can shift+click on the branch to see these grouped if needed. --- src/components/tree/infoPanels/hover.js | 54 ++++++++++++------------- src/components/tree/tree.js | 1 + 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/components/tree/infoPanels/hover.js b/src/components/tree/infoPanels/hover.js index efb61a12e..71e392fab 100644 --- a/src/components/tree/infoPanels/hover.js +++ b/src/components/tree/infoPanels/hover.js @@ -4,9 +4,10 @@ import { numericToCalendar } from "../../../util/dateHelpers"; import { getTipColorAttribute } from "../../../util/colorHelpers"; import { isColorByGenotype, decodeColorByGenotype } from "../../../util/getGenotype"; import { getTraitFromNode, getDivFromNode, getVaccineFromNode, - getFullAuthorInfoFromNode, getTipChanges } from "../../../util/treeMiscHelpers"; + getFullAuthorInfoFromNode, getTipChanges, getBranchMutations } from "../../../util/treeMiscHelpers"; import { isValueValid } from "../../../util/globals"; import { formatDivergence, getIdxOfInViewRootNode } from "../phyloTree/helpers"; +import { parseIntervalsOfNsOrGaps } from "./MutationTable"; const InfoLine = ({name, value, padBelow=false}) => { const renderValues = () => { @@ -173,41 +174,39 @@ const TipMutations = ({node, t}) => { * @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, t}) => { +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; - /* --------- NUCLEOTIDE MUTATIONS --------------- */ - /* Nt mutations are found at `mutations.nuc` -> Array of strings */ - if (mutations.nuc && mutations.nuc.length) { - const nDisplay = 9; // max number of mutations to display - - const isMutGap = (mut) => mut.slice(-1) === "-" || mut.slice(0, 1) === "-"; - const isMutUnknown = (mut) => mut.slice(-1) === "N" || mut.slice(0, 1) === "N"; + const categorisedMutations = getBranchMutations(node, observedMutations); - // gather muts which aren't to/from a gap or a "N" - const nucs = mutations.nuc.filter((mut) => (!isMutGap(mut) && !(isMutUnknown(mut)))); - const nucLen = nucs.length; // number of mutations that exist without N/- + const subset = (muts, maxNum) => + muts.slice(0, Math.min(maxNum, muts.length)).join(", ") + + (muts.length > maxNum ? ` + ${muts.length-maxNum} more` : ''); - let m = nucs.slice(0, Math.min(nDisplay, nucLen)).join(", "); - if (nucLen > nDisplay) { - m += " + " + t("{{x}} more", {x: nucLen - nDisplay}); + /* --------- NUCLEOTIDE MUTATIONS --------------- */ + if (categorisedMutations.nuc) { + const nDisplay = 5; // max number of mutations to display per category + if (categorisedMutations.nuc.unique.length) { + elements.push(); } - - if (nucLen !== 0) { - elements.push(); + if (categorisedMutations.nuc.homoplasies.length) { + elements.push(); } - - const nGapMutations = mutations.nuc.filter((mut) => isMutGap(mut)).length; - if (nGapMutations) { - elements.push(); + if (categorisedMutations.nuc.reversionsToRoot.length) { + elements.push(); } - - const nUnknownMutations = mutations.nuc.filter((mut) => isMutUnknown(mut)).length; - if (nUnknownMutations) { - elements.push(); + if (categorisedMutations.nuc.gaps.length) { + const value = `${parseIntervalsOfNsOrGaps(categorisedMutations.nuc.gaps).length} regions, ${categorisedMutations.nuc.gaps.length}bp.`; + elements.push(); + } + if (categorisedMutations.nuc.ns.length) { + const value = `${parseIntervalsOfNsOrGaps(categorisedMutations.nuc.ns).length} regions, ${categorisedMutations.nuc.ns.length}bp.`; + elements.push(); } } else { elements.push(); @@ -390,6 +389,7 @@ const HoverInfoPanel = ({ panelDims, colorings, geneSortFn, + observedMutations, t }) => { if (selectedNode.event !== "hover") return null; @@ -411,7 +411,7 @@ const HoverInfoPanel = ({ ) : ( <> - + diff --git a/src/components/tree/tree.js b/src/components/tree/tree.js index 01333cdc0..d9eb25c3c 100644 --- a/src/components/tree/tree.js +++ b/src/components/tree/tree.js @@ -191,6 +191,7 @@ class Tree extends React.Component { colorScale={this.props.colorScale} colorings={this.props.metadata.colorings} geneSortFn={this.state.geneSortFn} + observedMutations={this.props.tree.observedMutations} panelDims={{width: this.props.width, height: this.props.height, spaceBetweenTrees}} t={t} />