From a1c76dc261f34c28b1c3c0c57b8aa64966fe045f Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 22 Jul 2026 09:38:57 +0800 Subject: [PATCH] feat: add result-bearing 1D NMR integral curves Render normalized cumulative integral curves consistently across the canvas and export backends. Unify editable reference values for 1D and 2D NMR, synchronize integral results after processing and undo/redo, and document the updated workflows. --- crates/app/src/ui/canvas/integrals.rs | 11 +- crates/app/src/ui/canvas/integrals2d.rs | 24 +- crates/app/src/ui/canvas/mod.rs | 1 - crates/app/src/ui/canvas/painting.rs | 58 +-- .../src/ui/primary_sidebar/data_browser.rs | 2 +- crates/app/src/ui/tools/mod.rs | 110 +++-- crates/core/src/actions/processing_state.rs | 10 +- .../core/src/actions/tests/integral_curve.rs | 274 ++++++++++++ crates/core/src/actions/tests/mod.rs | 1 + crates/core/src/data_export/tests.rs | 7 +- crates/core/src/data_export/write.rs | 9 +- crates/core/src/lib.rs | 13 +- crates/core/src/project/convert.rs | 2 +- crates/core/src/project/convert_recipes.rs | 16 +- crates/core/src/project/linefit_tests.rs | 2 +- crates/core/src/project/multiplet_tests.rs | 2 +- crates/core/src/project/tests.rs | 2 +- crates/core/src/state/app_impl.rs | 12 +- crates/core/src/state/app_impl_peaks.rs | 43 +- crates/core/src/state/document.rs | 6 + crates/core/src/state/nmr_integrals.rs | 41 +- crates/core/src/state/nmr_integrals_2d.rs | 70 ++-- crates/core/src/state/stack.rs | 3 + crates/core/src/workflow.rs | 3 + crates/figure/src/lib.rs | 18 + crates/render/src/emf.rs | 14 +- crates/render/src/integral.rs | 389 ++++++++++++++++++ crates/render/src/lib.rs | 1 + crates/render/src/screen.rs | 27 +- crates/render/src/svg.rs | 56 ++- .../src/content/docs/guides/2d-integration.md | 7 +- .../content/docs/guides/peaks-and-regions.md | 26 ++ .../docs/zh-cn/guides/2d-integration.md | 5 +- .../docs/zh-cn/guides/peaks-and-regions.md | 19 + 34 files changed, 1102 insertions(+), 182 deletions(-) create mode 100644 crates/core/src/actions/tests/integral_curve.rs create mode 100644 crates/render/src/integral.rs diff --git a/crates/app/src/ui/canvas/integrals.rs b/crates/app/src/ui/canvas/integrals.rs index b82bb0f..dfa5bc2 100644 --- a/crates/app/src/ui/canvas/integrals.rs +++ b/crates/app/src/ui/canvas/integrals.rs @@ -222,6 +222,7 @@ fn apply_integral_drag_live(app: &mut PlotxApp, dataset: usize, ppm: f64) { } } n.recompute_integrals(); + app.sync_integral_curves_for(dataset); } fn finish_integral_drag(app: &mut PlotxApp, dataset: usize, xspan: f64) { @@ -254,15 +255,15 @@ fn finish_integral_drag(app: &mut PlotxApp, dataset: usize, xspan: f64) { { let id = n.next_integral_id; n.next_integral_id += 1; - let is_reference = n.integrals.is_empty(); + let reference_value = n.integrals.is_empty().then_some(1.0); n.integrals.push(IntegralResult { id, start_ppm: lo, end_ppm: hi, area: 0.0, - normalized_area: 0.0, + normalized_area: reference_value.unwrap_or(0.0), mode: plotx_core::DisplayModeLabel::Real, - is_reference, + reference_value, }); n.recompute_integrals(); app.session.ui.selected_integral = Some(id); @@ -306,8 +307,8 @@ fn integral_context_menu( ui.close(); return; } - if ui.button("Set as reference (=1)").clicked() { - app.set_integral_reference(dataset, id); + if ui.button("Use as normalization reference").clicked() { + app.set_integral_reference(dataset, id, 1.0); ui.close(); } if ui.button("Delete").clicked() { diff --git a/crates/app/src/ui/canvas/integrals2d.rs b/crates/app/src/ui/canvas/integrals2d.rs index 62ba1b3..96bb140 100644 --- a/crates/app/src/ui/canvas/integrals2d.rs +++ b/crates/app/src/ui/canvas/integrals2d.rs @@ -382,7 +382,7 @@ fn finish_integral_2d_drag( .and_then(Dataset::as_nmr2d_mut) { let id = n.next_integral_id(); - let is_reference = n.integrals.is_empty(); + let reference_value = n.integrals.is_empty().then_some(1.0); let mode = n.display_mode().into(); n.integrals.push(Integral2D { id, @@ -391,8 +391,7 @@ fn finish_integral_2d_drag( f1, volume: 0.0, normalized_volume: None, - is_reference, - reference_value: 1.0, + reference_value, mode, method: IntegralMethod::Sum, baseline: BaselineMode::None, @@ -437,8 +436,8 @@ fn integral_2d_context_menu( ui.close(); return; }; - if ui.button("Set as reference").clicked() { - app.set_integral_2d_reference(dataset, id); + if ui.button("Use as normalization reference").clicked() { + app.set_integral_2d_reference(dataset, id, 1.0); ui.close(); } if ui.button("Delete").clicked() { @@ -488,11 +487,7 @@ pub(crate) fn paint_integrals_2d( if r.width() < 1.0 || r.height() < 1.0 { continue; } - let color = if integral.is_reference { - INTEGRAL_REF_COLOR - } else { - INTEGRAL_COLOR - }; + let color = INTEGRAL_COLOR; let [red, green, blue, _] = color.to_array(); painter.rect_filled( r, @@ -509,11 +504,10 @@ pub(crate) fn paint_integrals_2d( let value = integral .normalized_volume .map_or_else(|| "—".to_owned(), |v| format!("{v:.3}")); - let reference = if integral.is_reference { " (ref)" } else { "" }; painter.text( r.left_top() + egui::vec2(3.0, 2.0), egui::Align2::LEFT_TOP, - format!("{}: {}{}", integral.name, value, reference), + format!("{}: {}", integral.name, value), egui::FontId::proportional(11.0), color, ); @@ -548,8 +542,7 @@ pub(crate) fn paint_integrals_2d( f1: (drag.anchor[1], drag.current[1]), volume: 0.0, normalized_volume: None, - is_reference: false, - reference_value: 1.0, + reference_value: None, mode: DisplayModeLabel::Real, method: IntegralMethod::Sum, baseline: BaselineMode::None, @@ -577,8 +570,7 @@ mod tests { f1: (3.0, 6.0), volume: 0.0, normalized_volume: None, - is_reference: false, - reference_value: 1.0, + reference_value: None, mode: DisplayModeLabel::Real, method: IntegralMethod::Sum, baseline: BaselineMode::None, diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 8c32855..c49f4b5 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -32,7 +32,6 @@ const SNAP_PX: f32 = 6.0; const GRID_COLOR: Color32 = Color32::from_rgb(0x5a, 0xa9, 0xc4); const GUIDE_COLOR: Color32 = Color32::from_rgb(0xff, 0x2d, 0x92); const INTEGRAL_COLOR: Color32 = Color32::from_rgb(0x2b, 0x6c, 0xb0); -const INTEGRAL_REF_COLOR: Color32 = Color32::from_rgb(0xc7, 0x8a, 0x14); const PEAK_COLOR: Color32 = Color32::from_rgb(0x8a, 0x1c, 0x1c); mod authoring; diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index a4daaf2..f33a05e 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -225,6 +225,9 @@ pub(crate) fn paint_integrals( plot: PlotRect, painter: &egui::Painter, ) { + if app.session.tool != Tool::Integrate { + return; + } let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.plot()) @@ -236,6 +239,13 @@ pub(crate) fn paint_integrals( return; }; let selected = app.session.ui.selected_integral; + let hover_x = painter.ctx().input(|input| { + input + .pointer + .hover_pos() + .filter(|position| plot_rect(plot).contains(*position)) + .map(|position| position.x) + }); for integ in &n.integrals { let x0 = x_to_screen( integ.start_ppm, @@ -253,37 +263,31 @@ pub(crate) fn paint_integrals( if r.width() < 1.0 { continue; } - let color = if integ.is_reference { - INTEGRAL_REF_COLOR - } else { - INTEGRAL_COLOR - }; + let color = INTEGRAL_COLOR; let [cr, cg, cb, _] = color.to_array(); - painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(cr, cg, cb, 30)); let is_sel = selected == Some(integ.id); - painter.rect_stroke( - r, - 0.0, - Stroke::new(if is_sel { 2.0_f32 } else { 1.0_f32 }, color), - StrokeKind::Inside, - ); - let label = if integ.is_reference { - format!("{:.3} (ref)", integ.normalized_area) - } else { - format!("{:.3}", integ.normalized_area) - }; - painter.text( - Pos2::new(r.left() + 3.0, r.top() + 2.0), - egui::Align2::LEFT_TOP, - label, - egui::FontId::proportional(11.0), - color, - ); + let is_hovered = hover_x.is_some_and(|x| x >= r.left() && x <= r.right()); + if is_sel || is_hovered { + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(cr, cg, cb, 30)); + } + for edge in [r.left(), r.right()] { + painter.line_segment( + [Pos2::new(edge, r.top()), Pos2::new(edge, r.bottom())], + Stroke::new( + if is_sel { 2.0_f32 } else { 1.0_f32 }, + color.gamma_multiply(0.65), + ), + ); + } if is_sel { for ex in [r.left(), r.right()] { - painter.line_segment( - [Pos2::new(ex, r.top()), Pos2::new(ex, r.bottom())], - Stroke::new(2.5_f32, color), + painter.rect_filled( + EguiRect::from_center_size( + Pos2::new(ex, (r.top() + r.bottom()) * 0.5), + Vec2::new(6.0, 16.0), + ), + 1.0, + color, ); } } diff --git a/crates/app/src/ui/primary_sidebar/data_browser.rs b/crates/app/src/ui/primary_sidebar/data_browser.rs index 0f4077b..697a63c 100644 --- a/crates/app/src/ui/primary_sidebar/data_browser.rs +++ b/crates/app/src/ui/primary_sidebar/data_browser.rs @@ -430,7 +430,7 @@ mod tests { area: 1.0, normalized_area: 1.0, mode: DisplayModeLabel::Real, - is_reference: false, + reference_value: None, }); nmr.line_fits.push(StoredLineFit { id: 13, diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index 03c1baf..41a7cea 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -13,7 +13,7 @@ mod statistics_config; mod task_card; use curve_fit::curve_fit_group; -use egui::{Button, DragValue, Response, Ui}; +use egui::{Button, DragValue, Id, Response, Ui}; use egui_phosphor::regular as icon; use line_fit::line_fit_group; use plotx_core::actions::{DatasetProcessingState, PendingProcessingEdit}; @@ -24,6 +24,40 @@ use slice::slice_group; pub(super) use line_fit::line_fit_shape_id; +#[derive(Clone, Copy, Default)] +struct DeferredReferenceValue { + value: f64, + changed: bool, +} + +/// Keep a reference-value edit outside the document until the widget gesture +/// ends, so one drag or typing run produces exactly one undoable action. +fn reference_value_drag(ui: &mut Ui, id: Id, committed: f64) -> Option { + let mut pending = ui + .data_mut(|data| data.get_temp::(id)) + .unwrap_or(DeferredReferenceValue { + value: committed, + changed: false, + }); + let response = ui + .add( + DragValue::new(&mut pending.value) + .speed(0.1) + .max_decimals(3), + ) + .on_hover_text("Normalization value assigned to this reference integral"); + pending.changed |= response.changed(); + + if response.drag_stopped() || response.lost_focus() { + ui.data_mut(|data| data.remove_temp::(id)); + return pending.changed.then_some(pending.value); + } + if response.dragged() || response.has_focus() || response.changed() { + ui.data_mut(|data| data.insert_temp(id, pending)); + } + None +} + pub(crate) fn render_region_task(app: &mut PlotxApp, ui: &mut Ui) { region_analysis::render_task(app, ui); } @@ -278,12 +312,12 @@ pub(super) fn integrate_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { if drawing { ui.small( "Drag across a peak to add · drag edges to resize · drag middle to move · \ - right-click to set the reference or delete.", + right-click to set the normalization reference or delete.", ); } let selected = app.session.ui.selected_integral; - let mut set_ref: Option = None; + let mut set_ref: Option<(u64, f64)> = None; let mut delete_id: Option = None; let mut select_id: Option = None; @@ -309,13 +343,21 @@ pub(super) fn integrate_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { { select_id = Some(integ.id); } - ui.label(format!("{:.3}", integ.normalized_area)); - if ui - .selectable_label(integ.is_reference, "ref") - .on_hover_text("Use this integral as the =1 reference") - .clicked() - { - set_ref = Some(integ.id); + if let Some(value) = integ.reference_value { + let id = ui.make_persistent_id(("integral_reference_1d", di, integ.id)); + if let Some(value) = reference_value_drag(ui, id, value) { + set_ref = Some((integ.id, value)); + } + ui.weak("reference"); + } else { + ui.label(format!("{:.3}", integ.normalized_area)); + if ui + .small_button("set reference") + .on_hover_text("Use this integral as the normalization reference") + .clicked() + { + set_ref = Some((integ.id, 1.0)); + } } if ui.small_button(icon::X).clicked() { delete_id = Some(integ.id); @@ -326,8 +368,8 @@ pub(super) fn integrate_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { if let Some(id) = select_id { app.session.ui.selected_integral = Some(id); } - if let Some(id) = set_ref { - app.set_integral_reference(di, id); + if let Some((id, value)) = set_ref { + app.set_integral_reference(di, id, value); } if let Some(id) = delete_id { app.delete_integral(di, id); @@ -370,7 +412,15 @@ fn integrate_2d_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { if integrals.is_empty() { ui.weak("No 2D integrals yet — draw a rectangle around a peak."); } - if integrals + let has_reference = integrals + .iter() + .any(|integral| integral.reference_value.is_some()); + if !integrals.is_empty() && !has_reference { + ui.colored_label( + ui.visuals().warn_fg_color, + "Choose a normalization reference to show normalized values.", + ); + } else if integrals .iter() .any(|integral| integral.normalized_volume.is_none()) { @@ -406,9 +456,21 @@ fn integrate_2d_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { let normalized = integral .normalized_volume .map_or_else(|| "—".to_owned(), |value| format!("{value:.3}")); - ui.label(normalized); - if ui.selectable_label(integral.is_reference, "ref").clicked() { - app.set_integral_2d_reference(di, integral.id); + if let Some(value) = integral.reference_value { + let id = ui.make_persistent_id(("integral_reference_2d", di, integral.id)); + if let Some(value) = reference_value_drag(ui, id, value) { + app.set_integral_2d_reference(di, integral.id, value); + } + ui.weak("reference"); + } else { + ui.label(normalized); + if ui + .small_button("set reference") + .on_hover_text("Use this integral as the normalization reference") + .clicked() + { + app.set_integral_2d_reference(di, integral.id, 1.0); + } } if ui.small_button(icon::X).clicked() { app.delete_integral_2d(di, integral.id); @@ -448,22 +510,6 @@ fn integrate_2d_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) { } }); } - if integral.is_reference { - ui.label("Reference value"); - let mut reference_value = integral.reference_value; - if ui - .add(DragValue::new(&mut reference_value).speed(0.1)) - .changed() - { - app.edit_integrals_2d(di, |values, _| { - if let Some(value) = - values.iter_mut().find(|value| value.id == integral.id) - { - value.reference_value = reference_value; - } - }); - } - } }); }); } diff --git a/crates/core/src/actions/processing_state.rs b/crates/core/src/actions/processing_state.rs index c8b9bef..67959df 100644 --- a/crates/core/src/actions/processing_state.rs +++ b/crates/core/src/actions/processing_state.rs @@ -39,13 +39,15 @@ impl DatasetProcessingState { ); n.pipeline = pipeline.clone(); n.group_delay_correct = *group_delay_correct; - if full { + let rebuild = if full { n.retransform(); - Ok(ProcessingRebuild::Retransformed) + ProcessingRebuild::Retransformed } else { n.rebuild(); - Ok(ProcessingRebuild::Rebuilt) - } + ProcessingRebuild::Rebuilt + }; + n.recompute_integrals(); + Ok(rebuild) } (Dataset::Nmr2D(n), Self::Nmr2D { params, preset }) => { let full = plotx_processing::needs_retransform_2d(params, &n.params); diff --git a/crates/core/src/actions/tests/integral_curve.rs b/crates/core/src/actions/tests/integral_curve.rs new file mode 100644 index 0000000..5b5e4d8 --- /dev/null +++ b/crates/core/src/actions/tests/integral_curve.rs @@ -0,0 +1,274 @@ +use super::{push_canvas, sample_app}; +use crate::actions::Action; +use crate::state::{IntegralDrag, Interaction, RegionDragKind, Tool}; +use crate::{DisplayModeLabel, IntegralResult}; + +fn sample_integral(id: u64, normalized_area: f64, reference_value: Option) -> IntegralResult { + let app = sample_app(); + let spectrum = &app.doc.datasets[0].as_nmr().unwrap().spectrum; + let (lo, hi) = spectrum.ppm_bounds(); + IntegralResult { + id, + start_ppm: lo, + end_ppm: hi, + area: normalized_area, + normalized_area, + mode: DisplayModeLabel::Real, + reference_value, + } +} + +#[test] +fn set_integrals_apply_undo_redo_keeps_all_primary_figures_synced() { + let mut app = sample_app(); + push_canvas(&mut app, 0, "second", [120.0, 80.0]); + let integral = sample_integral(7, 3.0, Some(3.0)); + app.execute_action(Action::set_integrals(0, Vec::new(), vec![integral])); + assert!(app.doc.canvases.iter().all(|canvas| { + let curve = &canvas.objects[0].plot().unwrap().figure.integral_curves; + curve.len() == 1 && curve[0].label == "3.000" + })); + + app.undo(); + assert!(app.doc.canvases.iter().all(|canvas| { + canvas.objects[0] + .plot() + .unwrap() + .figure + .integral_curves + .is_empty() + })); + app.redo(); + assert!(app.doc.canvases.iter().all(|canvas| { + canvas.objects[0] + .plot() + .unwrap() + .figure + .integral_curves + .len() + == 1 + })); +} + +#[test] +fn cancelling_live_integral_edit_restores_curve_description() { + let mut app = sample_app(); + let before = vec![sample_integral(3, 3.0, Some(3.0))]; + app.set_integrals(0, &before); + let object = app.doc.canvases[0].objects[0].id; + app.set_tool(Tool::Integrate); + app.set_interaction(Interaction::Integral(IntegralDrag { + canvas: 0, + object, + dataset: 0, + kind: RegionDragKind::Move, + integral_id: Some(3), + before: before.clone(), + anchor_ppm: 0.0, + grab_lo: before[0].start_ppm, + grab_hi: before[0].end_ppm, + current_ppm: 0.0, + })); + app.doc.datasets[0].as_nmr_mut().unwrap().integrals[0].normalized_area = 9.0; + app.sync_integral_curves_for(0); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .integral_curves[0] + .label, + "9.000" + ); + + app.cancel_interaction(); + assert_eq!(app.doc.datasets[0].as_nmr().unwrap().integrals, before); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .integral_curves[0] + .label, + "3.000" + ); +} + +#[test] +fn integral_curve_json_has_no_duplicate_points() { + let mut figure = plotx_figure::Figure::new( + "", + plotx_figure::Axis::new("x", 0.0, 1.0), + plotx_figure::Axis::new("y", 0.0, 1.0), + ); + figure.integral_curves = vec![plotx_figure::IntegralCurve { + start_ppm: 0.0, + end_ppm: 1.0, + normalized_area: 1.0, + label: "1.000".to_owned(), + color: plotx_figure::Color::TRACE, + width: 1.0, + source_series: 0, + }]; + let value = serde_json::to_value(&figure).unwrap(); + assert!(value["integral_curves"][0].get("points").is_none()); +} + +#[test] +fn additive_integral_fields_default_when_absent() { + let mut value = serde_json::to_value(sample_integral(7, 1.0, Some(1.0))).unwrap(); + value.as_object_mut().unwrap().remove("id"); + value.as_object_mut().unwrap().remove("reference_value"); + + let restored: IntegralResult = serde_json::from_value(value).unwrap(); + + assert_eq!(restored.id, 0); + assert_eq!(restored.reference_value, None); +} + +#[test] +fn figure_without_integral_curves_deserializes_with_an_empty_layer() { + let figure = plotx_figure::Figure::new( + "", + plotx_figure::Axis::new("x", 0.0, 1.0), + plotx_figure::Axis::new("y", 0.0, 1.0), + ); + let mut value = serde_json::to_value(figure).unwrap(); + value.as_object_mut().unwrap().remove("integral_curves"); + + let restored: plotx_figure::Figure = serde_json::from_value(value).unwrap(); + + assert!(restored.integral_curves.is_empty()); +} + +#[test] +fn overlay_only_dataset_does_not_contribute_integrals() { + use crate::state::{DataBinding, SeriesBinding, StackSpec}; + let mut app = sample_app(); + let mut secondary = app.doc.datasets[0].clone(); + secondary.as_nmr_mut().unwrap().integrals = vec![sample_integral(4, 2.0, None)]; + app.doc.datasets.push(secondary); + let binding = DataBinding { + series: vec![SeriesBinding::new(0), SeriesBinding::new(1)], + }; + let fig = app.build_stacked_figure(&binding, &StackSpec::default(), [120.0, 80.0]); + assert!(fig.integral_curves.is_empty()); + + app.doc.datasets[0].as_nmr_mut().unwrap().integrals = vec![sample_integral(2, 3.0, Some(3.0))]; + let fig = app.build_stacked_figure(&binding, &StackSpec::default(), [120.0, 80.0]); + assert_eq!(fig.integral_curves.len(), 1); + assert_eq!(fig.integral_curves[0].source_series, 0); +} + +#[test] +fn lightweight_sync_respects_hidden_primary_series() { + let mut app = sample_app(); + app.set_integrals(0, &[sample_integral(2, 3.0, Some(3.0))]); + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.binding.series[0].visible = false; + assert_eq!(plot.figure.integral_curves.len(), 1); + + app.sync_integral_curves_for(0); + + assert!( + app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .integral_curves + .is_empty() + ); +} + +#[test] +fn one_dimensional_processing_commit_recomputes_integral_and_curve() { + let mut app = sample_app(); + let mut integral = sample_integral(8, 999.0, Some(3.0)); + integral.area = 999.0; + app.doc.datasets[0].as_nmr_mut().unwrap().integrals = vec![integral]; + + app.apply_dataset_edit(0); + + let recomputed = app.doc.datasets[0].as_nmr().unwrap().integrals[0]; + assert_ne!(recomputed.area, 999.0); + assert_eq!(recomputed.normalized_area, 3.0); + let curve = &app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .integral_curves[0]; + assert_eq!(curve.normalized_area, recomputed.normalized_area); + assert_eq!(curve.start_ppm, recomputed.start_ppm); + assert_eq!(curve.end_ppm, recomputed.end_ppm); +} + +#[test] +fn processing_action_apply_undo_and_redo_recompute_integrals() { + use crate::actions::DatasetProcessingState; + use plotx_processing::{PhaseParams, StepKind}; + + let mut app = sample_app(); + app.doc.datasets[0].as_nmr_mut().unwrap().integrals = + vec![sample_integral(8, 999.0, Some(3.0))]; + let before = DatasetProcessingState::from_dataset(&app.doc.datasets[0]); + let mut after = before.clone(); + let DatasetProcessingState::Nmr { pipeline, .. } = &mut after else { + unreachable!(); + }; + for step in &mut pipeline.steps { + if let StepKind::Phase(params) = &mut step.kind { + *params = PhaseParams { + phase0: 0.5, + ..PhaseParams::MANUAL_ZERO + }; + } + } + + app.execute_action(Action::update_dataset_processing(0, before, after)); + assert_ne!( + app.doc.datasets[0].as_nmr().unwrap().integrals[0].area, + 999.0 + ); + + app.doc.datasets[0].as_nmr_mut().unwrap().integrals[0].area = 777.0; + app.doc.datasets[0].as_nmr_mut().unwrap().integrals[0].normalized_area = 777.0; + app.undo(); + let restored = app.doc.datasets[0].as_nmr().unwrap().integrals[0]; + assert_ne!(restored.area, 777.0); + assert_eq!(restored.normalized_area, 3.0); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .integral_curves[0] + .label, + "3.000" + ); + + app.doc.datasets[0].as_nmr_mut().unwrap().integrals[0].area = 555.0; + app.redo(); + assert_ne!( + app.doc.datasets[0].as_nmr().unwrap().integrals[0].area, + 555.0 + ); +} + +#[test] +fn reference_accepts_arbitrary_target_without_plot_marker() { + let mut app = sample_app(); + app.set_integrals(0, &[sample_integral(5, 0.25, None)]); + + app.set_integral_reference(0, 5, 100.0); + + let integral = app.doc.datasets[0].as_nmr().unwrap().integrals[0]; + assert_eq!(integral.reference_value, Some(100.0)); + assert_eq!(integral.normalized_area, 100.0); + let curve = &app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure + .integral_curves[0]; + assert_eq!(curve.label, "100.000"); + assert_eq!(curve.color, plotx_figure::Color::rgb(0x2b, 0x6c, 0xb0)); +} diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index 9c3b63e..155445e 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -9,6 +9,7 @@ mod arithmetic; mod authoring; mod board; mod composite; +mod integral_curve; mod interaction; mod linefit; mod more; diff --git a/crates/core/src/data_export/tests.rs b/crates/core/src/data_export/tests.rs index 8f6aae5..5ab8523 100644 --- a/crates/core/src/data_export/tests.rs +++ b/crates/core/src/data_export/tests.rs @@ -268,8 +268,7 @@ fn analysis_tables_keep_stable_headers_and_escape_user_text() { f1: (3.0, 4.0), volume: -5.0, normalized_volume: None, - is_reference: true, - reference_value: 2.0, + reference_value: Some(2.0), mode: crate::DisplayModeLabel::Real, method: IntegralMethod::Sum, baseline: BaselineMode::Plane, @@ -280,9 +279,9 @@ fn analysis_tables_keep_stable_headers_and_escape_user_text() { ); let text = integrals.to_text(Delimiter::Comma).unwrap(); assert!(text.starts_with( - "name,f2_lo,f2_hi,f1_lo,f1_hi,volume,normalized_volume,is_reference,reference_value,mode,method,baseline\n" + "name,f2_lo,f2_hi,f1_lo,f1_hi,volume,normalized_volume,reference_value,mode,method,baseline\n" )); - assert!(text.ends_with("\"cross, peak\",1,2,3,4,-5,,true,2,real,sum,plane\n")); + assert!(text.ends_with("\"cross, peak\",1,2,3,4,-5,,2,real,sum,plane\n")); } #[test] diff --git a/crates/core/src/data_export/write.rs b/crates/core/src/data_export/write.rs index df5d3ba..1ab156b 100644 --- a/crates/core/src/data_export/write.rs +++ b/crates/core/src/data_export/write.rs @@ -187,6 +187,7 @@ pub(super) fn write_integrals_1d( Field::Text("end_ppm"), Field::Text("area"), Field::Text("normalized_area"), + Field::Text("reference_value"), Field::Text("mode"), ])?; for value in values { @@ -195,6 +196,10 @@ pub(super) fn write_integrals_1d( Field::Number(value.end_ppm), Field::Number(value.area), Field::Number(value.normalized_area), + value + .reference_value + .map(Field::Number) + .unwrap_or(Field::Empty), Field::Text(value.mode.as_str()), ])?; } @@ -213,7 +218,6 @@ pub(super) fn write_integrals_2d( Field::Text("f1_hi"), Field::Text("volume"), Field::Text("normalized_volume"), - Field::Text("is_reference"), Field::Text("reference_value"), Field::Text("mode"), Field::Text("method"), @@ -237,8 +241,7 @@ pub(super) fn write_integrals_2d( Field::Number(value.f1.1), Field::Number(value.volume), normalized, - Field::Text(if value.is_reference { "true" } else { "false" }), - Field::Number(value.reference_value), + value.reference_value.map_or(Field::Empty, Field::Number), Field::Text(value.mode.as_str()), Field::Text(method), Field::Text(baseline), diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3275abb..4e731b8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -40,9 +40,10 @@ pub struct IntegralResult { pub area: f64, pub normalized_area: f64, pub mode: DisplayModeLabel, - /// The band whose area normalizes the rest (its normalized value is 1.000). + /// `Some(value)` marks this band as the normalization reference and sets its + /// displayed target value. `None` is an ordinary integral. #[serde(default)] - pub is_reference: bool, + pub reference_value: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -74,8 +75,10 @@ pub struct Integral2D { /// Raw signed volume in intensity·ppm². pub volume: f64, pub normalized_volume: Option, - pub is_reference: bool, - pub reference_value: f64, + /// `Some(value)` marks this rectangle as the normalization reference and + /// sets its displayed target value. + #[serde(default)] + pub reference_value: Option, pub mode: DisplayModeLabel, pub method: IntegralMethod, pub baseline: BaselineMode, @@ -183,7 +186,7 @@ pub fn integrate_region( area, normalized_area: area / total_abs_area, mode: mode.into(), - is_reference: false, + reference_value: None, }) } diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 4663003..070a404 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -163,7 +163,7 @@ pub fn object_to_dataset( source: nmr_source(data), group_delay: dim.group_delay.unwrap_or(0.0), }); - apply_1d_recipe(&mut dataset, recipe); + apply_1d_recipe(&mut dataset, recipe)?; dataset.name = data.label.clone(); dataset.retransform(); Ok(Dataset::Nmr(Box::new(dataset))) diff --git a/crates/core/src/project/convert_recipes.rs b/crates/core/src/project/convert_recipes.rs index f59ed65..848aa3d 100644 --- a/crates/core/src/project/convert_recipes.rs +++ b/crates/core/src/project/convert_recipes.rs @@ -4,7 +4,7 @@ use super::*; use crate::state::{PeakMark, PeakOrigin, PeakSet}; -pub fn apply_1d_recipe(dataset: &mut NmrDataset, recipe: &RecipeObject) { +pub fn apply_1d_recipe(dataset: &mut NmrDataset, recipe: &RecipeObject) -> Result<()> { let p = &recipe.parameters; if let Some(dto) = p.pipelines.first() { dataset.pipeline = pipeline_from_dto(dto); @@ -17,12 +17,13 @@ pub fn apply_1d_recipe(dataset: &mut NmrDataset, recipe: &RecipeObject) { .cloned() .and_then(|v| serde_json::from_value(v).ok()) .unwrap_or_else(|| legacy_peaks(analysis)); - dataset.integrals = analysis - .get("integrals") - .cloned() - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_default(); - dataset.normalize_integral_ids(); + dataset.integrals = match analysis.get("integrals") { + Some(value) => serde_json::from_value(value.clone()).map_err(|error| { + ProjectError::Invalid(format!("plotx.analysis.integrals is malformed: {error}")) + })?, + None => Vec::new(), + }; + dataset.reseed_integral_ids(); dataset.line_fits = analysis .get("line_fits") .cloned() @@ -46,6 +47,7 @@ pub fn apply_1d_recipe(dataset: &mut NmrDataset, recipe: &RecipeObject) { .max() .unwrap_or(0); } + Ok(()) } fn legacy_peaks(analysis: &serde_json::Value) -> PeakSet { diff --git a/crates/core/src/project/linefit_tests.rs b/crates/core/src/project/linefit_tests.rs index 1ab233a..da1e074 100644 --- a/crates/core/src/project/linefit_tests.rs +++ b/crates/core/src/project/linefit_tests.rs @@ -132,7 +132,7 @@ fn recipe_without_line_fits_key_loads_with_empty_fits() { }), }; - apply_1d_recipe(&mut dataset, &recipe); + apply_1d_recipe(&mut dataset, &recipe).unwrap(); assert!(dataset.line_fits.is_empty()); assert_eq!(dataset.next_line_fit_id, 0); diff --git a/crates/core/src/project/multiplet_tests.rs b/crates/core/src/project/multiplet_tests.rs index 7fa6653..aa6b539 100644 --- a/crates/core/src/project/multiplet_tests.rs +++ b/crates/core/src/project/multiplet_tests.rs @@ -63,7 +63,7 @@ fn recipe_without_multiplets_key_loads_with_empty() { }), }; - apply_1d_recipe(&mut dataset, &recipe); + apply_1d_recipe(&mut dataset, &recipe).unwrap(); assert!(dataset.multiplets.is_empty()); assert_eq!(dataset.next_multiplet_id, 0); diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index d529588..1409774 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -141,7 +141,7 @@ pub(super) fn sample_app() -> PlotxApp { area: 3.0, normalized_area: 0.5, mode: DisplayModeLabel::Real, - is_reference: false, + reference_value: None, }); n.line_fits.push(sample_line_fit()); n.next_line_fit_id = 8; diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 76863ae..65ebe67 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -104,6 +104,9 @@ impl PlotxApp { ) -> Figure { let mut figure = crate::workflow::build_dataset_figure(&self.doc.datasets[dataset], chart, size_mm); + if let Some(nmr) = self.doc.datasets[dataset].as_nmr() { + figure.integral_curves = nmr.integral_curves(); + } // Every figure build stamps the document's typography, so a doc-level // edit reaches each plot on its next rebuild without per-plot state. figure.typography = self.doc.style_library.figure_typography; @@ -262,6 +265,9 @@ impl PlotxApp { ); if let Some(plot) = object.plot_mut() { plot.figure.typography = self.doc.style_library.figure_typography; + if let Some(nmr) = self.doc.datasets[dataset].as_nmr() { + plot.figure.integral_curves = nmr.integral_curves(); + } } object } @@ -372,14 +378,16 @@ impl PlotxApp { } } Interaction::Integral(drag) => { + let dataset = drag.dataset; if let Some(n) = self .doc .datasets - .get_mut(drag.dataset) + .get_mut(dataset) .and_then(Dataset::as_nmr_mut) { n.integrals = drag.before; } + self.sync_integral_curves_for(dataset); } Interaction::Integral2D(drag) => { if let Some(n) = self @@ -624,6 +632,7 @@ impl PlotxApp { pub fn apply_dataset_edit(&mut self, dataset: usize) { if let Some(n) = self.doc.datasets[dataset].as_nmr_mut() { n.rebuild(); + n.recompute_integrals(); } else if self.doc.datasets[dataset].as_nmr2d().is_some() { self.schedule_2d_processing(dataset, false); self.doc.dirty = true; @@ -638,6 +647,7 @@ impl PlotxApp { pub fn apply_dataset_retransform(&mut self, dataset: usize) { if let Some(n) = self.doc.datasets[dataset].as_nmr_mut() { n.retransform(); + n.recompute_integrals(); } else if self.doc.datasets[dataset].as_nmr2d().is_some() { self.schedule_2d_processing(dataset, true); self.doc.dirty = true; diff --git a/crates/core/src/state/app_impl_peaks.rs b/crates/core/src/state/app_impl_peaks.rs index acd551e..2c7ea9d 100644 --- a/crates/core/src/state/app_impl_peaks.rs +++ b/crates/core/src/state/app_impl_peaks.rs @@ -71,10 +71,13 @@ impl PlotxApp { self.execute_action(Action::set_integrals_2d(dataset, before, after)); } - pub fn set_integral_2d_reference(&mut self, dataset: usize, id: u64) { + pub fn set_integral_2d_reference(&mut self, dataset: usize, id: u64, value: f64) { + if !value.is_finite() { + return; + } self.edit_integrals_2d(dataset, |integrals, _| { for integral in integrals { - integral.is_reference = integral.id == id; + integral.reference_value = (integral.id == id).then_some(value); } }); } @@ -91,6 +94,32 @@ impl PlotxApp { if let Some(n) = self.doc.datasets[dataset].as_nmr_mut() { n.integrals = integrals.to_vec(); } + self.sync_integral_curves_for(dataset); + } + + /// Refresh only the persistent integral description layer on plots whose + /// primary dataset is `dataset`. Overlay-only datasets never contribute + /// integral curves. + pub fn sync_integral_curves_for(&mut self, dataset: usize) { + let curves = self + .doc + .datasets + .get(dataset) + .and_then(Dataset::as_nmr) + .map(NmrDataset::integral_curves) + .unwrap_or_default(); + for canvas in &mut self.doc.canvases { + for object in &mut canvas.objects { + let Some(plot) = object.plot_mut() else { + continue; + }; + if plot.binding.primary_dataset() == dataset && plot.binding.primary_visible() { + plot.figure.integral_curves.clone_from(&curves); + } else if plot.binding.primary_dataset() == dataset { + plot.figure.integral_curves.clear(); + } + } + } } /// Snapshot the integrals, let `edit` mutate a working copy (handing out fresh @@ -117,12 +146,14 @@ impl PlotxApp { self.execute_action(Action::set_integrals(dataset, before, after)); } - /// Flag one integral as the normalization reference (its value becomes 1.000), - /// clearing the flag on the rest. - pub fn set_integral_reference(&mut self, dataset: usize, id: u64) { + /// Use one integral as the normalization reference at a user-selected value. + pub fn set_integral_reference(&mut self, dataset: usize, id: u64, value: f64) { + if !value.is_finite() { + return; + } self.edit_integrals(dataset, |integrals, _| { for integ in integrals.iter_mut() { - integ.is_reference = integ.id == id; + integ.reference_value = (integ.id == id).then_some(value); } }); } diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index 7a52e7f..636d9d2 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -242,6 +242,12 @@ impl DataBinding { self.series.first().map(|s| s.dataset).unwrap_or(0) } + /// Result overlays belonging to the primary dataset follow the visibility + /// of its source trace. + pub fn primary_visible(&self) -> bool { + self.series.first().is_some_and(|series| series.visible) + } + pub fn dataset_indices(&self) -> Vec { self.series.iter().map(|s| s.dataset).collect() } diff --git a/crates/core/src/state/nmr_integrals.rs b/crates/core/src/state/nmr_integrals.rs index 8f5ad6f..673ecb1 100644 --- a/crates/core/src/state/nmr_integrals.rs +++ b/crates/core/src/state/nmr_integrals.rs @@ -3,19 +3,35 @@ use super::*; impl NmrDataset { - /// Reassign contiguous ids to the loaded integrals and reseed the id source, so - /// bands stay individually addressable after a project round-trip. - pub fn normalize_integral_ids(&mut self) { - for (i, integ) in self.integrals.iter_mut().enumerate() { - integ.id = i as u64; - } - self.next_integral_id = self.integrals.len() as u64; + pub(crate) fn integral_curves(&self) -> Vec { + self.integrals + .iter() + .map(|integral| plotx_figure::IntegralCurve { + start_ppm: integral.start_ppm, + end_ppm: integral.end_ppm, + normalized_area: integral.normalized_area, + label: format!("{:.3}", integral.normalized_area), + color: plotx_figure::Color::rgb(0x2b, 0x6c, 0xb0), + width: 1.0, + source_series: 0, + }) + .collect() + } + + /// Rebuild the runtime id source without changing persisted ids. + pub fn reseed_integral_ids(&mut self) { + self.next_integral_id = self + .integrals + .iter() + .map(|integral| integral.id.saturating_add(1)) + .max() + .unwrap_or(0); } /// Refresh every integral's area from the current spectrum (after a band moved /// or resized) and renormalize: with a reference band, values are `area / - /// reference-area` (the reference reads 1.000); without one, each keeps its - /// total-spectrum fraction. + /// reference-area × the reference's user-selected target value; without a + /// reference, each keeps its total-spectrum fraction. pub fn recompute_integrals(&mut self) { let refreshed: Vec> = self .integrals @@ -35,11 +51,10 @@ impl NmrDataset { integ.normalized_area = norm; } } - if let Some(ref_area) = self + if let Some((ref_area, reference_value)) = self .integrals .iter() - .find(|integ| integ.is_reference) - .map(|integ| integ.area) + .find_map(|integ| integ.reference_value.map(|value| (integ.area, value))) { let ref_area = if ref_area.abs() < f64::MIN_POSITIVE { f64::MIN_POSITIVE @@ -47,7 +62,7 @@ impl NmrDataset { ref_area }; for integ in &mut self.integrals { - integ.normalized_area = integ.area / ref_area; + integ.normalized_area = integ.area / ref_area * reference_value; } } } diff --git a/crates/core/src/state/nmr_integrals_2d.rs b/crates/core/src/state/nmr_integrals_2d.rs index f3286ed..144b9ce 100644 --- a/crates/core/src/state/nmr_integrals_2d.rs +++ b/crates/core/src/state/nmr_integrals_2d.rs @@ -94,43 +94,32 @@ impl Nmr2DDataset { id } - /// Enforce one reference and refresh all presentation-layer normalized values. - /// - /// The first marked reference wins when malformed input marks more than one; - /// when none is marked the first collection entry is promoted. A reference - /// indistinguishable from zero relative to the collection's largest volume is - /// unusable and leaves every normalized value unavailable. + /// Refresh normalized values from the optional user-selected reference. pub fn renormalize_integrals(&mut self) { - if self.integrals.is_empty() { - return; - } - - let reference_index = self - .integrals - .iter() - .position(|integral| integral.is_reference) - .unwrap_or(0); - for (index, integral) in self.integrals.iter_mut().enumerate() { - integral.is_reference = index == reference_index; - } - - let reference = &self.integrals[reference_index]; - let reference_volume = reference.volume; - let reference_value = reference.reference_value; + let reference = self.integrals.iter().find_map(|integral| { + integral + .reference_value + .map(|value| (integral.volume, value)) + }); let max_volume = self .integrals .iter() .map(|integral| integral.volume.abs()) .fold(0.0, f64::max); - let usable = reference_volume.is_finite() - && reference_value.is_finite() - && max_volume.is_finite() - && max_volume > 0.0 - && reference_volume.abs() >= 1e-12 * max_volume; + let usable = reference.is_some_and(|(volume, value)| { + volume.is_finite() + && value.is_finite() + && max_volume.is_finite() + && max_volume > 0.0 + && volume.abs() >= 1e-12 * max_volume + }); for integral in &mut self.integrals { integral.normalized_volume = usable - .then(|| integral.volume / reference_volume * reference_value) + .then(|| { + let (reference_volume, reference_value) = reference.unwrap(); + integral.volume / reference_volume * reference_value + }) .filter(|value| value.is_finite()); } } @@ -142,7 +131,7 @@ mod tests { use super::*; - fn integral(id: u64, volume: f64, is_reference: bool) -> Integral2D { + fn integral(id: u64, volume: f64, reference_value: Option) -> Integral2D { Integral2D { id, name: format!("I{id}"), @@ -150,8 +139,7 @@ mod tests { f1: (3.0, 4.0), volume, normalized_volume: None, - is_reference, - reference_value: 1.0, + reference_value, mode: DisplayModeLabel::Real, method: IntegralMethod::Sum, baseline: BaselineMode::None, @@ -159,22 +147,20 @@ mod tests { } #[test] - fn normalization_promotes_first_and_supports_signed_reference_weight() { - let mut values = vec![integral(7, -2.0, false), integral(11, 6.0, false)]; - values[0].reference_value = 2.0; + fn normalization_supports_signed_reference_weight() { + let values = vec![integral(7, -2.0, Some(2.0)), integral(11, 6.0, None)]; let mut dataset = test_dataset(); dataset.integrals = values; dataset.renormalize_integrals(); - assert!(dataset.integrals[0].is_reference); assert_eq!(dataset.integrals[0].normalized_volume, Some(2.0)); assert_eq!(dataset.integrals[1].normalized_volume, Some(-6.0)); } #[test] - fn near_zero_reference_is_unusable_and_delete_promotes_first() { + fn near_zero_reference_is_unusable_and_delete_leaves_no_reference() { let mut dataset = test_dataset(); - dataset.integrals = vec![integral(5, 1e-13, true), integral(9, 1.0, false)]; + dataset.integrals = vec![integral(5, 1e-13, Some(1.0)), integral(9, 1.0, None)]; dataset.renormalize_integrals(); assert!( dataset @@ -185,14 +171,14 @@ mod tests { dataset.integrals.retain(|integral| integral.id != 5); dataset.renormalize_integrals(); - assert!(dataset.integrals[0].is_reference); - assert_eq!(dataset.integrals[0].normalized_volume, Some(1.0)); + assert_eq!(dataset.integrals[0].reference_value, None); + assert_eq!(dataset.integrals[0].normalized_volume, None); } #[test] fn reseeding_preserves_sparse_stable_ids() { let mut dataset = test_dataset(); - dataset.integrals = vec![integral(3, 1.0, true), integral(41, 2.0, false)]; + dataset.integrals = vec![integral(3, 1.0, Some(1.0)), integral(41, 2.0, None)]; dataset.reseed_integral_ids(); assert_eq!(dataset.integrals[0].id, 3); assert_eq!(dataset.integrals[1].id, 41); @@ -202,7 +188,7 @@ mod tests { #[test] fn processing_preview_defers_volume_recompute_until_commit() { let mut dataset = test_dataset(); - dataset.integrals = vec![integral(0, 123.0, true)]; + dataset.integrals = vec![integral(0, 123.0, Some(1.0))]; dataset.rebuild(); assert_eq!(dataset.integrals[0].volume, 123.0); @@ -217,7 +203,7 @@ mod tests { use std::f64::consts::PI; let mut dataset = test_dataset(); - dataset.integrals = vec![integral(0, 0.0, true)]; + dataset.integrals = vec![integral(0, 0.0, Some(1.0))]; dataset.recompute_integrals().unwrap(); assert!(dataset.integrals[0].volume > 0.0); diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 420e50d..d7364ba 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -145,6 +145,9 @@ impl PlotxApp { fig.x.max = x_max; fig.y.min = y_min; fig.y.max = y_max; + if !binding.primary_visible() { + fig.integral_curves.clear(); + } fig.show_legend = true; fig } diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 578899f..b2527d2 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -205,6 +205,9 @@ pub fn build_dataset_figure(dataset: &Dataset, chart: &ChartSpec, size_mm: [f32; figure.title.clear(); figure.width = size_mm[0] * MM_TO_PT; figure.height = size_mm[1] * MM_TO_PT; + if let Some(nmr) = dataset.as_nmr() { + figure.integral_curves = nmr.integral_curves(); + } figure } diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index 6fdec2c..a4522ff 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -121,6 +121,20 @@ pub struct Series { pub kind: SeriesKind, } +/// A stored 1D NMR integral description. Renderers derive the cumulative trace +/// from `Figure::series[source_series]`, keeping high-resolution spectrum points +/// in one place in project snapshots. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct IntegralCurve { + pub start_ppm: f64, + pub end_ppm: f64, + pub normalized_area: f64, + pub label: String, + pub color: Color, + pub width: f32, + pub source_series: usize, +} + /// A vertical uncertainty whisker in data-space coordinates. `center` is the /// plotted observation and `negative`/`positive` are non-negative distances /// below and above it. Cap width is expressed in output-space logical units so @@ -345,6 +359,9 @@ pub struct Figure { pub x: Axis, pub y: Axis, pub series: Vec, + /// Persistent descriptions of result-bearing 1D NMR integral curves. + #[serde(default)] + pub integral_curves: Vec, /// Filled polygons, painted after the heatmap and before contours/series so /// bodies (bars, boxes, violins, wedges) sit under outlines and markers. #[serde(default)] @@ -395,6 +412,7 @@ impl Figure { x, y, series: Vec::new(), + integral_curves: Vec::new(), polygons: Vec::new(), heatmap: None, error_bars: Vec::new(), diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index f52e56e..8cc71c0 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -5,8 +5,8 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, Margins, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShapeKind, Projector, Rect, TICK_LABEL_PAD, - TICK_LENGTH, arrow_head, axis_ticks_for, error_bar_segments, heatmap_cells, legend_entries, - polygon_outline, projection_points, + TICK_LENGTH, arrow_head, axis_ticks_for, error_bar_segments, heatmap_cells, integral, + legend_entries, polygon_outline, projection_points, }; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; use std::collections::HashMap; @@ -371,6 +371,16 @@ fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { } } write_error_bars(dc, fig, &proj, true); + for curve in integral::layout(fig, plot, 1.0) { + dc.polyline(&curve.points, curve.color, curve.width); + dc.text( + &curve.label.text, + curve.label.position, + TextStyle::new(curve.label.font_size, curve.label.color, TA_CENTER) + .middle() + .rotated(), + ); + } for a in &fig.annotations { let (px, py) = proj.project(a.at); dc.text( diff --git a/crates/render/src/integral.rs b/crates/render/src/integral.rs new file mode 100644 index 0000000..090b0f5 --- /dev/null +++ b/crates/render/src/integral.rs @@ -0,0 +1,389 @@ +//! Shared 1D NMR integral geometry and label layout. + +use crate::Rect; +use plotx_figure::{Axis, Color, Figure, IntegralCurve}; + +const CANCELLATION_EPS: f64 = 1e-12; +const CURVE_EDGE_INSET: f32 = 3.0; +const LABEL_FONT_SIZE: f32 = 6.0; +const LABEL_GAP: f32 = 2.0; + +#[derive(Debug, Clone, PartialEq)] +pub struct IntegralLabelLayout { + pub text: String, + pub position: (f32, f32), + pub font_size: f32, + pub color: Color, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IntegralPathLayout { + pub points: Vec<(f32, f32)>, + pub color: Color, + pub width: f32, + pub label: IntegralLabelLayout, +} + +/// Cumulative values in screen-left-to-right order. Trapezoids use absolute +/// x spacing, so reversing the ppm axis changes presentation order, not sign. +pub fn cumulative_values( + curve: &IntegralCurve, + source: &[[f64; 2]], + x_axis: &Axis, +) -> Vec<[f64; 2]> { + let lo = curve.start_ppm.min(curve.end_ppm); + let hi = curve.start_ppm.max(curve.end_ppm); + let mut samples: Vec<[f64; 2]> = source + .iter() + .copied() + .filter(|p| p[0] >= lo && p[0] <= hi) + .collect(); + + // Spectrum points are normally monotonic in ppm. Preserve that order (or + // reverse it for the screen axis) instead of sorting every integral on every + // repaint. Retain a robust fallback for hand-built, non-monotonic figures. + let monotonic = samples.windows(2).all(|pair| pair[0][0] <= pair[1][0]) + || samples.windows(2).all(|pair| pair[0][0] >= pair[1][0]); + if monotonic { + if samples + .first() + .zip(samples.last()) + .is_some_and(|(first, last)| x_axis.normalize(first[0]) > x_axis.normalize(last[0])) + { + samples.reverse(); + } + } else { + samples.sort_by(|a, b| x_axis.normalize(a[0]).total_cmp(&x_axis.normalize(b[0]))); + } + + if samples.len() < 2 || samples.iter().flatten().any(|v| !v.is_finite()) { + return zero_values(curve, x_axis); + } + + let mut cumulative = Vec::with_capacity(samples.len()); + cumulative.push([samples[0][0], 0.0]); + let mut total = 0.0; + let mut absolute_scale = 0.0; + for pair in samples.windows(2) { + let dx = (pair[1][0] - pair[0][0]).abs(); + let contribution = 0.5 * (pair[0][1] + pair[1][1]) * dx; + total += contribution; + absolute_scale += contribution.abs(); + cumulative.push([pair[1][0], total]); + } + if !total.is_finite() + || !absolute_scale.is_finite() + || !curve.normalized_area.is_finite() + || total.abs() <= CANCELLATION_EPS * absolute_scale + { + return zero_values(curve, x_axis); + } + let scale = curve.normalized_area / total; + for point in &mut cumulative { + point[1] *= scale; + } + cumulative +} + +fn zero_values(curve: &IntegralCurve, x_axis: &Axis) -> Vec<[f64; 2]> { + let mut endpoints = [[curve.start_ppm, 0.0], [curve.end_ppm, 0.0]]; + if x_axis.normalize(endpoints[0][0]) > x_axis.normalize(endpoints[1][0]) { + endpoints.reverse(); + } + endpoints.to_vec() +} + +/// Lay out every integral in output space. All paths share a zero line and +/// vertical scale; their band grows gently with count and remains 20%–50% of +/// the viewport height. +pub fn layout(fig: &Figure, plot: Rect, output_scale: f32) -> Vec { + let values: Vec<(&IntegralCurve, Vec<[f64; 2]>)> = fig + .integral_curves + .iter() + .map(|curve| { + let points = fig.series.get(curve.source_series).map_or_else( + || zero_values(curve, &fig.x), + |series| cumulative_values(curve, &series.points, &fig.x), + ); + (curve, points) + }) + .collect(); + if values.is_empty() { + return Vec::new(); + } + + let (mut min_value, mut max_value) = (0.0f64, 0.0f64); + for (_, points) in &values { + for point in points { + min_value = min_value.min(point[1]); + max_value = max_value.max(point[1]); + } + } + let band_fraction = (0.2 + values.len().saturating_sub(1) as f32 * 0.03).clamp(0.2, 0.5); + let band_height = plot.height * band_fraction; + let edge_inset = (CURVE_EDGE_INSET * output_scale).min(band_height * 0.1); + let curve_height = (band_height - edge_inset * 2.0).max(0.0); + let curve_top = plot.top + edge_inset; + let zero_y = if min_value < 0.0 && max_value > 0.0 { + curve_top + curve_height * (max_value / (max_value - min_value)) as f32 + } else if min_value < 0.0 { + curve_top + } else { + curve_top + curve_height + }; + let scale = if min_value < 0.0 && max_value > 0.0 { + curve_height / (max_value - min_value) as f32 + } else { + let extent = max_value.abs().max(min_value.abs()); + if extent.is_finite() && extent > 0.0 { + curve_height / extent as f32 + } else { + 0.0 + } + }; + + let mut paths: Vec<_> = values + .into_iter() + .map(|(curve, points)| { + let points: Vec<(f32, f32)> = points + .into_iter() + .map(|point| { + let x = plot.left + fig.x.normalize(point[0]) as f32 * plot.width; + (x, zero_y - point[1] as f32 * scale) + }) + .collect(); + let end = points.last().copied().unwrap_or((plot.left, zero_y)); + IntegralPathLayout { + label: label_layout(&curve.label, end, curve.color, plot, output_scale), + points, + color: curve.color, + width: curve.width, + } + }) + .collect(); + avoid_label_overlaps(&mut paths, plot, output_scale); + paths +} + +/// Backend-independent vertical placement immediately to the right of the +/// screen-right curve end. +pub fn label_layout( + text: &str, + end: (f32, f32), + color: Color, + plot: Rect, + output_scale: f32, +) -> IntegralLabelLayout { + let font_size = LABEL_FONT_SIZE * output_scale; + let label_height = estimated_rotated_height(text, font_size); + let half_width = font_size * 0.5; + let half_height = label_height * 0.5; + IntegralLabelLayout { + text: text.to_owned(), + position: ( + (end.0 + LABEL_GAP * output_scale + half_width) + .clamp(plot.left + half_width, plot.right() - half_width), + end.1 + .clamp(plot.top + half_height, plot.bottom() - half_height), + ), + font_size, + color, + } +} + +fn estimated_rotated_height(text: &str, font_size: f32) -> f32 { + text.chars().count() as f32 * font_size * 0.58 +} + +fn avoid_label_overlaps(paths: &mut [IntegralPathLayout], plot: Rect, output_scale: f32) { + let gap = LABEL_GAP * output_scale; + let mut placed: Vec<(f32, f32, f32, f32)> = Vec::new(); + let max_attempts = paths.len() * 2; + for path in paths { + let width = path.label.font_size; + let height = estimated_rotated_height(&path.label.text, path.label.font_size); + let original_y = path.label.position.1; + let step = height + gap * 2.0; + let mut chosen = original_y; + for attempt in 0..=max_attempts { + let offset = if attempt == 0 { + 0.0 + } else { + let distance = attempt.div_ceil(2) as f32 * step; + if attempt % 2 == 1 { + distance + } else { + -distance + } + }; + let candidate = + (original_y + offset).clamp(plot.top + height * 0.5, plot.bottom() - height * 0.5); + let bounds = ( + path.label.position.0 - width * 0.5 - gap, + candidate - height * 0.5 - gap, + path.label.position.0 + width * 0.5 + gap, + candidate + height * 0.5 + gap, + ); + if placed.iter().all(|other| !rects_overlap(bounds, *other)) { + chosen = candidate; + placed.push(bounds); + break; + } + } + path.label.position.1 = chosen; + } +} + +fn rects_overlap(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool { + a.0 < b.2 && a.2 > b.0 && a.1 < b.3 && a.3 > b.1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn curve(target: f64) -> IntegralCurve { + IntegralCurve { + start_ppm: 0.0, + end_ppm: 3.0, + normalized_area: target, + label: format!("{target:.3}"), + color: Color::TRACE, + width: 1.0, + source_series: 0, + } + } + + #[test] + fn screen_order_honors_forward_and_reversed_axes() { + let source = [[0.0, 1.0], [1.0, 1.0], [3.0, 1.0]]; + let forward = Axis::new("x", 0.0, 3.0); + let reversed = Axis::new("x", 0.0, 3.0).reversed(true); + assert_eq!(cumulative_values(&curve(3.0), &source, &forward)[0][0], 0.0); + assert_eq!( + cumulative_values(&curve(3.0), &source, &reversed)[0][0], + 3.0 + ); + } + + #[test] + fn nonuniform_trapezoids_reach_the_exact_target() { + let source = [[0.0, 0.0], [1.0, 2.0], [3.0, 2.0]]; + let values = cumulative_values(&curve(7.0), &source, &Axis::new("x", 0.0, 3.0)); + assert_eq!(values[1][1], 1.4); + assert_eq!(values.last().unwrap()[1], 7.0); + } + + #[test] + fn negative_target_accumulates_downward() { + let values = cumulative_values( + &curve(-2.0), + &[[0.0, 1.0], [1.0, 1.0], [3.0, 1.0]], + &Axis::new("x", 0.0, 3.0), + ); + assert_eq!(values.last().unwrap()[1], -2.0); + } + + #[test] + fn unstable_and_short_inputs_become_horizontal_zero() { + let axis = Axis::new("x", 0.0, 3.0); + for source in [ + vec![[0.0, 1.0]], + vec![[0.0, f64::NAN], [3.0, 1.0]], + vec![[0.0, 0.0], [3.0, 0.0]], + vec![[0.0, 1.0], [1.0, -1.0]], + vec![ + [0.0, 1.0], + [1.0, 1.0], + [2.0, -1.0 + 1e-13], + [3.0, -1.0 + 1e-13], + ], + ] { + assert!( + cumulative_values(&curve(1.0), &source, &axis) + .iter() + .all(|p| p[1] == 0.0) + ); + } + } + + #[test] + fn non_monotonic_sources_use_the_ordering_fallback() { + let values = cumulative_values( + &curve(3.0), + &[[3.0, 1.0], [0.0, 1.0], [1.0, 1.0]], + &Axis::new("x", 0.0, 3.0), + ); + assert_eq!( + values.iter().map(|point| point[0]).collect::>(), + vec![0.0, 1.0, 3.0] + ); + } + + #[test] + fn all_zero_layout_has_only_finite_coordinates() { + use plotx_figure::{Figure, Series}; + let mut fig = + Figure::new("", Axis::new("x", 0.0, 3.0), Axis::new("y", 0.0, 1.0)).with_series( + Series::line("spectrum", vec![[0.0, 0.0], [1.0, 0.0], [3.0, 0.0]]), + ); + fig.integral_curves = vec![curve(1.0)]; + + let paths = layout(&fig, Rect::new(0.0, 0.0, 300.0, 100.0), 1.0); + + assert!( + paths[0] + .points + .iter() + .all(|(x, y)| x.is_finite() && y.is_finite()) + ); + assert!( + paths[0] + .points + .windows(2) + .all(|points| points[0].1 == points[1].1) + ); + } + + #[test] + fn layouts_share_zero_and_scale_for_mixed_signs() { + use plotx_figure::{Figure, Series}; + let mut fig = + Figure::new("", Axis::new("x", 0.0, 3.0), Axis::new("y", 0.0, 1.0)).with_series( + Series::line("spectrum", vec![[0.0, 1.0], [1.0, 1.0], [3.0, 1.0]]), + ); + fig.integral_curves = vec![curve(2.0), curve(-1.0)]; + let paths = layout(&fig, Rect::new(0.0, 0.0, 300.0, 100.0), 1.0); + assert_eq!(paths[0].points[0].1, paths[1].points[0].1); + let zero = paths[0].points[0].1; + let positive = paths[0].points.last().unwrap().1; + let negative = paths[1].points.last().unwrap().1; + assert!(((zero - positive) / (negative - zero) - 2.0).abs() < 1e-5); + } + + #[test] + fn tallest_curve_keeps_clear_of_the_viewport_edge() { + use plotx_figure::{Figure, Series}; + let mut fig = + Figure::new("", Axis::new("x", 0.0, 3.0), Axis::new("y", 0.0, 1.0)).with_series( + Series::line("spectrum", vec![[0.0, 1.0], [1.0, 1.0], [3.0, 1.0]]), + ); + fig.integral_curves = vec![curve(2.0)]; + let paths = layout(&fig, Rect::new(0.0, 0.0, 300.0, 100.0), 1.0); + assert!(paths[0].points.iter().all(|point| point.1 > 0.0)); + } + + #[test] + fn coincident_vertical_labels_are_separated() { + use plotx_figure::{Figure, Series}; + let mut fig = + Figure::new("", Axis::new("x", 0.0, 4.0), Axis::new("y", 0.0, 1.0)).with_series( + Series::line("spectrum", vec![[0.0, 1.0], [1.0, 1.0], [3.0, 1.0]]), + ); + fig.integral_curves = vec![curve(2.0), curve(2.0)]; + let paths = layout(&fig, Rect::new(0.0, 0.0, 400.0, 140.0), 1.0); + assert_eq!(paths[0].label.position.0, paths[1].label.position.0); + assert_ne!(paths[0].label.position.1, paths[1].label.position.1); + assert_eq!(paths[0].label.font_size, 6.0); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cb87b0..a52d61b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -3,6 +3,7 @@ //! share [`Projector`] and [`ticks`]. pub mod contour; +pub mod integral; pub mod svg; #[cfg(feature = "screen")] diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 0ee5038..c85ecce 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -2,7 +2,8 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, DocumentViewport, LegendMark, Margins, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShape, OverlayShapeKind, OverlayText, Projector, Rect, TICK_LABEL_PAD, TICK_LENGTH, arrow_head, axis_ticks_for, - error_bar_segments, heatmap_cells, legend_entries, polygon_outline, projection_points, + error_bar_segments, heatmap_cells, integral, legend_entries, polygon_outline, + projection_points, }; use egui::{Align2, Color32, FontId, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2}; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; @@ -306,6 +307,30 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { } paint_error_bars(&clipped, &proj, fig, scale, true); + for curve in integral::layout(fig, plot, scale) { + if curve.points.len() >= 2 { + let points = curve.points.iter().map(|&(x, y)| to_pos(x, y)).collect(); + clipped.add(Shape::line( + points, + Stroke::new(curve.width * scale, col(curve.color)), + )); + } + let galley = clipped.layout_no_wrap( + curve.label.text, + FontId::proportional(curve.label.font_size), + col(curve.label.color), + ); + let size = galley.size(); + let mut label = egui::epaint::TextShape::new( + Pos2::new(-size.x * 0.5, -size.y * 0.5), + galley, + col(curve.label.color), + ) + .with_angle_and_anchor(-std::f32::consts::FRAC_PI_2, Align2::CENTER_CENTER); + label.pos += Vec2::new(curve.label.position.0, curve.label.position.1); + clipped.add(label); + } + for a in &fig.annotations { let (px, py) = proj.project(a.at); clipped.text( diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 8ed9da8..27c9060 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -1,8 +1,8 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, Margins, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShapeKind, Projector, Rect, TICK_LABEL_PAD, - TICK_LENGTH, arrow_head, axis_ticks_for, error_bar_segments, heatmap_cells, legend_entries, - polygon_outline, projection_points, + TICK_LENGTH, arrow_head, axis_ticks_for, error_bar_segments, heatmap_cells, integral, + legend_entries, polygon_outline, projection_points, }; use plotx_figure::{AxisFrame, AxisTrace, Figure, SeriesKind}; use std::fmt::Write as _; @@ -428,6 +428,27 @@ fn write_figure(s: &mut String, fig: &Figure, outer: Rect, clip_id: &str) { } } write_error_bars(s, fig, &proj, true); + for curve in integral::layout(fig, plot, 1.0) { + let mut points = String::new(); + for (x, y) in curve.points { + let _ = write!(points, "{x:.2},{y:.2} "); + } + let _ = write!( + s, + r#""#, + color = curve.color.to_hex(), + width = curve.width, + ); + let _ = write!( + s, + r#"{text}"#, + x = curve.label.position.0, + y = curve.label.position.1, + font = curve.label.font_size, + color = curve.label.color.to_hex(), + text = escape(&curve.label.text), + ); + } for a in &fig.annotations { let (px, py) = proj.project(a.at); let _ = write!( @@ -613,7 +634,7 @@ fn escape_id(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use plotx_figure::{Axis, AxisFrame, ErrorBar, Figure, Series}; + use plotx_figure::{Axis, AxisFrame, Color, ErrorBar, Figure, IntegralCurve, Series}; #[test] fn exports_wellformed_ish_svg_with_polyline() { @@ -633,6 +654,35 @@ mod tests { assert!(out.contains("Demo")); } + #[test] + fn exports_integral_result_curve_and_label() { + let mut fig = Figure::new( + "", + Axis::new("ppm", 0.0, 2.0).reversed(true), + Axis::new("intensity", 0.0, 1.0), + ) + .with_series(Series::line( + "trace", + vec![[0.0, 1.0], [1.0, 2.0], [2.0, 1.0]], + )); + fig.integral_curves.push(IntegralCurve { + start_ppm: 0.0, + end_ppm: 2.0, + normalized_area: 3.0, + label: "3.000".to_owned(), + color: Color::rgb(0x22, 0x8b, 0x57), + width: 1.5, + source_series: 0, + }); + let out = export(&fig); + assert!(out.contains("class=\"integral-curve\"")); + assert!(out.contains("class=\"integral-label\"")); + assert!(out.contains("3.000")); + assert!(!out.contains("(ref)")); + assert!(out.contains("rotate(-90")); + assert!(!out.contains("integral-selection")); + } + #[test] fn escapes_xml_special_chars() { let fig = Figure::new( diff --git a/docs/src/content/docs/guides/2d-integration.md b/docs/src/content/docs/guides/2d-integration.md index d12df04..3326e08 100644 --- a/docs/src/content/docs/guides/2d-integration.md +++ b/docs/src/content/docs/guides/2d-integration.md @@ -20,8 +20,8 @@ workflow. committed. Press `Esc` to cancel an in-progress edit. Press `Delete` or `Backspace` to -remove the selected integral. The context menu also provides **Set as -reference** and **Delete**. Creating, moving, resizing, renaming, changing the +remove the selected integral. The context menu also provides **Use as +normalization reference** and **Delete**. Creating, moving, resizing, renaming, changing the reference, and deleting can all be undone and redone. The Integrals table lists the F2 and F1 bounds, raw and normalized volumes, the @@ -77,7 +77,8 @@ the opposite sign from the reference. If the reference volume is effectively zero relative to the other integrals, normalization is unavailable. The table and plot label show `—` instead of an -unstable ratio. Deleting the reference promotes the first remaining integral. +unstable ratio. Deleting the reference leaves the remaining integrals without +one — their normalized volumes show `—` until you choose a new reference. ## Limits and placement advice diff --git a/docs/src/content/docs/guides/peaks-and-regions.md b/docs/src/content/docs/guides/peaks-and-regions.md index 8b7944c..0b82403 100644 --- a/docs/src/content/docs/guides/peaks-and-regions.md +++ b/docs/src/content/docs/guides/peaks-and-regions.md @@ -13,6 +13,32 @@ the plot to adjust detection — peaks are recomputed when you release it. Detected peaks can be edited, added, and removed by hand. Choose **Export Data…** and **Peak table** to save or copy the current peak list. +## 1D NMR integrals + +Choose **Integrate** and drag across a 1D NMR signal. Each completed interval +is drawn as a cumulative integral curve with a normalized value to three +decimals. All intervals share one zero line and vertical scale, and positive +integrals rise while negative integrals fall, so phase or baseline problems +stay visible instead of being hidden by an absolute value. + +The first integral becomes the normalization reference with value `1`. To use +a different interval, right-click it and choose **Use as normalization +reference**, or press **set reference** next to it in the **Integrate** panel. +Type any target value for the reference there — for example `1`, `3`, or +`100` — and the other values scale against it. The plot itself does not mark +the reference; the panel shows which integral it is. Without a reference, +each integral shows its fraction of the total spectrum area. + +With **Integrate** active, drag inside an interval to move it or drag an edge +handle to resize it. The shaded interval and handles are editing aids only; +outside the tool, the plot shows just the curve and value. + +Each value appears as a small vertical label at the right end of its curve, +and nearby labels shift apart to avoid overlapping. The curves and values are +part of the figure: SVG, PDF, bitmap, and vector clipboard output match the +canvas, without selection boxes or drag previews, and reprocessing the +spectrum recalculates both. + ## Regions Regions measure the same x-axis interval across every member of a series. This diff --git a/docs/src/content/docs/zh-cn/guides/2d-integration.md b/docs/src/content/docs/zh-cn/guides/2d-integration.md index a83a484..8698908 100644 --- a/docs/src/content/docs/zh-cn/guides/2d-integration.md +++ b/docs/src/content/docs/zh-cn/guides/2d-integration.md @@ -15,7 +15,7 @@ COSY、HSQC、HMBC、NOESY、ROESY 和 TOCSY 等真实二维等高线谱可以 4. 松开指针后计算体积。拖动时矩形会实时更新,但只在提交编辑时重新计算体积。 按 `Esc` 取消正在进行的编辑。按 `Delete` 或 `Backspace` 删除所选积分。右键菜单还 -提供**设为参照**和**删除**。创建、移动、调整大小、重命名、更换参照和删除均可撤销及 +提供**用作归一化参考**和**删除**。创建、移动、调整大小、重命名、更换参照和删除均可撤销及 重做。 积分表列出 F2/F1 边界、原始体积、归一化体积、计算时使用的显示模式和基线设置。 @@ -58,7 +58,8 @@ PlotX 对计算积分时屏幕上正在显示的曲面进行积分: 设为 `2`。实部模式的有符号体积中,若某峰与参照符号相反,归一化值可以为负数。 若参照体积相对于其他积分实际上接近零,则无法可靠归一化。表格和图中标签会显示 -`—`,而不是不稳定的比值。删除参照后,剩余的第一个积分会成为新参照。 +`—`,而不是不稳定的比值。删除参照后,其余积分不再有参照,归一化体积显示 +`—`,直到你选择新的参照。 ## 限制与放置建议 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 975b810..9030d4d 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 @@ -12,6 +12,25 @@ description: 峰拾取与交互式区域分析。 松开时重新计算峰。检测到的峰也可以手动编辑、添加和删除。 选择**导出数据…**和**峰表**可保存或复制当前峰列表。 +## 1D NMR 积分 + +选择**积分**工具,在 1D NMR 信号上拖动。每个完成的区间会显示为累积积分 +曲线,并标注三位小数的归一化数值。所有区间使用同一条零位线和纵向尺度; +正积分向上、负积分向下,因此相位或基线问题不会被绝对值掩盖。 + +第一个积分自动成为归一化参考,参考值为 `1`。若要改用其他区间,可右键单击 +该区间并选择**用作归一化参考**,或在**积分**面板中点击该行的**设为参考**。 +参考的目标值可以在面板中直接输入,例如 `1`、`3` 或 `100`,其余数值会相对 +该目标缩放。图中不会标记参考;哪个积分是参考只在面板中显示。没有参考时, +每个积分显示其占整个谱总面积的比例。 + +**积分**工具激活时,可拖动区间内部来移动它,也可拖动边缘手柄调整宽度。 +半透明区间和手柄只用于编辑;离开该工具后,图中仅保留曲线和数值。 + +每个数值以较小的竖排标签显示在其曲线右端,相邻标签会自动错开以避免重叠。 +积分曲线和数值是图形的一部分:SVG、PDF、位图和剪贴板矢量输出与画布一致, +不会包含选择框或拖动预览;重新处理谱图时,数值和曲线都会重新计算。 + ## 区域 区域分析会在系列中的每一组数据上测量相同的横轴范围。凡是需要观察某个信号