diff --git a/Cargo.lock b/Cargo.lock index 5935871..48ff6f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "calibrt" -version = "0.25.0" +version = "0.26.0" dependencies = [ "insta", "rand 0.8.5", @@ -3460,9 +3460,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" @@ -3670,7 +3670,7 @@ dependencies = [ [[package]] name = "micromzpaf" -version = "0.25.0" +version = "0.26.0" dependencies = [ "rustyms", "serde", @@ -5897,7 +5897,7 @@ dependencies = [ [[package]] name = "timscentroid" -version = "0.25.0" +version = "0.26.0" dependencies = [ "arrow", "async-trait", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "timsquery" -version = "0.25.0" +version = "0.26.0" dependencies = [ "arrow", "bincode 1.3.3", @@ -5949,7 +5949,7 @@ dependencies = [ [[package]] name = "timsquery_cli" -version = "0.25.0" +version = "0.26.0" dependencies = [ "clap", "half", @@ -5968,7 +5968,7 @@ dependencies = [ [[package]] name = "timsquery_viewer" -version = "0.25.0" +version = "0.26.0" dependencies = [ "clap", "eframe", @@ -5976,6 +5976,7 @@ dependencies = [ "egui_dock", "egui_extras", "egui_plot", + "image", "mimalloc", "rayon", "rfd", @@ -6010,7 +6011,7 @@ dependencies = [ [[package]] name = "timsseek" -version = "0.25.0" +version = "0.26.0" dependencies = [ "calibrt", "forust-ml", @@ -6034,7 +6035,7 @@ dependencies = [ [[package]] name = "timsseek_cli" -version = "0.25.0" +version = "0.26.0" dependencies = [ "clap", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index 53333ef..19db98d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.25.0" +version = "0.26.0" edition = "2024" authors = ["Sebastian Paez"] license = "Apache-2.0" diff --git a/pyproject.toml b/pyproject.toml index a769fb4..1b5f346 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "timsseek-workspace" -version = "0.25.0" +version = "0.26.0" requires-python = ">=3.11,<3.13" dependencies = [ "jupyter[python]>=1.1.1", @@ -55,7 +55,7 @@ packages = [ ] [tool.bumpver] -current_version = "0.25.0" +current_version = "0.26.0" version_pattern = "MAJOR.MINOR.PATCH[-PYTAGNUM]" tag_message = "v{new_version}" commit_message = "chore: bump version to {new_version}" diff --git a/python/speclib_builder/pyproject.toml b/python/speclib_builder/pyproject.toml index 55a80be..be61fa8 100644 --- a/python/speclib_builder/pyproject.toml +++ b/python/speclib_builder/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "speclib_builder" -version = "0.25.0" +version = "0.26.0" requires-python = ">=3.11,<3.13" dependencies = [ "rich", diff --git a/python/timsseek_rescore/pyproject.toml b/python/timsseek_rescore/pyproject.toml index c40f10a..77963d8 100644 --- a/python/timsseek_rescore/pyproject.toml +++ b/python/timsseek_rescore/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "timsseek_rescore" -version = "0.25.0" +version = "0.26.0" requires-python = ">=3.11,<3.13" dependencies = [ "polars", diff --git a/python/timsseek_rts_receiver/pyproject.toml b/python/timsseek_rts_receiver/pyproject.toml index b5b000a..7163a87 100644 --- a/python/timsseek_rts_receiver/pyproject.toml +++ b/python/timsseek_rts_receiver/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "timsseek_rts_receiver" -version = "0.25.0" +version = "0.26.0" requires-python = ">=3.11,<3.13" description = "Add your description here" dependencies = [ diff --git a/rust/timsquery/src/models/aggregators/spectrum_agg.rs b/rust/timsquery/src/models/aggregators/spectrum_agg.rs index 39d94fa..bb55288 100644 --- a/rust/timsquery/src/models/aggregators/spectrum_agg.rs +++ b/rust/timsquery/src/models/aggregators/spectrum_agg.rs @@ -167,3 +167,4 @@ impl Add for MzMobilityStatsCollector { } } } + diff --git a/rust/timsquery/src/models/indexed_data.rs b/rust/timsquery/src/models/indexed_data.rs index e732017..d9bf429 100644 --- a/rust/timsquery/src/models/indexed_data.rs +++ b/rust/timsquery/src/models/indexed_data.rs @@ -65,6 +65,7 @@ use crate::models::aggregators::{ ChromatogramCollector, + MzMobilityStatsCollector, PointIntensityAggregator, SpectralCollector, }; @@ -453,3 +454,54 @@ impl QueriableData> for IndexedPeaks } } } + +impl QueriableData> for IndexedPeaksHandle { + fn add_query( + &self, + aggregator: &mut SpectralCollector, + tolerance: &Tolerance, + ) { + match self { + IndexedPeaksHandle::Eager(eager) => eager.add_query(aggregator, tolerance), + IndexedPeaksHandle::Lazy(lazy) => { + let ranges = QueryRanges::from_elution_group(aggregator, tolerance, |rt| { + lazy.rt_ms_to_cycle_index(rt) + }); + + let cycle_range_u32 = match ranges.ms1_cycle_range { + Restricted(x) => Restricted( + TupleRange::try_new(x.start().as_u32(), x.end().as_u32()).unwrap(), + ), + Unrestricted => Unrestricted, + }; + + aggregator + .iter_mut_precursors() + .for_each(|((_idx, mz), ion)| { + let mz_range = tolerance.mz_range_f32(mz as f32); + lazy.query_peaks_ms1(mz_range, cycle_range_u32, ranges.im_range) + .for_each(|peak| { + *ion += peak; + }); + }); + + aggregator + .iter_mut_fragments() + .for_each(|((_idx, mz), ion)| { + let mz_range = tolerance.mz_range_f32(*mz as f32); + let results = lazy.query_peaks_ms2( + ranges.quad_range, + mz_range, + cycle_range_u32, + ranges.im_range, + ); + for (_isolation_scheme, peaks) in results { + for peak in peaks { + *ion += peak; + } + } + }); + } + } + } +} diff --git a/rust/timsquery/src/models/tolerance.rs b/rust/timsquery/src/models/tolerance.rs index f7cdb69..4bca5ef 100644 --- a/rust/timsquery/src/models/tolerance.rs +++ b/rust/timsquery/src/models/tolerance.rs @@ -406,4 +406,21 @@ impl Tolerance { ..self } } + + /// Scale the mobility tolerance by `factor` (e.g. 2.0 to double the window). + pub fn with_wider_mobility(self, factor: f32) -> Self { + let wider = match self.mobility { + MobilityTolerance::Absolute((low, high)) => { + MobilityTolerance::Absolute((low * factor, high * factor)) + } + MobilityTolerance::Pct((low, high)) => { + MobilityTolerance::Pct((low * factor, high * factor)) + } + MobilityTolerance::Unrestricted => MobilityTolerance::Unrestricted, + }; + Self { + mobility: wider, + ..self + } + } } diff --git a/rust/timsquery/src/serde/library_file.rs b/rust/timsquery/src/serde/library_file.rs index 1c8f0b3..1da6597 100644 --- a/rust/timsquery/src/serde/library_file.rs +++ b/rust/timsquery/src/serde/library_file.rs @@ -9,6 +9,11 @@ use super::elution_group_inputs::{ ElutionGroupInput, ElutionGroupInputError, }; +pub use super::spectronaut_io::SpectronautPrecursorExtras; +use super::spectronaut_io::{ + read_library_file as read_spectronaut_tsv, + sniff_spectronaut_library_file, +}; use crate::TimsElutionGroup; use crate::ion::IonAnnot; use std::path::Path; @@ -40,6 +45,7 @@ impl From for LibraryReadingError { #[derive(Debug)] pub enum FileReadingExtras { Diann(Vec), + Spectronaut(Vec), } #[derive(Debug)] @@ -187,14 +193,48 @@ impl ElutionGroupCollection { Err(LibraryReadingError::UnableToParseElutionGroups) } + + fn try_read_spectronaut(path: &Path) -> Result { + match sniff_spectronaut_library_file(path) { + Ok(()) => { + info!("Detected Spectronaut TSV library file"); + let egs = match read_spectronaut_tsv(path) { + Ok(egs) => egs, + Err(e) => { + warn!("Failed to read Spectronaut TSV library file: {:?}", e); + return Err(LibraryReadingError::UnableToParseElutionGroups); + } + }; + let (egs, extras): (Vec<_>, Vec<_>) = egs.into_iter().unzip(); + info!("Successfully read Spectronaut TSV library file"); + Ok(ElutionGroupCollection::MzpafLabels( + egs, + Some(FileReadingExtras::Spectronaut(extras)), + )) + } + Err(e) => { + debug!("File is not Spectronaut format: {:?}", e); + Err(LibraryReadingError::UnableToParseElutionGroups) + } + } + } } pub fn read_library_file>( path: T, ) -> Result { + // Try DIA-NN first let diann_attempt = ElutionGroupCollection::try_read_diann(path.as_ref()); if let Ok(egs) = diann_attempt { return Ok(egs); } + + // Try Spectronaut next + let spectronaut_attempt = ElutionGroupCollection::try_read_spectronaut(path.as_ref()); + if let Ok(egs) = spectronaut_attempt { + return Ok(egs); + } + + // Fall back to JSON ElutionGroupCollection::try_read_json(path.as_ref()) } diff --git a/rust/timsquery/src/serde/mod.rs b/rust/timsquery/src/serde/mod.rs index a13858e..bcf272f 100644 --- a/rust/timsquery/src/serde/mod.rs +++ b/rust/timsquery/src/serde/mod.rs @@ -3,6 +3,7 @@ mod diann_io; mod elution_group_inputs; pub mod index_serde; mod library_file; +mod spectronaut_io; pub use chromatogram_output::*; pub use index_serde::*; @@ -11,5 +12,7 @@ pub use library_file::{ ElutionGroupCollection, FileReadingExtras, LibraryReadingError, + SpectronautPrecursorExtras, read_library_file, }; +pub use spectronaut_io::LibrarySniffError; diff --git a/rust/timsquery/src/serde/spectronaut_io.rs b/rust/timsquery/src/serde/spectronaut_io.rs new file mode 100644 index 0000000..f503748 --- /dev/null +++ b/rust/timsquery/src/serde/spectronaut_io.rs @@ -0,0 +1,539 @@ +use crate::TimsElutionGroup; +use crate::ion::{ + IonAnnot, + IonParsingError, +}; +use serde::Deserialize; +use std::path::Path; +use tinyvec::tiny_vec; +use tracing::{ + error, + info, + warn, +}; + +#[derive(Debug)] +pub enum SpectronautReadingError { + IoError, + CsvError, + SpectronautPrecursorParsingError, +} + +/// Error type for library format detection (sniffing) +#[derive(Debug)] +pub enum LibrarySniffError { + /// Failed to open or read the file + IoError(String), + /// Failed to parse CSV/TSV headers + InvalidFormat(String), + /// File is valid but missing required columns + MissingColumns(Vec), +} + +impl std::fmt::Display for SpectronautReadingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpectronautReadingError::IoError => write!(f, "IO error"), + SpectronautReadingError::CsvError => write!(f, "CSV parsing error"), + SpectronautReadingError::SpectronautPrecursorParsingError => { + write!(f, "Spectronaut precursor parsing error") + } + } + } +} + +impl std::error::Error for SpectronautReadingError {} + +#[derive(Debug)] +pub enum SpectronautPrecursorParsingError { + IonParsingError, + IonOverCapacity, + EmptyIonString, + Other, +} + +impl From for SpectronautPrecursorParsingError { + fn from(err: IonParsingError) -> Self { + error!("Ion parsing error: {:?}", err); + SpectronautPrecursorParsingError::IonParsingError + } +} + +impl From for SpectronautReadingError { + fn from(_err: SpectronautPrecursorParsingError) -> Self { + SpectronautReadingError::SpectronautPrecursorParsingError + } +} + +impl From for SpectronautReadingError { + fn from(err: csv::Error) -> Self { + error!("CSV reading error: {:?}", err); + SpectronautReadingError::CsvError + } +} + +impl From for SpectronautReadingError { + fn from(err: std::io::Error) -> Self { + error!("IO error: {:?}", err); + SpectronautReadingError::IoError + } +} + +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub struct SpectronautPrecursorExtras { + pub modified_peptide: String, + pub stripped_peptide: String, + pub protein_id: String, + pub is_decoy: bool, + pub relative_intensities: Vec<(IonAnnot, f32)>, +} + +/// Represents a single row from a Spectronaut library TSV file +#[derive(Debug, Clone, Deserialize)] +struct SpectronautLibraryRow { + #[serde(rename = "ModifiedPeptide")] + modified_peptide: String, + #[serde(rename = "StrippedPeptide")] + stripped_peptide: String, + #[serde(rename = "PrecursorMz")] + precursor_mz: f64, + #[serde(rename = "PrecursorCharge")] + precursor_charge: i32, + #[serde(rename = "iRT")] + irt: f64, + #[serde(rename = "IonMobility")] + ion_mobility: f64, + #[serde(rename = "ProteinGroups")] + protein_groups: String, + #[serde(rename = "FragmentMz")] + fragment_mz: f64, + #[serde(rename = "FragmentType")] + fragment_type: String, + #[serde(rename = "FragmentNumber")] + fragment_number: i32, + #[serde(rename = "FragmentCharge")] + fragment_charge: i32, + #[serde(rename = "FragmentLossType")] + fragment_loss_type: String, + #[serde(rename = "RelativeIntensity")] + relative_intensity: f32, + #[serde(rename = "ExcludeFromAssay")] + exclude_from_assay: String, +} + +impl SpectronautLibraryRow { + /// Check if this row belongs to the same precursor as another row + fn is_same_precursor(&self, other: &SpectronautLibraryRow) -> bool { + self.modified_peptide == other.modified_peptide + && self.precursor_mz == other.precursor_mz + && self.precursor_charge == other.precursor_charge + && self.irt == other.irt + && self.ion_mobility == other.ion_mobility + && self.protein_groups == other.protein_groups + } + + /// Check if this fragment should be excluded from the assay + fn is_excluded(&self) -> bool { + self.exclude_from_assay == "True" + } +} + +/// Check if a file is a Spectronaut library TSV by looking for Spectronaut-specific columns. +/// +/// Returns `Ok(())` if the file matches Spectronaut format, or `Err` with details about why not. +pub fn sniff_spectronaut_library_file>(file: T) -> Result<(), LibrarySniffError> { + let file_handle = std::fs::File::open(file.as_ref()).map_err(|e| { + LibrarySniffError::IoError(format!("Failed to open {}: {}", file.as_ref().display(), e)) + })?; + + let mut rdr = csv::ReaderBuilder::new() + .delimiter(b'\t') + .from_reader(file_handle); + + let headers = rdr.headers().map_err(|e| { + LibrarySniffError::InvalidFormat(format!("Failed to parse TSV headers: {}", e)) + })?; + + let columns: Vec = headers.iter().map(|s| s.to_string()).collect(); + + // Required columns for Spectronaut libraries (includes Spectronaut-specific markers) + let required_columns = [ + "ModifiedPeptide", + "StrippedPeptide", + "PrecursorMz", + "PrecursorCharge", + "iRT", // Spectronaut-specific (DIA-NN uses Tr_recalibrated) + "IonMobility", + "ProteinGroups", + "FragmentMz", + "FragmentType", + "FragmentNumber", + "FragmentCharge", + "FragmentLossType", + "RelativeIntensity", + "ExcludeFromAssay", // Spectronaut-specific + ]; + + // Find missing columns + let missing: Vec = required_columns + .iter() + .filter(|col| !columns.contains(&col.to_string())) + .map(|s| s.to_string()) + .collect(); + + if missing.is_empty() { + Ok(()) + } else { + Err(LibrarySniffError::MissingColumns(missing)) + } +} + +struct ParsingBuffers { + fragment_labels: Vec, +} + +pub fn read_library_file>( + file: T, +) -> Result, SpectronautPrecursorExtras)>, SpectronautReadingError> +{ + let file_handle = std::fs::File::open(file.as_ref())?; + + let mut rdr = csv::ReaderBuilder::new() + .delimiter(b'\t') + .from_reader(file_handle); + + info!("Reading file content from {}", file.as_ref().display()); + + let mut elution_groups = Vec::new(); + let mut current_group: Vec = Vec::with_capacity(20); + let mut buffers = ParsingBuffers { + fragment_labels: Vec::with_capacity(20), + }; + + let mut group_id = 0; + + for result in rdr.deserialize() { + let row: SpectronautLibraryRow = result?; + + if let Some(last_row) = current_group.first() + && !row.is_same_precursor(last_row) + { + // New group found, parse the collected group + if let Some(eg) = parse_precursor_group(group_id, ¤t_group, &mut buffers)? { + elution_groups.push(eg); + group_id += 1; + } + + current_group.clear(); + } + + current_group.push(row); + } + + // Process the last group + if !current_group.is_empty() + && let Some(eg) = parse_precursor_group(group_id, ¤t_group, &mut buffers)? + { + elution_groups.push(eg); + } + + info!("Parsed {} elution groups", elution_groups.len()); + Ok(elution_groups) +} + +fn parse_precursor_group( + id: u64, + rows: &[SpectronautLibraryRow], + buffers: &mut ParsingBuffers, +) -> Result< + Option<(TimsElutionGroup, SpectronautPrecursorExtras)>, + SpectronautPrecursorParsingError, +> { + if rows.is_empty() { + error!("Empty precursor group encountered on {id}"); + return Err(SpectronautPrecursorParsingError::Other); + } + + // Filter out excluded fragments + let included_rows: Vec<&SpectronautLibraryRow> = + rows.iter().filter(|r| !r.is_excluded()).collect(); + + // If all fragments are excluded, skip this precursor + if included_rows.is_empty() { + warn!( + "All fragments excluded for precursor group {}, skipping", + id + ); + return Ok(None); + } + + let first_row = &rows[0]; + let mobility = first_row.ion_mobility as f32; + + // iRT values are in a similar scale to DIA-NN's Tr_recalibrated (loosely minutes) + // Convert to seconds + let rt_seconds = first_row.irt as f32 * 60.0; + let precursor_mz = first_row.precursor_mz; + let precursor_charge: u8 = + first_row + .precursor_charge + .try_into() + .map_err(|e: std::num::TryFromIntError| { + error!("Failed to convert PrecursorCharge to u8: {:?}", e); + SpectronautPrecursorParsingError::IonOverCapacity + })?; + + let mut fragment_mzs = Vec::with_capacity(included_rows.len()); + buffers.fragment_labels.clear(); + let mut relative_intensities = Vec::with_capacity(included_rows.len()); + let mut num_unknown_losses = 0; + + for (i, row) in included_rows.iter().enumerate() { + let fragment_mz = row.fragment_mz; + let frag_charge: u8 = + row.fragment_charge + .try_into() + .map_err(|e: std::num::TryFromIntError| { + error!("Failed to convert FragmentCharge to u8: {:?}", e); + SpectronautPrecursorParsingError::IonOverCapacity + })?; + let rel_intensity = row.relative_intensity; + + // Check for loss type - treat non-"noloss" as unknown ions (same as DIA-NN) + if row.fragment_loss_type != "noloss" { + warn!( + "Unsupported fragment loss type '{}' at row {}; falling back to calling it an unknown ion", + row.fragment_loss_type, i + ); + + num_unknown_losses += 1; + let ion_annot = IonAnnot::try_new('?', Some(num_unknown_losses), frag_charge as i8, 0)?; + buffers.fragment_labels.push(ion_annot); + fragment_mzs.push(fragment_mz); + relative_intensities.push((ion_annot, rel_intensity)); + continue; + } + + let frag_char = row.fragment_type.chars().next().ok_or_else(|| { + error!( + "Empty FragmentType at row {}; cannot parse ion annotation", + i + ); + SpectronautPrecursorParsingError::EmptyIonString + })?; + + let frag_num = row.fragment_number.try_into().map_err(|_| { + error!( + "Invalid fragment number (I expect all of them < 255): {}", + row.fragment_number + ); + SpectronautPrecursorParsingError::IonOverCapacity + })?; + + let ion_annot = IonAnnot::try_new(frag_char, Some(frag_num), frag_charge as i8, 0)?; + + buffers.fragment_labels.push(ion_annot); + fragment_mzs.push(fragment_mz); + relative_intensities.push((ion_annot, rel_intensity)); + } + + // Spectronaut libraries are target-only, so is_decoy is always false + let is_decoy = false; + + let precursor_extras = SpectronautPrecursorExtras { + modified_peptide: first_row.modified_peptide.clone(), + stripped_peptide: first_row.stripped_peptide.clone(), + protein_id: first_row.protein_groups.clone(), + is_decoy, + relative_intensities, + }; + + let eg = TimsElutionGroup::builder() + .id(id) + .mobility_ook0(mobility) + .rt_seconds(rt_seconds) + .fragment_labels(buffers.fragment_labels.as_slice().into()) + .fragment_mzs(fragment_mzs) + .precursor_labels(tiny_vec![0]) // Single monoisotopic precursor + .precursor(precursor_mz, precursor_charge) + .try_build() + .unwrap(); + + Ok(Some((eg, precursor_extras))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_sniff_spectronaut_library_file() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let file_path = PathBuf::from(manifest_dir) + .join("tests") + .join("spectronaut_io_files") + .join("sample_lib.tsv"); + + let result = sniff_spectronaut_library_file(&file_path); + assert!( + result.is_ok(), + "File should be detected as Spectronaut library: {:?}", + result.err() + ); + + // Cargo.toml should not be detected as Spectronaut library + let file_path = PathBuf::from(manifest_dir).join("Cargo.toml"); + let result = sniff_spectronaut_library_file(file_path); + assert!( + result.is_err(), + "Cargo.toml should not be detected as Spectronaut library" + ); + } + + #[test] + fn test_sniff_diann_not_spectronaut() { + // DIA-NN files should not be detected as Spectronaut + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let file_path = PathBuf::from(manifest_dir) + .join("tests") + .join("diann_io_files") + .join("sample_lib.txt"); + + let result = sniff_spectronaut_library_file(file_path); + assert!( + result.is_err(), + "DIA-NN library should not be detected as Spectronaut library" + ); + // Verify we get helpful error context + if let Err(LibrarySniffError::MissingColumns(cols)) = result { + assert!( + cols.contains(&"iRT".to_string()), + "Should report missing iRT column" + ); + } + } + + #[test] + fn test_read_library_file() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let file_path = PathBuf::from(manifest_dir) + .join("tests") + .join("spectronaut_io_files") + .join("sample_lib.tsv"); + + let result = read_library_file(&file_path); + assert!( + result.is_ok(), + "Failed to read library file: {:?}", + result.err() + ); + + let elution_groups = result.unwrap(); + + // Sample file has 2 unique precursors: + // KTVTAMDVVYALKR (charge 3) and MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK (charge 5) + assert_eq!(elution_groups.len(), 2, "Expected 2 elution groups"); + } + + #[test] + fn test_fragment_annotations_parsed_correctly() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let file_path = PathBuf::from(manifest_dir) + .join("tests") + .join("spectronaut_io_files") + .join("sample_lib.tsv"); + + let mut elution_groups = read_library_file(file_path).expect("Failed to read library"); + elution_groups.sort_by(|a, b| a.0.rt_seconds().partial_cmp(&b.0.rt_seconds()).unwrap()); + + // First precursor (KTVTAMDVVYALKR) has iRT=44.467922 -> rt_seconds ~ 2668 + // Second precursor (MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK) has iRT=83.00864 -> rt_seconds ~ 4980 + + let first_eg = &elution_groups[0].0; + let second_eg = &elution_groups[1].0; + + // Check that the first elution group has the expected RT (approximately 44.467922 * 60) + assert!( + (first_eg.rt_seconds() - 44.467922 * 60.0).abs() < 0.01, + "First elution group RT mismatch" + ); + + // Check that fragment count matches expected (excluded fragments should be filtered) + // For KTVTAMDVVYALKR: 6 fragments with ExcludeFromAssay=False out of many total rows + assert!( + first_eg.fragment_count() >= 5, + "Expected at least 5 fragments for first precursor, got {}", + first_eg.fragment_count() + ); + + // Check second precursor RT + assert!( + (second_eg.rt_seconds() - 83.00864 * 60.0).abs() < 0.01, + "Second elution group RT mismatch" + ); + } + + #[test] + fn test_exclude_from_assay_filtering() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let file_path = PathBuf::from(manifest_dir) + .join("tests") + .join("spectronaut_io_files") + .join("sample_lib.tsv"); + + let elution_groups = read_library_file(file_path).expect("Failed to read library"); + + // Find the KTVTAMDVVYALKR precursor (first one) + let (eg, extras) = &elution_groups[0]; + + // Verify that the extras are populated correctly + assert_eq!(extras.stripped_peptide, "KTVTAMDVVYALKR"); + assert!( + !extras.is_decoy, + "Spectronaut libraries should be target-only" + ); + + // The sample file has 36 rows for this precursor, but many have ExcludeFromAssay=True + // Only 6 rows have ExcludeFromAssay=False (looking at the sample: rows 2,4,5,6,7,8) + // So we should have 6 fragments + assert_eq!( + eg.fragment_count(), + 6, + "Expected 6 non-excluded fragments for KTVTAMDVVYALKR" + ); + + // Verify that the relative intensities in extras match the fragment count + assert_eq!( + extras.relative_intensities.len(), + eg.fragment_count(), + "Relative intensities count should match fragment count" + ); + } + + #[test] + fn test_precursor_extras_populated() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let file_path = PathBuf::from(manifest_dir) + .join("tests") + .join("spectronaut_io_files") + .join("sample_lib.tsv"); + + let elution_groups = read_library_file(file_path).expect("Failed to read library"); + + for (_, extras) in &elution_groups { + // All Spectronaut library entries should be targets (not decoys) + assert!(!extras.is_decoy); + + // Should have non-empty peptide sequences + assert!(!extras.modified_peptide.is_empty()); + assert!(!extras.stripped_peptide.is_empty()); + + // Should have protein ID + assert!(!extras.protein_id.is_empty()); + + // Should have relative intensities + assert!(!extras.relative_intensities.is_empty()); + } + } +} diff --git a/rust/timsquery/src/traits/key_like.rs b/rust/timsquery/src/traits/key_like.rs index 6211e26..3baf917 100644 --- a/rust/timsquery/src/traits/key_like.rs +++ b/rust/timsquery/src/traits/key_like.rs @@ -38,15 +38,11 @@ pub trait KeyLike: Clone + Eq + Serialize + Hash + Send + Sync + Debug + Default /// /// # Required Bounds /// -/// - `Copy`: Values must be trivially copyable (typically numeric types) -/// - `Clone`: Implied by `Copy`, but explicit for clarity +/// - `Clone`: Values must be cloneable /// - `Serialize`: Values must be serializable for output and analysis /// - `Send + Sync`: Values must be thread-safe for parallel aggregation /// - `Debug`: Values must be debuggable for verification /// -/// Note: The `Copy` bound restricts values to small, stack-allocated types like -/// numeric primitives (`f32`, `f64`, `u32`, etc.) and small aggregates. -/// /// # Examples /// /// Common value types include: @@ -61,7 +57,7 @@ pub trait KeyLike: Clone + Eq + Serialize + Hash + Send + Sync + Debug + Default /// use half::f16; /// let _value: f16 = f16::from_f32(123.4); /// ``` -pub trait ValueLike: Copy + Clone + Serialize + Send + Sync + Debug {} +pub trait ValueLike: Clone + Serialize + Send + Sync + Debug {} impl KeyLike for T {} -impl ValueLike for T {} +impl ValueLike for T {} diff --git a/rust/timsquery/tests/spectronaut_io_files/sample_lib.tsv b/rust/timsquery/tests/spectronaut_io_files/sample_lib.tsv new file mode 100644 index 0000000..4e68419 --- /dev/null +++ b/rust/timsquery/tests/spectronaut_io_files/sample_lib.tsv @@ -0,0 +1,57 @@ +ReferenceRun PrecursorCharge Workflow IntModifiedPeptide CV AllowForNormalization ModifiedPeptide StrippedPeptide iRT IonMobility iRTSourceSpecific BGSInferenceId IsProteotypic IntLabeledPeptide LabeledPeptide PrecursorMz ReferenceRunQvalue ReferenceRunMS1Response FragmentLossType FragmentNumber FragmentType FragmentCharge FragmentMz RelativeIntensity ExcludeFromAssay Database ProteinGroups UniProtIds Protein Name ProteinDescription Organisms OrganismId Genes Protein Existence Sequence Version FASTAName +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 4 y 1 487.33509294995196 38.799873 False sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 5 b 1 501.303124115332 9.581698 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 5 y 1 650.3984214825019 100 False sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 6 y 1 749.4668353954919 32.406113 False sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 7 b 1 747.370552052152 44.23784 False sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 8 b 1 846.438965965142 34.23763 False sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 11 y 2 633.8473729826169 14.795426 False sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 3 y 1 416.297979165242 14.671935 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 6 y 2 375.237055931152 4.699404 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 7 y 1 848.5352493084819 5.3079996 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 8 y 1 963.5621923323118 7.6828856 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 9 b 1 945.5073798781319 11.35378 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 9 y 2 547.8049768560569 3.2712524 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 12 y 2 683.3815799391119 10.789131 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 13 y 2 733.9054191733169 11.30675 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 H2O 5 b 1 483.2925593112549 1.3758478 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 NH3 5 b 1 484.2765757297119 1.0202676 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 H2O 7 b 1 729.3599872480748 3.7208781 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 H2O 8 b 1 828.4284011610648 4.0462584 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 3 b 1 329.21833186221204 1.6154078 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 4 b 1 430.26601033062207 1.7848012 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 5 y 2 325.702848974657 8.842667 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 6 b 1 632.343609028322 6.0451264 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 7 b 2 374.18891425948203 2.5635202 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 7 y 2 424.77126288764697 3.1527066 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 8 b 2 423.723121215977 1.5835316 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 8 y 2 482.28473439956196 1.9537677 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 9 y 1 1094.602677245302 3.370663 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 10 y 2 583.3235337484119 6.0417933 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 10 y 1 1165.639791030012 1.6819614 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 noloss 11 y 1 1266.687469498422 1.1914712 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 NH3 5 y 1 633.3718730968818 1.7086108 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 NH3 7 b 1 730.3440036665319 4.920253 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 NH3 8 b 1 829.4124175795218 2.7466528 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 H2O 9 b 1 927.4968150740548 1.3353369 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 3 _KTVTAMDVVYALKR_ True _KTVTAMDVVYALKR_ KTVTAMDVVYALKR 44.467922 0.856 44.467922 P62806 True _KTVTAMDVVYALKR_ _KTVTAMDVVYALKR_ 532.3043592758154 0 42009.664 H2O 13 y 2 724.9001367712783 2.5939355 True sp P62806 P62806 H4_MOUSE Histone H4 Mus musculus 10090 H4c1 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 4 y 1 446.224539441462 5.673315 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 9 y 1 974.4247724529819 34.83127 False sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 13 b 2 720.3477456622819 50.372395 False sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 14 b 2 769.881952618777 65.14738 False sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 15 b 2 833.911241371417 100 False sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 15 b 3 556.2765864032153 1.5839157 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 16 b 2 890.453273359982 11.79841 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 16 b 3 593.971274395592 1.9807116 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 16 y 2 906.4061857758019 15.743793 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 17 b 3 612.9784289691153 4.816533 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 17 y 2 987.9378500420769 9.551777 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 18 b 2 975.9854689408369 6.190753 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 18 y 2 1044.479882030642 21.895517 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 19 b 3 674.6717760443986 81.66024 False sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 19 b 2 1011.5040258331919 25.096367 False sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 19 y 2 1109.0011785746271 17.973492 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 20 b 2 1091.519350085832 14.228237 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 20 y 2 1202.0408350495572 12.221228 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 21 y 2 1282.056159302197 13.944383 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 +43M2_liver_S5-B3_1_6812 5 _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ True _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ MRECISIHVGQAGVQIGNACWELYCLEHGIQPDGQMPSDK 83.00864 1.1 83.00864 P05213 False _MREC[+57]ISIHVGQAGVQIGNAC[+57]WELYC[+57]LEHGIQPDGQMPSDK_ _MREC[Carbamidomethyl (C)]ISIHVGQAGVQIGNAC[Carbamidomethyl (C)]WELYC[Carbamidomethyl (C)]LEHGIQPDGQMPSDK_ 917.6255293475182 0 1576.7849 noloss 21 y 3 855.0398650237354 4.594482 True sp P05213 P05213 TBA1B_MOUSE Tubulin alpha-1B chain Mus musculus 10090 Tuba1b 1 2 Mouse_UniProt_reviewed_2025_07_21 diff --git a/rust/timsquery_viewer/Cargo.toml b/rust/timsquery_viewer/Cargo.toml index 76177eb..a519025 100644 --- a/rust/timsquery_viewer/Cargo.toml +++ b/rust/timsquery_viewer/Cargo.toml @@ -21,6 +21,7 @@ egui_plot = "0.34" egui_extras = { version = "0.33", features = ["serde"] } egui_dock = { version = "0.18", features = ["serde"] } rfd = "0.16" # File dialogs dependency +image = { version = "0.25", default-features = false, features = ["png"] } ron = "0.12.0" # Yet another serialization format ... # Workspace-inherited deps diff --git a/rust/timsquery_viewer/src/app.rs b/rust/timsquery_viewer/src/app.rs index c619787..0658ad0 100644 --- a/rust/timsquery_viewer/src/app.rs +++ b/rust/timsquery_viewer/src/app.rs @@ -8,6 +8,7 @@ use egui_dock::{ }; use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; use timsquery::models::tolerance::Tolerance; use timsquery::serde::IndexedPeaksHandle; @@ -24,10 +25,12 @@ use crate::file_loader::{ use crate::plot_renderer::AutoZoomMode; use crate::ui::panels::{ ConfigPanel, + MobilityPanel, + ScreenshotAction, + ScreenshotState, SpectrumPanel, TablePanel, }; -use std::sync::Arc as StdArc; use std::sync::atomic::{ AtomicBool, Ordering, @@ -44,6 +47,7 @@ type ChromatogramComputeResult = Result< crate::chromatogram_processor::ChromatogramCollector, timsseek::ExpectedIntensities, u64, // selected_idx as cache key + timsquery::models::elution_group::TimsElutionGroup, ), String, >; @@ -51,13 +55,13 @@ type ChromatogramComputeResult = Result< /// Handle to cancel a running background computation #[derive(Clone)] struct CancellationToken { - cancelled: StdArc, + cancelled: Arc, } impl CancellationToken { fn new() -> Self { Self { - cancelled: StdArc::new(AtomicBool::new(false)), + cancelled: Arc::new(AtomicBool::new(false)), } } @@ -83,7 +87,7 @@ const LOCATION_FRAME_PADDING_H: i8 = 8; const LOCATION_FRAME_PADDING_V: i8 = 4; /// Pane types for the tile layout -#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize)] enum Pane { ConfigPanel, TablePanel, @@ -91,8 +95,21 @@ enum Pane { PrecursorPlot, FragmentPlot, ScoresPlot, + Mobility, } +/// All pane variants that should exist in the dock. +/// New variants added here will be auto-injected into saved layouts. +const ALL_PANES: &[Pane] = &[ + Pane::ConfigPanel, + Pane::TablePanel, + Pane::MS2Spectrum, + Pane::PrecursorPlot, + Pane::FragmentPlot, + Pane::ScoresPlot, + Pane::Mobility, +]; + /// State to be persisted across restarts #[derive(serde::Deserialize, serde::Serialize)] struct PersistentState { @@ -103,13 +120,19 @@ struct PersistentState { dock_state: DockState, } +fn default_true() -> bool { + true +} + /// State of indexed raw data loading #[derive(Debug, Default)] pub enum IndexedDataState { /// No data loaded #[default] None, - /// Currently loading data from location (path or URL) + /// Load requested but not yet started (triggers load on next frame) + LoadRequested(String), + /// Load in progress - shows spinner, does NOT re-trigger load Loading(String), /// Loading failed with error message Failed(String, String), @@ -126,7 +149,9 @@ pub enum ElutionGroupState { /// No library loaded #[default] None, - /// Currently loading from path + /// Load requested but not yet started (triggers load on next frame) + LoadRequested(PathBuf), + /// Load in progress - shows spinner, does NOT re-trigger load Loading(PathBuf), /// Loading failed with error message (path, error) Failed(PathBuf, String), @@ -150,6 +175,20 @@ impl ElutionGroupState { } } +/// State of tolerance file loading +#[derive(Debug, Default)] +pub enum ToleranceState { + /// No tolerance file loaded (using defaults or user-edited values) + #[default] + None, + /// Load requested but not yet started (triggers load on next frame) + LoadRequested(PathBuf), + /// Tolerance file successfully loaded (user can freely edit via UI) + Loaded { source: PathBuf }, + /// Loading failed with error message (path, error) + Failed(PathBuf, String), +} + /// Domain/data state - represents loaded data and analysis parameters #[derive(Debug, Default)] pub struct DataState { @@ -157,7 +196,9 @@ pub struct DataState { pub elution_groups: ElutionGroupState, /// Indexed timsTOF data with loading state pub indexed_data: IndexedDataState, - /// Tolerance settings + /// Tolerance file loading state + pub tolerance_state: ToleranceState, + /// Tolerance settings (live-editable values) pub tolerance: Tolerance, /// Smoothing method configuration pub smoothing: SmoothingMethod, @@ -186,6 +227,9 @@ pub struct UiState { pub search_input: String, /// Raw data input mode (Local or Cloud) pub raw_data_input_mode: RawDataInputMode, + /// Dark mode preference (true = dark, false = light) + #[serde(default = "default_true")] + pub dark_mode: bool, } /// Main application state @@ -209,11 +253,25 @@ pub struct ViewerApp { config_panel: ConfigPanel, table_panel: TablePanel, spectrum_panel: SpectrumPanel, + mobility_panel: MobilityPanel, /// Receiver for background chromatogram computation chromatogram_receiver: Option>, /// Token to cancel current background computation cancellation_token: Option, + + /// Screenshot capture state machine + screenshot_state: ScreenshotState, + /// Countdown duration in seconds before capture + screenshot_delay_secs: f32, +} + +fn apply_theme(ctx: &egui::Context, dark_mode: bool) { + ctx.set_visuals(if dark_mode { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }); } impl ViewerApp { @@ -247,6 +305,19 @@ impl ViewerApp { }; if let Some(state) = state { + let mut dock_state = state.dock_state; + // Inject any pane variants added since the state was saved + for &pane in ALL_PANES { + if dock_state.find_tab(&pane).is_none() { + tracing::info!("Adding missing pane {:?} to saved layout", pane); + dock_state + .main_surface_mut() + .push_to_first_leaf(pane); + } + } + + apply_theme(&cc.egui_ctx, state.ui_state.dark_mode); + return Self { file_loader: state .file_loader @@ -258,12 +329,15 @@ impl ViewerApp { }, ui: state.ui_state, computed: ComputedState::default(), - dock_state: state.dock_state, + dock_state, config_panel: ConfigPanel::new(), table_panel: TablePanel::new(), spectrum_panel: SpectrumPanel::new(), + mobility_panel: MobilityPanel::new(), chromatogram_receiver: None, cancellation_token: None, + screenshot_state: ScreenshotState::default(), + screenshot_delay_secs: 3.0, }; } } else { @@ -271,7 +345,7 @@ impl ViewerApp { } } - // Create initial tabs: Settings, Table, Precursors, Fragments, MS2 + // Create initial tabs: Settings, Table, Precursors, Fragments, MS2, Scores, Mobilogram let tabs = vec![ Pane::ConfigPanel, Pane::TablePanel, @@ -279,6 +353,7 @@ impl ViewerApp { Pane::FragmentPlot, Pane::MS2Spectrum, Pane::ScoresPlot, + Pane::Mobility, ]; let dock_state = DockState::new(tabs); @@ -293,8 +368,11 @@ impl ViewerApp { config_panel: ConfigPanel::new(), table_panel: TablePanel::new(), spectrum_panel: SpectrumPanel::new(), + mobility_panel: MobilityPanel::new(), chromatogram_receiver: None, cancellation_token: None, + screenshot_state: ScreenshotState::default(), + screenshot_delay_secs: 3.0, } } @@ -346,9 +424,9 @@ impl ViewerApp { }); } - /// Generate MS2 spectrum if user clicked on a new RT position - fn generate_ms2_spectrum_if_needed(&mut self) { - let Some(requested_rt) = self.computed.clicked_rt else { + /// Respond to a user RT click: generate MS2 spectrum and mobility data. + fn handle_rt_click(&mut self) { + let Some(requested_rt) = self.computed.clicked_rt() else { return; }; @@ -356,6 +434,11 @@ impl ViewerApp { self.computed .insert_reference_line("Clicked RT".into(), requested_rt, Color32::GREEN); } + + if let IndexedDataState::Loaded { index, .. } = &self.data.indexed_data { + self.computed + .generate_mobility_at_rt(requested_rt, index, &self.data.tolerance); + } } fn generate_chromatogram(&mut self, ctx: &egui::Context) { @@ -464,7 +547,13 @@ impl ViewerApp { } Err(e) => { tracing::error!("Failed to spawn chromatogram thread: {}", e); - self.computed.cancel_computing(); + let index = self.computed.computing_index().unwrap_or(0); + self.computed.fail_computing( + index, + &self.data.tolerance, + &self.data.smoothing, + format!("Failed to spawn computation thread: {}", e), + ); self.chromatogram_receiver = None; self.cancellation_token = None; } @@ -514,7 +603,7 @@ impl ViewerApp { return Err("Computation cancelled".to_string()); } - Ok((output, collector, expected_intensities, selected_idx as u64)) + Ok((output, collector, expected_intensities, selected_idx as u64, elution_group)) } /// Check if background chromatogram computation completed @@ -529,7 +618,7 @@ impl ViewerApp { self.cancellation_token = None; match result { - Ok((output, collector, expected_intensities, selected_idx)) => { + Ok((output, collector, expected_intensities, selected_idx, elution_group)) => { tracing::debug!( "Chromatogram computation result received for index {}", selected_idx @@ -541,6 +630,7 @@ impl ViewerApp { output, collector, expected_intensities, + elution_group, }; self.computed.complete_chromatogram_computation( @@ -565,7 +655,14 @@ impl ViewerApp { } Err(e) => { tracing::error!("Chromatogram computation failed: {}", e); - self.computed.cancel_computing(); + // Capture the computing index before clearing state + let index = self.computed.computing_index().unwrap_or(0); + self.computed.fail_computing( + index, + &self.data.tolerance, + &self.data.smoothing, + e, + ); } } } @@ -767,13 +864,36 @@ impl ViewerApp { Self::display_filename(ui, path); } - Self::load_tolerance_if_needed(file_loader, data); + Self::load_tolerance_if_needed(ui, file_loader, data); - ui.add_space(SMALL_SPACING); - ui.label( - egui::RichText::new("✓ Tolerance settings loaded") - .color(egui::Color32::DARK_GREEN), - ); + match &data.tolerance_state { + ToleranceState::Loaded { .. } => { + ui.add_space(SMALL_SPACING); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("✓ Tolerance settings loaded") + .color(egui::Color32::DARK_GREEN), + ); + if ui.button("Reset to file").clicked() + && let Some(path) = &file_loader.tolerance_path + { + data.tolerance_state = ToleranceState::LoadRequested(path.clone()); + } + }); + } + ToleranceState::None => { + ui.add_space(SMALL_SPACING); + ui.label( + egui::RichText::new("Using current tolerance values") + .small() + .weak(), + ); + } + // Failed renders error UI inside load_tolerance_if_needed above. + // LoadRequested always transitions out within the same frame, so it never + // reaches this match. + ToleranceState::Failed(_, _) | ToleranceState::LoadRequested(_) => {} + } }); } @@ -793,39 +913,45 @@ impl ViewerApp { ui_state: &mut UiState, ) { if let Some(path) = &file_loader.elution_groups_path { - // Check if we need to load new library - let should_load = match &data.elution_groups { + // Check if we need to request a new load + let should_request_load = match &data.elution_groups { ElutionGroupState::None => true, + ElutionGroupState::LoadRequested(current_path) => current_path != path, ElutionGroupState::Loading(current_path) => current_path != path, ElutionGroupState::Loaded { source, .. } => source != path, ElutionGroupState::Failed(_, _) => true, }; - // Transition to Loading if new path - if should_load { - tracing::info!("Starting to load elution groups from: {}", path.display()); - data.elution_groups = ElutionGroupState::Loading(path.clone()); + // Transition to LoadRequested if new path + if should_request_load { + tracing::info!("Requesting load of elution groups from: {}", path.display()); + data.elution_groups = ElutionGroupState::LoadRequested(path.clone()); // Reset selected index when loading a new library to avoid out-of-bounds errors ui_state.selected_index = None; ui.ctx().request_repaint(); } } - // Handle loading state + // Handle state machine transitions match &data.elution_groups { - ElutionGroupState::Loading(path) => { + ElutionGroupState::LoadRequested(path) => { + // Transition to Loading and perform the actual load + let path = path.clone(); + tracing::info!("Starting to load elution groups from: {}", path.display()); + data.elution_groups = ElutionGroupState::Loading(path.clone()); + ui.horizontal(|ui| { ui.spinner(); ui.label("Loading elution groups..."); }); - match file_loader.load_elution_groups(path) { + match file_loader.load_elution_groups(&path) { Ok(egs) => { let count = egs.len(); tracing::info!("Loaded {} elution groups", count); data.elution_groups = ElutionGroupState::Loaded { data: egs, - source: path.clone(), + source: path, }; // Validate selected_index is within bounds of new library if let Some(selected) = ui_state.selected_index @@ -842,10 +968,18 @@ impl ViewerApp { Err(e) => { let error_msg = format!("{:?}", e); tracing::error!("Failed to load elution groups: {}", error_msg); - data.elution_groups = ElutionGroupState::Failed(path.clone(), error_msg); + data.elution_groups = ElutionGroupState::Failed(path, error_msg); } } } + ElutionGroupState::Loading(_) => { + // Load already in progress - just show spinner, don't re-trigger + // (This state is transient - we transition out of it in the same frame) + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading elution groups..."); + }); + } ElutionGroupState::Failed(path, error) => { ui.label( egui::RichText::new(format!("Failed to load library from {}:", path.display())) @@ -868,36 +1002,43 @@ impl ViewerApp { computed: &mut ComputedState, ) { if let Some(location) = file_loader.get_raw_data_location() { - // Check if we need to load new data - let should_load = match &data.indexed_data { + // Check if we need to request a new load + let should_request_load = match &data.indexed_data { IndexedDataState::None => true, + IndexedDataState::LoadRequested(current_location) => current_location != &location, IndexedDataState::Loading(current_location) => current_location != &location, IndexedDataState::Loaded { source, .. } => source != &location, IndexedDataState::Failed(_, _) => true, }; - // Transition to Loading if new location - if should_load { - tracing::info!("Starting to load new raw data from: {}", location); + // Transition to LoadRequested if new location + if should_request_load { + tracing::info!("Requesting load of raw data from: {}", location); // Clear computed state to avoid showing stale chromatograms from old index computed.clear(); - data.indexed_data = IndexedDataState::Loading(location.clone()); + data.indexed_data = IndexedDataState::LoadRequested(location.clone()); ui.ctx().request_repaint(); } } + // Handle state machine transitions match &data.indexed_data { - IndexedDataState::Loading(location) => { + IndexedDataState::LoadRequested(location) => { + // Transition to Loading and perform the actual load + let location = location.clone(); + tracing::info!("Starting to load raw data from: {}", location); + data.indexed_data = IndexedDataState::Loading(location.clone()); + ui.horizontal(|ui| { ui.spinner(); ui.label("Indexing raw data... (this may take 10-30 seconds)"); }); - match file_loader.load_raw_data_from_location(location) { + match file_loader.load_raw_data_from_location(&location) { Ok(index) => { data.indexed_data = IndexedDataState::Loaded { index, - source: location.to_string(), + source: location, }; file_loader.clear_raw_data(); tracing::info!("Raw data indexing completed"); @@ -905,11 +1046,19 @@ impl ViewerApp { Err(e) => { let error_msg = format!("{:?}", e); tracing::error!("Failed to load raw data: {}", error_msg); - data.indexed_data = IndexedDataState::Failed(location.clone(), error_msg); + data.indexed_data = IndexedDataState::Failed(location, error_msg); file_loader.clear_raw_data(); } } } + IndexedDataState::Loading(_) => { + // Load already in progress - just show spinner, don't re-trigger + // (This state is transient - we transition out of it in the same frame) + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Indexing raw data... (this may take 10-30 seconds)"); + }); + } IndexedDataState::Failed(location, error) => { ui.label( egui::RichText::new(format!("Failed to load raw data from {}:", location)) @@ -924,16 +1073,62 @@ impl ViewerApp { } } - fn load_tolerance_if_needed(file_loader: &mut FileLoader, data: &mut DataState) { + fn load_tolerance_if_needed( + ui: &mut egui::Ui, + file_loader: &mut FileLoader, + data: &mut DataState, + ) { if let Some(path) = &file_loader.tolerance_path { - match file_loader.load_tolerance(path) { - Ok(tol) => { - data.tolerance = tol; + // Check if we need to request a new load + let should_request_load = match &data.tolerance_state { + ToleranceState::None => true, + ToleranceState::LoadRequested(current_path) => current_path != path, + ToleranceState::Loaded { source } => source != path, + // Only retry if the path changed; same-path failures require the user to clear the error + ToleranceState::Failed(failed_path, _) => failed_path != path, + }; + + if should_request_load { + tracing::info!("Requesting load of tolerance from: {}", path.display()); + data.tolerance_state = ToleranceState::LoadRequested(path.clone()); + ui.ctx().request_repaint(); + } + } + + // Handle state machine transitions + match &data.tolerance_state { + ToleranceState::LoadRequested(path) => { + let path = path.clone(); + tracing::info!("Loading tolerance from: {}", path.display()); + + match file_loader.load_tolerance(&path) { + Ok(tol) => { + tracing::info!("Tolerance loaded successfully"); + data.tolerance = tol; + data.tolerance_state = ToleranceState::Loaded { source: path }; + } + Err(e) => { + let error_msg = format!("{:?}", e); + tracing::error!("Failed to load tolerance: {}", error_msg); + data.tolerance_state = ToleranceState::Failed(path, error_msg); + } } - Err(e) => { - tracing::error!("Failed to load tolerance: {:?}", e); + } + ToleranceState::Failed(path, error) => { + ui.label( + egui::RichText::new(format!( + "Failed to load tolerance from {}:", + path.display() + )) + .color(egui::Color32::from_rgb(255, 100, 100)), + ); + ui.label(egui::RichText::new(error).color(egui::Color32::RED).small()); + if ui.button("Clear Error").clicked() { + data.tolerance_state = ToleranceState::None; + file_loader.tolerance_path = None; } } + _ => {} } } @@ -990,6 +1185,136 @@ impl ViewerApp { fn select_elution_group(cursor: &mut Option, idx: usize) { *cursor = Some(idx); } + + /// Handle screenshot state machine transitions and capture + fn handle_screenshot(&mut self, ctx: &egui::Context) { + // Check for Escape to cancel countdown + if matches!(self.screenshot_state, ScreenshotState::Countdown { .. }) + && ctx.input(|i| i.key_pressed(egui::Key::Escape)) + { + tracing::info!("Screenshot countdown cancelled by user"); + self.screenshot_state = ScreenshotState::Idle; + return; + } + + match &self.screenshot_state { + ScreenshotState::Idle => {} + ScreenshotState::Countdown { deadline } => { + let now = Instant::now(); + if now >= *deadline { + // Deadline reached — capture at native resolution + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot( + egui::UserData::default(), + )); + self.screenshot_state = ScreenshotState::Capturing; + tracing::info!("Screenshot capturing at native resolution"); + } else { + // Keep repainting during countdown + ctx.request_repaint(); + } + } + ScreenshotState::Capturing => { + // Waiting for the screenshot event + } + ScreenshotState::Saving(image) => { + let image = Arc::clone(image); + + // Open save dialog and write PNG + if let Some(path) = rfd::FileDialog::new() + .set_title("Save Screenshot") + .add_filter("PNG Image", &["png"]) + .set_file_name("screenshot.png") + .save_file() + { + if let Err(e) = save_color_image_as_png(&image, &path) { + tracing::error!("Failed to save screenshot: {}", e); + } else { + tracing::info!("Screenshot saved to {}", path.display()); + } + } + + self.screenshot_state = ScreenshotState::Idle; + } + } + + // Check for screenshot events from egui + let screenshot_image = ctx.input(|i| { + for event in &i.raw.events { + if let egui::Event::Screenshot { image, .. } = event { + return Some(Arc::clone(image)); + } + } + None + }); + + if let Some(image) = screenshot_image { + tracing::info!( + "Screenshot received: {}x{} pixels", + image.width(), + image.height() + ); + self.screenshot_state = ScreenshotState::Saving(image); + // Request repaint so the Saving state is processed next frame + ctx.request_repaint(); + } + } + + /// Draw countdown overlay when screenshot timer is running + fn draw_screenshot_countdown_overlay(&self, ctx: &egui::Context) { + let ScreenshotState::Countdown { deadline } = &self.screenshot_state else { + return; + }; + + let remaining = deadline.saturating_duration_since(Instant::now()); + let secs = remaining.as_secs_f32().ceil() as u32; + + egui::Area::new(egui::Id::new("screenshot_countdown")) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::new() + .fill(egui::Color32::from_black_alpha(180)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(40, 24)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(secs.to_string()) + .size(72.0) + .color(egui::Color32::WHITE) + .strong(), + ); + ui.label( + egui::RichText::new("Press Escape to cancel") + .size(14.0) + .color(egui::Color32::from_white_alpha(180)), + ); + }); + }); + }); + } +} + +/// Encode an egui ColorImage as PNG and write to disk +fn save_color_image_as_png( + image: &egui::ColorImage, + path: &std::path::Path, +) -> Result<(), String> { + let width = image.width() as u32; + let height = image.height() as u32; + let pixels: Vec = image + .pixels + .iter() + .flat_map(|c| [c.r(), c.g(), c.b(), c.a()]) + .collect(); + + let img_buf: image::ImageBuffer, Vec> = + image::ImageBuffer::from_raw(width, height, pixels) + .ok_or_else(|| "Failed to create image buffer from pixel data".to_string())?; + + img_buf + .save(path) + .map_err(|e| format!("Failed to write PNG: {}", e)) } impl eframe::App for ViewerApp { @@ -1008,6 +1333,7 @@ impl eframe::App for ViewerApp { search_mode: self.ui.search_mode, search_input: self.ui.search_input.clone(), raw_data_input_mode: self.ui.raw_data_input_mode, + dark_mode: self.ui.dark_mode, }, tolerance: self.data.tolerance.clone(), smoothing: self.data.smoothing, @@ -1028,16 +1354,14 @@ impl eframe::App for ViewerApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.handle_vim_keys(ctx); - egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { - ui.heading("TimsQuery Viewer"); - ui.separator(); - }); + // Handle screenshot state machine + self.handle_screenshot(ctx); // Check if background computation completed self.check_chromatogram_completion(); - // Generate MS2 spectrum if RT was clicked - self.generate_ms2_spectrum_if_needed(); + // Generate MS2 spectrum and mobility data if RT was clicked + self.handle_rt_click(); // Generate chromatogram if needed self.generate_chromatogram(ctx); @@ -1050,6 +1374,9 @@ impl eframe::App for ViewerApp { left_panel: &mut self.config_panel, table_panel: &mut self.table_panel, spectrum_panel: &mut self.spectrum_panel, + mobility_panel: &mut self.mobility_panel, + screenshot_delay_secs: &mut self.screenshot_delay_secs, + screenshot_state: &mut self.screenshot_state, }; egui::CentralPanel::default().show(ctx, |ui| { @@ -1057,6 +1384,9 @@ impl eframe::App for ViewerApp { .style(Style::from_egui(ui.style().as_ref())) .show_inside(ui, &mut tab_viewer); }); + + // Draw countdown overlay on top of everything + self.draw_screenshot_countdown_overlay(ctx); } } @@ -1068,6 +1398,9 @@ struct AppTabViewer<'a> { left_panel: &'a mut ConfigPanel, table_panel: &'a mut TablePanel, spectrum_panel: &'a mut SpectrumPanel, + mobility_panel: &'a mut MobilityPanel, + screenshot_delay_secs: &'a mut f32, + screenshot_state: &'a mut ScreenshotState, } impl<'a> AppTabViewer<'a> { @@ -1100,6 +1433,46 @@ impl<'a> AppTabViewer<'a> { &mut self.data.smoothing, &mut self.data.auto_zoom_mode, ); + + ui.add_space(SEPARATOR_SPACING); + ui.separator(); + ui.add_space(INTERNAL_SPACING); + + // Export section + let action = ConfigPanel::render_export_section( + ui, + self.screenshot_delay_secs, + &self.screenshot_state, + ); + match action { + ScreenshotAction::None => {} + ScreenshotAction::Start => { + let deadline = Instant::now() + + std::time::Duration::from_secs_f32(*self.screenshot_delay_secs); + *self.screenshot_state = ScreenshotState::Countdown { deadline }; + } + ScreenshotAction::Cancel => { + *self.screenshot_state = ScreenshotState::Idle; + } + } + + ui.add_space(SEPARATOR_SPACING); + ui.separator(); + ui.add_space(INTERNAL_SPACING); + + // Theme toggle + ui.horizontal(|ui| { + ui.label("Theme:"); + let label = if self.ui.dark_mode { + "Switch to Light" + } else { + "Switch to Dark" + }; + if ui.button(label).clicked() { + self.ui.dark_mode = !self.ui.dark_mode; + apply_theme(ui.ctx(), self.ui.dark_mode); + } + }); } } @@ -1114,10 +1487,12 @@ impl<'a> TabViewer for AppTabViewer<'a> { Pane::PrecursorPlot => "Precursors".into(), Pane::FragmentPlot => "Fragments".into(), Pane::ScoresPlot => "Scores".into(), + Pane::Mobility => self.mobility_panel.title().into(), } } fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) { + tracing::trace!("rendering pane: {:?}", tab); let mode = self.data.auto_zoom_mode; // TODO: figure out how to prevent this allocation per frame... @@ -1148,92 +1523,124 @@ impl<'a> TabViewer for AppTabViewer<'a> { Pane::MS2Spectrum => { self.spectrum_panel.render( ui, - &self.computed.ms2_spectrum, - &self.computed.expected_intensities, + self.computed.ms2_spectrum(), + self.computed.expected_intensities(), ); } Pane::PrecursorPlot => { - // Show loading indicator if computing if self.computed.is_computing() { ui.centered_and_justified(|ui| { ui.spinner(); ui.label("Computing chromatogram..."); }); - } else if let Some(chromatogram) = &self.computed.chromatogram_lines { - // Use shared link_id for synchronized X-axis with Fragments - let click_response = crate::plot_renderer::render_chromatogram_plot( - ui, - chromatogram, - crate::plot_renderer::PlotMode::PrecursorsOnly, - Some("precursor_fragment_x_axis"), - true, - &mut self.computed.auto_zoom_frame_counter, - &mode, - &ref_lines, - self.computed.apex_score.as_ref(), + } else if let Some(error) = self.computed.failure_error() { + ui.label( + egui::RichText::new(format!("Chromatogram computation failed: {}", error)) + .color(egui::Color32::RED), ); - if let Some(clicked_rt) = click_response { - self.computed.clicked_rt = Some(clicked_rt); - } - } else if self.ui.selected_index.is_some() { - ui.label("Generating chromatogram..."); } else { - ui.label("Select a precursor from the table to view chromatogram"); + let click = if let Some((chromatogram, apex, counter)) = + self.computed.chromatogram_render_context() + { + crate::plot_renderer::render_chromatogram_plot( + ui, + chromatogram, + crate::plot_renderer::PlotMode::PrecursorsOnly, + Some("precursor_fragment_x_axis"), + true, + counter, + &mode, + &ref_lines, + apex, + ) + } else if self.ui.selected_index.is_some() { + ui.label("Generating chromatogram..."); + None + } else { + ui.label("Select a precursor from the table to view chromatogram"); + None + }; + if let Some(rt) = click { + self.computed.set_clicked_rt(rt); + } } } Pane::FragmentPlot => { - // Show loading indicator if computing if self.computed.is_computing() { ui.centered_and_justified(|ui| { ui.spinner(); ui.label("Computing chromatogram..."); }); - } else if let Some(chromatogram) = &self.computed.chromatogram_lines { - // Use shared link_id for synchronized X-axis with Precursors - let response = crate::plot_renderer::render_chromatogram_plot( - ui, - chromatogram, - crate::plot_renderer::PlotMode::FragmentsOnly, - Some("precursor_fragment_x_axis"), - false, - &mut self.computed.auto_zoom_frame_counter, - &mode, - &ref_lines, - self.computed.apex_score.as_ref(), + } else if let Some(error) = self.computed.failure_error() { + ui.label( + egui::RichText::new(format!("Chromatogram computation failed: {}", error)) + .color(egui::Color32::RED), ); - if let Some(clicked_rt) = response { - self.computed.clicked_rt = Some(clicked_rt); - } - } else if self.ui.selected_index.is_some() { - ui.label("Generating chromatogram..."); } else { - ui.label("Select a precursor to view fragment traces"); + let click = if let Some((chromatogram, apex, counter)) = + self.computed.chromatogram_render_context() + { + crate::plot_renderer::render_chromatogram_plot( + ui, + chromatogram, + crate::plot_renderer::PlotMode::FragmentsOnly, + Some("precursor_fragment_x_axis"), + false, + counter, + &mode, + &ref_lines, + apex, + ) + } else if self.ui.selected_index.is_some() { + ui.label("Generating chromatogram..."); + None + } else { + ui.label("Select a precursor to view fragment traces"); + None + }; + if let Some(rt) = click { + self.computed.set_clicked_rt(rt); + } } } Pane::ScoresPlot => { - // Show loading indicator if computing if self.computed.is_computing() { ui.centered_and_justified(|ui| { ui.spinner(); ui.label("Computing scores..."); }); - } else if let Some(score_lines) = &self.computed.score_lines { - let response = score_lines.render( - ui, - Some("precursor_fragment_x_axis"), - &mut self.computed.auto_zoom_frame_counter, - &mode, - &ref_lines, + } else if let Some(error) = self.computed.failure_error() { + ui.label( + egui::RichText::new(format!("Score computation failed: {}", error)) + .color(egui::Color32::RED), ); - if let Some(clicked_rt) = response { - self.computed.clicked_rt = Some(clicked_rt); - } - } else if self.ui.selected_index.is_some() { - ui.label("Generating score plot..."); } else { - ui.label("Select a precursor to view score traces"); + let click = if let Some((score_lines, counter)) = + self.computed.score_render_context() + { + score_lines.render( + ui, + Some("precursor_fragment_x_axis"), + counter, + &mode, + &ref_lines, + ) + } else if self.ui.selected_index.is_some() { + ui.label("Generating score plot..."); + None + } else { + ui.label("Select a precursor to view score traces"); + None + }; + if let Some(rt) = click { + self.computed.set_clicked_rt(rt); + } } } + Pane::Mobility => { + self.mobility_panel + .render(ui, self.computed.mobility_data()); + } } } diff --git a/rust/timsquery_viewer/src/computed_state.rs b/rust/timsquery_viewer/src/computed_state.rs index a41bafa..c7003cf 100644 --- a/rust/timsquery_viewer/src/computed_state.rs +++ b/rust/timsquery_viewer/src/computed_state.rs @@ -1,9 +1,14 @@ use egui::Color32; use std::collections::HashMap; use timsquery::models::elution_group::TimsElutionGroup; -use timsquery::models::tolerance::Tolerance; +use timsquery::models::tolerance::{ + RtTolerance, + Tolerance, +}; use timsquery::{ + MzMobilityStatsCollector, QueriableData, + SpectralCollector, TupleRange, }; use timsseek::ExpectedIntensities; @@ -27,6 +32,7 @@ use timscentroid::rt_mapping::{ MS1CycleIndex, RTIndex, }; +use timscentroid::utils::OptionallyRestricted; use timsquery::serde::IndexedPeaksHandle; use timsseek::scoring::apex_finding::{ ApexFinder, @@ -40,33 +46,146 @@ pub(crate) struct ChromatogramComputationResult { pub output: ChromatogramOutput, pub collector: ChromatogramCollector, pub expected_intensities: ExpectedIntensities, + pub elution_group: TimsElutionGroup, +} + +#[derive(Debug)] +struct ScoringResult { + apex_score: ApexScore, + score_lines: ScoreLines, + apex_rt_seconds: f64, +} + +/// All state derived from a single chromatogram computation. +/// Grouped so that replacing one chromatogram with the next drops all derived state. +#[derive(Debug)] +struct ChromatogramResult { + lines: ChromatogramLines, + output: ChromatogramOutput, + scoring: Option, + expected_intensities: ExpectedIntensities, + elution_group: TimsElutionGroup, +} + +#[derive(Debug, Default)] +struct Ms2State { + clicked_rt: Option, + spectrum: Option, + last_requested_rt: Option, +} + +/// Per-ion aggregated stats for mobility visualization. +#[derive(Debug, Clone)] +pub struct IonMobilityStats { + pub label: String, + /// Intensity-weighted mean m/z + pub mean_mz: f64, + /// Intensity-weighted mean mobility (1/K0) + pub mean_mobility: f64, + /// Total intensity (sum of weights) + pub total_intensity: f64, +} + +/// All data needed to render the mobility visualization. +#[derive(Debug, Clone)] +pub struct MobilityData { + pub ions: Vec, + pub ref_mobility: f64, + /// 1x tolerance window (for integration box overlay) + pub mobility_range: (f64, f64), + /// 2x tolerance window (the actual query range) + pub wide_mobility_range: (f64, f64), + /// Monotonically increasing counter — used to reset plot zoom on new data. + pub generation: u64, +} + +#[derive(Debug, Default)] +struct MobilityState { + data: Option, + last_requested_rt: Option, + generation: u64, +} + +#[derive(Debug, Default)] +struct ScratchBuffers { + apex_finder: Option, +} + +/// State machine for chromatogram computation lifecycle. +/// +/// Follows the same pattern as `IndexedDataState` and `ElutionGroupState`: +/// `None → Computing → Computed | Failed` +#[derive(Debug, Default)] +enum ChromatogramState { + #[default] + None, + /// Background computation in progress + Computing { index: u64 }, + /// Computation completed successfully; cache key stored for invalidation checks + Computed { + cache_key: (u64, Tolerance, SmoothingMethod), + }, + /// Computation failed for this combination — prevents infinite retry + Failed { + cache_key: (u64, Tolerance, SmoothingMethod), + error: String, + }, } /// Computed/cached state - derived from data and UI state #[derive(Debug, Default)] pub struct ComputedState { - /// Computed chromatogram for the selected elution group (plot data) - pub chromatogram_lines: Option, - pub score_lines: Option, - pub chromatogram_x_bounds: Option<(f64, f64)>, - pub ms2_spectrum: Option, + chromatogram_state: ChromatogramState, + + result: Option, pub auto_zoom_frame_counter: u8, - pub clicked_rt: Option, - pub expected_intensities: Option>, - pub apex_score: Option, - - // Internal state (private) - is_computing_chromatogram: bool, - computing_index: Option, - chromatogram_output: Option, - chromatogram_collector_buffer: Option>, - apex_finder_buffer: Option, - cache_key: Option<(u64, Tolerance, SmoothingMethod)>, - last_requested_rt: Option, reference_lines: HashMap, + ms2: Ms2State, + mobility: MobilityState, + /// Reusable allocation — not reset between elution groups. + scratch: ScratchBuffers, } impl ComputedState { + pub fn ms2_spectrum(&self) -> Option<&MS2Spectrum> { + self.ms2.spectrum.as_ref() + } + + pub fn mobility_data(&self) -> Option<&MobilityData> { + self.mobility.data.as_ref() + } + + pub fn expected_intensities(&self) -> Option<&ExpectedIntensities> { + self.result.as_ref().map(|r| &r.expected_intensities) + } + + pub fn clicked_rt(&self) -> Option { + self.ms2.clicked_rt + } + + pub fn set_clicked_rt(&mut self, rt: f64) { + self.ms2.clicked_rt = Some(rt); + } + + /// Borrows chromatogram data and the zoom counter simultaneously. + /// Split out as a method so the borrow checker can see they are disjoint fields. + pub fn chromatogram_render_context( + &mut self, + ) -> Option<(&ChromatogramLines, Option<&ApexScore>, &mut u8)> { + let result = self.result.as_ref()?; + let lines = &result.lines; + let apex = result.scoring.as_ref().map(|s| &s.apex_score); + let counter = &mut self.auto_zoom_frame_counter; + Some((lines, apex, counter)) + } + + /// Same idea as [`Self::chromatogram_render_context`] for the score plot. + pub fn score_render_context(&mut self) -> Option<(&ScoreLines, &mut u8)> { + let score_lines = &self.result.as_ref()?.scoring.as_ref()?.score_lines; + let counter = &mut self.auto_zoom_frame_counter; + Some((score_lines, counter)) + } + pub fn reference_lines(&self) -> &HashMap { &self.reference_lines } @@ -74,23 +193,53 @@ impl ComputedState { pub fn insert_reference_line(&mut self, name: String, rt: f64, color: Color32) { self.reference_lines.insert(name, (rt, color)); } +} +impl ComputedState { pub fn is_computing(&self) -> bool { - self.is_computing_chromatogram + matches!(self.chromatogram_state, ChromatogramState::Computing { .. }) } pub fn computing_index(&self) -> Option { - self.computing_index + match &self.chromatogram_state { + ChromatogramState::Computing { index } => Some(*index), + _ => None, + } } pub fn start_computing(&mut self, index: u64) { - self.is_computing_chromatogram = true; - self.computing_index = Some(index); + self.chromatogram_state = ChromatogramState::Computing { index }; } - pub fn cancel_computing(&mut self) { - self.is_computing_chromatogram = false; - self.computing_index = None; + /// Record a failed computation so the same parameters won't be retried. + pub fn fail_computing( + &mut self, + index: u64, + tolerance: &Tolerance, + smoothing: &SmoothingMethod, + error: String, + ) { + self.chromatogram_state = ChromatogramState::Failed { + cache_key: (index, tolerance.clone(), *smoothing), + error, + }; + } + + /// Returns the error message if in `Failed` state. + pub fn failure_error(&self) -> Option<&str> { + match &self.chromatogram_state { + ChromatogramState::Failed { error, .. } => Some(error), + _ => None, + } + } + + /// Returns the cached elution group ID (for tracing instrumentation). + fn cached_eg_id(&self) -> u64 { + match &self.chromatogram_state { + ChromatogramState::Computed { cache_key } => cache_key.0, + ChromatogramState::Failed { cache_key, .. } => cache_key.0, + _ => 0, + } } pub fn is_cache_valid( @@ -99,30 +248,23 @@ impl ComputedState { tolerance: &Tolerance, smoothing: &SmoothingMethod, ) -> bool { - if let Some((cached_id, cached_tolerance, cached_smoothing)) = &self.cache_key { - return *cached_id == selected_idx as u64 - && cached_tolerance == tolerance - && cached_smoothing == smoothing; - } - false + let cache_key = match &self.chromatogram_state { + ChromatogramState::Computed { cache_key } => cache_key, + ChromatogramState::Failed { cache_key, .. } => cache_key, + _ => return false, + }; + cache_key.0 == selected_idx as u64 && &cache_key.1 == tolerance && &cache_key.2 == smoothing } - /// Resets computed state when data or UI changes significantly + /// Resets computed state when data or UI changes significantly. + /// Scratch buffers are preserved for reuse. pub fn clear(&mut self) { - self.chromatogram_lines = None; - self.chromatogram_x_bounds = None; - self.chromatogram_output = None; - self.ms2_spectrum = None; + self.result = None; + self.ms2 = Ms2State::default(); + self.mobility = MobilityState::default(); self.auto_zoom_frame_counter = 0; - self.clicked_rt = None; - self.cache_key = None; - self.computing_index = None; - self.last_requested_rt = None; - self.apex_score = None; - self.score_lines = None; - self.expected_intensities = None; self.reference_lines.clear(); - self.is_computing_chromatogram = false; + self.chromatogram_state = ChromatogramState::None; } pub(crate) fn build_collector( @@ -130,7 +272,6 @@ impl ComputedState { elution_group: TimsElutionGroup, ) -> Result, ViewerError> { let max_range = index.ms1_cycle_mapping().range_milis(); - // Create collector for this elution group let collector = ChromatogramCollector::new( elution_group, TupleRange::try_new(max_range.0, max_range.1) @@ -141,17 +282,6 @@ impl ComputedState { Ok(collector) } - /// Generate a chromatogram for a single elution group - /// - /// # Arguments - /// * `elution_group` - The elution group to generate a chromatogram for - /// * `index` - Indexed timsTOF peaks data - /// * `ms1_rts` - MS1 retention times in milliseconds - /// * `tolerance` - Tolerance settings for querying - /// * `smoothing` - Smoothing method to apply - /// - /// # Returns - /// A `ChromatogramOutput` containing the generated chromatogram data #[instrument(skip_all, fields(eg_id = %elution_group.id()))] pub(crate) fn generate_chromatogram( collector: &mut ChromatogramCollector, @@ -160,16 +290,22 @@ impl ComputedState { tolerance: &Tolerance, smoothing: &SmoothingMethod, ) -> Result { - // Query the index index.add_query(collector, tolerance); - // Convert to output format let mut output = ChromatogramOutput::try_new(collector, index.ms1_cycle_mapping()) .map_err(|e| { - ViewerError::General(format!("Failed to generate chromatogram output: {:?}", e)) + let msg = match e { + timsquery::errors::DataProcessingError::ExpectedNonEmptyData => { + "No data found with the current tolerances. \ + Try widening the mass or mobility tolerance, \ + or removing the retention time restriction." + .to_string() + } + other => format!("Failed to generate chromatogram output: {:?}", other), + }; + ViewerError::General(msg) })?; - // Apply smoothing if configured apply_smoothing_chromatogram(&mut output, smoothing); tracing::info!( @@ -194,16 +330,20 @@ impl ComputedState { .rt_milis_for_index(&MS1CycleIndex::new(idx as u32)) .unwrap() }).map_err(|x| { - match x { + let user_msg = match &x { DataProcessingError::ExpectedNonEmptyData { context: err_context } => { tracing::warn!( - "{:#?}", context.query_values.eg, + "{:#?}", context.query_values.eg, ); tracing::warn!( "Apex finding failed for elution group {}: No valid data found in context {:?}", context.query_values.eg.id(), err_context ); + "No data found with the current tolerances. \ + Try widening the mass or mobility tolerance, \ + or removing the retention time restriction." + .to_string() } _ => { tracing::error!( @@ -211,143 +351,272 @@ impl ComputedState { context.query_values.eg.id(), x ); + format!("Apex finding failed: {:?}", x) } }; - ViewerError::General("Apex finding error".into()) - + ViewerError::General(user_msg) }) } /// Generate MS2 spectrum at the given retention time. /// Returns true if a new spectrum was generated, false if skipped (already generated for this RT). - #[instrument(level = "trace", skip(self), fields(eg_id = %self.cache_key.as_ref().map(|(id, _, _)| *id).unwrap_or(0)))] + #[instrument(level = "trace", skip(self), fields(eg_id = %self.cached_eg_id()))] pub fn generate_spectrum_at_rt(&mut self, rt_seconds: f64) -> bool { - // Skip if we already generated spectrum for this RT - // Use a tolerance of 1 microsecond (1e-6 seconds) instead of f64::EPSILON - // which is far too strict for RT values and can fail on different FPU implementations const RT_TOLERANCE_SECONDS: f64 = 1e-6; - if let Some(last_rt) = self.last_requested_rt + if let Some(last_rt) = self.ms2.last_requested_rt && (last_rt - rt_seconds).abs() < RT_TOLERANCE_SECONDS { return false; } - // Cache miss - generating new MS2 spectrum at this RT tracing::debug!( "MS2 spectrum cache MISS - generating spectrum at RT {:.2}s (previous: {:?})", rt_seconds, - self.last_requested_rt + self.ms2.last_requested_rt ); - self.last_requested_rt = Some(rt_seconds); - if let Some(chrom_output) = &self.chromatogram_output { - match chromatogram_processor::extract_ms2_spectrum_from_chromatogram( - chrom_output, - rt_seconds, - ) { - Ok(spectrum) => { - let num_peaks = spectrum.mz_values.len(); - self.ms2_spectrum = Some(spectrum); - tracing::info!( - "Extracted MS2 spectrum at RT {:.2}s with {} peaks", - rt_seconds, - num_peaks - ); - true - } - Err(e) => { - tracing::error!("Failed to extract MS2 spectrum: {:?}", e); - self.ms2_spectrum = None; - false - } - } - } else { - tracing::warn!("No chromatogram data available for MS2 extraction"); - false - } - } + self.ms2.last_requested_rt = Some(rt_seconds); - /// Complete chromatogram computation with results from background thread - pub(crate) fn complete_chromatogram_computation( - &mut self, - result: ChromatogramComputationResult, - index: &IndexedPeaksHandle, - tolerance: &Tolerance, - smoothing: SmoothingMethod, - ) { - // Store chromatogram output and lines - let chrom_lines = ChromatogramLines::from_chromatogram(&result.output); - self.chromatogram_x_bounds = Some(chrom_lines.rt_seconds_range); - self.chromatogram_lines = Some(chrom_lines); - self.chromatogram_output = Some(result.output.clone()); - self.auto_zoom_frame_counter = 5; - - // Update cache key - self.cache_key = Some((result.selected_idx, tolerance.clone(), smoothing)); - - // Store for scoring - self.expected_intensities = Some(result.expected_intensities.clone()); - self.chromatogram_collector_buffer = Some(result.collector.clone()); + let Some(result) = &self.result else { + tracing::warn!("No chromatogram data available for MS2 extraction"); + return false; + }; - // Compute scores on main thread - self.compute_scores_from_buffers( - index, + match chromatogram_processor::extract_ms2_spectrum_from_chromatogram( &result.output, - &result.expected_intensities, - &result.collector, - ); - - // Clear computing state - self.is_computing_chromatogram = false; - self.computing_index = None; + rt_seconds, + ) { + Ok(spectrum) => { + let num_peaks = spectrum.mz_values.len(); + self.ms2.spectrum = Some(spectrum); + tracing::info!( + "Extracted MS2 spectrum at RT {:.2}s with {} peaks", + rt_seconds, + num_peaks + ); + true + } + Err(e) => { + tracing::error!("Failed to extract MS2 spectrum: {:?}", e); + self.ms2.spectrum = None; + false + } + } } - /// Compute scores from buffers - fn compute_scores_from_buffers( - &mut self, + fn try_score( + scratch: &mut ScratchBuffers, index: &IndexedPeaksHandle, output: &ChromatogramOutput, expected_intensities: &ExpectedIntensities, collector: &ChromatogramCollector, - ) { + ) -> Option { let num_cycles = output.retention_time_results_seconds.len(); + tracing::debug!( + "try_score: num_cycles={} num_fragments={}", + num_cycles, + collector.fragments.mz_order.len(), + ); - // Prepare apex finder - let apex_finder = if self.apex_finder_buffer.is_none() { - self.apex_finder_buffer = Some(ApexFinder::new(num_cycles)); - self.apex_finder_buffer.as_mut().unwrap() - } else { - self.apex_finder_buffer.as_mut().unwrap() - }; + let apex_finder = scratch + .apex_finder + .get_or_insert_with(|| ApexFinder::new(num_cycles)); - // Build scoring context let scoring_ctx = ScoringContext { expected_intensities: expected_intensities.clone(), query_values: collector.clone(), }; - // Find apex let apex_score = match Self::find_apex(apex_finder, &scoring_ctx, index) { - Ok(score) => score, + Ok(score) => { + tracing::info!("Apex found at RT={:.2}ms", score.retention_time_ms); + score + } Err(e) => { - tracing::error!("Failed to compute apex score: {:?}", e); - return; + tracing::warn!( + "Apex scoring skipped (chromatogram data still available): {:?}", + e + ); + return None; } }; - // Update RT reference (use insert to overwrite any previous apex RT) - self.clicked_rt = Some(apex_score.retention_time_ms as f64 / 1000.0); - self.reference_lines.insert( - "Apex RT".into(), - (apex_score.retention_time_ms as f64 / 1000.0, Color32::RED), - ); + let apex_rt_seconds = apex_score.retention_time_ms as f64 / 1000.0; - // Generate score lines - self.score_lines = Some(ScoreLines::from_scores( + let score_lines = ScoreLines::from_scores( apex_score, &apex_finder.traces, index.ms1_cycle_mapping(), collector.cycle_offset(), - )); - self.apex_score = Some(apex_score); + ); + + Some(ScoringResult { + apex_score, + score_lines, + apex_rt_seconds, + }) + } + + /// Finalize a background chromatogram computation: build plot lines, + /// attempt scoring, and replace `self.result` in one shot. + pub(crate) fn complete_chromatogram_computation( + &mut self, + computation: ChromatogramComputationResult, + index: &IndexedPeaksHandle, + tolerance: &Tolerance, + smoothing: SmoothingMethod, + ) { + let (n_rt, n_prec, n_frag) = ( + computation.output.retention_time_results_seconds.len(), + computation.output.precursor_mzs.len(), + computation.output.fragment_mzs.len(), + ); + tracing::info!( + "complete_chromatogram_computation: idx={} rt_points={} precursors={} fragments={} \ + est_output={:.1}KB est_collector={:.1}KB", + computation.selected_idx, + n_rt, + n_prec, + n_frag, + (n_rt * (n_prec + n_frag) * std::mem::size_of::()) as f64 / 1024.0, + (computation.collector.fragments.mz_order.len() * n_rt * std::mem::size_of::()) + as f64 + / 1024.0, + ); + + let lines = ChromatogramLines::from_chromatogram(&computation.output); + let scoring = Self::try_score( + &mut self.scratch, + index, + &computation.output, + &computation.expected_intensities, + &computation.collector, + ); + + let apex_rt_seconds = scoring.as_ref().map(|s| s.apex_rt_seconds); + self.result = Some(ChromatogramResult { + lines, + output: computation.output.clone(), + scoring, + expected_intensities: computation.expected_intensities.clone(), + elution_group: computation.elution_group, + }); + + self.ms2 = Ms2State::default(); + self.mobility = MobilityState::default(); + self.reference_lines.clear(); + self.auto_zoom_frame_counter = 5; + self.chromatogram_state = ChromatogramState::Computed { + cache_key: (computation.selected_idx, tolerance.clone(), smoothing), + }; + + if let Some(apex_rt) = apex_rt_seconds { + self.ms2.clicked_rt = Some(apex_rt); + self.reference_lines + .insert("Apex RT".into(), (apex_rt, Color32::RED)); + } + + tracing::info!( + "complete_chromatogram_computation finished for idx={}", + computation.selected_idx + ); + } + + /// Extract mobility peak data at a given RT. + /// Returns true if new data was generated. + #[instrument(level = "trace", skip(self, index), fields(eg_id = %self.cached_eg_id()))] + pub fn generate_mobility_at_rt( + &mut self, + rt_seconds: f64, + index: &IndexedPeaksHandle, + tolerance: &Tolerance, + ) -> bool { + const RT_TOLERANCE_SECONDS: f64 = 1e-6; + if let Some(last_rt) = self.mobility.last_requested_rt + && (last_rt - rt_seconds).abs() < RT_TOLERANCE_SECONDS + { + return false; + } + self.mobility.last_requested_rt = Some(rt_seconds); + + let (eg, ref_mobility) = { + let Some(result) = &self.result else { + tracing::warn!("No chromatogram data available for mobility extraction"); + return false; + }; + ( + result.elution_group.clone().with_rt_seconds(rt_seconds as f32), + result.output.mobility_ook0 as f64, + ) + }; + + let ook0 = eg.mobility_ook0(); + + // Build a tolerance with 2x mobility and a narrow 5-second RT window + let wide_tolerance = tolerance + .clone() + .with_rt_tolerance(RtTolerance::Minutes((5.0 / 60.0, 5.0 / 60.0))) + .with_wider_mobility(2.0); + + let mut collector: SpectralCollector = + SpectralCollector::new(eg); + index.add_query(&mut collector, &wide_tolerance); + + // Compute 1x and 2x mobility ranges for overlays + let mob_1x = tolerance.mobility_range(ook0); + let mob_2x = wide_tolerance.mobility_range(ook0); + + let to_range = |r: OptionallyRestricted>| -> (f64, f64) { + match r { + OptionallyRestricted::Restricted(tr) => { + (tr.start() as f64, tr.end() as f64) + } + OptionallyRestricted::Unrestricted => (0.0, 2.0), + } + }; + + let mobility_range = to_range(mob_1x); + let wide_mobility_range = to_range(mob_2x); + + // Collect per-ion aggregated stats + let mut ions = Vec::new(); + + for ((isotope_idx, _mz), stats) in collector.iter_precursors() { + if let (Ok(mean_mz), Ok(mean_mobility)) = (stats.mean_mz(), stats.mean_mobility()) { + ions.push(IonMobilityStats { + label: format!("Prec[{:+}]", isotope_idx), + mean_mz, + mean_mobility, + total_intensity: stats.weight(), + }); + } + } + + for ((label, _mz), stats) in collector.iter_fragments() { + if let (Ok(mean_mz), Ok(mean_mobility)) = (stats.mean_mz(), stats.mean_mobility()) { + ions.push(IonMobilityStats { + label: label.clone(), + mean_mz, + mean_mobility, + total_intensity: stats.weight(), + }); + } + } + + tracing::info!( + "Extracted mobility data at RT {:.2}s: {} ions with signal", + rt_seconds, + ions.len(), + ); + + self.mobility.generation += 1; + self.mobility.data = Some(MobilityData { + ions, + ref_mobility, + mobility_range, + wide_mobility_range, + generation: self.mobility.generation, + }); + + true } } diff --git a/rust/timsquery_viewer/src/file_loader.rs b/rust/timsquery_viewer/src/file_loader.rs index 37c9a48..166f172 100644 --- a/rust/timsquery_viewer/src/file_loader.rs +++ b/rust/timsquery_viewer/src/file_loader.rs @@ -10,9 +10,9 @@ use std::path::{ PathBuf, }; use std::sync::Arc; +use timsquery::ion::IonAnnot; use timsquery::models::tolerance::Tolerance; use timsquery::serde::{ - DiannPrecursorExtras, ElutionGroupCollection, FileReadingExtras, IndexedPeaksHandle, @@ -152,7 +152,15 @@ const BASE_LABELS: [&str; 6] = [ "Fragments", ]; -const DIANN_EXTRA_LABELS: [&str; 3] = ["Modified Peptide", "Protein ID(s)", "Is Decoy"]; +/// Labels for library-specific extra columns (DIA-NN and Spectronaut share the same structure) +const LIBRARY_EXTRA_LABELS: [&str; 3] = ["Modified Peptide", "Protein ID(s)", "Is Decoy"]; + +/// Common view structure for library extras that both DIA-NN and Spectronaut can map to +struct LibraryExtrasView { + modified_peptide: String, + protein_id: String, + is_decoy: bool, +} impl ElutionGroupData { pub fn new(inner: ElutionGroupCollection) -> Self { @@ -181,9 +189,15 @@ impl ElutionGroupData { return; } + // Case-insensitive substring matching. to_lowercase() allocates per iteration, + // but eq_ignore_ascii_case only supports full equality, not substring search. + // Acceptable here since filtering runs only on search input changes, not per-frame. + let filter_lower = filter.to_lowercase(); let mut str_buffer = String::new(); for i in 0..self.len() { - if self.key_onto(i, &mut str_buffer).is_ok() && str_buffer.contains(filter) { + if self.key_onto(i, &mut str_buffer).is_ok() + && str_buffer.to_lowercase().contains(&filter_lower) + { buffer.push(i); } } @@ -207,26 +221,94 @@ impl ElutionGroupData { write!(buffer, "{}", egs[idx].id()).map_err(|_| ())?; } } - let extras = match &self.inner { - ElutionGroupCollection::StringLabels(_, extras) - | ElutionGroupCollection::MzpafLabels(_, extras) - | ElutionGroupCollection::TinyIntLabels(_, extras) - | ElutionGroupCollection::IntLabels(_, extras) => match extras { - Some(FileReadingExtras::Diann(diann_extras)) => Some(&diann_extras[idx]), - _ => None, - }, - }; - if let Some(diann_extra) = extras { + let extras_view = self.get_library_extras_view(idx); + if let Some(extra) = extras_view { write!( buffer, "|{}|{}|{}", - diann_extra.modified_peptide, diann_extra.protein_id, diann_extra.is_decoy + extra.modified_peptide, extra.protein_id, extra.is_decoy ) .map_err(|_| ())?; } Ok(()) } + /// Extracts a common view of library extras from either DIA-NN or Spectronaut format + fn get_library_extras_view(&self, idx: usize) -> Option { + let extras = match &self.inner { + ElutionGroupCollection::StringLabels(_, extras) + | ElutionGroupCollection::MzpafLabels(_, extras) + | ElutionGroupCollection::TinyIntLabels(_, extras) + | ElutionGroupCollection::IntLabels(_, extras) => extras.as_ref()?, + }; + match extras { + FileReadingExtras::Diann(diann_extras) => { + let de = diann_extras.get(idx)?; + Some(LibraryExtrasView { + modified_peptide: de.modified_peptide.clone(), + protein_id: de.protein_id.clone(), + is_decoy: de.is_decoy, + }) + } + FileReadingExtras::Spectronaut(spectronaut_extras) => { + let se = spectronaut_extras.get(idx)?; + Some(LibraryExtrasView { + modified_peptide: se.modified_peptide.clone(), + protein_id: se.protein_id.clone(), + is_decoy: se.is_decoy, + }) + } + } + } + + /// Checks if the collection has library extras (DIA-NN or Spectronaut) + fn has_library_extras(&self) -> bool { + match &self.inner { + ElutionGroupCollection::StringLabels(_, extras) + | ElutionGroupCollection::MzpafLabels(_, extras) + | ElutionGroupCollection::TinyIntLabels(_, extras) + | ElutionGroupCollection::IntLabels(_, extras) => extras.is_some(), + } + } + + /// Builds ExpectedIntensities from library extras (shared by DIA-NN and Spectronaut) + fn build_expected_intensities( + stripped_peptide: &str, + relative_intensities: &[(IonAnnot, f32)], + eg: &mut TimsElutionGroup, + ) -> ExpectedIntensities { + let fragment_intensities = HashMap::from_iter( + relative_intensities + .iter() + .cloned() + .map(|(k, v)| (k.to_string(), v)), + ); + + let isotopes = match isotope_dist_from_seq(stripped_peptide) { + Ok(isotopes) => isotopes, + Err(e) => { + warn!( + "Failed to calculate isotope distribution for sequence {}: {}", + stripped_peptide, e + ); + [1.0, 0.0, 0.0] + } + }; + + eg.set_precursor_labels([0, 1, 2].iter().cloned()); + let precursor_intensities: HashMap = isotopes + .iter() + .cloned() + .enumerate() + .map(|(i, intensity)| (i as i8, intensity)) + .collect(); + + ExpectedIntensities { + precursor_intensities, + fragment_intensities, + } + } + pub fn get_elem( &self, index: usize, @@ -254,43 +336,30 @@ impl ElutionGroupData { "Diann extras index {} out of bounds", index )))?; - let fragment_intensities = HashMap::from_iter( - de.relative_intensities - .iter() - .cloned() - .map(|(k, v)| (k.clone().to_string(), v)), - ); - - // TODO: Actually get expected intensities ... I could make - // The simple isotope calculation from number of carbons + sulphur. - let isotopes = match isotope_dist_from_seq(&de.stripped_peptide) { - Ok(isotopes) => isotopes, - Err(e) => { - warn!( - "Failed to calculate isotope distribution for sequence {}: {}", - &de.stripped_peptide, e - ); - [1.0, 0.0, 0.0] - } - }; - eg.set_precursor_labels([0, 1, 2].iter().cloned()); - let precursor_intensities: HashMap = isotopes - .iter() - .cloned() - .enumerate() - .map(|(i, intensity)| (i as i8, intensity)) - .collect(); - - ExpectedIntensities { - precursor_intensities, - fragment_intensities, - } + Self::build_expected_intensities( + &de.stripped_peptide, + &de.relative_intensities, + &mut eg, + ) + } + Some(FileReadingExtras::Spectronaut(spectronaut_extras)) => { + let se = spectronaut_extras + .get(index) + .ok_or(ViewerError::General(format!( + "Spectronaut extras index {} out of bounds", + index + )))?; + Self::build_expected_intensities( + &se.stripped_peptide, + &se.relative_intensities, + &mut eg, + ) } None => ExpectedIntensities { precursor_intensities: eg.iter_precursors().map(|(idx, _mz)| (idx, 1.0)).collect(), fragment_intensities: eg .iter_fragments() - .map(|(label, _mz)| (label.to_string(), 1.0)) + .map(|(label, _mz)| (label.to_string(), 0.1)) .collect(), }, }; @@ -345,18 +414,10 @@ impl ElutionGroupData { } fn add_columns<'a>(&self, mut table: TableBuilder<'a>) -> TableBuilder<'a> { - let has_diann_extras = match &self.inner { - ElutionGroupCollection::StringLabels(_, extras) - | ElutionGroupCollection::MzpafLabels(_, extras) - | ElutionGroupCollection::TinyIntLabels(_, extras) - | ElutionGroupCollection::IntLabels(_, extras) => { - matches!(extras, Some(FileReadingExtras::Diann(_))) - } - }; // Max column width ~40 chars at typical font size const MAX_COL_WIDTH: f32 = 280.0; - if has_diann_extras { - for _ in DIANN_EXTRA_LABELS.iter() { + if self.has_library_extras() { + for _ in LIBRARY_EXTRA_LABELS.iter() { table = table.column( egui_extras::Column::auto() .at_least(100.0) @@ -375,18 +436,9 @@ impl ElutionGroupData { } fn add_headers<'a>(&self, builder: TableBuilder<'a>) -> Table<'a> { - let has_diann_extras = match &self.inner { - ElutionGroupCollection::StringLabels(_, extras) - | ElutionGroupCollection::MzpafLabels(_, extras) - | ElutionGroupCollection::TinyIntLabels(_, extras) - | ElutionGroupCollection::IntLabels(_, extras) => { - matches!(extras, Some(FileReadingExtras::Diann(_))) - } - }; - builder.header(20.0, |mut header| { - if has_diann_extras { - for label in DIANN_EXTRA_LABELS.iter() { + if self.has_library_extras() { + for label in LIBRARY_EXTRA_LABELS.iter() { header.col(|ui| { ui.strong(*label); }); @@ -406,7 +458,7 @@ impl ElutionGroupData { /// Returns true if any of the content was clicked (for selection handling) fn add_row_content_inner( eg: &TimsElutionGroup, - extras: Option, + extras: Option, table_row: &mut egui_extras::TableRow, is_selected: bool, ) -> bool { @@ -432,19 +484,16 @@ impl ElutionGroupData { clicked = true; } }; - match extras { - Some(diann_extra) => { - table_row.col(|ui| { - add_col(ui, &diann_extra.modified_peptide); - }); - table_row.col(|ui| { - add_col(ui, &diann_extra.protein_id); - }); - table_row.col(|ui| { - add_col(ui, if diann_extra.is_decoy { "Yes" } else { "No" }); - }); - } - None => { /* No extra columns */ } + if let Some(extra) = extras { + table_row.col(|ui| { + add_col(ui, &extra.modified_peptide); + }); + table_row.col(|ui| { + add_col(ui, &extra.protein_id); + }); + table_row.col(|ui| { + add_col(ui, if extra.is_decoy { "Yes" } else { "No" }); + }); } table_row.col(|ui| { add_col(ui, &eg.id().to_string()); @@ -478,28 +527,20 @@ impl ElutionGroupData { selected_index: &mut Option, table_row: &mut egui_extras::TableRow, ) { - let diann_extra = match &self.inner { - ElutionGroupCollection::StringLabels(_, extras) - | ElutionGroupCollection::MzpafLabels(_, extras) - | ElutionGroupCollection::TinyIntLabels(_, extras) - | ElutionGroupCollection::IntLabels(_, extras) => match extras { - Some(FileReadingExtras::Diann(diann_extras)) => Some(diann_extras[idx].clone()), - _ => None, - }, - }; + let library_extras = self.get_library_extras_view(idx); let is_selected = Some(idx) == *selected_index; let clicked = match &self.inner { ElutionGroupCollection::StringLabels(egs, _) => { - Self::add_row_content_inner(&egs[idx], diann_extra, table_row, is_selected) + Self::add_row_content_inner(&egs[idx], library_extras, table_row, is_selected) } ElutionGroupCollection::MzpafLabels(egs, _) => { - Self::add_row_content_inner(&egs[idx], diann_extra, table_row, is_selected) + Self::add_row_content_inner(&egs[idx], library_extras, table_row, is_selected) } ElutionGroupCollection::TinyIntLabels(egs, _) => { - Self::add_row_content_inner(&egs[idx], diann_extra, table_row, is_selected) + Self::add_row_content_inner(&egs[idx], library_extras, table_row, is_selected) } ElutionGroupCollection::IntLabels(egs, _) => { - Self::add_row_content_inner(&egs[idx], diann_extra, table_row, is_selected) + Self::add_row_content_inner(&egs[idx], library_extras, table_row, is_selected) } }; if clicked { diff --git a/rust/timsquery_viewer/src/plot_renderer.rs b/rust/timsquery_viewer/src/plot_renderer.rs index 638ffb4..129670a 100644 --- a/rust/timsquery_viewer/src/plot_renderer.rs +++ b/rust/timsquery_viewer/src/plot_renderer.rs @@ -110,7 +110,7 @@ impl ScoreLines { }) .collect(); - // Check that all lines are the same length + // Check that all lines are the same length let first_line_len = lines.first().map(|line| line.points.len()).unwrap_or(0); for line in &lines { assert_eq!( @@ -313,8 +313,7 @@ impl ChromatogramLines { .iter() .zip(chromatogram.fragment_intensities.iter()) .zip(chromatogram.fragment_labels.iter()) - .enumerate() - .map(|(i, ((mz, intensities), label))| { + .map(|((mz, intensities), label)| { let points: Vec = chromatogram .retention_time_results_seconds .iter() @@ -328,7 +327,7 @@ impl ChromatogramLines { .fold(f32::NEG_INFINITY, f32::max) as f64; global_max_intensity = global_max_intensity.max(intensity_max as f32); - let color = get_fragment_color(i); + let color = crate::ui::panels::ion_color(label, Some(255)); ChromatogramLine { data: LineData { points, @@ -454,8 +453,15 @@ pub fn render_chromatogram_plot( PlotMode::PrecursorsOnly => "chromatogram_plot_precursors", PlotMode::FragmentsOnly => "chromatogram_plot_fragments", }; + // Hide legend when there are too many traces to keep the plot readable + const MAX_LEGEND_TRACES: usize = 20; + let total_traces = match mode { + PlotMode::All => chromatogram.precursor_lines.len() + chromatogram.fragment_lines.len(), + PlotMode::PrecursorsOnly => chromatogram.precursor_lines.len(), + PlotMode::FragmentsOnly => chromatogram.fragment_lines.len(), + }; + let mut plot = Plot::new(plot_id) - .legend(Legend::default()) .show_axes([true, true]) .x_axis_label("Retention Time (s)") .y_axis_label("Intensity") @@ -463,6 +469,10 @@ pub fn render_chromatogram_plot( .allow_drag(false) .allow_scroll(false); + if total_traces <= MAX_LEGEND_TRACES { + plot = plot.legend(Legend::default()); + } + if let Some(link_id) = link_group_id { const ONLY_X_AXIS: [bool; 2] = [true, false]; plot = plot.link_axis(link_id.to_string(), ONLY_X_AXIS); @@ -476,6 +486,16 @@ pub fn render_chromatogram_plot( PlotMode::FragmentsOnly => chromatogram.get_fragment_intensity_max(), }; + // Guard: skip rendering if max_polygon_height is non-finite or negative + if !max_polygon_height.is_finite() || max_polygon_height < 0.0 { + tracing::error!( + "Bad max_polygon_height={} for mode={:?}, skipping plot content", + max_polygon_height, + mode, + ); + return; + } + let reference_band = Polygon::new( "Reference RT", PlotPoints::new(vec![ @@ -498,7 +518,7 @@ pub fn render_chromatogram_plot( match mode { PlotMode::All | PlotMode::PrecursorsOnly => { - for line in chromatogram.precursor_lines.iter() { + for line in &chromatogram.precursor_lines { plot_ui.line(line.data.to_plot_line()); } } @@ -507,7 +527,7 @@ pub fn render_chromatogram_plot( match mode { PlotMode::All | PlotMode::FragmentsOnly => { - for line in chromatogram.fragment_lines.iter() { + for line in &chromatogram.fragment_lines { plot_ui.line(line.data.to_plot_line()); } } @@ -516,6 +536,7 @@ pub fn render_chromatogram_plot( plot_reflines(label_lines, plot_ui, 0.0, max_polygon_height); zoom_behavior(plot_ui, &scroll_delta); + if *auto_zoom_frame_counter > 0 { match auto_zoom_mode { AutoZoomMode::QueryRange => { @@ -709,20 +730,6 @@ fn get_precursor_color(index: usize) -> egui::Color32 { colors[index % colors.len()] } -fn get_fragment_color(index: usize) -> egui::Color32 { - let colors = [ - egui::Color32::from_rgb(230, 159, 0), // Orange - egui::Color32::from_rgb(213, 94, 0), // Vermillion - egui::Color32::from_rgb(204, 121, 167), // Reddish Purple - egui::Color32::from_rgb(240, 228, 66), // Yellow - egui::Color32::from_rgb(255, 165, 0), // Bright Orange - egui::Color32::from_rgb(220, 50, 47), // Red - egui::Color32::from_rgb(255, 99, 71), // Tomato - egui::Color32::from_rgb(255, 140, 0), // Dark Orange - ]; - colors[index % colors.len()] -} - fn get_palette1_colors(idx: usize) -> egui::Color32 { const COLORS: [&str; 5] = ["eac435", "345995", "03cea4", "fb4d3d", "ca1551"]; let color = COLORS[idx % COLORS.len()]; diff --git a/rust/timsquery_viewer/src/ui/panels/config_panel.rs b/rust/timsquery_viewer/src/ui/panels/config_panel.rs index 5be4424..0039193 100644 --- a/rust/timsquery_viewer/src/ui/panels/config_panel.rs +++ b/rust/timsquery_viewer/src/ui/panels/config_panel.rs @@ -1,4 +1,6 @@ use eframe::egui; +use std::sync::Arc; +use std::time::Instant; use crate::chromatogram_processor::SmoothingMethod; use crate::plot_renderer::AutoZoomMode; @@ -10,6 +12,31 @@ const SECTION_MARGIN: i8 = 10; const SECTION_SPACING: f32 = 12.0; const INTERNAL_SPACING: f32 = 8.0; +/// Screenshot capture lifecycle +pub enum ScreenshotState { + /// Nothing happening + Idle, + /// Timer running, show remaining seconds overlay + Countdown { deadline: Instant }, + /// Deadline reached, scale applied, screenshot command sent this frame + Capturing, + /// Screenshot received, saving to file + Saving(Arc), +} + +impl Default for ScreenshotState { + fn default() -> Self { + Self::Idle + } +} + +/// Actions the export UI can request +pub enum ScreenshotAction { + None, + Start, + Cancel, +} + /// Panel for configuration settings pub struct ConfigPanel; @@ -167,6 +194,79 @@ impl ConfigPanel { pub fn title(&self) -> &str { "Settings" } + + /// Renders the Export/Screenshot section at the bottom of the config panel + pub fn render_export_section( + ui: &mut egui::Ui, + screenshot_delay_secs: &mut f32, + screenshot_state: &ScreenshotState, + ) -> ScreenshotAction { + let mut action = ScreenshotAction::None; + + ui.label(egui::RichText::new("EXPORT").strong().size(13.0)); + ui.add_space(INTERNAL_SPACING); + + egui::Frame::group(ui.style()) + .inner_margin(egui::Margin::same(SECTION_MARGIN)) + .show(ui, |ui| { + ui.heading("Screenshot"); + ui.add_space(INTERNAL_SPACING); + + let is_active = !matches!(screenshot_state, ScreenshotState::Idle); + + // Delay selector + ui.horizontal(|ui| { + ui.label("Delay:"); + ui.add_enabled_ui(!is_active, |ui| { + egui::ComboBox::from_id_salt("screenshot_delay") + .selected_text(if *screenshot_delay_secs == 0.0 { + "None".to_string() + } else { + format!("{}s", *screenshot_delay_secs as u32) + }) + .show_ui(ui, |ui| { + ui.selectable_value(screenshot_delay_secs, 0.0, "None"); + ui.selectable_value(screenshot_delay_secs, 3.0, "3s"); + ui.selectable_value(screenshot_delay_secs, 5.0, "5s"); + ui.selectable_value(screenshot_delay_secs, 10.0, "10s"); + }); + }); + }); + + ui.add_space(INTERNAL_SPACING); + + // Action buttons + match screenshot_state { + ScreenshotState::Countdown { deadline } => { + let remaining = deadline + .saturating_duration_since(Instant::now()) + .as_secs_f32() + .ceil() as u32; + ui.horizontal(|ui| { + ui.add_enabled(false, egui::Button::new( + format!("Capturing in {}s...", remaining), + )); + if ui.button("Cancel").clicked() { + action = ScreenshotAction::Cancel; + } + }); + } + ScreenshotState::Capturing => { + ui.add_enabled(false, egui::Button::new("Capturing...")); + } + ScreenshotState::Saving(_) => { + ui.add_enabled(false, egui::Button::new("Saving...")); + } + ScreenshotState::Idle => { + if ui.button("Save Screenshot").clicked() { + action = ScreenshotAction::Start; + } + } + } + }); + + action + } } impl Default for ConfigPanel { diff --git a/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs b/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs new file mode 100644 index 0000000..17842e4 --- /dev/null +++ b/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs @@ -0,0 +1,112 @@ +use eframe::egui; +use egui::Color32; +use egui_plot::{ + HLine, + Plot, + PlotPoints, + Points, +}; + +use super::ion_color; +use crate::computed_state::MobilityData; + +/// Consolidated mobility visualization. +/// x = intensity-weighted mean m/z, y = intensity-weighted mean mobility. +/// Point size proportional to relative intensity, color = ion type. +/// Dashed horizontal lines = 1x (integration) mobility range. +/// Solid horizontal lines = 2x (query) mobility range. +#[derive(Default)] +pub struct MobilityPanel; + +impl MobilityPanel { + pub fn new() -> Self { + Self::default() + } + + pub fn title(&self) -> &str { + "Mobility" + } + + pub fn render(&self, ui: &mut egui::Ui, data: Option<&MobilityData>) { + let Some(data) = data else { + ui.centered_and_justified(|ui| { + ui.label("Click on XIC plot to view mobility"); + }); + return; + }; + + const MIN_RADIUS: f32 = 3.0; + const MAX_RADIUS: f32 = 14.0; + + let max_intensity = data + .ions + .iter() + .map(|ion| ion.total_intensity) + .fold(f64::NEG_INFINITY, f64::max); + + // Include a generation counter in the plot ID so egui_plot + // resets its persisted zoom bounds whenever the data changes. + let plot_id = format!("mobility_summary_{}", data.generation); + + let plot = Plot::new(plot_id) + .height(ui.available_height()) + .x_axis_label("m/z") + .y_axis_label("1/K0 (V·s/cm²)") + .allow_zoom(true) + .allow_drag(true); + + plot.show(ui, |plot_ui| { + // 2x (wide query) range — solid lines + let (wide_lo, wide_hi) = data.wide_mobility_range; + plot_ui + .hline(HLine::new("2x range lo", wide_lo).color(Color32::from_rgb(120, 120, 120))); + plot_ui + .hline(HLine::new("2x range hi", wide_hi).color(Color32::from_rgb(120, 120, 120))); + + // 1x (integration) range — dashed lines + let (tol_lo, tol_hi) = data.mobility_range; + plot_ui.hline( + HLine::new("1x range lo", tol_lo) + .color(Color32::from_rgb(200, 80, 80)) + .style(egui_plot::LineStyle::dashed_dense()), + ); + plot_ui.hline( + HLine::new("1x range hi", tol_hi) + .color(Color32::from_rgb(200, 80, 80)) + .style(egui_plot::LineStyle::dashed_dense()), + ); + + // Reference mobility — thin dashed + plot_ui.hline( + HLine::new("ref mobility", data.ref_mobility) + .color(Color32::from_rgb(255, 60, 60)) + .style(egui_plot::LineStyle::dashed_loose()), + ); + + for ion in &data.ions { + let frac = if max_intensity > 0.0 { + (ion.total_intensity / max_intensity) as f32 + } else { + 0.0 + }; + let radius = MIN_RADIUS + frac.sqrt() * (MAX_RADIUS - MIN_RADIUS); + let color_solid = ion_color(&ion.label, Some(255)); + let color_transparent = ion_color(&ion.label, Some(200)); + + plot_ui.points( + Points::new( + &ion.label, + PlotPoints::new(vec![[ion.mean_mz, ion.mean_mobility]]), + ) + .color(color_transparent) + .radius(radius), + ); + plot_ui.text(egui_plot::Text::new( + format!("{}_label", ion.label), + egui_plot::PlotPoint::new(ion.mean_mz, ion.mean_mobility), + egui::RichText::new(&ion.label).strong().color(color_solid), + )); + } + }); + } +} diff --git a/rust/timsquery_viewer/src/ui/panels/mod.rs b/rust/timsquery_viewer/src/ui/panels/mod.rs index e5f6846..14528b9 100644 --- a/rust/timsquery_viewer/src/ui/panels/mod.rs +++ b/rust/timsquery_viewer/src/ui/panels/mod.rs @@ -1,11 +1,26 @@ -// Panel modules for UI organization -// Each panel is responsible for rendering its portion of the UI -// and returning commands for state changes - pub mod config_panel; +pub mod mobility_panel; pub mod precursor_table_panel; pub mod spectrum_display_panel; -pub use config_panel::ConfigPanel; +pub use config_panel::{ + ConfigPanel, + ScreenshotAction, + ScreenshotState, +}; +pub use mobility_panel::MobilityPanel; pub use precursor_table_panel::TablePanel; pub use spectrum_display_panel::SpectrumPanel; + +use egui::Color32; + +/// Shared color mapping for ion labels. +pub(crate) fn ion_color(label: &str, alpha: Option) -> Color32 { + let alpha = alpha.unwrap_or(255); + match label.chars().next() { + Some('b') | Some('B') => Color32::from_rgba_unmultiplied(100, 149, 237, alpha), /* Blue (Cornflower) */ + Some('y') | Some('Y') => Color32::from_rgba_unmultiplied(220, 80, 80, alpha), // Red + Some('P') | Some('p') => Color32::from_rgba_unmultiplied(50, 205, 50, alpha), /* Green (Lime) */ + _ => Color32::from_rgba_unmultiplied(255, 200, 50, alpha), // Yellow (Gold) + } +} diff --git a/rust/timsquery_viewer/src/ui/panels/spectrum_display_panel.rs b/rust/timsquery_viewer/src/ui/panels/spectrum_display_panel.rs index 9c3955c..abc5485 100644 --- a/rust/timsquery_viewer/src/ui/panels/spectrum_display_panel.rs +++ b/rust/timsquery_viewer/src/ui/panels/spectrum_display_panel.rs @@ -1,7 +1,4 @@ -use eframe::egui::{ - self, - Color32, -}; +use eframe::egui; use egui_plot::{ Line, Plot, @@ -25,25 +22,14 @@ impl SpectrumPanel { "MS2" } - /// Get color based on fragment label prefix - fn get_fragment_color(label: &str) -> Color32 { - match label.chars().next() { - Some('b') | Some('B') => Color32::from_rgb(100, 149, 237), // Blue (Cornflower) - Some('y') | Some('Y') => Color32::from_rgb(220, 20, 60), // Red (Crimson) - Some('p') | Some('P') => Color32::from_rgb(255, 200, 0), // Yellow - _ => Color32::from_rgb(50, 205, 50), // Green (Lime) - } - } - pub fn render( &mut self, ui: &mut egui::Ui, - ms2_spectrum: &Option, - expected_intensities: &Option>, + ms2_spectrum: Option<&MS2Spectrum>, + expected_intensities: Option<&ExpectedIntensities>, ) { if let Some(spec) = ms2_spectrum { let expected_intensities = expected_intensities - .as_ref() .expect("If a spectrum is present we should also have expected intensities."); ui.label(format!("RT: {:.2} seconds", spec.rt_seconds)); ui.separator(); @@ -58,7 +44,7 @@ impl SpectrumPanel { .values() .cloned() .max_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap_or(1.0); + .unwrap_or(0.1); Plot::new("ms2_spectrum") .height(ui.available_height()) @@ -74,7 +60,7 @@ impl SpectrumPanel { spec.mz_values.iter().zip(&spec.intensities).enumerate() { let label_str = &spec.fragment_labels[idx]; - let color = Self::get_fragment_color(label_str); + let color = super::ion_color(label_str, Some(255)); let y_value = (intensity / norm_factor) as f64; let points = PlotPoints::new(vec![[mz, 0.0], [mz, y_value]]); diff --git a/rust/timsseek/src/scoring/scores/coelution/coelution_score.rs b/rust/timsseek/src/scoring/scores/coelution/coelution_score.rs index eb6a81d..89b4113 100644 --- a/rust/timsseek/src/scoring/scores/coelution/coelution_score.rs +++ b/rust/timsseek/src/scoring/scores/coelution/coelution_score.rs @@ -45,21 +45,30 @@ fn coelution_vref_score_filter_onto( ) -> Result<(), DataProcessingError> { if slices.ncols() < window_size { trace!("Not enough data to calculate coelution score"); - return Err(DataProcessingError::ExpectedNonEmptyData { context: None }); + return Err(DataProcessingError::ExpectedNonEmptyData { + context: Some(format!( + "Not enough columns in slices to apply the rolling window of size {}", + window_size + )), + }); } let num_elems = (0..slices.nrows()).filter(|&i| filter(i)).count(); if num_elems == 0 { trace!("No valid slices after filtering"); - return Err(DataProcessingError::ExpectedNonEmptyData { context: None }); + return Err(DataProcessingError::ExpectedNonEmptyData { + context: Some("No valid slices after filtering, check your filter function".into()), + }); } let norm_factor = 1f32 / num_elems as f32; - if num_elems > 50 { + if num_elems > 150 { trace!( "There are too many valid slices after filtering, probably an mz-major and an rt-major array got mixed up" ); // TODO: make this a more specific error - return Err(DataProcessingError::ExpectedNonEmptyData { context: None }); + return Err(DataProcessingError::ExpectedNonEmptyData { context: Some( + "Too many valid slices after filtering, probably an mz-major and an rt-major array got mixed up".into() + ) }); } buffer.clear(); buffer.resize(slices.ncols(), 0.0); diff --git a/uv.lock b/uv.lock index a8d2661..20e3174 100644 --- a/uv.lock +++ b/uv.lock @@ -2608,7 +2608,7 @@ wheels = [ [[package]] name = "speclib-builder" -version = "0.25.0" +version = "0.26.0" source = { editable = "python/speclib_builder" } dependencies = [ { name = "loguru" }, @@ -2768,7 +2768,7 @@ wheels = [ [[package]] name = "timsseek-rescore" -version = "0.25.0" +version = "0.26.0" source = { editable = "python/timsseek_rescore" } dependencies = [ { name = "matplotlib" }, @@ -2797,7 +2797,7 @@ requires-dist = [ [[package]] name = "timsseek-rts-receiver" -version = "0.25.0" +version = "0.26.0" source = { editable = "python/timsseek_rts_receiver" } dependencies = [ { name = "matplotlib" }, @@ -2822,7 +2822,7 @@ requires-dist = [ [[package]] name = "timsseek-workspace" -version = "0.25.0" +version = "0.26.0" source = { virtual = "." } dependencies = [ { name = "jupyter" },