diff --git a/packages_rs/nextclade-web/src/components/Results/ColumnCustomNodeAttr.tsx b/packages_rs/nextclade-web/src/components/Results/ColumnCustomNodeAttr.tsx index e55d43d3d..a42c5c9a1 100644 --- a/packages_rs/nextclade-web/src/components/Results/ColumnCustomNodeAttr.tsx +++ b/packages_rs/nextclade-web/src/components/Results/ColumnCustomNodeAttr.tsx @@ -1,9 +1,15 @@ -import React from 'react' - -import { get } from 'lodash' +import React, { useCallback, useMemo, useState } from 'react' +import { get, values } from 'lodash' +import styled from 'styled-components' import type { AnalysisResult } from 'src/types' import { getSafeId } from 'src/helpers/getSafeId' +import { TableSlimWithBorders } from 'src/components/Common/TableSlim' +import { Tooltip } from './Tooltip' + +const Table = styled(TableSlimWithBorders)` + margin-top: 1rem; +` export interface ColumnCustomNodeAttrProps { sequence: AnalysisResult @@ -11,14 +17,43 @@ export interface ColumnCustomNodeAttrProps { } export function ColumnCustomNodeAttr({ sequence, attrKey }: ColumnCustomNodeAttrProps) { - const { index, seqName, customNodeAttributes } = sequence - const attrValue = get(customNodeAttributes, attrKey, '') + const [showTooltip, setShowTooltip] = useState(false) + const onMouseEnter = useCallback(() => setShowTooltip(true), []) + const onMouseLeave = useCallback(() => setShowTooltip(false), []) + + const { id, attrValue, secondaryValues } = useMemo(() => { + const { index, seqName, customNodeAttributes } = sequence + const attr = get(customNodeAttributes, attrKey, undefined) + + const secondaryValues = values(attr?.secondaryValues ?? {}).map(({ key, displayName, value }) => ( + + {displayName} + {value} + + )) + + if (attr) { + secondaryValues.unshift( + + {attr.displayName} + {attr.value} + , + ) + } + + const id = getSafeId('col-custom-attr', { index, seqName, attrKey }) - const id = getSafeId('col-custom-attr', { index, seqName, attrKey }) + return { id, attrValue: attr?.value, secondaryValues } + }, [attrKey, sequence]) return ( -
- {attrValue} -
+ <> +
+ {attrValue} +
+ 0 && showTooltip} target={id} wide fullWidth> + {secondaryValues}
+
+ ) } diff --git a/packages_rs/nextclade-web/src/types.ts b/packages_rs/nextclade-web/src/types.ts index bfed398f8..7a34a3764 100644 --- a/packages_rs/nextclade-web/src/types.ts +++ b/packages_rs/nextclade-web/src/types.ts @@ -380,11 +380,24 @@ export interface AnalysisResult { privateAaMutations: Record coverage: number qc: QcResult - customNodeAttributes: Record + customNodeAttributes: Record warnings: PeptideWarning[] missingGenes: string[] } +export interface SecondaryCustomNodeAttrValue { + key: string + displayName: string + value: string +} + +export interface CustomNodeAttrValue { + key: string + displayName: string + value: string + secondaryValues: Record +} + export interface AnalysisError { index: number seqName: string diff --git a/packages_rs/nextclade/src/io/nextclade_csv.rs b/packages_rs/nextclade/src/io/nextclade_csv.rs index 61e4d73b1..ae7803b08 100644 --- a/packages_rs/nextclade/src/io/nextclade_csv.rs +++ b/packages_rs/nextclade/src/io/nextclade_csv.rs @@ -182,7 +182,7 @@ impl NextcladeResultsCsvWriter { custom_node_attributes .clone() .into_iter() - .try_for_each(|(key, val)| self.add_entry(&key, &val))?; + .try_for_each(|(key, attr)| self.add_entry(&key, &attr.value))?; self.add_entry("seqName", seq_name)?; self.add_entry("clade", clade)?; diff --git a/packages_rs/nextclade/src/run/nextclade_run_one.rs b/packages_rs/nextclade/src/run/nextclade_run_one.rs index 40e8f8776..eb92c47bf 100644 --- a/packages_rs/nextclade/src/run/nextclade_run_one.rs +++ b/packages_rs/nextclade/src/run/nextclade_run_one.rs @@ -117,7 +117,7 @@ pub fn nextclade_run_one( let clade = node.clade(); let clade_node_attr_keys = tree.clade_node_attr_descs(); - let clade_node_attrs = node.get_clade_node_attrs(clade_node_attr_keys); + let custom_node_attributes = node.get_clade_node_attrs(clade_node_attr_keys); let private_nuc_mutations = find_private_nuc_mutations( node, @@ -203,7 +203,7 @@ pub fn nextclade_run_one( divergence, coverage, qc, - custom_node_attributes: clade_node_attrs, + custom_node_attributes, nearest_node_id, is_reverse_complement, }, diff --git a/packages_rs/nextclade/src/tree/tree.rs b/packages_rs/nextclade/src/tree/tree.rs index 6fad9e786..f7726f440 100644 --- a/packages_rs/nextclade/src/tree/tree.rs +++ b/packages_rs/nextclade/src/tree/tree.rs @@ -2,10 +2,9 @@ use crate::io::aa::Aa; use crate::io::fs::read_file_to_string; use crate::io::json::json_parse; use crate::io::nuc::Nuc; +use crate::types::outputs::{CustomNodeAttr, SecondaryCustomNodeAttrValue}; use eyre::{Report, WrapErr}; -use indexmap::IndexMap; use serde::{Deserialize, Serialize}; -use std::any::Any; use std::collections::BTreeMap; use std::path::Path; use std::slice::Iter; @@ -144,29 +143,75 @@ impl AuspiceTreeNode { self.node_attrs.clade_membership.value.clone() } - /// Extracts clade-like node attributes, given a list of key descriptions - pub fn get_clade_node_attrs(&self, clade_node_attr_keys: &[CladeNodeAttrKeyDesc]) -> BTreeMap { - clade_node_attr_keys + fn _extract_clade_node_attr(&self, key: &str) -> Option { + match self.node_attrs.other.get(key) { + Some(attr) => attr.get("value").and_then(|value| value.as_str().map(str::to_owned)), + None => None, + } + } + + fn _extract_clade_node_attrs(&self, meta_attr: &CladeNodeAttrKeyDesc) -> Option<(String, CustomNodeAttr)> { + let secondary_values: BTreeMap = meta_attr + .secondary_attrs .iter() - .filter_map(|attr| { - let key = &attr.name; - let attr_obj = self.node_attrs.other.get(key); - match attr_obj { - Some(attr) => attr.get("value"), - None => None, - } - .and_then(|val| val.as_str().map(|val| (key.clone(), val.to_owned()))) + .filter_map(|sec_attr| { + self._extract_clade_node_attr(&sec_attr.name).map(|value| { + ( + sec_attr.name.clone(), + SecondaryCustomNodeAttrValue { + key: sec_attr.name.clone(), + display_name: sec_attr.display_name.clone(), + value, + }, + ) + }) }) + .collect(); + + let key = &meta_attr.name; + self._extract_clade_node_attr(key).map(|value| { + ( + key.clone(), + CustomNodeAttr { + key: key.clone(), + display_name: meta_attr.display_name.clone(), + value, + secondary_values, + }, + ) + }) + } + + /// Extracts clade-like node attribute values, given a list of key descriptions from tree meta + pub fn get_clade_node_attrs( + &self, + clade_node_attr_key_descs: &[CladeNodeAttrKeyDesc], + ) -> BTreeMap { + clade_node_attr_key_descs + .iter() + .filter_map(|meta_attr| self._extract_clade_node_attrs(meta_attr)) .collect() } } +#[derive(Clone, Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SecondaryCustomNodeAttr { + name: String, + display_name: String, + description: String, +} + #[derive(Clone, Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct CladeNodeAttrKeyDesc { pub name: String, pub display_name: String, pub description: String, + + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub secondary_attrs: Vec, } #[derive(Clone, Serialize, Deserialize, Validate, Debug)] diff --git a/packages_rs/nextclade/src/types/outputs.rs b/packages_rs/nextclade/src/types/outputs.rs index 11a9a092a..287740176 100644 --- a/packages_rs/nextclade/src/types/outputs.rs +++ b/packages_rs/nextclade/src/types/outputs.rs @@ -34,6 +34,23 @@ pub struct NextalignOutputs { pub is_reverse_complement: bool, } +#[derive(Clone, Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SecondaryCustomNodeAttrValue { + pub key: String, + pub display_name: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomNodeAttr { + pub key: String, + pub display_name: String, + pub value: String, + pub secondary_values: BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NextcladeOutputs { @@ -76,7 +93,7 @@ pub struct NextcladeOutputs { pub divergence: f64, pub coverage: f64, pub qc: QcResult, - pub custom_node_attributes: BTreeMap, + pub custom_node_attributes: BTreeMap, pub nearest_node_id: usize, pub is_reverse_complement: bool, }