diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 5a0c95d..0eb5f9c 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -649,7 +649,10 @@ pub(crate) fn paint_peaks( return; } let hover_x = screen_to_x(hp.x, plot, fig.x.min, fig.x.span(), fig.x.reversed); - let (px, py) = trace.snap(hover_x); + // The preview must resolve exactly as the click would, modifier included. + let shift = painter.ctx().input(|i| i.modifiers.shift); + let snap = super::peaks::manual_peak_snap(shift, fig.x.span(), plot.width); + let (px, py) = trace.pick(hover_x, snap); let at = Pos2::new(sx(px), sy(py)); if plot_contains(plot, at) { painter.circle_stroke(at, 4.0, Stroke::new(1.5_f32, chrome.selection_active)); diff --git a/crates/app/src/ui/canvas/peaks.rs b/crates/app/src/ui/canvas/peaks.rs index 768659d..4621c2c 100644 --- a/crates/app/src/ui/canvas/peaks.rs +++ b/crates/app/src/ui/canvas/peaks.rs @@ -1,9 +1,27 @@ use super::*; -use plotx_core::state::{PeakBandDrag, PeakSet, PeakThresholdDrag, ResolvedPeak, Trace1d}; +use plotx_core::state::{ + ManualPeakSnap, PeakBandDrag, PeakSet, PeakThresholdDrag, ResolvedPeak, Trace1d, +}; const PEAK_GRAB_PX: f32 = 10.0; const LINE_GRAB_PX: f32 = 6.0; const DRAG_DEADZONE_PX: f32 = 4.0; +/// Pixel radius of the manual-pick apex search. Fixed in screen space so +/// zooming in narrows the data window along with the click precision it +/// affords, letting weak lines be picked next to strong ones. +const PEAK_SNAP_PX: f32 = 12.0; + +/// The snap for a manual pick: an apex search over ± `PEAK_SNAP_PX` at the +/// current zoom, or the nearest sample while `Shift` is held (free placement +/// for shoulders the apex search refuses to land on). +pub(crate) fn manual_peak_snap(shift: bool, x_span: f64, plot_width: f32) -> ManualPeakSnap { + if shift { + return ManualPeakSnap::NearestSample; + } + ManualPeakSnap::Apex { + half_width: x_span.abs() / f64::from(plot_width.max(1.0)) * f64::from(PEAK_SNAP_PX), + } +} fn peak_hit(resolved: &[ResolvedPeak], sc: &Screen, p: Pos2) -> Option { let mut best: Option<(u64, f32)> = None; @@ -93,7 +111,7 @@ pub(crate) fn handle_peaks( yrev: fig.y.reversed, }; - let (hover, pressed, down, released, del, esc) = ui.input(|i| { + let (hover, pressed, down, released, del, esc, shift) = ui.input(|i| { ( i.pointer.hover_pos(), i.pointer.primary_pressed(), @@ -101,6 +119,7 @@ pub(crate) fn handle_peaks( i.pointer.primary_released(), i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape), + i.modifiers.shift, ) }); @@ -146,7 +165,7 @@ pub(crate) fn handle_peaks( drag.current_x = sc.to_x(p.x.clamp(plot.left, plot.right())); } if released || !down { - finish_band_drag(app, dataset, &sc, column); + finish_band_drag(app, dataset, &sc, column, shift); } return; } @@ -212,18 +231,20 @@ fn finish_threshold_drag( } /// A band wider than the click dead-zone picks every peak inside it; a narrower one -/// is a plain click that places a single snapped peak. +/// is a plain click that places a single snapped peak (`Shift` skips the snap). fn finish_band_drag( app: &mut PlotxApp, dataset: usize, sc: &Screen, column: Option, + shift: bool, ) { let Interaction::PeakBand(drag) = app.take_interaction() else { return; }; if (sc.x(drag.anchor_x) - sc.x(drag.current_x)).abs() < DRAG_DEADZONE_PX { - app.add_manual_peak(dataset, drag.anchor_x, column); + let snap = manual_peak_snap(shift, sc.xspan, sc.plot.width); + app.add_manual_peak(dataset, drag.anchor_x, column, snap); } else { app.add_peaks_in_range(dataset, drag.anchor_x, drag.current_x, column); } diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index 07c71ff..2713a6f 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -431,7 +431,12 @@ fn transient_state_never_changes_ribbon_group_visibility() { .active_plot_object_id() .expect("NMR plot object"); let range = app.analysis_range_for(0).expect("visible NMR range"); - app.add_manual_peak(0, (range.min + range.max) / 2.0, None); + app.add_manual_peak( + 0, + (range.min + range.max) / 2.0, + None, + plotx_core::state::ManualPeakSnap::NearestSample, + ); app.session.ui.selection = Selection::single(object); let expected = ribbon_groups(&app); diff --git a/crates/core/src/state/app_impl_peaks.rs b/crates/core/src/state/app_impl_peaks.rs index 67921ac..e48d6c0 100644 --- a/crates/core/src/state/app_impl_peaks.rs +++ b/crates/core/src/state/app_impl_peaks.rs @@ -210,13 +210,15 @@ impl PlotxApp { self.execute_action(Action::set_peaks(dataset_id, before, after)); } - /// Place a hand-picked peak, snapping the clicked `x` to the nearest local - /// maximum of the displayed trace. + /// Place a hand-picked peak, resolving the clicked `x` per `snap` — an + /// apex search within a zoom-derived window, or the nearest sample for the + /// modifier-click free placement. pub fn add_manual_peak( &mut self, dataset: usize, x: f64, column: Option, + snap: ManualPeakSnap, ) { let column_id = table_peak_column(&self.doc.datasets, dataset, column); let Some(trace) = self @@ -228,7 +230,7 @@ impl PlotxApp { self.session.status = "Peaks are available for 1D traces only.".into(); return; }; - let (px, py) = trace.snap(x); + let (px, py) = trace.pick(x, snap); self.edit_peaks(dataset, |peaks| { peaks.column = column_id; let id = peaks.next_id(); diff --git a/crates/core/src/state/peaks.rs b/crates/core/src/state/peaks.rs index 7289779..c4e0304 100644 --- a/crates/core/src/state/peaks.rs +++ b/crates/core/src/state/peaks.rs @@ -20,15 +20,29 @@ impl Trace1d { x_tolerance(self) } - /// Snap `x` to the tallest local maximum within a small window, so a click near - /// a peak lands on its apex. Falls back to the nearest sample. - pub fn snap(&self, x: f64) -> (f64, f64) { + /// Resolve a manual pick at `x` per `snap`. See [`ManualPeakSnap`]. + pub fn pick(&self, x: f64, snap: ManualPeakSnap) -> (f64, f64) { + match snap { + ManualPeakSnap::Apex { half_width } => self.snap_within(x, half_width), + ManualPeakSnap::NearestSample => self.nearest_sample(x), + } + } + + /// Snap `x` to the tallest local maximum within ± `half_width` (data + /// units), so a click near a peak lands on its apex. Falls back to the + /// nearest sample when the window holds no local maximum. + /// + /// Callers derive `half_width` from screen pixels: zooming in narrows the + /// data window in step with the click precision the zoom affords, which is + /// what lets a weak line be picked next to a strong one. Tallest-in-window + /// (rather than nearest local maximum) keeps zoomed-out clicks landing on + /// real peaks instead of the noise wiggle closest to the pointer. + pub fn snap_within(&self, x: f64, half_width: f64) -> (f64, f64) { let n = self.xs.len(); - let window = x_tolerance(self) * 15.0; let mut best: Option<(f64, f64)> = None; for i in 1..n.saturating_sub(1) { let px = self.xs[i]; - if (px - x).abs() > window { + if (px - x).abs() > half_width { continue; } let v = self.ys[i]; @@ -36,15 +50,32 @@ impl Trace1d { best = Some((px, v)); } } - best.unwrap_or_else(|| { - self.xs - .iter() - .zip(&self.ys) - .min_by(|a, b| (a.0 - x).abs().partial_cmp(&(b.0 - x).abs()).unwrap()) - .map(|(&px, &py)| (px, py)) - .unwrap_or((x, 0.0)) - }) + best.unwrap_or_else(|| self.nearest_sample(x)) } + + /// The sample closest to `x`, with no apex search. + pub fn nearest_sample(&self, x: f64) -> (f64, f64) { + self.xs + .iter() + .zip(&self.ys) + .filter(|(px, py)| px.is_finite() && py.is_finite()) + .min_by(|a, b| (a.0 - x).abs().partial_cmp(&(b.0 - x).abs()).unwrap()) + .map(|(&px, &py)| (px, py)) + .unwrap_or((x, 0.0)) + } +} + +/// How a manual pick chooses its apex from the clicked x position. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ManualPeakSnap { + /// Snap to the tallest local maximum within ± `half_width` data units. + /// UI callers convert a fixed pixel radius at the current zoom, so the + /// search window narrows as the user zooms in. + Apex { half_width: f64 }, + /// No apex search: place on the sample nearest the click. The escape + /// hatch (modifier-click) for shoulders and signals snapping refuses to + /// resolve. + NearestSample, } /// Provenance only — both kinds are ordinary, individually editable marks. @@ -230,6 +261,10 @@ fn baseline(ys: &[f64]) -> f64 { finite[finite.len() / 2] } +#[cfg(test)] +#[path = "peaks_tests.rs"] +mod tests; + fn x_tolerance(trace: &Trace1d) -> f64 { let (lo, hi) = trace .xs diff --git a/crates/core/src/state/peaks_tests.rs b/crates/core/src/state/peaks_tests.rs new file mode 100644 index 0000000..b79dac2 --- /dev/null +++ b/crates/core/src/state/peaks_tests.rs @@ -0,0 +1,59 @@ +use super::*; + +fn trace(ys: Vec) -> Trace1d { + Trace1d { + xs: (0..ys.len()).map(|i| i as f64).collect(), + ys, + x_reversed: false, + } +} + +/// A strong apex at x = 5 and a weak one at x = 10. +fn two_peaks() -> Trace1d { + let mut ys = vec![0.0; 16]; + ys[4] = 40.0; + ys[5] = 100.0; + ys[6] = 40.0; + ys[9] = 2.0; + ys[10] = 5.0; + ys[11] = 2.0; + trace(ys) +} + +#[test] +fn a_narrow_window_picks_the_weak_apex_beside_a_strong_one() { + let trace = two_peaks(); + // Clicking at the weak line while zoomed in: the pixel-derived window no + // longer reaches the strong apex. + assert_eq!(trace.snap_within(10.4, 2.0), (10.0, 5.0)); +} + +#[test] +fn a_wide_window_still_lands_on_the_tallest_apex() { + let trace = two_peaks(); + // Zoomed out, the same click may sit several samples off; the tallest + // apex in reach is the intended target, not the nearest noise wiggle. + assert_eq!(trace.snap_within(7.0, 6.0), (5.0, 100.0)); +} + +#[test] +fn free_placement_takes_the_nearest_sample_without_an_apex_search() { + let trace = two_peaks(); + assert_eq!(trace.pick(9.4, ManualPeakSnap::NearestSample), (9.0, 2.0)); +} + +#[test] +fn an_empty_window_falls_back_to_the_nearest_sample() { + let trace = two_peaks(); + // No local maximum within reach of x = 14. + assert_eq!(trace.snap_within(14.2, 1.0), (14.0, 0.0)); +} + +#[test] +fn apex_snap_routes_through_pick() { + let trace = two_peaks(); + assert_eq!( + trace.pick(10.4, ManualPeakSnap::Apex { half_width: 2.0 }), + (10.0, 5.0) + ); +} diff --git a/docs/src/content/docs/guides/peaks-and-regions.md b/docs/src/content/docs/guides/peaks-and-regions.md index 872b7bd..9e873cf 100644 --- a/docs/src/content/docs/guides/peaks-and-regions.md +++ b/docs/src/content/docs/guides/peaks-and-regions.md @@ -11,6 +11,13 @@ with [Choosing an analysis tool](/guides/choosing-a-tool/). The **Peaks** tool detects peaks by prominence. Drag the threshold line on the plot to adjust detection — peaks are recomputed when you release it. Detected peaks can be edited, added, and removed by hand. + +A click places one peak, snapped to the tallest apex within a small radius +around the pointer. The radius is fixed on screen, so zooming in narrows it — +zoom in to pick a weak line sitting next to a strong one. Hold `Shift` while +clicking to skip the snap entirely and place the mark on the nearest data +point (useful for shoulders). The hover preview shows exactly where the mark +will land. Dragging across a range picks every prominent peak inside it. Choose **Export Data…** and **Peak table** to save or copy the current peak list. ## 1D NMR integrals diff --git a/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md b/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md index 1fcfc89..93c3485 100644 --- a/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md +++ b/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md @@ -10,6 +10,11 @@ description: 峰拾取与交互式区域分析。 **峰**工具按显著度(prominence)检测峰。在图上拖动阈值线即可调整检测—— 松开时重新计算峰。检测到的峰也可以手动编辑、添加和删除。 + +单击放置一个峰,标记会吸附到指针附近小半径内最高的峰顶。该半径以屏幕像素 +为准,放大视图时会随之收窄——想选中紧邻强峰的弱峰,放大后再点击即可。按住 +`Shift` 单击可完全跳过吸附,把标记放在最近的数据点上(适合肩峰)。悬停预览 +会显示标记将要落到的位置。横向拖选一段范围则拾取其中所有显著的峰。 选择**导出数据…**和**峰表**可保存或复制当前峰列表。 ## 1D NMR 积分