From 26f085dc63216835d9967f07daa905b15db418fb Mon Sep 17 00:00:00 2001 From: "J. Sebastian Paez" Date: Fri, 20 Feb 2026 11:25:22 -0800 Subject: [PATCH 1/3] feat: add mobility plot --- Cargo.lock | 1 + .../src/models/aggregators/spectrum_agg.rs | 1 + rust/timsquery/src/models/indexed_data.rs | 52 ++++ rust/timsquery/src/models/tolerance.rs | 17 ++ rust/timsquery/src/traits/key_like.rs | 10 +- rust/timsquery_viewer/Cargo.toml | 1 + rust/timsquery_viewer/src/app.rs | 238 +++++++++++++++++- rust/timsquery_viewer/src/computed_state.rs | 149 ++++++++++- rust/timsquery_viewer/src/plot_renderer.rs | 18 +- .../src/ui/panels/config_panel.rs | 100 ++++++++ .../src/ui/panels/mobility_panel.rs | 104 ++++++++ rust/timsquery_viewer/src/ui/panels/mod.rs | 20 +- .../src/ui/panels/spectrum_display_panel.rs | 16 +- 13 files changed, 671 insertions(+), 56 deletions(-) create mode 100644 rust/timsquery_viewer/src/ui/panels/mobility_panel.rs diff --git a/Cargo.lock b/Cargo.lock index cd5cb4c..da539bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5976,6 +5976,7 @@ dependencies = [ "egui_dock", "egui_extras", "egui_plot", + "image", "mimalloc", "rayon", "rfd", 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/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_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 dd611ab..522bb82 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,6 +25,9 @@ use crate::file_loader::{ use crate::plot_renderer::AutoZoomMode; use crate::ui::panels::{ ConfigPanel, + MobilityPanel, + ScreenshotAction, + ScreenshotState, SpectrumPanel, TablePanel, }; @@ -43,6 +47,7 @@ type ChromatogramComputeResult = Result< crate::chromatogram_processor::ChromatogramCollector, timsseek::ExpectedIntensities, u64, // selected_idx as cache key + timsquery::models::elution_group::TimsElutionGroup, ), String, >; @@ -82,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, @@ -90,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 { @@ -228,11 +246,17 @@ 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, } impl ViewerApp { @@ -266,6 +290,17 @@ 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); + } + } + return Self { file_loader: state .file_loader @@ -277,12 +312,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 { @@ -290,7 +328,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, @@ -298,6 +336,7 @@ impl ViewerApp { Pane::FragmentPlot, Pane::MS2Spectrum, Pane::ScoresPlot, + Pane::Mobility, ]; let dock_state = DockState::new(tabs); @@ -312,8 +351,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, } } @@ -365,8 +407,8 @@ impl ViewerApp { }); } - /// Generate MS2 spectrum if user clicked on a new RT position - fn generate_ms2_spectrum_if_needed(&mut self) { + /// 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; }; @@ -375,6 +417,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) { @@ -539,7 +586,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 @@ -554,7 +601,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 @@ -566,6 +613,7 @@ impl ViewerApp { output, collector, expected_intensities, + elution_group, }; self.computed.complete_chromatogram_computation( @@ -1120,6 +1168,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 { @@ -1158,16 +1336,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); @@ -1180,6 +1356,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| { @@ -1187,6 +1366,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); } } @@ -1198,6 +1380,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> { @@ -1230,6 +1415,28 @@ 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; + } + } } } @@ -1244,6 +1451,7 @@ 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(), } } @@ -1393,6 +1601,10 @@ impl<'a> TabViewer for AppTabViewer<'a> { } } } + 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 b9de74c..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,6 +46,7 @@ pub(crate) struct ChromatogramComputationResult { pub output: ChromatogramOutput, pub collector: ChromatogramCollector, pub expected_intensities: ExpectedIntensities, + pub elution_group: TimsElutionGroup, } #[derive(Debug)] @@ -57,6 +64,7 @@ struct ChromatogramResult { output: ChromatogramOutput, scoring: Option, expected_intensities: ExpectedIntensities, + elution_group: TimsElutionGroup, } #[derive(Debug, Default)] @@ -66,6 +74,38 @@ struct Ms2State { 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, @@ -101,6 +141,7 @@ pub struct ComputedState { pub auto_zoom_frame_counter: u8, reference_lines: HashMap, ms2: Ms2State, + mobility: MobilityState, /// Reusable allocation — not reset between elution groups. scratch: ScratchBuffers, } @@ -110,6 +151,10 @@ impl ComputedState { 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) } @@ -216,6 +261,7 @@ impl ComputedState { pub fn clear(&mut self) { self.result = None; self.ms2 = Ms2State::default(); + self.mobility = MobilityState::default(); self.auto_zoom_frame_counter = 0; self.reference_lines.clear(); self.chromatogram_state = ChromatogramState::None; @@ -452,9 +498,11 @@ impl ComputedState { 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 { @@ -472,4 +520,103 @@ impl ComputedState { 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/plot_renderer.rs b/rust/timsquery_viewer/src/plot_renderer.rs index 35e34b0..cbe272c 100644 --- a/rust/timsquery_viewer/src/plot_renderer.rs +++ b/rust/timsquery_viewer/src/plot_renderer.rs @@ -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); ChromatogramLine { data: LineData { points, @@ -731,19 +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"]; 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..d0f6ac4 --- /dev/null +++ b/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs @@ -0,0 +1,104 @@ +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 = ion_color(&ion.label); + + plot_ui.points( + Points::new(&ion.label, PlotPoints::new(vec![[ion.mean_mz, ion.mean_mobility]])) + .color(color) + .radius(radius), + ); + plot_ui.text( + egui_plot::Text::new( + format!("{}_label", ion.label), + egui_plot::PlotPoint::new(ion.mean_mz, ion.mean_mobility), + &ion.label, + ) + .color(color), + ); + } + }); + } +} diff --git a/rust/timsquery_viewer/src/ui/panels/mod.rs b/rust/timsquery_viewer/src/ui/panels/mod.rs index e5f6846..da81f57 100644 --- a/rust/timsquery_viewer/src/ui/panels/mod.rs +++ b/rust/timsquery_viewer/src/ui/panels/mod.rs @@ -1,11 +1,21 @@ -// 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) -> 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, 80, 80), // Red + Some('P') => Color32::from_rgb(255, 200, 50), // Yellow (Precursor) + _ => Color32::from_rgb(50, 205, 50), // Green (Lime) + } +} 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 18601ce..58c5294 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,15 +22,6 @@ 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, @@ -73,7 +61,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); let y_value = (intensity / norm_factor) as f64; let points = PlotPoints::new(vec![[mz, 0.0], [mz, y_value]]); From f43128980beca7992c931f19897b2119a531c53a Mon Sep 17 00:00:00 2001 From: "J. Sebastian Paez" Date: Fri, 20 Feb 2026 11:35:59 -0800 Subject: [PATCH 2/3] chore: bump version --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- pyproject.toml | 4 ++-- python/speclib_builder/pyproject.toml | 2 +- python/timsseek_rescore/pyproject.toml | 2 +- python/timsseek_rts_receiver/pyproject.toml | 2 +- uv.lock | 8 ++++---- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da539bd..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", @@ -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", @@ -6011,7 +6011,7 @@ dependencies = [ [[package]] name = "timsseek" -version = "0.25.0" +version = "0.26.0" dependencies = [ "calibrt", "forust-ml", @@ -6035,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/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" }, From f256697859b1b593f20a0e0a7f46a6d677dac6a8 Mon Sep 17 00:00:00 2001 From: "J. Sebastian Paez" Date: Fri, 20 Feb 2026 16:05:49 -0800 Subject: [PATCH 3/3] feat(viewer): color mode swap and colour change --- rust/timsquery_viewer/src/app.rs | 36 +++++++++++++++++++ rust/timsquery_viewer/src/plot_renderer.rs | 3 +- .../src/ui/panels/mobility_panel.rs | 34 +++++++++++------- rust/timsquery_viewer/src/ui/panels/mod.rs | 17 +++++---- .../src/ui/panels/spectrum_display_panel.rs | 3 +- 5 files changed, 70 insertions(+), 23 deletions(-) diff --git a/rust/timsquery_viewer/src/app.rs b/rust/timsquery_viewer/src/app.rs index 522bb82..0658ad0 100644 --- a/rust/timsquery_viewer/src/app.rs +++ b/rust/timsquery_viewer/src/app.rs @@ -120,6 +120,10 @@ struct PersistentState { dock_state: DockState, } +fn default_true() -> bool { + true +} + /// State of indexed raw data loading #[derive(Debug, Default)] pub enum IndexedDataState { @@ -223,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 @@ -259,6 +266,14 @@ pub struct ViewerApp { 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 { pub fn new(cc: &eframe::CreationContext<'_>, args: &Cli) -> Self { // Try to load previous state @@ -301,6 +316,8 @@ impl ViewerApp { } } + apply_theme(&cc.egui_ctx, state.ui_state.dark_mode); + return Self { file_loader: state .file_loader @@ -1316,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, @@ -1437,6 +1455,24 @@ impl<'a> AppTabViewer<'a> { *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); + } + }); } } diff --git a/rust/timsquery_viewer/src/plot_renderer.rs b/rust/timsquery_viewer/src/plot_renderer.rs index cbe272c..129670a 100644 --- a/rust/timsquery_viewer/src/plot_renderer.rs +++ b/rust/timsquery_viewer/src/plot_renderer.rs @@ -327,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 = crate::ui::panels::ion_color(label); + let color = crate::ui::panels::ion_color(label, Some(255)); ChromatogramLine { data: LineData { points, @@ -730,7 +730,6 @@ fn get_precursor_color(index: usize) -> egui::Color32 { 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/mobility_panel.rs b/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs index d0f6ac4..17842e4 100644 --- a/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs +++ b/rust/timsquery_viewer/src/ui/panels/mobility_panel.rs @@ -1,6 +1,11 @@ use eframe::egui; use egui::Color32; -use egui_plot::{HLine, Plot, PlotPoints, Points}; +use egui_plot::{ + HLine, + Plot, + PlotPoints, + Points, +}; use super::ion_color; use crate::computed_state::MobilityData; @@ -53,8 +58,10 @@ impl MobilityPanel { 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))); + 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; @@ -83,21 +90,22 @@ impl MobilityPanel { 0.0 }; let radius = MIN_RADIUS + frac.sqrt() * (MAX_RADIUS - MIN_RADIUS); - let color = ion_color(&ion.label); + 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) - .radius(radius), - ); - plot_ui.text( - egui_plot::Text::new( - format!("{}_label", ion.label), - egui_plot::PlotPoint::new(ion.mean_mz, ion.mean_mobility), + Points::new( &ion.label, + PlotPoints::new(vec![[ion.mean_mz, ion.mean_mobility]]), ) - .color(color), + .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 da81f57..14528b9 100644 --- a/rust/timsquery_viewer/src/ui/panels/mod.rs +++ b/rust/timsquery_viewer/src/ui/panels/mod.rs @@ -3,7 +3,11 @@ pub mod mobility_panel; pub mod precursor_table_panel; pub mod spectrum_display_panel; -pub use config_panel::{ConfigPanel, ScreenshotAction, ScreenshotState}; +pub use config_panel::{ + ConfigPanel, + ScreenshotAction, + ScreenshotState, +}; pub use mobility_panel::MobilityPanel; pub use precursor_table_panel::TablePanel; pub use spectrum_display_panel::SpectrumPanel; @@ -11,11 +15,12 @@ pub use spectrum_display_panel::SpectrumPanel; use egui::Color32; /// Shared color mapping for ion labels. -pub(crate) fn ion_color(label: &str) -> Color32 { +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_rgb(100, 149, 237), // Blue (Cornflower) - Some('y') | Some('Y') => Color32::from_rgb(220, 80, 80), // Red - Some('P') => Color32::from_rgb(255, 200, 50), // Yellow (Precursor) - _ => Color32::from_rgb(50, 205, 50), // Green (Lime) + 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 58c5294..abc5485 100644 --- a/rust/timsquery_viewer/src/ui/panels/spectrum_display_panel.rs +++ b/rust/timsquery_viewer/src/ui/panels/spectrum_display_panel.rs @@ -22,7 +22,6 @@ impl SpectrumPanel { "MS2" } - pub fn render( &mut self, ui: &mut egui::Ui, @@ -61,7 +60,7 @@ impl SpectrumPanel { spec.mz_values.iter().zip(&spec.intensities).enumerate() { let label_str = &spec.fragment_labels[idx]; - let color = super::ion_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]]);