Skip to content
Merged
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
5 changes: 4 additions & 1 deletion crates/app/src/ui/canvas/painting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
31 changes: 26 additions & 5 deletions crates/app/src/ui/canvas/peaks.rs
Original file line number Diff line number Diff line change
@@ -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<u64> {
let mut best: Option<(u64, f32)> = None;
Expand Down Expand Up @@ -93,14 +111,15 @@ 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(),
i.pointer.primary_down(),
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,
)
});

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<plotx_core::data::ColumnId>,
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);
}
Expand Down
7 changes: 6 additions & 1 deletion crates/app/src/ui/commands_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 5 additions & 3 deletions crates/core/src/state/app_impl_peaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<plotx_data::ColumnId>,
snap: ManualPeakSnap,
) {
let column_id = table_peak_column(&self.doc.datasets, dataset, column);
let Some(trace) = self
Expand All @@ -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();
Expand Down
61 changes: 48 additions & 13 deletions crates/core/src/state/peaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,62 @@ 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];
if v >= self.ys[i - 1] && v > self.ys[i + 1] && best.is_none_or(|(_, bv)| v > bv) {
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.
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions crates/core/src/state/peaks_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use super::*;

fn trace(ys: Vec<f64>) -> 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)
);
}
7 changes: 7 additions & 0 deletions docs/src/content/docs/guides/peaks-and-regions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/zh-cn/guides/peaks-and-regions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ description: 峰拾取与交互式区域分析。

**峰**工具按显著度(prominence)检测峰。在图上拖动阈值线即可调整检测——
松开时重新计算峰。检测到的峰也可以手动编辑、添加和删除。

单击放置一个峰,标记会吸附到指针附近小半径内最高的峰顶。该半径以屏幕像素
为准,放大视图时会随之收窄——想选中紧邻强峰的弱峰,放大后再点击即可。按住
`Shift` 单击可完全跳过吸附,把标记放在最近的数据点上(适合肩峰)。悬停预览
会显示标记将要落到的位置。横向拖选一段范围则拾取其中所有显著的峰。
选择**导出数据…**和**峰表**可保存或复制当前峰列表。

## 1D NMR 积分
Expand Down
Loading