From 57f8fc7ae7787664f55438a37999e6ef2aa1b9c9 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 10 Jun 2024 16:30:29 +1200 Subject: [PATCH 1/6] [tangletrees] check if genome maps are identical This property will be used in upcoming work to show genotype colourings on both trees --- src/actions/recomputeReduxState.js | 42 ++++++++++++++++++++++++++++-- src/reducers/metadata.js | 1 + 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index d5859bced..1e52b95e5 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -1,5 +1,5 @@ import queryString from "query-string"; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEqualWith } from 'lodash'; import { numericToCalendar, calendarToNumeric } from "../util/dateHelpers"; import { reallySmallNumber, twoColumnBreakpoint, defaultColorBy, defaultGeoResolution, defaultDateRange, nucleotide_gene, strainSymbol, genotypeSymbol } from "../util/globals"; import { calcBrowserDimensionsInitialState } from "../reducers/browserDimensions"; @@ -12,7 +12,7 @@ import { countTraitsAcrossTree, calcTotalTipsInTree, gatherTraitNames } from ".. import { calcEntropyInView } from "../util/entropy"; import { treeJsonToState } from "../util/treeJsonProcessing"; import { castIncorrectTypes } from "../util/castJsonTypes"; -import { entropyCreateState } from "../util/entropyCreateStateFromJsons"; +import { entropyCreateState, genomeMap as createGenomeMap } from "../util/entropyCreateStateFromJsons"; import { calcNodeColor } from "../util/colorHelpers"; import { calcColorScale, createVisibleLegendValues } from "../util/colorScale"; import { computeMatrixFromRawData, checkIfNormalizableFromRawData } from "../util/processFrequencies"; @@ -800,6 +800,42 @@ const createMetadataStateFromJSON = (json) => { return metadata; }; +/** + * Conceptually similar to `createMetadataStateFromJSON` but here looking at metadata from + * the (optional) second tree and only considers certain properties at the moment. + */ +function updateMetadataStateViaSecondTree(metadata, json, genomeMap) { + + // For genotype colourings across multiple trees we need to know if the genome + // maps are identical¹ across both trees + // + // ¹ This could be relaxed in the future - currently we enforce that the order + // of genes matches. We could also make this more fine grained and allow the + // 2nd tree to have a subset of CDSs (wrt the main tree), with genotypes + // only working for the shared CDSs. + if (genomeMap && json.meta.genome_annotations) { + try { + metadata.identicalGenomeMapAcrossBothTrees = isEqualWith( + genomeMap, + createGenomeMap(json.meta.genome_annotations), + (objValue, othValue, indexOrKey) => { + if (indexOrKey==='color') return true; // don't compare CDS colors! + // don't compare metadata section as there may be Infinities here + // (and if everything else is equal then the metadata will be the same too) + if (indexOrKey==='metadata') return true; + return undefined; // use lodash's default comparison + } + ) + } catch (e) { + if (e instanceof Error) console.error(e.message); + } + if (!metadata.identicalGenomeMapAcrossBothTrees) { + console.warn("Heads up! The two trees have different genome_annotations and thus genotype colorings will only be applied to the LHS tree") + } + } + +} + export const getNarrativePageFromQuery = (query, narrative) => { let n = parseInt(query.n, 10) || 0; /* If the query has defined a block which doesn't exist then default to n=0 */ @@ -842,6 +878,8 @@ export const createStateFromQueryOrJSONs = ({ castIncorrectTypes(metadata, treeToo); treeToo.debug = "RIGHT"; treeToo.name = secondTreeName; + updateMetadataStateViaSecondTree(metadata, secondTreeDataset, entropy?.genomeMap) + /* TODO: calc & display num tips in 2nd tree */ // metadata.secondTreeNumTips = calcTotalTipsInTree(treeToo.nodes); } diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js index 959fe89f7..d512fe815 100644 --- a/src/reducers/metadata.js +++ b/src/reducers/metadata.js @@ -10,6 +10,7 @@ const Metadata = (state = { loaded: false, /* see comment in the sequences reducer for explanation */ metadata: null, rootSequence: undefined, + identicalGenomeMapAcrossBothTrees: false, colorOptions // this can't be removed as the colorScale currently runs before it should }, action) => { switch (action.type) { From 07b7fc83ba988302e743c834f94d91ddf41ef650 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 10 Jun 2024 21:15:23 +1200 Subject: [PATCH 2/6] [tangletrees] recompute data when loading via UI This particular method of loading a second tree is (I believe) rarely used and rarely developed -- see both the TODOs in the surrounding code and the (dev-only) redux immutability errors fixed in this commit. Still, we should support it! --- src/actions/loadData.js | 2 +- src/actions/recomputeReduxState.js | 14 +++++++++----- src/reducers/metadata.js | 6 ++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/actions/loadData.js b/src/actions/loadData.js index c87c90128..c16e43781 100644 --- a/src/actions/loadData.js +++ b/src/actions/loadData.js @@ -50,7 +50,7 @@ export const loadSecondTree = (secondTreeUrl, firstTreeUrl) => async (dispatch, dispatch(explodeTree(undefined)); } - const newState = createTreeTooState({treeTooJSON: secondJson.tree, oldState, originalTreeUrl: firstTreeUrl, secondTreeUrl: secondTreeUrl, dispatch}); + const newState = createTreeTooState({json: secondJson, oldState, originalTreeUrl: firstTreeUrl, secondTreeUrl: secondTreeUrl, dispatch}); dispatch({type: types.TREE_TOO_DATA, ...newState}); }; diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 1e52b95e5..90fbdee63 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -1032,7 +1032,7 @@ export const createStateFromQueryOrJSONs = ({ }; export const createTreeTooState = ({ - treeTooJSON, /* raw json data */ + json, /* raw json data */ oldState, originalTreeUrl, secondTreeUrl, /* treeToo URL */ @@ -1040,10 +1040,14 @@ export const createTreeTooState = ({ }) => { /* TODO: reconcile choices (filters, colorBys etc) with this new tree */ /* TODO: reconcile query with visibility etc */ - let controls = oldState.controls; + + const metadata = {...oldState.metadata}; + updateMetadataStateViaSecondTree(metadata, json, oldState.entropy?.genomeMap); + + let controls = {...oldState.controls}; const tree = Object.assign({}, oldState.tree); tree.name = originalTreeUrl; - let treeToo = treeJsonToState(treeTooJSON); + let treeToo = treeJsonToState(json.tree); treeToo.name = secondTreeUrl; treeToo.debug = "RIGHT"; controls = modifyControlsStateViaTree(controls, tree, treeToo, oldState.metadata.colorings); @@ -1051,7 +1055,7 @@ export const createTreeTooState = ({ treeToo = modifyTreeStateVisAndBranchThickness(treeToo, undefined, controls, dispatch); /* calculate colours if loading from JSONs or if the query demands change */ - const colorScale = calcColorScale(controls.colorBy, controls, tree, treeToo, oldState.metadata); + const colorScale = calcColorScale(controls.colorBy, controls, tree, treeToo, metadata); const nodeColors = calcNodeColor(treeToo, colorScale); tree.nodeColors = calcNodeColor(tree, colorScale); // also update main tree's colours tree.nodeColorsVersion++; @@ -1065,5 +1069,5 @@ export const createTreeTooState = ({ tree.nodes, treeToo.nodes, tree.visibility, treeToo.visibility ); - return {tree, treeToo, controls}; + return {tree, treeToo, controls, metadata}; }; diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js index d512fe815..d9196fab4 100644 --- a/src/reducers/metadata.js +++ b/src/reducers/metadata.js @@ -19,6 +19,7 @@ const Metadata = (state = { loaded: false }); case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: + case types.TREE_TOO_DATA: case types.CLEAN_START: return action.metadata; case types.ADD_EXTRA_METADATA: { @@ -42,6 +43,11 @@ const Metadata = (state = { } case types.SET_ROOT_SEQUENCE: return {...state, rootSequence: action.data}; + case types.REMOVE_TREE_TOO: + return Object.assign({}, state, { + identicalGenomeMapAcrossBothTrees: false, + rootSequenceSecondTree: undefined, + }); default: return state; } From 8440adb987d0ba8e8b92a8b03a6670fa66c26eeb Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 10 Jun 2024 16:50:49 +1200 Subject: [PATCH 3/6] [tangletrees] store root-sequence of second tree In preparation for showing genotype colorings across multiple trees We don't (yet) consider root-sequences which are defined via sidecar files (i.e. not inline in the main JSON) We could improve memory efficiency if we compared the nuc/aa sequences for equality and used references to the main tree's root-sequence data, but as we're here only considering inlined root-sequences (i.e. small ones!) this implementation is simpler. --- src/actions/recomputeReduxState.js | 5 +++++ src/reducers/metadata.js | 1 + 2 files changed, 6 insertions(+) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 90fbdee63..5139fda7f 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -834,6 +834,11 @@ function updateMetadataStateViaSecondTree(metadata, json, genomeMap) { } } + if (metadata.identicalGenomeMapAcrossBothTrees && json.root_sequence) { + /* in-line root sequence (not sidecar) */ + metadata.rootSequenceSecondTree = json.root_sequence; + } + } export const getNarrativePageFromQuery = (query, narrative) => { diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js index d9196fab4..818159496 100644 --- a/src/reducers/metadata.js +++ b/src/reducers/metadata.js @@ -11,6 +11,7 @@ const Metadata = (state = { metadata: null, rootSequence: undefined, identicalGenomeMapAcrossBothTrees: false, + rootSequenceSecondTree: undefined, colorOptions // this can't be removed as the colorScale currently runs before it should }, action) => { switch (action.type) { From d540022fc5ec0429f3e04a8fbed9ddefb3dc9149 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 10 Jun 2024 17:16:03 +1200 Subject: [PATCH 4/6] [tangletrees] show genotype coloring on both trees Includes some (overly!) strict conditionals. This functionality is particularly useful when comparing trees using the same (or very similar) sequences / sets of sequences to aid in understanding the different tree structures. Closes #1773 --- src/util/colorScale.js | 9 ++++++--- src/util/setGenotype.js | 11 ++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/util/colorScale.js b/src/util/colorScale.js index 604516157..ce8a6aa9b 100644 --- a/src/util/colorScale.js +++ b/src/util/colorScale.js @@ -39,10 +39,13 @@ export const calcColorScale = (colorBy, controls, tree, treeToo, metadata) => { if (isColorByGenotype(colorBy)) { genotype = decodeColorByGenotype(colorBy); setGenotype(tree.nodes, genotype.gene, genotype.positions, metadata.rootSequence); /* modifies nodes recursively */ + if (treeToo && metadata.identicalGenomeMapAcrossBothTrees) { + setGenotype(treeToo.nodes, genotype.gene, genotype.positions, metadata.rootSequenceSecondTree); + } } const scaleType = genotype ? "categorical" : colorings[colorBy].type; if (genotype) { - ({legendValues, colorScale} = createScaleForGenotype(tree.nodes, genotype.aa)); + ({legendValues, colorScale} = createScaleForGenotype(tree.nodes, treeToo?.nodes, genotype.aa)); domain = [...legendValues]; } else if (colorings && colorings[colorBy]) { if (scaleType === "continuous" || scaleType==="temporal") { @@ -152,8 +155,8 @@ export function createNonContinuousScaleFromProvidedScaleMap(colorBy, providedSc }; } -function createScaleForGenotype(t1nodes, aaGenotype) { - const legendValues = orderOfGenotypeAppearance(t1nodes, aaGenotype); +function createScaleForGenotype(t1nodes, t2nodes, aaGenotype) { + const legendValues = orderOfGenotypeAppearance(t1nodes, t2nodes, aaGenotype); const trueValues = aaGenotype ? legendValues.filter((x) => x !== "X" && x !== "-" && x !== "") : legendValues.filter((x) => x !== "X" && x !== "-" && x !== "N" && x !== ""); diff --git a/src/util/setGenotype.js b/src/util/setGenotype.js index b543c9165..d206c9b8e 100644 --- a/src/util/setGenotype.js +++ b/src/util/setGenotype.js @@ -67,15 +67,20 @@ export const setGenotype = (nodes, prot, positions, rootSequence) => { // console.log(`set ${ancNodes.length} nodes to the ancestral state: ${ancState}`) }; -export const orderOfGenotypeAppearance = (nodes, aaGenotype) => { +export const orderOfGenotypeAppearance = (nodes, nodesSecondTree, aaGenotype) => { const seen = {}; - nodes.forEach((n) => { + + function addGenotype(n) { let numDate = getTraitFromNode(n, "num_date"); if (numDate === undefined) numDate = 0; if (!seen[n.currentGt] || numDate < seen[n.currentGt]) { seen[n.currentGt] = numDate; } - }); + } + + nodes.forEach(addGenotype); + if (nodesSecondTree) nodesSecondTree.forEach(addGenotype); + const ordered = Object.keys(seen); ordered.sort((a, b) => seen[a] < seen[b] ? -1 : 1); let orderedBases; From a87c595fe239bcf42eb60fe213d373c923ae84eb Mon Sep 17 00:00:00 2001 From: james hadfield Date: Mon, 10 Jun 2024 19:33:58 +1200 Subject: [PATCH 5/6] [bugfix] typo in tree visibility property Oh how TypeScript would have helped! This bug was found while debugging the ability to show genotype colorings on both trees (added in the parent commit) where the visibility of the second tree was `undefined` upon loading, leading to `createVisibleLegendValues` not considering the second tree and thus removing legend items which were only observed in the second tree. Bug introduced ~4 years ago via 391bca4150415c61efcf2e8d3dde0786b7f0226c although its effect was minor (and probably never noticed?) --- src/actions/recomputeReduxState.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 5139fda7f..9a1f45d45 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -983,7 +983,7 @@ export const createStateFromQueryOrJSONs = ({ treeNodes: tree.nodes, treeTooNodes: treeToo ? treeToo.nodes : undefined, visibility: tree.visibility, - visibilityToo: treeToo ? treeToo.visibilityToo : undefined + visibilityToo: treeToo?.visibility, }); /* calculate entropy in view */ From 86ccfa1ff6542bb7714232ba35ffb28fbecd4a7f Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 11 Jun 2024 09:39:16 +1200 Subject: [PATCH 6/6] changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6dc0126..98cf870b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog -## version 2.54.1 - 2024/06/10 +* Tangletrees can now be coloured by genotype (previously such a colouring would only work for the LHS tree). This requires the genome annotation (in the dataset JSON) to be identical across both datasets. This can be especially useful when comparing trees generated from the same sequences or a similar set of sequences in order to understand the differences in tree structure. ([#1785](https://github.com/nextstrain/auspice/pull/1783)) +* Bugfix: The legend entries shown for a tangletree may not have shown values only observed in the RHS tree when the dataset was first loaded. ([#1785](https://github.com/nextstrain/auspice/pull/1783)) +## version 2.54.1 - 2024/06/10 * Fixed a big bug where clicking on tips (and shift-clicking on branches) on the RHS tree in a tanglegram would bring up a modal detailing a node in the LHS tree. ([#1783](https://github.com/nextstrain/auspice/pull/1783)) * Fixed a small bug where branch labels prevented you from hovering on the branch itself, a situation that was more common in tangletrees. ([#1783](https://github.com/nextstrain/auspice/pull/1783))