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)) }
+}
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,