diff --git a/src/components/tree/infoPanels/MutationTable.js b/src/components/tree/infoPanels/MutationTable.js new file mode 100644 index 000000000..3f56d0888 --- /dev/null +++ b/src/components/tree/infoPanels/MutationTable.js @@ -0,0 +1,169 @@ +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 { + 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; +`; +const SubHeading = styled.span` + padding-right: 8px; +`; +const MutationList = styled.span` +`; +const MutationLine = styled.p` + margin: 0px 0px 4px 0px; + font-weight: 300; + font-size: 16px; +`; +const TableFirstColumn = styled.td` + font-weight: 500; + white-space: nowrap; + vertical-align: baseline; +`; +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", + 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/infoPanels/hover.js b/src/components/tree/infoPanels/hover.js index 52d34bc64..71e392fab 100644 --- a/src/components/tree/infoPanels/hover.js +++ b/src/components/tree/infoPanels/hover.js @@ -3,9 +3,11 @@ 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, 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 = () => { @@ -137,46 +139,74 @@ 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 + * @param {Object} props.observedMutations counts of all observed mutations across the tree + */ -const Mutations = ({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(); @@ -359,6 +389,7 @@ const HoverInfoPanel = ({ panelDims, colorings, geneSortFn, + observedMutations, t }) => { if (selectedNode.event !== "hover") return null; @@ -371,7 +402,7 @@ const HoverInfoPanel = ({ <> - + @@ -380,7 +411,7 @@ const HoverInfoPanel = ({ ) : ( <> - + diff --git a/src/components/tree/tree.js b/src/components/tree/tree.js index a0011d201..d9eb25c3c 100644 --- a/src/components/tree/tree.js +++ b/src/components/tree/tree.js @@ -191,12 +191,14 @@ 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} /> { 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/src/util/treeMiscHelpers.js b/src/util/treeMiscHelpers.js index 696cadbca..dff02c07e 100644 --- a/src/util/treeMiscHelpers.js +++ b/src/util/treeMiscHelpers.js @@ -129,18 +129,17 @@ export function collectGenotypeStates(nodes) { } /** - * Collect mutations from node `fromNode` to the root. - * Reversions (e.g. root -> 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 362e820a6..371f9cf70 100644 --- a/test/treeHelpers.test.js +++ b/test/treeHelpers.test.js @@ -1,14 +1,17 @@ -import { collectMutations, getUrlFromNode, getAccessionFromNode } from "../src/util/treeMiscHelpers"; +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 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 + * 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 + F300G (homoplasy) */ const dummyTree = treeJsonToState({ name: "ROOT", @@ -28,22 +31,60 @@ const dummyTree = treeJsonToState({ }, { // start 2nd child of node1 name: "tipY" + }, + { // 3rd child of node1 + name: "tipZ", + branch_attrs: {mutations: {GENE: ["F300G", "B100A"]}} } ] } ] -}).nodes; +}); +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, + "GENE:B100A": 1, + }); + }); -test("Tip->root mutations are correctly parsed", () => { - const tipXMutations = collectMutations(getNodeByName(dummyTree, "tipX")).GENE; - const tipYMutations = collectMutations(getNodeByName(dummyTree, "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()); + 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) => { @@ -87,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: '-'}] + ); +});