Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions rust/timsquery/src/serde/chromatogram_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ pub struct ChromatogramOutput {
pub fragment_intensities: Vec<Vec<f32>>,
pub fragment_labels: Vec<String>,
pub retention_time_results_seconds: Vec<f32>,
/// Library (predicted) fragment intensities aligned with `fragment_labels`.
/// Used for mirror plot visualization to compare observed vs predicted spectra.
/// Populated from library sidecar when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub library_fragment_intensities: Option<Vec<f32>>,
}

impl ChromatogramOutput {
Expand Down Expand Up @@ -121,6 +126,7 @@ impl ChromatogramOutput {
.iter()
.map(|&x| x as f32 / 1000.0)
.collect(),
library_fragment_intensities: None,
})
}
}
43 changes: 43 additions & 0 deletions rust/timsquery/src/serde/diann_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::ion::{
IonParsingError,
};
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use tinyvec::tiny_vec;
use tracing::{
Expand Down Expand Up @@ -211,6 +212,48 @@ pub fn read_library_file<T: AsRef<Path>>(
}

info!("Parsed {} elution groups", elution_groups.len());
// Write a small sidecar JSON with fragment label + relative intensity per elution group
#[derive(Serialize)]
struct SidecarEntry {
id: u64,
modified_peptide: String,
precursor_charge: u8,
labels: Vec<String>,
intensities: Vec<f32>,
}

let sidecar_path = file.as_ref().with_extension("library_extras.json");
let mut sidecar = Vec::with_capacity(elution_groups.len());
for (eg, extras) in &elution_groups {
let labels: Vec<String> = extras
.relative_intensities
.iter()
.map(|(ia, _)| format!("{}", ia))
.collect();
let intensities: Vec<f32> = extras
.relative_intensities
.iter()
.map(|(_, v)| *v)
.collect();
sidecar.push(SidecarEntry {
id: eg.id(),
modified_peptide: extras.modified_peptide.clone(),
precursor_charge: eg.precursor_charge(),
labels,
intensities,
});
}

if let Ok(json) = serde_json::to_string_pretty(&sidecar) {
if let Err(e) = std::fs::write(&sidecar_path, json) {
warn!("Failed to write DIA-NN sidecar {}: {:?}", sidecar_path.display(), e);
} else {
info!("Wrote DIA-NN extras sidecar to {}", sidecar_path.display());
}
} else {
warn!("Failed to serialize DIA-NN extras sidecar");
}

Ok(elution_groups)
}

Expand Down
20 changes: 19 additions & 1 deletion rust/timsquery_viewer/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,25 @@ impl ViewerApp {
&self.data.tolerance,
&self.data.smoothing,
) {
Ok(chrom) => {
Ok(mut chrom) => {
// If we have library extras sidecar, attach library fragment intensities
if let Some(extras_map) = &elution_groups.extras {
if let Some(extras) = extras_map.get(&chrom.id) {
let lib_ints: Vec<f32> = chrom
.fragment_labels
.iter()
.map(|lbl| {
extras.fragment_intensities
.iter()
.find(|(l, _)| l == lbl)
.map(|(_, v)| *v)
.unwrap_or(0.0)
})
.collect();
chrom.library_fragment_intensities = Some(lib_ints);
}
}

let chrom_lines = ChromatogramLines::from_chromatogram(&chrom);
self.computed.chromatogram_x_bounds =
Some(chrom_lines.rt_seconds_range);
Expand Down
10 changes: 10 additions & 0 deletions rust/timsquery_viewer/src/chromatogram_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub fn extract_ms2_spectrum_from_chromatogram(
let mut mz_values = Vec::new();
let mut intensities = Vec::new();
let mut labels = Vec::new();
let mut lib_intensities: Vec<f32> = Vec::new();

for (frag_idx, (&mz, intensity_vec)) in chromatogram
.fragment_mzs
Expand All @@ -119,6 +120,10 @@ pub fn extract_ms2_spectrum_from_chromatogram(
.cloned()
.unwrap_or_else(|| format!("Fragment {}", frag_idx + 1)),
);
// library_fragment_intensities (if present) are aligned with fragment_labels
if let Some(lib) = &chromatogram.library_fragment_intensities {
lib_intensities.push(*lib.get(frag_idx).unwrap_or(&0.0));
}
}
}

Expand All @@ -133,6 +138,11 @@ pub fn extract_ms2_spectrum_from_chromatogram(
intensities,
rt_seconds: actual_rt,
fragment_labels: labels,
library_fragment_intensities: if lib_intensities.is_empty() {
None
} else {
Some(lib_intensities)
},
})
}

