Skip to content

Commit

Permalink
Categorise mutations in tip-clicked panel
Browse files Browse the repository at this point in the history
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] #1444
  • Loading branch information
jameshadfield committed Jan 27, 2022
1 parent 68f57f6 commit bec7d82
Show file tree
Hide file tree
Showing 5 changed files with 269 additions and 100 deletions.
128 changes: 128 additions & 0 deletions src/components/tree/infoPanels/MutationTable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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<bb ? -1 : 1;
};

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 = ({title, muts}) => (
<MutationLine>
<SubHeading key={title}>{title}</SubHeading>
<MutationList>{muts.sort(mutSortFn).join(", ")}</MutationList>
</MutationLine>
);

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 (
<tr key={gene}>
<TableFirstColumn>{gene==="nuc" ? "Nt" : gene}</TableFirstColumn>
<td>
{Object.entries(mutCategoryLookup).map(([key, name]) => (
(key in mutsPerCat && mutsPerCat[key].length) ?
(<ListOfMutations
title={`${name} (${mutsPerCat[key].length}):`}
muts={mutsPerCat[key]}
/>) :
null
))}
</td>
</tr>
);
};

export const MutationTable = ({node, geneSortFn, isTip, observedMutations}) => {
const categorisedMutations = isTip ?
getTipChanges(node) :
getBranchMutations(node, observedMutations);

if (Object.keys(categorisedMutations).length===0) {
return (
<Heading>
{isTip ? `No sequence changes observed` : `No mutations observed on branch`}
</Heading>
);
}

return (
<>
<Heading>
{isTip ? `Sequence changes observed (from root):` : `Mutations observed on branch:`}
</Heading>
<table>
{Object.keys(categorisedMutations).sort(geneSortFn).map(
(gene) => displayGeneMutations(gene, categorisedMutations[gene], isTip)
)}
<tr>
<td/>
<td>
<Button onClick={() => {navigator.clipboard.writeText(mutationsToTsv(categorisedMutations, geneSortFn));}}>
{`Click to copy all ${isTip ? 'changes' : 'mutations'} to clipboard as TSV`}
</Button>
</td>
</tr>
</table>
</>
);
};
69 changes: 4 additions & 65 deletions src/components/tree/infoPanels/click.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -52,67 +52,6 @@ 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 = ({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}>{title}</th>
<td style={infoPanelStyles.item}>{
Object.keys(mutations)
.sort(geneSortFn)
.map((gene) => displayGeneMutations(gene, mutations[gene]))
}</td>
</tr>
);
};


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. */
Expand Down Expand Up @@ -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%";
Expand Down Expand Up @@ -320,9 +259,9 @@ const NodeClickedPanel = ({selectedNode, clearSelectedNode, colorings, geneSortF
))}
{isTip && <AccessionAndUrl node={node}/>}
{item("", "")}
<MutationTable node={node} geneSortFn={geneSortFn} isTip={isTip}/>
</tbody>
</table>
<MutationTable node={node} geneSortFn={geneSortFn} isTip={isTip} observedMutations={observedMutations}/>
<p style={infoPanelStyles.comment}>
{t("Click outside this box to go back to the tree")}
</p>
Expand Down
1 change: 1 addition & 0 deletions src/components/tree/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ class Tree extends React.Component {
<NodeClickedPanel
clearSelectedNode={this.clearSelectedNode}
selectedNode={this.state.selectedNode}
observedMutations={this.props.tree.observedMutations}
colorings={this.props.metadata.colorings}
geneSortFn={this.state.geneSortFn}
t={t}
Expand Down
123 changes: 103 additions & 20 deletions src/util/treeMiscHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,17 @@ export function collectGenotypeStates(nodes) {
}

/**
* Collect mutations from node `fromNode` to the root.
* Reversions (e.g. root -> A<pos>B -> B<pos>A -> fromNode) will not be reported
* Multiple mutations (e.g. root -> A<pos>B -> B<pos>C -> fromNode) will be represented as A<pos>C
* 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 -> A<pos>B -> B<pos>C -> fromNode) will be collapsed to as A<pos>C
* Reversions to root (e.g. root -> A<pos>B -> B<pos>A -> fromNode) will be reported as A<pos>A
* Returned structure is <returnedObject>.<geneName>.<position> = [<from>, <to>]
*/
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
Expand All @@ -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;
Expand Down
Loading

0 comments on commit bec7d82

Please sign in to comment.