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、位图和剪贴板矢量输出与画布一致, +不会包含选择框或拖动预览;重新处理谱图时,数值和曲线都会重新计算。 + ## 区域 区域分析会在系列中的每一组数据上测量相同的横轴范围。凡是需要观察某个信号