diff --git a/crates/app/src/ui/tools/mass_spec.rs b/crates/app/src/ui/tools/mass_spec.rs index 11a5307..ae9a7af 100644 --- a/crates/app/src/ui/tools/mass_spec.rs +++ b/crates/app/src/ui/tools/mass_spec.rs @@ -11,6 +11,7 @@ pub(super) fn mass_spectrometry_group(app: &mut PlotxApp, di: usize, ui: &mut Ui return false; }; let dataset_id = dataset.resource_id; + let selected_channel = app.selected_mass_spec_field(dataset_id); let active_stream = dataset.active_stream; let streams = dataset .supported_ms_streams() @@ -81,6 +82,31 @@ pub(super) fn mass_spectrometry_group(app: &mut PlotxApp, di: usize, ui: &mut Ui "{} channels · {transition_count} transitions", dataset.run.chromatograms.len() )); + } + + let channel_change = super::mass_spec_browser::channel_browser(dataset, selected_channel, ui); + let channel_change = channel_change.map(|id| { + let label = dataset + .run + .chromatograms + .iter() + .find(|channel| channel.id == id) + .map(|channel| channel.description.clone()) + .unwrap_or_else(|| id.0.clone()); + (id, label) + }); + if let Some((channel, label)) = channel_change { + match app.select_mass_spec_channel(dataset_id, &channel) { + Ok(true) => { + app.focus_single(di); + app.session.status = format!("Selected chromatogram channel {label}."); + } + Ok(false) => {} + Err(error) => app.session.status = error, + } + } + + if streams.is_empty() { return false; } diff --git a/crates/app/src/ui/tools/mass_spec_browser.rs b/crates/app/src/ui/tools/mass_spec_browser.rs new file mode 100644 index 0000000..f58889d --- /dev/null +++ b/crates/app/src/ui/tools/mass_spec_browser.rs @@ -0,0 +1,542 @@ +use egui::{ComboBox, TextEdit, Ui}; +use egui_phosphor::regular as icon; +use plotx_core::state::{FieldId, MassSpecDataset}; +use plotx_io::{ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, Polarity}; +use std::cmp::Ordering; +use std::sync::Arc; + +const ROW_HEIGHT: f32 = 44.0; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum PolarityFilter { + #[default] + All, + Positive, + Negative, + Unknown, +} + +impl PolarityFilter { + fn matches(self, polarity: Polarity) -> bool { + match self { + Self::All => true, + Self::Positive => polarity == Polarity::Positive, + Self::Negative => polarity == Polarity::Negative, + Self::Unknown => polarity == Polarity::Unknown, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct FilterSpec { + text: String, + precursor_mz: String, + product_mz: String, + collision_energy: String, + polarity: PolarityFilter, + activation_method: String, +} + +impl FilterSpec { + fn is_clear(&self) -> bool { + self == &Self::default() + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum NumericPredicate { + Equal(f64), + Less(f64, bool), + Greater(f64, bool), + Range(f64, f64), +} + +impl NumericPredicate { + fn parse(text: &str) -> Result, ()> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + if let Some((first, second)) = text.split_once("..") { + let first = parse_finite(first)?; + let second = parse_finite(second)?; + return Ok(Some(Self::Range(first.min(second), first.max(second)))); + } + for (prefix, inclusive, less) in [ + ("<=", true, true), + (">=", true, false), + ("<", false, true), + (">", false, false), + ] { + if let Some(value) = text.strip_prefix(prefix) { + let value = parse_finite(value)?; + return Ok(Some(if less { + Self::Less(value, inclusive) + } else { + Self::Greater(value, inclusive) + })); + } + } + Ok(Some(Self::Equal(parse_finite(text)?))) + } + + fn matches(self, value: f64) -> bool { + match self { + Self::Equal(target) => { + let tolerance = (target.abs().max(value.abs()).max(1.0) * 1.0e-9).max(5.0e-5); + (value - target).abs() <= tolerance + } + Self::Less(target, true) => value <= target, + Self::Less(target, false) => value < target, + Self::Greater(target, true) => value >= target, + Self::Greater(target, false) => value > target, + Self::Range(min, max) => (min..=max).contains(&value), + } + } +} + +fn parse_finite(text: &str) -> Result { + text.trim() + .parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or(()) +} + +#[derive(Clone, Debug)] +struct BrowserEntry { + id: ChromatogramChannelId, + field: Option, + name: String, + native_id: String, + kind: ChromatogramKind, + polarity: Polarity, + precursor_mz: Option, + product_mz: Option, + collision_energy: Option, + activation_method: Option, + searchable: String, +} + +impl BrowserEntry { + fn from_channel(channel: &ChromatogramChannel) -> Self { + let transition = channel.transition.as_ref(); + let activation_method = transition.and_then(|item| item.activation_method.clone()); + let precursor_mz = transition.and_then(|item| item.precursor_mz); + let product_mz = transition.and_then(|item| item.product_mz); + let collision_energy = transition.and_then(|item| item.collision_energy); + let searchable = format!("{} {}", channel.description, channel.id.0).to_lowercase(); + Self { + id: channel.id.clone(), + field: None, + name: channel.description.clone(), + native_id: channel.id.0.clone(), + kind: channel.kind, + polarity: channel.polarity, + precursor_mz, + product_mz, + collision_energy, + activation_method, + searchable, + } + } + + fn matches( + &self, + filters: &FilterSpec, + precursor: Option, + product: Option, + collision: Option, + ) -> bool { + let text_matches = filters + .text + .split_whitespace() + .map(str::to_lowercase) + .all(|term| self.searchable.contains(&term)); + text_matches + && filters.polarity.matches(self.polarity) + && numeric_matches(precursor, self.precursor_mz) + && numeric_matches(product, self.product_mz) + && numeric_matches(collision, self.collision_energy) + && (filters.activation_method.is_empty() + || self + .activation_method + .as_deref() + .is_some_and(|method| method.eq_ignore_ascii_case(&filters.activation_method))) + } + + fn row_text(&self) -> String { + let transition = match (self.precursor_mz, self.product_mz) { + (Some(precursor), Some(product)) => { + format!("{precursor:.4} -> {product:.4}") + } + (Some(precursor), None) => format!("{precursor:.4} -> ?"), + (None, Some(product)) => format!("? -> {product:.4}"), + (None, None) => kind_label(self.kind).to_owned(), + }; + let energy = self + .collision_energy + .map(|value| format!(" · CE {} eV", format_number(value))) + .unwrap_or_default(); + format!( + "{transition} · {}{energy}\n{}", + polarity_label(self.polarity), + abbreviate(&self.name, 48), + ) + } + + fn detail_text(&self) -> String { + let mut lines = vec![format!("Native ID: {}", self.native_id)]; + if let Some(method) = &self.activation_method { + lines.push(format!("Activation: {method}")); + } + lines.join("\n") + } +} + +fn numeric_matches(predicate: Option, value: Option) -> bool { + predicate.is_none_or(|predicate| value.is_some_and(|value| predicate.matches(value))) +} + +#[derive(Clone, Debug)] +struct ChannelIndex { + entries: Arc<[Arc]>, + transition_count: usize, + has_precursor: bool, + has_product: bool, + has_collision_energy: bool, + activation_methods: Arc<[String]>, +} + +impl ChannelIndex { + fn build(dataset: &MassSpecDataset) -> Self { + let mut index = Self::from_channels(&dataset.run.chromatograms); + index.entries = index + .entries + .iter() + .map(|entry| { + let mut entry = entry.as_ref().clone(); + entry.field = dataset.channel_field_id(&entry.id); + Arc::new(entry) + }) + .collect(); + index + } + + fn from_channels(channels: &[ChromatogramChannel]) -> Self { + let mut entries = channels + .iter() + .filter(|channel| channel.source_stream.is_none() && channel.kind.is_signal()) + .map(BrowserEntry::from_channel) + .collect::>(); + entries.sort_by(compare_entries); + let transition_count = entries + .iter() + .filter(|entry| { + entry.precursor_mz.is_some() + || entry.product_mz.is_some() + || entry.collision_energy.is_some() + || entry.activation_method.is_some() + }) + .count(); + let has_precursor = entries.iter().any(|entry| entry.precursor_mz.is_some()); + let has_product = entries.iter().any(|entry| entry.product_mz.is_some()); + let has_collision_energy = entries.iter().any(|entry| entry.collision_energy.is_some()); + let mut activation_methods = entries + .iter() + .filter_map(|entry| entry.activation_method.clone()) + .collect::>(); + activation_methods.sort_by_key(|method| method.to_lowercase()); + activation_methods.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + Self { + entries: entries.into_iter().map(Arc::new).collect(), + transition_count, + has_precursor, + has_product, + has_collision_energy, + activation_methods: activation_methods.into(), + } + } +} + +fn compare_entries(left: &BrowserEntry, right: &BrowserEntry) -> Ordering { + kind_rank(left.kind) + .cmp(&kind_rank(right.kind)) + .then_with(|| compare_optional_f64(left.precursor_mz, right.precursor_mz)) + .then_with(|| compare_optional_f64(left.product_mz, right.product_mz)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.native_id.cmp(&right.native_id)) +} + +fn compare_optional_f64(left: Option, right: Option) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => left.total_cmp(&right), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +fn kind_rank(kind: ChromatogramKind) -> u8 { + match kind { + ChromatogramKind::TotalIonCurrent => 0, + ChromatogramKind::BasePeak => 1, + ChromatogramKind::SelectedIonMonitoring => 2, + ChromatogramKind::SelectedReactionMonitoring => 3, + ChromatogramKind::Optical => 4, + ChromatogramKind::Unknown => 5, + ChromatogramKind::Temperature + | ChromatogramKind::Pressure + | ChromatogramKind::Housekeeping => 6, + } +} + +fn kind_label(kind: ChromatogramKind) -> &'static str { + match kind { + ChromatogramKind::TotalIonCurrent => "TIC", + ChromatogramKind::BasePeak => "BPC", + ChromatogramKind::SelectedIonMonitoring => "SIM", + ChromatogramKind::SelectedReactionMonitoring => "SRM", + ChromatogramKind::Optical => "Optical", + ChromatogramKind::Temperature => "Temperature", + ChromatogramKind::Pressure => "Pressure", + ChromatogramKind::Housekeeping => "Housekeeping", + ChromatogramKind::Unknown => "Channel", + } +} + +fn polarity_label(polarity: Polarity) -> &'static str { + match polarity { + Polarity::Positive => "+", + Polarity::Negative => "-", + Polarity::Unknown => "?", + } +} + +fn format_number(value: f64) -> String { + let formatted = format!("{value:.4}"); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned() +} + +fn abbreviate(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_owned(); + } + let mut value = text + .chars() + .take(max_chars.saturating_sub(3)) + .collect::(); + value.push_str("..."); + value +} + +#[derive(Clone, Debug)] +struct BrowserState { + index: Arc, + filters: FilterSpec, + applied_filters: Option, + matches: Arc<[Arc]>, + #[cfg(test)] + filter_scans: usize, +} + +impl BrowserState { + fn new(index: ChannelIndex) -> Self { + let mut state = Self { + index: Arc::new(index), + filters: FilterSpec::default(), + applied_filters: None, + matches: Arc::default(), + #[cfg(test)] + filter_scans: 0, + }; + state.refresh(); + state + } + + fn refresh(&mut self) -> bool { + if self.applied_filters.as_ref() == Some(&self.filters) { + return false; + } + let precursor = NumericPredicate::parse(&self.filters.precursor_mz) + .ok() + .flatten(); + let product = NumericPredicate::parse(&self.filters.product_mz) + .ok() + .flatten(); + let collision = NumericPredicate::parse(&self.filters.collision_energy) + .ok() + .flatten(); + self.matches = self + .index + .entries + .iter() + .filter(|entry| entry.matches(&self.filters, precursor, product, collision)) + .cloned() + .collect(); + self.applied_filters = Some(self.filters.clone()); + #[cfg(test)] + { + self.filter_scans += 1; + } + true + } +} + +pub(super) fn channel_browser( + dataset: &MassSpecDataset, + selected: Option, + ui: &mut Ui, +) -> Option { + let state_id = ui.make_persistent_id(("mass_spec_channel_browser", dataset.resource_id)); + let mut state = ui + .data_mut(|data| data.get_temp::(state_id)) + .unwrap_or_else(|| BrowserState::new(ChannelIndex::build(dataset))); + + ui.separator(); + ui.label(crate::typography::headline("Chromatogram channels")); + if state.index.entries.is_empty() { + ui.weak("No plottable chromatogram channels are available in this run."); + ui.data_mut(|data| data.insert_temp(state_id, state)); + return None; + } + + ui.horizontal(|ui| { + ui.label(icon::MAGNIFYING_GLASS); + ui.add( + TextEdit::singleline(&mut state.filters.text) + .hint_text("Name or native ID") + .desired_width(f32::INFINITY), + ); + if ui + .add_enabled(!state.filters.is_clear(), egui::Button::new(icon::X)) + .on_hover_text("Clear channel filters") + .clicked() + { + state.filters = FilterSpec::default(); + } + }); + + if state.index.transition_count == 0 { + ui.weak("This run has channels, but no structured transition metadata."); + } else { + transition_filters(&mut state, ui); + } + state.refresh(); + + for (label, value) in [ + ("Precursor m/z", &state.filters.precursor_mz), + ("Product m/z", &state.filters.product_mz), + ("Collision energy", &state.filters.collision_energy), + ] { + if NumericPredicate::parse(value).is_err() { + ui.colored_label( + ui.visuals().error_fg_color, + format!("{label}: use a number, comparison, or min..max range."), + ); + } + } + + ui.weak(format!( + "{} of {} channels · {} transitions", + state.matches.len(), + state.index.entries.len(), + state.index.transition_count + )); + if state.matches.is_empty() { + ui.weak("No channels match the current filters."); + ui.data_mut(|data| data.insert_temp(state_id, state)); + return None; + } + + let mut chosen = None; + egui::ScrollArea::vertical() + .id_salt(("mass_spec_channel_rows", dataset.resource_id)) + .max_height(352.0) + .auto_shrink([false, true]) + .show_rows(ui, ROW_HEIGHT, state.matches.len(), |ui, rows| { + for row in rows { + let entry = &state.matches[row]; + let is_selected = selected.is_some_and(|field| entry.field == Some(field)); + let response = ui.add_sized( + [ui.available_width(), ROW_HEIGHT], + egui::Button::new(entry.row_text()) + .selected(is_selected) + .frame(is_selected), + ); + if response.on_hover_text(entry.detail_text()).clicked() { + chosen = Some(entry.id.clone()); + } + } + }); + + ui.data_mut(|data| data.insert_temp(state_id, state)); + chosen +} + +fn transition_filters(state: &mut BrowserState, ui: &mut Ui) { + egui::Grid::new("mass_spec_transition_filters") + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + if state.index.has_precursor { + ui.label("Precursor m/z"); + ui.add( + TextEdit::singleline(&mut state.filters.precursor_mz) + .hint_text("450 or 400..500"), + ); + ui.end_row(); + } + if state.index.has_product { + ui.label("Product m/z"); + ui.add( + TextEdit::singleline(&mut state.filters.product_mz).hint_text("184 or >=100"), + ); + ui.end_row(); + } + if state.index.has_collision_energy { + ui.label("Collision energy"); + ui.add( + TextEdit::singleline(&mut state.filters.collision_energy) + .hint_text("30 or 20..40"), + ); + ui.end_row(); + } + }); + ui.horizontal(|ui| { + ui.label("Polarity"); + for (value, label) in [ + (PolarityFilter::All, "All"), + (PolarityFilter::Positive, "+"), + (PolarityFilter::Negative, "-"), + (PolarityFilter::Unknown, "?"), + ] { + ui.selectable_value(&mut state.filters.polarity, value, label); + } + }); + if !state.index.activation_methods.is_empty() { + ComboBox::from_label("Activation method") + .selected_text(if state.filters.activation_method.is_empty() { + "All" + } else { + state.filters.activation_method.as_str() + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut state.filters.activation_method, String::new(), "All"); + for method in state.index.activation_methods.iter() { + ui.selectable_value( + &mut state.filters.activation_method, + method.clone(), + method, + ); + } + }); + } +} + +#[cfg(test)] +#[path = "mass_spec_browser_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/tools/mass_spec_browser_tests.rs b/crates/app/src/ui/tools/mass_spec_browser_tests.rs new file mode 100644 index 0000000..ee0e822 --- /dev/null +++ b/crates/app/src/ui/tools/mass_spec_browser_tests.rs @@ -0,0 +1,228 @@ +use super::*; +use plotx_io::{MassTransition, Polarity}; +use std::path::Path; + +fn channel( + id: &str, + name: &str, + kind: ChromatogramKind, + polarity: Polarity, + transition: Option<(f64, f64, Option, Option<&str>)>, +) -> ChromatogramChannel { + ChromatogramChannel { + id: ChromatogramChannelId(id.to_owned()), + kind, + polarity, + transition: transition.map( + |(precursor_mz, product_mz, collision_energy, activation_method)| MassTransition { + precursor_mz: Some(precursor_mz), + product_mz: Some(product_mz), + collision_energy, + activation_method: activation_method.map(str::to_owned), + }, + ), + source_stream: None, + coordinate: None, + description: name.to_owned(), + unit: "cps".to_owned(), + time_min: vec![0.0, 1.0], + values: vec![1.0, 2.0], + } +} + +fn fixture_channels() -> Vec { + vec![ + channel( + "chrom=beta native=22", + "Beta transition", + ChromatogramKind::SelectedReactionMonitoring, + Polarity::Negative, + Some((500.2, 200.1, Some(35.0), Some("HCD"))), + ), + channel( + "chrom=alpha native=11", + "Alpha transition", + ChromatogramKind::SelectedReactionMonitoring, + Polarity::Positive, + Some((400.2, 100.1, Some(20.0), Some("CID"))), + ), + channel( + "chrom=tic", + "Total ion current", + ChromatogramKind::TotalIonCurrent, + Polarity::Unknown, + None, + ), + ] +} + +#[test] +fn free_text_matches_channel_name_and_native_id() { + let mut state = BrowserState::new(ChannelIndex::from_channels(&fixture_channels())); + state.filters.text = "beta".to_owned(); + assert!(state.refresh()); + assert_eq!(state.matches.len(), 1); + assert_eq!(state.matches[0].id.0, "chrom=beta native=22"); + + state.filters.text = "native=11".to_owned(); + assert!(state.refresh()); + assert_eq!(state.matches.len(), 1); + assert_eq!(state.matches[0].name, "Alpha transition"); +} + +#[test] +fn transition_metadata_filters_compose_deterministically() { + let mut state = BrowserState::new(ChannelIndex::from_channels(&fixture_channels())); + state.filters.precursor_mz = "400..450".to_owned(); + state.filters.product_mz = "<=150".to_owned(); + state.filters.collision_energy = ">=20".to_owned(); + state.filters.polarity = PolarityFilter::Positive; + state.filters.activation_method = "cid".to_owned(); + assert!(state.refresh()); + assert_eq!(state.matches.len(), 1); + assert_eq!(state.matches[0].name, "Alpha transition"); + + assert!(NumericPredicate::parse("not-a-number").is_err()); + assert_eq!( + NumericPredicate::parse("500.2").unwrap(), + Some(NumericPredicate::Equal(500.2)) + ); +} + +#[test] +fn channel_order_is_stable_and_transition_aware() { + let index = ChannelIndex::from_channels(&fixture_channels()); + let names = index + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(); + assert_eq!( + names, + ["Total ion current", "Alpha transition", "Beta transition"] + ); +} + +#[test] +fn empty_and_metadata_free_runs_are_distinguished() { + let empty = ChannelIndex::from_channels(&[]); + assert!(empty.entries.is_empty()); + assert_eq!(empty.transition_count, 0); + + let channels = [channel( + "chrom=uv", + "UV 280 nm", + ChromatogramKind::Optical, + Polarity::Unknown, + None, + )]; + let metadata_free = ChannelIndex::from_channels(&channels); + assert_eq!(metadata_free.entries.len(), 1); + assert_eq!(metadata_free.transition_count, 0); + let state = BrowserState::new(metadata_free); + assert_eq!(state.matches.len(), 1); +} + +#[test] +fn large_channel_cache_scans_only_when_filters_change() { + let mut channels = vec![ + channel( + "tic", + "TIC", + ChromatogramKind::TotalIonCurrent, + Polarity::Unknown, + None, + ), + channel( + "bpc", + "BPC", + ChromatogramKind::BasePeak, + Polarity::Unknown, + None, + ), + ]; + channels.extend((0..720).map(|index| { + channel( + &format!("transition={index:03}"), + &format!("MRM transition {index:03}"), + ChromatogramKind::SelectedReactionMonitoring, + if index % 2 == 0 { + Polarity::Positive + } else { + Polarity::Negative + }, + Some(( + 400.0 + index as f64, + 100.0 + index as f64, + Some(30.0), + Some("CID"), + )), + ) + })); + let mut state = BrowserState::new(ChannelIndex::from_channels(&channels)); + assert_eq!(state.index.entries.len(), 722); + assert_eq!(state.index.transition_count, 720); + assert_eq!(state.filter_scans, 1); + assert!(!state.refresh()); + assert_eq!(state.filter_scans, 1); + + state.filters.text = "transition 719".to_owned(); + assert!(state.refresh()); + assert_eq!(state.filter_scans, 2); + assert_eq!(state.matches.len(), 1); + assert_eq!(state.matches[0].id.0, "transition=719"); +} + +#[test] +fn local_pxd066465_builds_720_transition_fields_when_present() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(".tmp/MS-data/pride-PXD066465/Drug_substance_3_scheduled_MRM.mzML"); + if !path.is_file() { + return; + } + let loaded = plotx_io::mzml::load(&path).expect("PXD066465 mzML import"); + let plotx_io::Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("PXD066465 did not import as mass spectrometry data"); + }; + let dataset = MassSpecDataset::load(*run); + let index = ChannelIndex::build(&dataset); + + assert_eq!(dataset.run.streams.len(), 0); + assert_eq!(dataset.run.chromatograms.len(), 722); + assert_eq!(index.entries.len(), 722); + assert_eq!(index.transition_count, 720); + assert!( + index + .entries + .iter() + .all(|entry| dataset.channel_field_id(&entry.id).is_some()) + ); + + let first_transition = index + .entries + .iter() + .find(|entry| entry.precursor_mz.is_some() && entry.product_mz.is_some()) + .expect("structured transition"); + let precursor_mz = first_transition.precursor_mz.unwrap(); + let product_mz = first_transition.product_mz.unwrap(); + let mut state = BrowserState::new(index); + state.filters.precursor_mz = format_number(precursor_mz); + state.filters.product_mz = format_number(product_mz); + assert!(state.refresh()); + assert!(!state.matches.is_empty()); + + let ctx = crate::typography::test_context(); + let mut rendered_counts = None; + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.set_width(360.0); + let selected = dataset.channel_field_id(&dataset.run.chromatograms[0].id); + assert!(channel_browser(&dataset, selected, ui).is_none()); + let state_id = ui.make_persistent_id(("mass_spec_channel_browser", dataset.resource_id)); + let rendered = ui + .data(|data| data.get_temp::(state_id)) + .expect("browser UI cache"); + rendered_counts = Some((rendered.index.entries.len(), rendered.matches.len())); + }); + assert_eq!(rendered_counts, Some((722, 722))); +} diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index 17b1a35..cf3d9eb 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -7,6 +7,7 @@ mod curve_fit; mod electrophysiology; mod line_fit; mod mass_spec; +mod mass_spec_browser; mod processing; mod pseudo; mod region_analysis; diff --git a/crates/core/src/project/mass_spec_convert_project_tests.rs b/crates/core/src/project/mass_spec_convert_project_tests.rs index 9890900..99cbb8c 100644 --- a/crates/core/src/project/mass_spec_convert_project_tests.rs +++ b/crates/core/src/project/mass_spec_convert_project_tests.rs @@ -69,3 +69,36 @@ fn imported_mzml_run_survives_project_round_trip() { assert_eq!(spectrum.mz, [1.0]); assert_eq!(spectrum.intensity, [2.0]); } + +#[test] +fn selected_chromatogram_channel_binding_survives_project_round_trip() { + let mut run = crate::state::sample_mass_spec_run(); + run.streams.clear(); + run.chromatograms + .retain(|channel| channel.kind == ChromatogramKind::Optical); + let selected = run.chromatograms[1].id.clone(); + let dataset = + crate::state::Dataset::MassSpec(Box::new(crate::state::MassSpecDataset::load(run))); + let dataset_id = dataset.resource_id(); + let mut app = crate::state::PlotxApp::new(); + app.doc.canvases.push(crate::workflow::build_default_canvas( + &dataset, + "channels.mzML", + )); + app.doc.datasets.push(dataset); + app.session.active_canvas = Some(0); + assert!(app.select_mass_spec_channel(dataset_id, &selected).unwrap()); + + let path = std::env::temp_dir().join(format!( + "plotx-channel-binding-round-trip-{}.plotx", + std::process::id() + )); + crate::project::save_project(&app, &path, false).unwrap(); + let loaded = crate::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + + assert_eq!( + loaded.selected_mass_spec_channel(dataset_id), + Some(selected) + ); +} diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index 5e55bdb..c74a42d 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -6,7 +6,8 @@ use super::{ }; use plotx_figure::{Axis, Figure, Series, SeriesKind}; use plotx_io::{ - AcquisitionStreamId, ChromatogramKind, MassSpecRun, MassSpectrum, SpectrumId, StreamRole, + AcquisitionStreamId, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, MassSpecRun, + MassSpectrum, SpectrumId, StreamRole, }; use serde::{Deserialize, Serialize}; use std::fmt; @@ -83,6 +84,17 @@ pub struct MassSpecDataset { } impl MassSpecDataset { + pub fn channel_field_id(&self, channel: &ChromatogramChannelId) -> Option { + self.field_catalog.id_for_key(&channel_key(&channel.0)) + } + + pub fn channel_for_field(&self, field: FieldId) -> Option<&ChromatogramChannel> { + self.run + .chromatograms + .iter() + .find(|channel| self.channel_field_id(&channel.id) == Some(field)) + } + /// Resolve a chromatogram field to the stream whose scan cursor it drives. /// Optical channels intentionally use the active MS stream. pub fn chromatogram_stream_for_field(&self, field: FieldId) -> Option { diff --git a/crates/core/src/state/mass_spec_channel_tests.rs b/crates/core/src/state/mass_spec_channel_tests.rs new file mode 100644 index 0000000..ddf91f2 --- /dev/null +++ b/crates/core/src/state/mass_spec_channel_tests.rs @@ -0,0 +1,94 @@ +use super::*; +use plotx_io::ChromatogramKind; + +#[test] +fn channel_selection_uses_stable_identity_and_shared_binding_history() { + let mut run = sample_mass_spec_run(); + run.streams.clear(); + run.chromatograms + .retain(|channel| channel.kind == ChromatogramKind::Optical); + let first = run.chromatograms[0].id.clone(); + let second = run.chromatograms[1].id.clone(); + let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(run))); + let dataset_id = dataset.resource_id(); + let mut app = PlotxApp::new(); + app.doc.canvases.push(crate::workflow::build_default_canvas( + &dataset, + "channels.mzML", + )); + app.doc.datasets.push(dataset); + app.session.active_canvas = Some(0); + + assert_eq!( + app.selected_mass_spec_channel(dataset_id), + Some(first.clone()) + ); + assert!(app.select_mass_spec_channel(dataset_id, &second).unwrap()); + assert_eq!(app.selected_mass_spec_channel(dataset_id), Some(second)); + let selected_binding = &app.doc.canvases[0].objects[0].plot().unwrap().binding; + assert_eq!(selected_binding.series.len(), 1); + app.undo(); + assert_eq!(app.selected_mass_spec_channel(dataset_id), Some(first)); + let restored_binding = &app.doc.canvases[0].objects[0].plot().unwrap().binding; + assert_eq!(restored_binding.series.len(), 2); + assert_ne!( + restored_binding.series[0].source.field, + restored_binding.series[1].source.field + ); +} + +#[test] +fn channel_selection_never_retargets_the_selected_mass_spectrum_plot() { + let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(sample_mass_spec_run()))); + let dataset_id = dataset.resource_id(); + let channel = dataset.as_mass_spec().unwrap().run.chromatograms[1] + .id + .clone(); + let channel_field = dataset + .as_mass_spec() + .unwrap() + .channel_field_id(&channel) + .unwrap(); + let mut app = PlotxApp::new(); + app.doc.canvases.push(crate::workflow::build_default_canvas( + &dataset, + "channels.mzML", + )); + app.doc.datasets.push(dataset); + app.session.active_canvas = Some(0); + app.pin_mass_spectrum_extraction( + dataset_id, + 0.4, + 1.0, + MassSpectrumExtractionMethod::HighestTic, + ) + .unwrap(); + + let spectrum_object = app.doc.canvases[0].objects[2].id; + assert_eq!( + app.doc.canvases[0].selected_plot_object_id(), + Some(spectrum_object) + ); + let spectrum_field = app.doc.canvases[0] + .object(spectrum_object) + .unwrap() + .plot() + .unwrap() + .binding + .series[0] + .source + .field; + + assert!(app.select_mass_spec_channel(dataset_id, &channel).unwrap()); + let spectrum = app.doc.canvases[0] + .object(spectrum_object) + .unwrap() + .plot() + .unwrap(); + assert_eq!(spectrum.chart.type_id, "mass_spectrum"); + assert_eq!(spectrum.binding.series[0].source.field, spectrum_field); + let chromatogram = app.doc.canvases[0].objects[0].plot().unwrap(); + assert_eq!(chromatogram.chart.type_id, "mass_chromatogram"); + assert_eq!(chromatogram.binding.series.len(), 1); + assert_eq!(chromatogram.binding.series[0].source.field, channel_field); +} diff --git a/crates/core/src/state/mass_spec_channels.rs b/crates/core/src/state/mass_spec_channels.rs new file mode 100644 index 0000000..682f4c3 --- /dev/null +++ b/crates/core/src/state/mass_spec_channels.rs @@ -0,0 +1,114 @@ +use super::*; +use crate::actions::Action; +use plotx_io::ChromatogramChannelId; + +impl PlotxApp { + pub fn selected_mass_spec_channel( + &self, + dataset_id: DatasetId, + ) -> Option { + let dataset = self.doc.dataset_by_id(dataset_id)?.as_mass_spec()?; + let field = self.selected_mass_spec_field(dataset_id)?; + dataset + .channel_for_field(field) + .map(|channel| channel.id.clone()) + } + + pub fn selected_mass_spec_field(&self, dataset_id: DatasetId) -> Option { + let (canvas, object) = self.mass_spec_plot_target(dataset_id)?; + Some( + self.doc.canvases[canvas] + .object(object)? + .plot()? + .binding + .series + .iter() + .find(|series| series.source.resource == dataset_id)? + .source + .field, + ) + } + + pub fn select_mass_spec_channel( + &mut self, + dataset_id: DatasetId, + channel_id: &ChromatogramChannelId, + ) -> Result { + let dataset = self + .doc + .dataset_by_id(dataset_id) + .and_then(Dataset::as_mass_spec) + .ok_or_else(|| "The LC-MS dataset is no longer available.".to_owned())?; + let field = dataset.channel_field_id(channel_id).ok_or_else(|| { + "The selected chromatogram channel is no longer available.".to_owned() + })?; + let (canvas, object) = self + .mass_spec_plot_target(dataset_id) + .ok_or_else(|| "No plot currently displays this LC-MS dataset.".to_owned())?; + let before = self.doc.canvases[canvas] + .object(object) + .and_then(CanvasObject::plot) + .map(|plot| plot.binding.clone()) + .ok_or_else(|| "The selected plot is no longer available.".to_owned())?; + let mut selected = before + .series + .iter() + .find(|series| series.source.resource == dataset_id && series.source.field == field) + .or_else(|| { + before + .series + .iter() + .find(|series| series.source.resource == dataset_id) + }) + .cloned() + .ok_or_else(|| "The selected plot no longer displays this LC-MS dataset.".to_owned())?; + if before.series.len() == 1 && selected.source.field == field { + return Ok(false); + } + selected.source.field = field; + selected.source.item = None; + selected.label = None; + let after = DataBinding { + series: vec![selected], + }; + self.try_execute_action(Action::set_data_binding(canvas, object, before, after)) + .map_err(|error| error.to_string())?; + self.session.ui.analysis_selection = None; + Ok(true) + } + + fn mass_spec_plot_target(&self, dataset_id: DatasetId) -> Option<(usize, ObjectId)> { + let is_channel_plot = |object: &CanvasObject| { + object.plot().is_some_and(|plot| { + plot.chart.type_id == "mass_chromatogram" + && plot + .binding + .series + .iter() + .any(|series| series.source.resource == dataset_id) + }) + }; + let candidates = self + .session + .active_canvas + .into_iter() + .chain(0..self.doc.canvases.len()); + for canvas_index in candidates { + let Some(canvas) = self.doc.canvases.get(canvas_index) else { + continue; + }; + let target = canvas + .selected_plot_object_id() + .and_then(|id| canvas.object(id).filter(|object| is_channel_plot(object))) + .or_else(|| canvas.objects.iter().find(|object| is_channel_plot(object))); + if let Some(object) = target { + return Some((canvas_index, object.id)); + } + } + None + } +} + +#[cfg(test)] +#[path = "mass_spec_channel_tests.rs"] +mod tests; diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index ef1570f..5983620 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -81,6 +81,7 @@ mod lineage; mod linefit; mod mass_spec; mod mass_spec_app; +mod mass_spec_channels; mod mass_spec_ranges; mod mass_spec_tic; mod mass_spec_xic; diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index ec58db7..93b4019 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -80,6 +80,22 @@ import warning identifies spectra with additional values and states that only the first was retained. Scientific Script scan snapshots expose the summary provenance, instrument configuration, source event or preset, and filter string. +For runs with many chromatogram channels, open **Dataset tools → Mass +spectrometry** from **Extract Mass Spectrum** or the command palette. The +**Chromatogram channels** browser lists a stable count and ordering, with TIC +and BPC ahead of SIM/SRM transitions. Search matches channel names and native +IDs. Structured transitions can also be filtered by precursor m/z, product +m/z, polarity, collision energy, and activation method. Numeric fields accept +an exact value, comparisons such as `>=400`, or a range such as `400..500`. + +Selecting a row replaces the series on the current LC–MS chromatogram plot with +that single channel through the normal PlotX field and binding workflow. Mass- +spectrum plots are never retargeted. The choice is undoable and is saved with +the page in a `.plotx` project. Chromatogram-only runs use the same browser. +The panel reports when a run has no plottable channels, no structured transition +metadata, or no channels matching the current filters. The list is virtualized, +so only visible rows are created for large scheduled-MRM runs. + The importer accepts little-endian 32-bit and 64-bit floating-point m/z, time, and intensity arrays with no compression or zlib compression. Numpress, big-endian arrays, and spectra or chromatograms without their required arrays diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 61ff2c2..783251d 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -67,6 +67,19 @@ DIA 没有 selected ion,其隔离窗目标也不会被误写成 selected-ion m 指出对应谱图,并说明只保留了第一个值。Scientific Script 的 scan 快照会暴露摘要 provenance、instrument configuration、源 event 或 preset,以及 filter string。 +对于包含大量色谱 channel 的数据,请通过 **Extract Mass Spectrum** 或命令面板打开 +**Dataset tools → Mass spectrometry**。**Chromatogram channels** 浏览器会显示稳定的 +数量和顺序,其中 TIC、BPC 排在 SIM/SRM transition 之前。自由文本可匹配 channel +名称和 native ID;对于结构化 transition,还可按 precursor m/z、product m/z、极性、 +碰撞能量和活化方法筛选。数值输入支持精确值、`>=400` 之类的比较式,以及 +`400..500` 之类的区间。 + +选择一行会通过 PlotX 原有的 field 与 binding 流程,把当前 LC–MS 色谱图中的 series +替换为该单一 channel;质谱图绝不会被重新绑定。该操作可使用标准命令撤销,并随页面 +保存到 `.plotx` 项目。只有色谱图的采集也使用同一浏览器。没有可绘制 channel、没有 +结构化 transition metadata 或当前筛选无结果时,面板都会给出明确提示。列表采用 +虚拟化渲染,大型 scheduled-MRM 数据只创建当前可见行。 + 导入器支持小端 32 位和 64 位浮点 m/z、时间与强度数组,可不压缩或使用 zlib 压缩。Numpress、大端数组以及缺少必需数组的谱图或色谱图会使导入停止并显示错误。