Expand Down
44 changes: 43 additions & 1 deletion rust/timsquery_viewer/src/domain/file_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use timsquery::models::tolerance::Tolerance;
use timsquery::serde::load_index_caching;
use timsrust::MSLevel;
use tracing::info;
use std::collections::HashMap;

use crate::error::ViewerError;
use crate::file_loader::ElutionGroupData;
Expand All @@ -32,7 +33,48 @@ impl FileService {
res.len(),
path.display()
);
Ok(ElutionGroupData { inner: res })

// Attempt to load library extras sidecar if present. Sidecar shape is Vec<{id, modified_peptide, precursor_charge, labels, intensities}>
#[derive(serde::Deserialize)]
struct SidecarEntry {
id: u64,
#[serde(default)]
modified_peptide: String,
#[serde(default)]
precursor_charge: u8,
labels: Vec<String>,
intensities: Vec<f32>,
}

use crate::file_loader::LibraryExtras;
let sidecar_path = path.with_extension("library_extras.json");
let extras_map: Option<HashMap<u64, LibraryExtras>> = match std::fs::read_to_string(&sidecar_path) {
Ok(s) => match serde_json::from_str::<Vec<SidecarEntry>>(&s) {
Ok(entries) => {
let mut map = HashMap::new();
for e in entries.into_iter() {
let pairs = e
.labels
.into_iter()
.zip(e.intensities.into_iter())
.collect::<Vec<(String, f32)>>();
map.insert(e.id, LibraryExtras {
modified_peptide: e.modified_peptide,
precursor_charge: e.precursor_charge,
fragment_intensities: pairs,
});
}
Some(map)
}
Err(err) => {
tracing::warn!("Failed to parse sidecar {}: {:?}", sidecar_path.display(), err);
None
}
},
Err(_) => None,
};

Ok(ElutionGroupData { inner: res, extras: extras_map })
}

/// Load and index raw timsTOF data
Expand Down
37 changes: 35 additions & 2 deletions rust/timsquery_viewer/src/file_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::sync::Arc;
use timscentroid::IndexedTimstofPeaks;
use timsquery::models::tolerance::Tolerance;
use timsquery::serde::ElutionGroupCollection;
use std::collections::HashMap;

use crate::domain::FileService;
use crate::error::ViewerError;
Expand Down Expand Up @@ -73,9 +74,23 @@ impl FileLoader {
}
}

/// Library extras metadata from sidecar file
#[derive(Debug, Clone)]
pub struct LibraryExtras {
pub modified_peptide: String,
pub precursor_charge: u8,
pub fragment_intensities: Vec<(String, f32)>,
}

/// Wrapper around elution group collection with optional library metadata
#[derive(Debug)]
pub struct ElutionGroupData {
/// The parsed elution groups
pub inner: ElutionGroupCollection,
/// Library extras from sidecar file.
/// Maps elution group ID → LibraryExtras (peptide, charge, fragment intensities).
/// Used for mirror plot visualization and precursor table display.
pub extras: Option<HashMap<u64, LibraryExtras>>,
}

