diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 0eb5f9c..b562faa 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -592,7 +592,7 @@ pub(crate) fn paint_peaks( } } - let resolved = peaks.resolve(); + let resolved = peaks.resolve(app.doc.datasets[dataset].peak_reference_offset_ppm()); let selected = app.session.ui.selected_peak; for peak in &resolved { let p = Pos2::new(sx(peak.x), sy(peak.y)); diff --git a/crates/app/src/ui/canvas/peaks.rs b/crates/app/src/ui/canvas/peaks.rs index 4621c2c..4298e9c 100644 --- a/crates/app/src/ui/canvas/peaks.rs +++ b/crates/app/src/ui/canvas/peaks.rs @@ -123,7 +123,7 @@ pub(crate) fn handle_peaks( ) }); - let resolved = peaks.resolve(); + let resolved = peaks.resolve(app.doc.datasets[dataset].peak_reference_offset_ppm()); peak_context_menu(app, dataset, &resolved, &sc, hover, resp); if esc { diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index ec7f3c1..17b1a35 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -284,7 +284,7 @@ fn peaks_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bool { } }); - let resolved = peaks.resolve(); + let resolved = peaks.resolve(app.doc.datasets[di].peak_reference_offset_ppm()); ui.horizontal(|ui| { ui.label(format!("Peaks: {}", resolved.len())); if ui diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index 364e05f..221458d 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -372,7 +372,7 @@ impl DataExportSnapshot { dataset .peaks() .ok_or(DataExportError::ContentUnavailable)? - .resolve(), + .resolve(dataset.peak_reference_offset_ppm()), ), DataExportContent::Integrals => { if let Some(nmr) = dataset.as_nmr() { diff --git a/crates/core/src/state/app_impl_linefit.rs b/crates/core/src/state/app_impl_linefit.rs index 1f24b4e..7bf238a 100644 --- a/crates/core/src/state/app_impl_linefit.rs +++ b/crates/core/src/state/app_impl_linefit.rs @@ -75,7 +75,7 @@ impl PlotxApp { let mut positions: Vec = self.doc.datasets[dataset] .peaks() - .map(|p| p.resolve()) + .map(|p| p.resolve(self.doc.datasets[dataset].peak_reference_offset_ppm())) .unwrap_or_default() .iter() .map(|p| p.x) diff --git a/crates/core/src/state/app_impl_multiplet.rs b/crates/core/src/state/app_impl_multiplet.rs index 0b33692..fe53e33 100644 --- a/crates/core/src/state/app_impl_multiplet.rs +++ b/crates/core/src/state/app_impl_multiplet.rs @@ -55,7 +55,7 @@ impl PlotxApp { if peaks.is_empty() { let marks = self.doc.datasets[dataset] .peaks() - .map(|p| p.resolve()) + .map(|p| p.resolve(self.doc.datasets[dataset].peak_reference_offset_ppm())) .unwrap_or_default(); for m in marks.iter().filter(|m| m.x >= lo && m.x <= hi) { peaks.push(MultipletPeak { diff --git a/crates/core/src/state/app_impl_peaks.rs b/crates/core/src/state/app_impl_peaks.rs index e48d6c0..80fa452 100644 --- a/crates/core/src/state/app_impl_peaks.rs +++ b/crates/core/src/state/app_impl_peaks.rs @@ -231,12 +231,13 @@ impl PlotxApp { return; }; let (px, py) = trace.pick(x, snap); + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; let id = peaks.next_id(); peaks.marks.push(PeakMark { id, - x: px, + x: px - offset, y: py, origin: PeakOrigin::Manual, label: None, @@ -269,10 +270,12 @@ impl PlotxApp { return; } let tol = trace.tol(); + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); let mut added = 0; self.edit_peaks(dataset, |peaks| { peaks.column = column_id; for (x, y) in found { + let x = x - offset; if peaks.marks.iter().any(|m| (m.x - x).abs() <= tol) { continue; } @@ -315,10 +318,11 @@ impl PlotxApp { else { return; }; + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; peaks.detector.threshold = threshold; - peaks.redetect(&trace); + peaks.redetect(&trace, offset); }); let count = self .doc @@ -345,10 +349,11 @@ impl PlotxApp { else { return; }; + let offset = self.doc.datasets[dataset].peak_reference_offset_ppm(); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; peaks.detector.max_count = max_count; - peaks.redetect(&trace); + peaks.redetect(&trace, offset); }); } diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index 0088f5c..965ee28 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -414,7 +414,8 @@ fn build_nmr_spectrum(dataset: &Dataset, _ctx: &ChartContext) -> Option
Some(build_processed_1d_figure( &n.data, &n.processed, - &n.peaks.resolve(), + &n.peaks + .resolve(n.pipeline.chemical_shift_reference_offset_ppm()), )) } @@ -473,7 +474,7 @@ fn build_nmr_2d(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ fn build_table_line(dataset: &Dataset, _ctx: &ChartContext) -> Option
{ let t = dataset.as_table()?; - Some(apply_peak_labels(t.figure(), &t.peaks.resolve())) + Some(apply_peak_labels(t.figure(), &t.peaks.resolve(0.0))) } fn build_table_bar(dataset: &Dataset, ctx: &ChartContext) -> Option
{ diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 3ed0aea..98011aa 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -245,6 +245,17 @@ impl Dataset { } /// The dataset's peak set, for domains that carry one (1D spectra and tables). + /// The net chemical-shift reference translation currently applied to the + /// dataset's finished 1D trace. Peak marks store uncalibrated positions + /// (finished x minus this value), so every reader resolves through it; + /// domains without reference steps calibrate by zero. + pub fn peak_reference_offset_ppm(&self) -> f64 { + match self { + Dataset::Nmr(d) => d.pipeline.chemical_shift_reference_offset_ppm(), + _ => 0.0, + } + } + pub fn peaks(&self) -> Option<&PeakSet> { match self { Dataset::Nmr(d) => Some(&d.peaks), diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 5f6d661..2b19d82 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -514,7 +514,8 @@ impl super::Dataset { Some(crate::figures::build_processed_1d_figure( &nmr.data, &nmr.processed, - &nmr.peaks.resolve(), + &nmr.peaks + .resolve(nmr.pipeline.chemical_shift_reference_offset_ppm()), )) }, |spec| nmr.craft_field_figure(spec), @@ -527,7 +528,7 @@ impl super::Dataset { Self::Table(table) => match encoding { SeriesEncoding::Line(_) => Some(crate::figures::apply_peak_labels( table.figure(), - &table.peaks.resolve(), + &table.peaks.resolve(0.0), )), SeriesEncoding::Contour(_) | SeriesEncoding::Heatmap(_) diff --git a/crates/core/src/state/peaks.rs b/crates/core/src/state/peaks.rs index c4e0304..0af1630 100644 --- a/crates/core/src/state/peaks.rs +++ b/crates/core/src/state/peaks.rs @@ -88,6 +88,12 @@ pub enum PeakOrigin { /// One peak: a labelled `(x, y)` apex. `label` overrides the shift-formatted /// default when set. +/// +/// `x` is stored *uncalibrated*: the finished-spectrum position minus the +/// pipeline's chemical-shift reference offset at pick time. Readers add the +/// current offset back through [`PeakSet::resolve`], so marks follow the +/// spectrum when a Reference step is edited instead of freezing at the old +/// axis. `y` remains a pick-time intensity snapshot. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PeakMark { pub id: u64, @@ -186,11 +192,13 @@ impl PeakSet { /// Re-run detection at the stored threshold: replace every detected mark with a /// fresh set, leaving hand-placed marks (and any detection coincident with one) - /// untouched. - pub fn redetect(&mut self, trace: &Trace1d) { + /// untouched. `reference_offset_ppm` is the calibration currently applied + /// to the finished `trace`; detections are stored uncalibrated. + pub fn redetect(&mut self, trace: &Trace1d, reference_offset_ppm: f64) { self.marks.retain(|m| m.origin == PeakOrigin::Manual); let tol = x_tolerance(trace); for (x, y) in Self::detect_at(trace, self.detector.threshold, self.detector.max_count) { + let x = x - reference_offset_ppm; if self.marks.iter().any(|m| (m.x - x).abs() <= tol) { continue; } @@ -234,15 +242,21 @@ impl PeakSet { .collect() } - pub fn resolve(&self) -> Vec { + /// Marks in finished-spectrum coordinates: the stored uncalibrated x plus + /// the pipeline's *current* chemical-shift reference offset, so labels + /// follow a Reference edit instead of pinning the pick-time axis. + pub fn resolve(&self, reference_offset_ppm: f64) -> Vec { self.marks .iter() - .map(|m| ResolvedPeak { - x: m.x, - y: m.y, - label: m.label.clone().unwrap_or_else(|| default_label(m.x)), - origin: m.origin, - mark_id: Some(m.id), + .map(|m| { + let x = m.x + reference_offset_ppm; + ResolvedPeak { + x, + y: m.y, + label: m.label.clone().unwrap_or_else(|| default_label(x)), + origin: m.origin, + mark_id: Some(m.id), + } }) .collect() } diff --git a/crates/core/src/state/peaks_tests.rs b/crates/core/src/state/peaks_tests.rs index b79dac2..ca32ed4 100644 --- a/crates/core/src/state/peaks_tests.rs +++ b/crates/core/src/state/peaks_tests.rs @@ -57,3 +57,100 @@ fn apex_snap_routes_through_pick() { (10.0, 5.0) ); } + +/// A small frequency-domain spectrum with one clear line, loaded as a dataset. +fn frequency_app() -> crate::state::PlotxApp { + let mut points = vec![num_complex::Complex64::new(0.0, 0.0); 64]; + points[32] = num_complex::Complex64::new(100.0, 0.0); + points[31] = num_complex::Complex64::new(40.0, 0.0); + points[33] = num_complex::Complex64::new(40.0, 0.0); + let data = plotx_io::NmrData { + points, + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 640.0, + observe_freq_mhz: 100.0, + carrier_ppm: 5.0, + nucleus: "1H".into(), + source: "test".into(), + group_delay: 0.0, + }; + let mut app = crate::state::PlotxApp::new(); + app.doc.datasets.push(crate::state::Dataset::Nmr(Box::new( + crate::state::NmrDataset::load(data), + ))); + app +} + +fn apply_reference(app: &mut crate::state::PlotxApp, at_ppm: f64, target_ppm: f64) { + let nmr = app.doc.datasets[0].as_nmr_mut().expect("NMR dataset"); + let id = plotx_processing::StepId::new(nmr.next_step_id); + nmr.next_step_id += 1; + nmr.pipeline + .steps + .push(plotx_processing::ProcessingStep::new( + id, + plotx_processing::StepKind::Reference(plotx_processing::ReferenceParams { + at_ppm, + target_ppm, + }), + plotx_processing::StepSource::User, + )); + let nmr = app.doc.datasets[0].as_nmr_mut().expect("NMR dataset"); + nmr.processed = plotx_processing::reapply_output(&nmr.base, &nmr.pipeline); +} + +fn resolved_marks(app: &crate::state::PlotxApp) -> Vec { + let dataset = &app.doc.datasets[0]; + dataset + .peaks() + .expect("peak set") + .resolve(dataset.peak_reference_offset_ppm()) +} + +/// The reported defect: mark a peak, then edit the Reference step — the mark +/// must follow the recalibrated axis instead of pinning the old coordinates. +#[test] +fn marks_follow_a_later_reference_edit() { + let mut app = frequency_app(); + let apex_x = app.doc.datasets[0] + .displayed_trace(None) + .expect("1D trace") + .xs[32]; + app.add_manual_peak(0, apex_x, None, ManualPeakSnap::NearestSample); + let before = resolved_marks(&app); + assert_eq!(before.len(), 1); + assert!((before[0].x - apex_x).abs() < 1e-12); + + apply_reference(&mut app, apex_x, apex_x + 0.5); + + let after = resolved_marks(&app); + assert!((after[0].x - (apex_x + 0.5)).abs() < 1e-12); + // The mark tracks the shifted trace: the same array position now reads + // the mark's resolved x. + let shifted = app.doc.datasets[0] + .displayed_trace(None) + .expect("1D trace") + .xs[32]; + assert!((after[0].x - shifted).abs() < 1e-12); + // The default label reads the calibrated position. + assert_eq!(after[0].label, format!("{:.2}", after[0].x)); +} + +/// Picks made on an already-referenced spectrum resolve back to the clicked +/// finished coordinate (the stored value is uncalibrated). +#[test] +fn picks_on_a_referenced_spectrum_round_trip() { + let mut app = frequency_app(); + apply_reference(&mut app, 0.0, 0.75); + let apex_x = app.doc.datasets[0] + .displayed_trace(None) + .expect("1D trace") + .xs[32]; + + app.add_manual_peak(0, apex_x, None, ManualPeakSnap::NearestSample); + + let resolved = resolved_marks(&app); + assert!((resolved[0].x - apex_x).abs() < 1e-12); + let stored = &app.doc.datasets[0].peaks().expect("peak set").marks[0]; + assert!((stored.x - (apex_x - 0.75)).abs() < 1e-12); +} diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index 715ba8a..569a390 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -29,7 +29,8 @@ screen is immediately usable, and a session touches at most three things: 2. **Baseline** — baseline correction is off by default; enable the step when the baseline rolls or offsets. 3. **Reference** — add a Reference step to pin a known peak to its - chemical-shift position. + chemical-shift position. Peak marks are calibrated with the spectrum: + editing the Reference step moves existing marks along with the axis. 2D datasets get a cosine-bell apodization enabled by default. A true 2D acquisition shows two pipelines, **F2 (direct)** then **F1 (indirect)**, in the diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index 678cb09..0726f19 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -21,7 +21,8 @@ XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe 1. **相位**——自动结果不理想时,打开相位校正步骤手动调节 φ0 / φ1 并 实时预览,或切换自动算法。 2. **基线**——基线校正默认关闭;基线起伏或偏移时启用该步骤。 -3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置。 +3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置。峰标记与谱图共用 + 同一定标:修改参考步骤时,已有的峰标记会随坐标轴一起平移。 2D 数据集默认启用余弦钟形切趾。真 2D 谱会按处理顺序显示两条管线:先 **F2 (direct)**,后 **F1 (indirect)**。已经变换过的数据标记为