From e5d2508e73d2953e56347fecedb73646f89244cf Mon Sep 17 00:00:00 2001 From: ivan-aksamentov Date: Fri, 19 Jul 2024 18:17:55 +0200 Subject: [PATCH 1/2] feat: calculate cds coverage --- packages/nextclade/src/io/nextclade_csv.rs | 12 ++++++++ .../nextclade/src/run/nextclade_run_one.rs | 28 ++++++++++++++++++- packages/nextclade/src/types/outputs.rs | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/nextclade/src/io/nextclade_csv.rs b/packages/nextclade/src/io/nextclade_csv.rs index 4d536d9e4..21b1c474b 100644 --- a/packages/nextclade/src/io/nextclade_csv.rs +++ b/packages/nextclade/src/io/nextclade_csv.rs @@ -28,6 +28,7 @@ use itertools::{chain, Either, Itertools}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use std::borrow::Cow; +use std::collections::BTreeMap; use std::fmt::Display; use std::path::Path; use std::str::FromStr; @@ -160,6 +161,7 @@ lazy_static! { o!("alignmentStart") => true, o!("alignmentEnd") => true, o!("coverage") => true, + o!("cdsCoverage") => true, o!("isReverseComplement") => true, }, CsvColumnCategory::RefMuts => indexmap! { @@ -414,6 +416,7 @@ impl NextcladeResultsCsvWriter { missing_cdses, // divergence, coverage, + cds_coverage, phenotype_values, qc, custom_node_attributes, @@ -599,6 +602,7 @@ impl NextcladeResultsCsvWriter { self.add_entry("alignmentStart", &(alignment_range.begin + 1).to_string())?; self.add_entry("alignmentEnd", &alignment_range.end.to_string())?; self.add_entry("coverage", coverage)?; + self.add_entry("cdsCoverage", &format_cds_coverage(cds_coverage, ARRAY_ITEM_DELIMITER))?; self.add_entry_maybe( "qc.missingData.missingDataThreshold", qc.missing_data.as_ref().map(|md| md.missing_data_threshold.to_string()), @@ -992,6 +996,14 @@ pub fn format_stop_codons(stop_codons: &[StopCodonLocation], delimiter: &str) -> .join(delimiter) } +#[inline] +pub fn format_cds_coverage(cds_coverage: &BTreeMap, delimiter: &str) -> String { + cds_coverage + .iter() + .map(|(cds, coverage)| format!("{cds}:{coverage}")) + .join(delimiter) +} + #[inline] pub fn format_failed_cdses(failed_cdses: &[String], delimiter: &str) -> String { failed_cdses.join(delimiter) diff --git a/packages/nextclade/src/run/nextclade_run_one.rs b/packages/nextclade/src/run/nextclade_run_one.rs index 386467511..2a15874c9 100644 --- a/packages/nextclade/src/run/nextclade_run_one.rs +++ b/packages/nextclade/src/run/nextclade_run_one.rs @@ -33,7 +33,7 @@ use crate::analyze::pcr_primer_changes::get_pcr_primer_changes; use crate::analyze::phenotype::calculate_phenotype; use crate::analyze::virus_properties::PhenotypeData; use crate::coord::coord_map_global::CoordMapGlobal; -use crate::coord::range::AaRefRange; +use crate::coord::range::{AaRefRange, Range}; use crate::graph::node::GraphNodeKey; use crate::qc::qc_run::qc_run; use crate::run::nextclade_wasm::{AnalysisOutput, Nextclade}; @@ -67,6 +67,7 @@ struct NextcladeResultWithAa { total_unknown_aa: usize, aa_alignment_ranges: BTreeMap>, aa_unsequenced_ranges: BTreeMap>, + cds_coverage: BTreeMap, } #[derive(Default)] @@ -170,6 +171,7 @@ pub fn nextclade_run_one( total_unknown_aa, aa_alignment_ranges, aa_unsequenced_ranges, + cds_coverage, } = if !gene_map.is_empty() { let coord_map_global = CoordMapGlobal::new(&alignment.ref_seq); @@ -235,6 +237,28 @@ pub fn nextclade_run_one( aa_unsequenced_ranges, } = gather_aa_alignment_ranges(&translation, gene_map); + let cds_coverage = translation + .cdses() + .map(|cds| { + let ref_peptide_len = ref_translation.get_cds(&cds.name)?.seq.len(); + let num_aligned_aa = cds.alignment_ranges.iter().map(Range::len).sum::(); + let num_unknown_aa = unknown_aa_ranges + .iter() + .filter(|r| r.cds_name == cds.name) + .map(|r| r.length) + .sum(); + let total_covered_aa = num_aligned_aa.saturating_sub(num_unknown_aa); + + let coverage_aa = if ref_peptide_len == 0 { + 0.0 + } else { + total_covered_aa as f64 / ref_peptide_len as f64 + }; + + Ok((cds.name.clone(), coverage_aa)) + }) + .collect::, Report>>()?; + NextcladeResultWithAa { translation, aa_changes_groups, @@ -253,6 +277,7 @@ pub fn nextclade_run_one( total_unknown_aa, aa_alignment_ranges, aa_unsequenced_ranges, + cds_coverage, } } else { NextcladeResultWithAa::default() @@ -441,6 +466,7 @@ pub fn nextclade_run_one( warnings, missing_cdses: missing_genes, coverage, + cds_coverage, aa_motifs, aa_motifs_changes, qc, diff --git a/packages/nextclade/src/types/outputs.rs b/packages/nextclade/src/types/outputs.rs index 0f271a21d..7fb8a399b 100644 --- a/packages/nextclade/src/types/outputs.rs +++ b/packages/nextclade/src/types/outputs.rs @@ -90,6 +90,7 @@ pub struct NextcladeOutputs { pub missing_cdses: Vec, pub divergence: f64, pub coverage: f64, + pub cds_coverage: BTreeMap, pub qc: QcResult, pub custom_node_attributes: BTreeMap, pub nearest_node_id: GraphNodeKey, From 846730c86da2b93971dd5a6bc94c221eb2c1781c Mon Sep 17 00:00:00 2001 From: ivan-aksamentov Date: Fri, 19 Jul 2024 18:36:55 +0200 Subject: [PATCH 2/2] feat(web): display coverage in results table --- .../src/components/Common/TableSlim.tsx | 2 +- .../src/components/Results/ColumnCoverage.tsx | 102 +++++++++++++++++- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/nextclade-web/src/components/Common/TableSlim.tsx b/packages/nextclade-web/src/components/Common/TableSlim.tsx index 671e0e0c7..a95f83382 100644 --- a/packages/nextclade-web/src/components/Common/TableSlim.tsx +++ b/packages/nextclade-web/src/components/Common/TableSlim.tsx @@ -5,7 +5,7 @@ import styled from 'styled-components' export const TableSlim = styled(ReactstrapTable)` & td { - padding: 0 0.5rem; + padding: 0.1rem 0.5rem; } & tr { diff --git a/packages/nextclade-web/src/components/Results/ColumnCoverage.tsx b/packages/nextclade-web/src/components/Results/ColumnCoverage.tsx index 5348a370e..d9e37b2d1 100644 --- a/packages/nextclade-web/src/components/Results/ColumnCoverage.tsx +++ b/packages/nextclade-web/src/components/Results/ColumnCoverage.tsx @@ -1,21 +1,113 @@ -import { round } from 'lodash' -import React, { useMemo } from 'react' +import { round, sortBy } from 'lodash' +import React, { useCallback, useMemo, useState } from 'react' +import { useRecoilValue } from 'recoil' +import { TableSlim } from 'src/components/Common/TableSlim' +import { Tooltip } from 'src/components/Results/Tooltip' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { cdsesAtom } from 'src/state/results.state' import type { AnalysisResult } from 'src/types' import { getSafeId } from 'src/helpers/getSafeId' +import styled from 'styled-components' + +const MAX_ROWS = 10 export interface ColumnCoverageProps { analysisResult: AnalysisResult } export function ColumnCoverage({ analysisResult }: ColumnCoverageProps) { - const { index, seqName, coverage } = analysisResult + const { t } = useTranslationSafe() + const [showTooltip, setShowTooltip] = useState(false) + const onMouseEnter = useCallback(() => setShowTooltip(true), []) + const onMouseLeave = useCallback(() => setShowTooltip(false), []) + + const { index, seqName, coverage, cdsCoverage } = analysisResult + const id = getSafeId('col-coverage', { index, seqName }) - const coveragePercentage = useMemo(() => `${round(coverage * 100, 1).toFixed(1)}%`, [coverage]) + + const coveragePercentage = useMemo(() => formatCoveragePercentage(coverage), [coverage]) + + const { rows, isTruncated } = useMemo(() => { + const cdsCoverageSorted = sortBy(Object.entries(cdsCoverage), ([_, coverage]) => coverage) + const { head, tail } = truncateMiddle(cdsCoverageSorted, MAX_ROWS * 2) + let rows = head.map(([cds, coverage]) => ) + if (tail) { + const tailRows = tail.map(([cds, coverage]) => ) + rows = [...rows, , ...tailRows] + } + return { rows, isTruncated: !!tail } + }, [cdsCoverage]) return ( -
+
{coveragePercentage} + +
+
{t('Nucleotide coverage: {{ value }}', { value: coveragePercentage })}
+
+ +
+
{t('CDS coverage')}
+ {isTruncated && ( +

+ {t('Showing only the {{ num }} CDS with lowest and {{ num }} CDS with highest coverage', { + num: MAX_ROWS, + })} +

+ )} + + + + {t('CDS')} + {t('Coverage')} + + + {rows} + +
+
) } + +function CdsCoverageRow({ cds, coverage }: { cds: string; coverage: number }) { + const cdses = useRecoilValue(cdsesAtom) + const color = cdses.find((c) => c.name === cds)?.color ?? '#aaa' + return ( + + + {cds} + + {formatCoveragePercentage(coverage)} + + ) +} + +function Spacer() { + return ( + + + {'...'} + + + ) +} + +const CdsText = styled.span<{ $background?: string; $color?: string }>` + padding: 1px 2px; + background-color: ${(props) => props.$background}; + color: ${(props) => props.$color ?? props.theme.gray100}; + font-weight: 700; + border-radius: 3px; +` + +function formatCoveragePercentage(coverage: number) { + return `${round(coverage * 100, 1).toFixed(1)}%` +} + +function truncateMiddle(arr: T[], n: number) { + if (n < 3 || arr.length <= n) return { head: arr, tail: undefined } + const half = Math.floor((n - 2) / 2) + return { head: arr.slice(0, half), tail: arr.slice(arr.length - (n - half - 1)) } +}