impl ElutionGroupData {
Expand All @@ -88,21 +103,39 @@ impl ElutionGroupData {
self.len() == 0
}

/// Returns indices of all elution groups matching the ID filter.
/// Returns indices of all elution groups matching the filter.
///
/// If filter is an empty string, returns ALL indices (no filtering applied).
/// This allows seamless toggling between filtered and unfiltered views.
/// Matches against ID and peptide sequence (if extras are available).
pub fn matching_indices_for_id_filter(&self, filter: &str) -> Vec<usize> {
if filter.is_empty() {
return (0..self.len()).collect();
}

let filter_lower = filter.to_lowercase();
let extras_ref = self.extras.as_ref();

macro_rules! get_ids {
($self:expr) => {
$self
.iter()
.enumerate()
.filter(|(_, eg)| eg.id().to_string().contains(filter))
.filter(|(_, eg)| {
// Match by ID
if eg.id().to_string().contains(filter) {
return true;
}
// Match by peptide sequence (case-insensitive)
if let Some(extras_map) = extras_ref {
if let Some(ext) = extras_map.get(&eg.id()) {
if ext.modified_peptide.to_lowercase().contains(&filter_lower) {
return true;
}
}
}
false
})
.map(|(idx, _)| idx)
.collect()
};
Expand Down
4 changes: 4 additions & 0 deletions rust/timsquery_viewer/src/plot_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ pub struct MS2Spectrum {
pub intensities: Vec<f32>,
pub rt_seconds: f64,
pub fragment_labels: Vec<String>,
/// Library (predicted) fragment intensities aligned with `fragment_labels`.
/// When present, the spectrum panel renders a mirror plot with observed peaks
/// above the x-axis and library peaks below (both max-normalized).
pub library_fragment_intensities: Option<Vec<f32>>,
}

/// Renders a chromatogram plot using egui_plot with custom zoom/pan controls
Expand Down
48 changes: 45 additions & 3 deletions rust/timsquery_viewer/src/ui/components/precursor_table.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,43 @@
use eframe::egui;
use std::collections::HashMap;
use timsquery::KeyLike;
use timsquery::models::elution_group::TimsElutionGroup;
use crate::file_loader::LibraryExtras;

pub fn render_precursor_table_filtered<T: KeyLike>(
ui: &mut egui::Ui,
filtered_eg_idxs: &[usize],
reference_eg_slice: &[TimsElutionGroup<T>],
selected_index: &mut Option<usize>,
extras: Option<&HashMap<u64, LibraryExtras>>,
) {
use egui_extras::{
Column,
TableBuilder,
};

TableBuilder::new(ui)
// Check if extras available to decide whether to show peptide/charge columns
let has_extras = extras.is_some();

let mut builder = TableBuilder::new(ui)
.striped(true)
.resizable(true)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto().at_least(60.0)) // ID
.column(Column::auto().at_least(80.0)) // RT
.column(Column::auto().at_least(80.0)) // Mobility
.column(Column::auto().at_least(80.0)); // Mobility

if has_extras {
builder = builder
.column(Column::auto().at_least(200.0)) // Peptide
.column(Column::auto().at_least(50.0)); // Charge
}

builder = builder
.column(Column::auto().at_least(120.0)) // Precursor m/z
.column(Column::auto().at_least(100.0)) // Fragment count
.column(Column::auto().at_least(100.0)); // Fragment count

builder
.header(20.0, |mut header| {
header.col(|ui| {
ui.strong("ID");
Expand All @@ -32,6 +48,14 @@ pub fn render_precursor_table_filtered<T: KeyLike>(
header.col(|ui| {
ui.strong("Mobility");
});
if has_extras {
header.col(|ui| {
ui.strong("Peptide");
});
header.col(|ui| {
ui.strong("Z");
});
}
header.col(|ui| {
ui.strong("Precursor m/z");
});
Expand Down Expand Up @@ -66,6 +90,24 @@ pub fn render_precursor_table_filtered<T: KeyLike>(
ui.label(text);
});

if let Some(extras_map) = extras {
let eg_extras = extras_map.get(&eg.id());
row.col(|ui| {
if let Some(ext) = eg_extras {
ui.label(&ext.modified_peptide);
} else {
ui.label("-");
}
});
row.col(|ui| {
if let Some(ext) = eg_extras {
ui.label(format!("+{}", ext.precursor_charge));
} else {
ui.label("-");
}
});
}

row.col(|ui| {
let lims = eg.get_precursor_mz_limits();
let display_text = format!("{:.4} - {:.4}", lims.0, lims.1);
Expand Down
4 changes: 3 additions & 1 deletion rust/timsquery_viewer/src/ui/panels/precursor_table_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl TablePanel {

fn render_filter_ui(&self, ui: &mut egui::Ui, ctx: &mut PanelContext) {
ui.horizontal(|ui| {
ui.label("Filter by ID:");
ui.label("Filter:");
ui.text_edit_singleline(&mut ctx.ui.table_filter);
if ui.button("Clear").clicked() {
ctx.ui.table_filter.clear();
Expand All @@ -51,6 +51,7 @@ impl TablePanel {
commands: &mut crate::ui::CommandSink,
) {
let old_selection = *selected_index;
let extras_ref = elution_groups.extras.as_ref();

egui::ScrollArea::vertical()
.auto_shrink([false; 2])
Expand All @@ -62,6 +63,7 @@ impl TablePanel {
filtered_indices,
$egs,
selected_index,
extras_ref,
)
};
}
Expand Down
Loading