diff --git a/crates/analysis/src/alignment.rs b/crates/analysis/src/alignment.rs index bf77319..426c2c1 100644 --- a/crates/analysis/src/alignment.rs +++ b/crates/analysis/src/alignment.rs @@ -4,13 +4,20 @@ use crate::peaks::{DetectParams, detect_peaks, estimate_noise}; const MIN_PROMINENCE_SIGMA: f64 = 5.0; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeakPolarity { + Positive, + Negative, + Magnitude, +} + /// The tallest significant peak of `ys` with `lo <= x <= hi`. pub fn reference_peak(x: &[f64], ys: &[f64], lo: f64, hi: f64) -> Option { let (lo, hi) = (lo.min(hi), lo.max(hi)); let mut xs_window = Vec::new(); let mut ys_window = Vec::new(); for (&x, &y) in x.iter().zip(ys) { - if x >= lo && x <= hi { + if x.is_finite() && y.is_finite() && x >= lo && x <= hi { xs_window.push(x); ys_window.push(y); } @@ -28,6 +35,68 @@ pub fn reference_peak(x: &[f64], ys: &[f64], lo: f64, hi: f64) -> Option { .map(|peak| peak.x) } +/// Most prominent significant feature of a generic trace in a displayed x window. +/// Magnitude compares upward and downward prominence rather than `abs(y)`, so a +/// non-zero baseline does not become a false feature. +pub fn trace_peak_anchor( + x: &[f64], + ys: &[f64], + lo: f64, + hi: f64, + polarity: PeakPolarity, +) -> Option { + let (lo, hi) = (lo.min(hi), lo.max(hi)); + let mut xs = Vec::new(); + let mut values = Vec::new(); + for (&x, &y) in x.iter().zip(ys) { + if x.is_finite() && y.is_finite() && x >= lo && x <= hi { + xs.push(x); + values.push(y); + } + } + let scale = values + .iter() + .fold(0.0_f64, |max, value| max.max(value.abs())); + let floor = (MIN_PROMINENCE_SIGMA * estimate_noise(&values)).max(f64::EPSILON * scale.max(1.0)); + let params = DetectParams { + min_height: None, + min_prominence: floor, + min_spacing: None, + max_count: Some(1), + }; + let strongest = |values: &[f64]| detect_peaks(&xs, values, ¶ms).into_iter().next(); + match polarity { + PeakPolarity::Positive => strongest(&values).map(|peak| peak.x), + PeakPolarity::Negative => { + let inverted: Vec<_> = values.iter().map(|value| -*value).collect(); + strongest(&inverted).map(|peak| peak.x) + } + PeakPolarity::Magnitude => { + let positive = strongest(&values); + let inverted: Vec<_> = values.iter().map(|value| -*value).collect(); + let negative = strongest(&inverted); + match (positive, negative) { + (Some(up), Some(down)) => { + let mut sorted = values.clone(); + sorted.sort_by(f64::total_cmp); + let baseline = sorted[sorted.len() / 2]; + let up_excursion = (values[up.index] - baseline).abs(); + let down_excursion = (values[down.index] - baseline).abs(); + if down.prominence > up.prominence + || (down.prominence == up.prominence && down_excursion > up_excursion) + { + Some(down.x) + } else { + Some(up.x) + } + } + (Some(peak), None) | (None, Some(peak)) => Some(peak.x), + (None, None) => None, + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -72,4 +141,22 @@ mod tests { assert_eq!(reference_peak(&xs, &noisy, 6.0, 9.0), None); assert!((reference_peak(&xs, &noisy, 0.0, 10.0).unwrap() - 2.0).abs() < 0.05); } + + #[test] + fn magnitude_compares_prominence_on_nonzero_baselines() { + let x: Vec<_> = (0..9).map(|i| i as f64).collect(); + let y = vec![10.0, 10.0, 12.0, 10.0, 10.0, 4.0, 10.0, 10.0, 10.0]; + assert_eq!( + trace_peak_anchor(&x, &y, 0.0, 8.0, PeakPolarity::Positive), + Some(2.0) + ); + assert_eq!( + trace_peak_anchor(&x, &y, 0.0, 8.0, PeakPolarity::Negative), + Some(5.0) + ); + assert_eq!( + trace_peak_anchor(&x, &y, 0.0, 8.0, PeakPolarity::Magnitude), + Some(5.0) + ); + } } diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 3bf5614..b605d05 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -132,6 +132,7 @@ fn execute_inner( } CommandId::SpectrumArithmetic => super::arithmetic::open_spectrum_arithmetic_dialog(app), CommandId::AlignSpectra => super::align::open_align_spectra_dialog(app), + CommandId::AlignTraces => super::trace_alignment::open_active_trace_alignment_dialog(app), CommandId::StackData => app.stack_selected_data(), CommandId::ExtractMassSpectrum => { app.set_tool(Tool::SelectRegion); diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 2542c5c..f50bbfd 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -33,6 +33,7 @@ pub struct RibbonPlacement { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Applicability { Always, + LineAlignmentOnly, TableOnly, SeriesOnly, Homonuclear2dOnly, @@ -87,6 +88,7 @@ pub enum CommandId { ApplyProcessingTemplate, SpectrumArithmetic, AlignSpectra, + AlignTraces, StackData, ExtractMassSpectrum, SelectRange, @@ -228,6 +230,7 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::ApplyProcessingTemplate, CommandId::SpectrumArithmetic, CommandId::AlignSpectra, + CommandId::AlignTraces, CommandId::StackData, CommandId::ExtractMassSpectrum, CommandId::SelectRange, @@ -483,6 +486,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { app.can_align_spectra(), "Select at least two non-empty 1D NMR spectra, or clear the selection to use all spectra.", ), + CommandId::AlignTraces => requires( + app.trace_alignment_target().is_some(), + "Select a plot with at least two visible line series that use the same x-axis unit.", + ), CommandId::StackData => requires( app.stackable_selection().is_some(), "Select at least two compatible datasets. Trace collections such as electrophysiology require compatible axes and units.", @@ -668,6 +675,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { && app.session.ui.processing_template_dialog.is_none() && app.session.ui.spectrum_arithmetic_dialog.is_none() && app.session.ui.align_spectra_dialog.is_none() + && app.session.ui.trace_alignment_dialog.is_none() && app.session.ui.trace_composer.is_none() && !app.session.ui.interaction.is_active()); // Activation requirements must not trap an already-active tool after the @@ -682,6 +690,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { // `groups_for_tab`, pushing usable groups into the overflow menu. let ribbon = ribbon_placement(id).filter(|placement| match placement.applicability { Applicability::Always => true, + Applicability::LineAlignmentOnly => app.trace_alignment_target().is_some(), Applicability::TableOnly => is_table(), Applicability::SeriesOnly => is_series(), Applicability::Homonuclear2dOnly => dataset() @@ -737,3 +746,7 @@ mod mass_spec_tests; #[cfg(test)] #[path = "commands_xps_tests.rs"] mod xps_tests; + +#[cfg(test)] +#[path = "commands_alignment_tests.rs"] +mod alignment_tests; diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 5e15c59..88fe7f4 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -137,6 +137,7 @@ pub(super) fn command_identity( } CommandId::SpectrumArithmetic => plain("Spectrum Arithmetic…", Some(icon::MATH_OPERATIONS)), CommandId::AlignSpectra => plain("Align Spectra…", Some(icon::ARROWS_LEFT_RIGHT)), + CommandId::AlignTraces => plain("Align Traces…", Some(icon::ARROWS_LEFT_RIGHT)), CommandId::StackData => plain("Stack Selected Data", Some(icon::STACK)), CommandId::ExtractMassSpectrum => plain("Extract Mass Spectrum", Some(icon::WAVE_SINE)), CommandId::SelectRange => ( @@ -378,6 +379,7 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::ApplyProcessingTemplate => "process.apply_template", CommandId::SpectrumArithmetic => "process.arithmetic", CommandId::AlignSpectra => "process.align_spectra", + CommandId::AlignTraces => "analysis.align_traces", CommandId::StackData => "data.stack", CommandId::ExtractMassSpectrum => "analysis.extract_mass_spectrum", CommandId::SelectRange => "analysis.select_range", diff --git a/crates/app/src/ui/commands/ribbon.rs b/crates/app/src/ui/commands/ribbon.rs index 1f39f8f..77c6746 100644 --- a/crates/app/src/ui/commands/ribbon.rs +++ b/crates/app/src/ui/commands/ribbon.rs @@ -4,7 +4,7 @@ use plotx_core::state::{Tool, ToolGroup, WorkflowTab}; use super::{Applicability, CommandId, RibbonPlacement}; pub(super) fn ribbon_placement(id: CommandId) -> Option { - use Applicability::{Always, Homonuclear2dOnly, SeriesOnly, TableOnly}; + use Applicability::{Always, Homonuclear2dOnly, LineAlignmentOnly, SeriesOnly, TableOnly}; use WorkflowTab::{Analyze, Arrange, Data, Figure, Process, View}; let (tab, group, priority, applicability) = match id { CommandId::Tool(Tool::BrowseZoom) | CommandId::ZoomToFit | CommandId::ZoomToSelection => { @@ -28,6 +28,7 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option { Applicability::ToolGroup(ToolGroup::Peaks), ), CommandId::Tool(Tool::Symmetry) => (Analyze, "Review", 1, Homonuclear2dOnly), + CommandId::AlignTraces => (Analyze, "Align", 1, LineAlignmentOnly), CommandId::Tool(Tool::ManualPhase) => (Process, "Correct", 0, Always), CommandId::SpectrumArithmetic | CommandId::AlignSpectra => { (Process, "Transform", 1, Always) diff --git a/crates/app/src/ui/commands_alignment_tests.rs b/crates/app/src/ui/commands_alignment_tests.rs new file mode 100644 index 0000000..2a30260 --- /dev/null +++ b/crates/app/src/ui/commands_alignment_tests.rs @@ -0,0 +1,114 @@ +use super::*; +use plotx_core::actions::Action; +use plotx_core::state::{ + CanvasObject, CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, ElectrophysiologyDataset, + ObjectFrame, TextBox, +}; + +fn alignable_plot_app() -> PlotxApp { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let recording = plotx_io::ElectrophysiologyData { + abf_version: "2.9.0.0".to_owned(), + sample_rate_hz: 10_000.0, + channels: vec![plotx_io::RecordedChannel { + name: "Current".to_owned(), + unit: plotx_io::ElectricalUnit::from_symbol("pA"), + }], + sweeps: vec![ + plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![0.0, -1.0, 0.0]], + commands: Vec::new(), + }, + plotx_io::Sweep { + start_time_s: 0.001, + channels: vec![vec![0.0, -2.0, 0.0]], + commands: Vec::new(), + }, + ], + protocol: None, + source: "alignment-command.abf".to_owned(), + import_warnings: Vec::new(), + }; + let action = Action::insert_dataset_with_default_canvas( + &app, + Dataset::Electrophysiology(Box::new(ElectrophysiologyDataset::load(recording))), + "Alignment command".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + app +} + +#[test] +fn trace_alignment_has_one_contextual_ribbon_command() { + let empty = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + let unavailable = describe(&empty, CommandId::AlignTraces); + assert!(!unavailable.enabled); + assert_eq!(unavailable.ribbon, None); + + let app = alignable_plot_app(); + let command = describe(&app, CommandId::AlignTraces); + assert!(command.enabled); + assert_eq!(command.label, "Align Traces…"); + assert_eq!(command.id.stable_id(), "analysis.align_traces"); + assert_eq!( + command.ribbon, + Some(RibbonPlacement { + tab: WorkflowTab::Analyze, + group: "Align", + priority: 1, + applicability: Applicability::LineAlignmentOnly, + }) + ); +} + +#[test] +fn ribbon_command_opens_the_shared_alignment_dialog() { + let mut app = alignable_plot_app(); + let target = app.trace_alignment_target().unwrap(); + execute_without_clipboard(CommandId::AlignTraces, &mut app, &egui::Context::default()); + let dialog = app.session.ui.trace_alignment_dialog.as_ref().unwrap(); + assert_eq!((dialog.canvas, dialog.object), target); +} + +#[test] +fn ribbon_target_never_guesses_on_a_multi_plot_canvas() { + let mut app = alignable_plot_app(); + let second_id = app.doc.canvases[0].allocate_object_id(); + let second = app.build_plot_object( + 0, + ObjectFrame::new(20.0, 20.0, 300.0, 200.0), + second_id, + "Second plot".to_owned(), + ); + app.doc.canvases[0].objects.push(second); + app.doc.canvases[0].selected_object = None; + assert_eq!(app.trace_alignment_target(), None); + assert_eq!(describe(&app, CommandId::AlignTraces).ribbon, None); + + app.doc.canvases[0].selected_object = Some(second_id); + assert_eq!( + app.trace_alignment_target(), + Some((app.doc.canvases[0].resource_id, second_id)) + ); +} + +#[test] +fn selected_non_plot_never_falls_back_to_an_unrelated_plot() { + let mut app = alignable_plot_app(); + let text_id = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(CanvasObject { + id: text_id, + name: "Note".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 120.0, 40.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(TextBox::label("Note".to_owned())), + }); + app.doc.canvases[0].selected_object = Some(text_id); + + assert_eq!(app.trace_alignment_target(), None); + assert_eq!(describe(&app, CommandId::AlignTraces).ribbon, None); +} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 4a104d1..6f5a8d2 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -34,6 +34,7 @@ mod switcher; #[cfg(not(target_os = "macos"))] mod title_bar; pub(crate) mod tools; +mod trace_alignment; mod trace_composer; mod windows; @@ -91,6 +92,7 @@ pub fn render( || app.session.ui.processing_template_dialog.is_some() || app.session.ui.spectrum_arithmetic_dialog.is_some() || app.session.ui.align_spectra_dialog.is_some() + || app.session.ui.trace_alignment_dialog.is_some() || app.session.ui.trace_composer.is_some() || app.session.ui.command_palette.is_some() || app.session.ui.save_project_options @@ -172,6 +174,7 @@ pub fn render( processing_templates::processing_template_window(app, &ctx); arithmetic::spectrum_arithmetic_window(app, &ctx); align::align_spectra_window(app, &ctx); + trace_alignment::trace_alignment_window(app, &ctx); trace_composer::trace_composer_window(app, &ctx); batch_workflow.show(app, &ctx); @@ -349,9 +352,11 @@ fn feedback_banner(app: &mut PlotxApp, ui: &mut Ui, dark: bool) { fn render_sidebars(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) { let compact = workspace_width < 1200.0; - if !app.session.secondary_sidebar_visible { + let inspector_visible = app.session.secondary_sidebar_visible; + if !inspector_visible { app.finish_axis_overrides_edit(); } + object_inspector::finish_series_edit_if_inactive(app, inspector_visible); if app.session.primary_sidebar_visible { let panel = egui::Panel::left("primary_sidebar") .frame(egui::Frame::NONE.inner_margin(egui::Margin { diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index a827f44..5e2eb4f 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -119,6 +119,26 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(2.0); } +pub(crate) fn finish_series_edit_if_inactive(app: &mut PlotxApp, inspector_visible: bool) { + let target_changed = app + .session + .ui + .series_presentation_edit + .as_ref() + .is_some_and(|edit| { + app.session.active_canvas != Some(edit.canvas) + || app + .doc + .canvases + .get(edit.canvas) + .and_then(|canvas| canvas.selected_object) + != Some(edit.object) + }); + if !inspector_visible || target_changed { + app.finish_series_presentation_edit(); + } +} + fn inspector_header(app: &PlotxApp, canvas: Option, ids: &[ObjectId], ui: &mut Ui) { let context = canvas .map(|canvas| selection_context_label(app, canvas, ids)) diff --git a/crates/app/src/ui/object_inspector/data.rs b/crates/app/src/ui/object_inspector/data.rs index d310ae0..9185242 100644 --- a/crates/app/src/ui/object_inspector/data.rs +++ b/crates/app/src/ui/object_inspector/data.rs @@ -18,7 +18,20 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: let multiple_datasets = binding.dataset_ids().len() > 1; let count = binding.series.len(); let mut next_binding: Option = None; + let mut next_presentation_binding: Option = None; let mut next_stack: Option = None; + let canvas_id = app.doc.canvases[ci].resource_id; + if app.can_align_plot_traces(canvas_id, object) && ui.button("Align traces…").clicked() { + crate::ui::trace_alignment::open_trace_alignment_dialog(app, canvas_id, object); + } + let x_unit = plot_x_unit( + app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| plot.figure().x.label.as_str()) + .unwrap_or("x"), + ) + .to_owned(); if is_stack { ui.horizontal(|ui| { if ui @@ -145,6 +158,19 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: next_binding = Some(b); } if matches!(sb.encoding, plotx_figure::SeriesEncoding::Line(_)) { + if let Some(after) = x_shift_control( + app, + ci, + object, + display_owner, + &persisted_binding, + &binding, + i, + &x_unit, + ui, + ) { + next_presentation_binding = Some(after); + } let mut scale = sb.line_scale(); if ui .add(DragValue::new(&mut scale).speed(0.02).range(0.01..=100.0)) @@ -160,10 +186,27 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: next_binding = Some(b); } } - } else if i != 0 && ui.small_button("Primary").clicked() { - let mut b = binding.clone(); - b.series.swap(0, i); - next_binding = Some(b); + } else { + if matches!(sb.encoding, plotx_figure::SeriesEncoding::Line(_)) + && let Some(after) = x_shift_control( + app, + ci, + object, + display_owner, + &persisted_binding, + &binding, + i, + &x_unit, + ui, + ) + { + next_presentation_binding = Some(after); + } + if i != 0 && ui.small_button("Primary").clicked() { + let mut b = binding.clone(); + b.series.swap(0, i); + next_binding = Some(b); + } } }); }); @@ -213,7 +256,18 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: ui.weak("Stacking is available for line-series plots."); } - if let Some(after) = next_binding + if let Some(after) = next_presentation_binding + && after != binding + { + let after = app.merge_display_binding(display_owner, &persisted_binding, after); + app.execute_action(Action::set_series_presentation( + ci, + object, + persisted_binding, + after, + )); + app.session.status = "Updated plot presentation.".to_owned(); + } else if let Some(after) = next_binding && after != binding { let after = app.merge_display_binding(display_owner, &persisted_binding, after); @@ -244,3 +298,58 @@ fn swatch(ui: &mut Ui, color: Color) { egui::Color32::from_rgb(color.r, color.g, color.b), ); } + +fn plot_x_unit(label: &str) -> &str { + label + .rsplit_once('(') + .and_then(|(_, suffix)| suffix.strip_suffix(')')) + .filter(|unit| !unit.trim().is_empty()) + .unwrap_or(label) +} + +#[allow(clippy::too_many_arguments)] +fn x_shift_control( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + display_owner: Option, + persisted: &DataBinding, + displayed: &DataBinding, + index: usize, + x_unit: &str, + ui: &mut Ui, +) -> Option { + let mut x_shift = displayed.series[index].line_x_shift().unwrap_or(0.0); + let response = ui + .add( + DragValue::new(&mut x_shift) + .speed(0.01) + .max_decimals(6) + .prefix(format!("X shift ({x_unit}) ")), + ) + .on_hover_text(format!("Manual x shift in {x_unit}")); + if response.drag_started() { + app.begin_series_presentation_edit(canvas, object); + } + let gesture_active = app + .session + .ui + .series_presentation_edit + .as_ref() + .is_some_and(|edit| edit.canvas == canvas && edit.object == object); + let mut typed_after = None; + if response.changed() && x_shift.is_finite() { + let mut after = displayed.clone(); + after.series[index].set_line_x_shift(x_shift); + if gesture_active { + let after = app.merge_display_binding(display_owner, persisted, after); + app.set_series_presentation_value(canvas, object, &after); + } else { + typed_after = Some(after); + } + } + if response.drag_stopped() { + app.finish_series_presentation_edit(); + } + typed_after +} diff --git a/crates/app/src/ui/trace_alignment.rs b/crates/app/src/ui/trace_alignment.rs new file mode 100644 index 0000000..b87ca23 --- /dev/null +++ b/crates/app/src/ui/trace_alignment.rs @@ -0,0 +1,289 @@ +use egui::{ComboBox, DragValue}; +use plotx_analysis::alignment::PeakPolarity; +use plotx_core::state::{ + CanvasId, ObjectId, PlotxApp, TraceAlignmentDialogState, TraceAlignmentMethod, + TraceAlignmentOutcome, TraceAlignmentRequest, +}; + +pub(crate) fn open_trace_alignment_dialog(app: &mut PlotxApp, canvas: CanvasId, object: ObjectId) { + let Some(reference) = app.default_trace_alignment_reference(canvas, object) else { + app.session.status = "Trace alignment needs at least two visible line series.".into(); + return; + }; + let Some(ci) = app.doc.canvas_index(canvas) else { + return; + }; + let (lo, hi) = app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| (plot.figure().x.min, plot.figure().x.max)) + .unwrap_or((0.0, 1.0)); + app.session.ui.trace_alignment_dialog = Some(TraceAlignmentDialogState { + canvas, + object, + reference, + method: TraceAlignmentMethod::TraceStart, + peak_window: (lo, hi), + peak_polarity: PeakPolarity::Positive, + plan: None, + history_mark: ( + app.session.undo_stack.len(), + app.session.redo_stack.len(), + app.doc.edit_generation, + ), + }); +} + +pub(crate) fn open_active_trace_alignment_dialog(app: &mut PlotxApp) { + let Some((canvas, object)) = app.trace_alignment_target() else { + app.session.status = "Select a plot with at least two compatible line series.".into(); + return; + }; + open_trace_alignment_dialog(app, canvas, object); +} + +pub(crate) fn trace_alignment_window(app: &mut PlotxApp, ctx: &egui::Context) { + let Some(mut state) = app.session.ui.trace_alignment_dialog.take() else { + return; + }; + let Some(ci) = app.doc.canvas_index(state.canvas) else { + app.session.status = "The alignment page is no longer available.".into(); + return; + }; + let Some((persisted, owner)) = app.doc.canvases[ci] + .object(state.object) + .and_then(|object| object.plot()) + .map(|plot| (plot.binding.clone(), plot.display_owner)) + else { + app.session.status = "The alignment plot is no longer available.".into(); + return; + }; + let binding = app.display_binding(owner, &persisted); + let visible_lines: Vec<_> = binding + .series + .iter() + .filter(|series| { + series.visible && matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_)) + }) + .collect(); + let reference_choices: Vec<_> = visible_lines + .iter() + .copied() + .filter(|candidate| { + let unit = app.line_series_x_unit(candidate); + unit.is_some() + && visible_lines + .iter() + .filter(|series| app.line_series_x_unit(series) == unit) + .count() + >= 2 + }) + .collect(); + if reference_choices.is_empty() { + app.session.status = "Trace alignment needs at least two visible line series.".into(); + return; + } + if !reference_choices + .iter() + .any(|series| series.id == state.reference) + { + state.reference = reference_choices[0].id; + } + let x_unit = app + .trace_alignment_x_unit(state.canvas, state.object, state.reference) + .unwrap_or_else(|| "x".into()); + let mut changed = false; + let mut apply = false; + let mut cancel = false; + let available = ctx.content_rect().size() - egui::vec2(32.0, 48.0); + let size = egui::vec2(820.0, 520.0).min(available).max(egui::vec2( + 320.0_f32.min(available.x), + 280.0_f32.min(available.y), + )); + let modal = super::modal(ctx, "trace_alignment_modal", super::ModalKind::Dialog).show(ctx, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + ui.heading("Align traces"); + ui.separator(); + ui.label("Shift visible traces along x without changing their source data."); + ui.horizontal_wrapped(|ui| { + ui.label("Method"); + let start = matches!(state.method, TraceAlignmentMethod::TraceStart); + if ui.selectable_label(start, "Trace start").clicked() && !start { + state.method = TraceAlignmentMethod::TraceStart; + changed = true; + } + let peak = !start; + if ui.selectable_label(peak, "Peak in window").clicked() && !peak { + let (lo, hi) = state.peak_window; + state.method = TraceAlignmentMethod::PeakInWindow { + lo, + hi, + polarity: state.peak_polarity, + }; + changed = true; + } + }); + ui.horizontal_wrapped(|ui| { + ui.label("Reference"); + let selected = reference_choices + .iter() + .find(|series| series.id == state.reference) + .map(|series| trace_label(app, series)) + .unwrap_or_else(|| "Unavailable".into()); + ComboBox::from_id_salt("trace_alignment_reference") + .selected_text(selected) + .show_ui(ui, |ui| { + for series in &reference_choices { + let label = trace_label(app, series); + if ui.selectable_label(series.id == state.reference, &label) + .on_hover_text(&label) + .clicked() + { + state.reference = series.id; + changed = true; + ui.close(); + } + } + }); + }); + if let TraceAlignmentMethod::PeakInWindow { lo, hi, polarity } = &mut state.method { + ui.horizontal_wrapped(|ui| { + ui.label(format!("Window ({x_unit})")); + changed |= ui.add(DragValue::new(lo).speed(0.01).max_decimals(6)).changed(); + ui.label("to"); + changed |= ui.add(DragValue::new(hi).speed(0.01).max_decimals(6)).changed(); + ui.label("Polarity"); + ComboBox::from_id_salt("trace_alignment_polarity") + .selected_text(polarity_label(*polarity)) + .show_ui(ui, |ui| { + for option in [PeakPolarity::Positive, PeakPolarity::Negative, PeakPolarity::Magnitude] { + changed |= ui.selectable_value(polarity, option, polarity_label(option)).changed(); + } + }); + }); + state.peak_window = (*lo, *hi); + state.peak_polarity = *polarity; + ui.weak("Positive finds upward peaks; Negative finds downward peaks; Magnitude compares positive and negative prominence."); + } else { + ui.weak("Trace start is the first finite plotted sample, not stimulus onset detection."); + } + + let mark = ( + app.session.undo_stack.len(), + app.session.redo_stack.len(), + app.doc.edit_generation, + ); + let request = TraceAlignmentRequest { + canvas: state.canvas, + object: state.object, + reference: state.reference, + method: state.method, + }; + if changed || state.plan.is_none() || state.history_mark != mark { + state.plan = Some(app.plan_trace_alignment(request)); + state.history_mark = mark; + } + ui.separator(); + egui::ScrollArea::both() + .id_salt("trace_alignment_rows") + .max_height((size.y - 250.0).max(100.0)) + .show(ui, |ui| { + egui::Grid::new("trace_alignment_grid").striped(true).show(ui, |ui| { + for heading in [ + "Series".to_owned(), + format!("Current shift ({x_unit})"), + format!("Anchor ({x_unit})"), + format!("Delta ({x_unit})"), + format!("Result ({x_unit})"), + ] { + ui.strong(heading); + } + ui.end_row(); + if let Some(Ok(plan)) = &state.plan { + for row in &plan.rows { + ui.add_sized([250.0, 20.0], egui::Label::new(&row.label).truncate()) + .on_hover_text(&row.label); + ui.monospace(format_number(row.current_shift)); + match &row.outcome { + TraceAlignmentOutcome::Align { anchor, delta, resulting_shift } => { + ui.monospace(format_number(*anchor)); + ui.monospace(format_signed(*delta)); + ui.monospace(format_number(*resulting_shift)); + } + TraceAlignmentOutcome::Reference { anchor } => { + ui.monospace(format_number(*anchor)); + ui.weak("Reference"); + ui.monospace(format_number(row.current_shift)); + } + TraceAlignmentOutcome::Skipped(reason) => { + ui.weak("—"); + ui.colored_label(ui.visuals().warn_fg_color, "Skipped"); + ui.weak(reason); + } + } + ui.end_row(); + } + } + }); + }); + if let Some(Err(error)) = &state.plan { + ui.colored_label(ui.visuals().error_fg_color, error); + } + ui.separator(); + let can_apply = state.plan.as_ref().is_some_and(|plan| { + plan.as_ref().is_ok_and(|plan| plan.alignment_count() > 0) + }); + ui.horizontal(|ui| { + if ui.add_enabled(can_apply, egui::Button::new("Apply")).clicked() { + apply = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if apply { + let request = TraceAlignmentRequest { + canvas: state.canvas, + object: state.object, + reference: state.reference, + method: state.method, + }; + match app.apply_trace_alignment(request) { + Ok(count) => app.session.status = format!("Aligned {count} traces."), + Err(error) => { + app.session.status = error; + app.session.ui.trace_alignment_dialog = Some(state); + } + } + } else if !cancel && !modal.should_close() { + app.session.ui.trace_alignment_dialog = Some(state); + } +} + +fn polarity_label(polarity: PeakPolarity) -> &'static str { + match polarity { + PeakPolarity::Positive => "Positive", + PeakPolarity::Negative => "Negative", + PeakPolarity::Magnitude => "Magnitude", + } +} + +fn format_number(value: f64) -> String { + format!("{value:.6}") +} + +fn format_signed(value: f64) -> String { + format!("{value:+.6}") +} + +fn trace_label(app: &PlotxApp, series: &plotx_core::state::SeriesBinding) -> String { + let source = app + .doc + .dataset_by_id(series.source.resource) + .map(plotx_core::state::Dataset::display_name) + .unwrap_or_else(|| "Missing source".into()); + format!("{source} — {}", app.series_label(series)) +} diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 95e5d41..e1d91ee 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -11,6 +11,7 @@ use validate::{ValidationShape, validate_action}; impl PlotxApp { pub fn execute_action(&mut self, action: Action) { + self.finish_series_presentation_edit(); self.finish_axis_overrides_edit(); if let Err(error) = self.try_execute_action(action) { self.session.status = error.to_string(); @@ -37,6 +38,7 @@ impl PlotxApp { pub fn undo(&mut self) { self.finish_pending_wheel_zoom(f64::INFINITY, true); self.finish_pending_wheel_property(f64::INFINITY, true); + self.finish_series_presentation_edit(); self.finish_axis_overrides_edit(); self.reset_interaction(); let Some(action) = self.session.undo_stack.pop() else { @@ -53,6 +55,7 @@ impl PlotxApp { pub fn redo(&mut self) { self.finish_pending_wheel_zoom(f64::INFINITY, true); self.finish_pending_wheel_property(f64::INFINITY, true); + self.finish_series_presentation_edit(); self.finish_axis_overrides_edit(); self.reset_interaction(); let Some(action) = self.session.redo_stack.pop() else { @@ -91,6 +94,7 @@ impl PlotxApp { self.session.ui.processing_session = None; self.session.ui.property_gesture = None; self.session.ui.inspector_edit = None; + self.session.ui.series_presentation_edit = None; self.session.ui.axis_overrides_before = None; self.session.ui.selection = Selection::None; self.session.ui.panel_note_inline_edit = None; @@ -376,6 +380,67 @@ impl PlotxApp { self.set_object_binding_with_viewport(canvas, object, binding, true); } + pub fn begin_series_presentation_edit(&mut self, canvas: usize, object: ObjectId) { + if self + .session + .ui + .series_presentation_edit + .as_ref() + .is_some_and(|edit| edit.canvas == canvas && edit.object == object) + { + return; + } + self.finish_series_presentation_edit(); + let Some(before) = self + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| object.plot()) + .map(|plot| plot.binding.clone()) + else { + return; + }; + self.session.ui.series_presentation_edit = Some(PendingSeriesPresentationEdit { + canvas, + object, + before, + }); + } + + pub fn set_series_presentation_value( + &mut self, + canvas: usize, + object: ObjectId, + binding: &DataBinding, + ) { + self.begin_series_presentation_edit(canvas, object); + self.set_object_presentation(canvas, object, binding); + self.mark_document_dirty(); + } + + pub fn finish_series_presentation_edit(&mut self) { + let Some(edit) = self.session.ui.series_presentation_edit.take() else { + return; + }; + let Some(after) = self + .doc + .canvases + .get(edit.canvas) + .and_then(|canvas| canvas.object(edit.object)) + .and_then(|object| object.plot()) + .map(|plot| plot.binding.clone()) + else { + return; + }; + self.execute_action(Action::set_series_presentation( + edit.canvas, + edit.object, + edit.before, + after, + )); + } + fn rebuild_plot_presentation(&mut self, canvas: usize, object: ObjectId) { let Some((owner, binding, chart, stack, projections, frame, previous_contours)) = self .doc diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index e81be0c..634c635 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -157,6 +157,14 @@ pub struct PendingInspectorEdit { pub frames: Vec<(ObjectId, ObjectFrame)>, } +/// Coalesces a continuous plot-series presentation gesture into one undo step. +#[derive(Clone)] +pub struct PendingSeriesPresentationEdit { + pub canvas: usize, + pub object: ObjectId, + pub before: DataBinding, +} + #[derive(Clone)] pub enum Action { Composite(Vec), diff --git a/crates/core/src/actions/tests/linefit.rs b/crates/core/src/actions/tests/linefit.rs index f45edfb..6720f72 100644 --- a/crates/core/src/actions/tests/linefit.rs +++ b/crates/core/src/actions/tests/linefit.rs @@ -488,3 +488,25 @@ fn single_plot_color_override_leaves_overlay_colors_alone() { assert_eq!(fig.series[0].color, override_color); assert!(fig.series[1..].iter().all(|s| s.color != override_color)); } + +#[test] +fn manual_x_shift_translates_data_and_fit_export_geometry_once() { + let mut app = two_lorentzian_app(); + app.execute_action(Action::set_line_fits( + dataset_id(&app, 0), + Vec::new(), + vec![stored_sample(0)], + )); + let mut binding = DataBinding::single(&app.doc.datasets[0]); + assert!(binding.series[0].set_line_x_shift(2.0)); + let figure = app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Nmr1d), + &StackSpec::default(), + [120.0, 80.0], + ); + assert_eq!(figure.series.len(), 3); + assert!((figure.series[0].points[0][0] - 2.0).abs() < 1e-12); + assert!((figure.series[1].points[0][0] - 3.0).abs() < 1e-12); + assert!((figure.series[2].points[0][0] - 3.0).abs() < 1e-12); +} diff --git a/crates/core/src/project/field_catalog.rs b/crates/core/src/project/field_catalog.rs index 9fbf0e5..307d7cf 100644 --- a/crates/core/src/project/field_catalog.rs +++ b/crates/core/src/project/field_catalog.rs @@ -35,6 +35,13 @@ pub(super) fn validate_series( "{context} uses an encoding not supported by field {field}" ))); } + if let plotx_figure::SeriesEncoding::Line(line) = encoding + && !line.scale.is_finite() + { + return Err(ProjectError::Invalid(format!( + "{context} uses a non-finite line scale" + ))); + } Ok(()) } diff --git a/crates/core/src/project/reference_tests.rs b/crates/core/src/project/reference_tests.rs index 43aa8ca..164b9f4 100644 --- a/crates/core/src/project/reference_tests.rs +++ b/crates/core/src/project/reference_tests.rs @@ -217,7 +217,7 @@ fn loading_a_maximum_series_id_reports_exhaustion() { "series": [{ "id": u64::MAX, "source": { "kind": "field", "input": recipe, "field": 0 }, - "encoding": {"kind":"line","spec":{"color":{"explicit":{"r":15,"g":77,"b":146}},"scale":1.0,"width":1.0}} + "encoding": {"kind":"line","spec":{"color":{"explicit":{"r":15,"g":77,"b":146}},"scale":1.0,"width":1.0,"x_shift":0.0}} }], "frame": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0 }, "locked": false, diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 1d105b6..6928234 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -217,7 +217,25 @@ impl PlotxApp { .as_nmr() .is_some_and(|nmr| nmr.output_domain() == plotx_io::Domain::Time); if fits_apply { + let data_series_count = fig.series.len(); + let x_shift = binding + .series + .first() + .and_then(SeriesBinding::line_x_shift) + .unwrap_or(0.0); + // Stored fit windows are source coordinates. Temporarily restore + // those bounds for applicability, then translate only the newly + // materialized overlays into the plot's displayed coordinates. + fig.x.min -= x_shift; + fig.x.max -= x_shift; fig = apply_line_fit_overlays(fig, self.doc.datasets[primary].line_fits()); + fig.x.min += x_shift; + fig.x.max += x_shift; + for fit in &mut fig.series[data_series_count..] { + for point in &mut fit.points { + point[0] += x_shift; + } + } } fig } @@ -236,6 +254,9 @@ impl PlotxApp { return; }; let color = line.color.resolve(); + let x_shift = line.x_shift.get(); + figure.x.min += x_shift; + figure.x.max += x_shift; let semantic_colors = figure.series_colors_are_semantic; for series in &mut figure.series { if let Some(label) = &binding.label { @@ -246,6 +267,7 @@ impl PlotxApp { } series.width = line.width.get(); for point in &mut series.points { + point[0] += x_shift; point[1] *= line.scale; } } @@ -254,10 +276,27 @@ impl PlotxApp { error_bar.color = color; } error_bar.width = line.width.get(); + error_bar.center[0] += x_shift; error_bar.center[1] *= line.scale; error_bar.negative *= line.scale.abs(); error_bar.positive *= line.scale.abs(); } + for curve in &mut figure.integral_curves { + curve.start_ppm += x_shift; + curve.end_ppm += x_shift; + } + for polygon in &mut figure.polygons { + for point in &mut polygon.points { + point[0] += x_shift; + } + } + for annotation in &mut figure.annotations { + annotation.at[0] += x_shift; + } + for range in &mut figure.range_annotations { + range.x0 += x_shift; + range.x1 += x_shift; + } if !semantic_colors && figure.heatmap.is_none() && figure.axis_frame != plotx_figure::AxisFrame::Hidden diff --git a/crates/core/src/state/datasets/pseudo_display_binding_tests.rs b/crates/core/src/state/datasets/pseudo_display_binding_tests.rs index 582b78f..3196e59 100644 --- a/crates/core/src/state/datasets/pseudo_display_binding_tests.rs +++ b/crates/core/src/state/datasets/pseudo_display_binding_tests.rs @@ -1,6 +1,64 @@ use super::pseudo_tests::{synthetic_dosy, wait_for_compute}; use super::*; +#[test] +fn trace_alignment_merges_stack_projection_without_changing_map_bindings() { + let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(owner.build_dosy_map()); + owner.display = PseudoDisplay::Stack; + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(owner))); + let page = crate::workflow::build_default_canvas(&app.doc.datasets[0], "Owner"); + app.doc.canvases.push(page); + let canvas = app.doc.canvases[0].resource_id; + let object = app.doc.canvases[0].objects[0].id; + let owner_id = app.doc.datasets[0].resource_id(); + let stack_field = app.doc.datasets[0] + .field_catalog() + .id_for_key("nmr.stack") + .unwrap(); + let displayed = app.display_binding( + Some(owner_id), + &app.doc.canvases[0].objects[0].plot().unwrap().binding, + ); + let reference = displayed.series[0].id; + let map_before: Vec<_> = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series + .iter() + .filter(|series| series.source.field != stack_field) + .cloned() + .collect(); + app.apply_trace_alignment(TraceAlignmentRequest { + canvas, + object, + reference, + method: TraceAlignmentMethod::TraceStart, + }) + .unwrap(); + let map_after: Vec<_> = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series + .iter() + .filter(|series| series.source.field != stack_field) + .cloned() + .collect(); + assert_eq!(map_after, map_before); + app.set_pseudo_display(0, PseudoDisplay::DosyMap); + let map_display = app.display_binding( + Some(owner_id), + &app.doc.canvases[0].objects[0].plot().unwrap().binding, + ); + assert!(matches!( + map_display.series[0].encoding, + plotx_figure::SeriesEncoding::Contour(_) + )); +} + #[test] fn live_binding_projects_the_current_field_and_keeps_external_series() { let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index a38a1a9..a11bfb0 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -12,12 +12,13 @@ use crate::automation::{ CAP_FIELD_SIGNED, CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_TABLE, CAP_FIELD_TRACE_COLLECTION, CAP_FIELD_XPS_SPECTRUM, CapabilityId, }; -use plotx_figure::{ - ContourBasePolicy, ContourStyle, EstimatorSelection, PositiveFiniteF64, SeriesEncoding, - UnitInterval, -}; +use plotx_figure::{ContourStyle, SeriesEncoding}; use std::collections::{BTreeMap, BTreeSet}; +#[path = "field_contour.rs"] +mod field_contour; +pub use field_contour::*; + impl super::Dataset { /// Describes stable child fields and their encoding capabilities. pub fn field_descriptors(&self) -> Vec { @@ -74,6 +75,7 @@ impl super::Dataset { }], "line", ) + .with_line_x_unit(domain_unit(nmr.output_domain())) }) .collect(), Self::Nmr2D(nmr) if nmr.is_true_2d() => { @@ -128,22 +130,25 @@ impl super::Dataset { }; let mut fields = Vec::new(); if let Some(id) = nmr.field_catalog.id_for_key("nmr.stack") { - fields.push(descriptor( - id, - "nmr.stack", - "Stack", - capabilities( + fields.push( + descriptor( id, - &[ - CAP_FIELD_TRACE_COLLECTION, - CAP_FIELD_NMR_STACK, - CAP_FIELD_REGION_SERIES, - ], - ), - vec![nmr.data.rows, nmr.data.cols], - vec![String::new(), domain_unit(stack.direct_domain)], - "line", - )); + "nmr.stack", + "Stack", + capabilities( + id, + &[ + CAP_FIELD_TRACE_COLLECTION, + CAP_FIELD_NMR_STACK, + CAP_FIELD_REGION_SERIES, + ], + ), + vec![nmr.data.rows, nmr.data.cols], + vec![String::new(), domain_unit(stack.direct_domain)], + "line", + ) + .with_line_x_unit(domain_unit(stack.direct_domain)), + ); } if let Some(id) = nmr.field_catalog.id_for_key("nmr.dosy_map") { let dimensions = nmr.dosy_map.as_ref().map_or_else( @@ -197,6 +202,7 @@ impl super::Dataset { Vec::new(), "line", ) + .with_line_x_unit(self.trace_x_unit()) }) .collect() } @@ -208,22 +214,25 @@ impl super::Dataset { .filter_map(|(index, channel)| { let key = electrophysiology_channel_key(recording, index)?; let id = recording.field_catalog.id_for_key(&key)?; - Some(descriptor( - id, - &key, - &channel.name, - capabilities( + Some( + descriptor( id, - &[ - CAP_FIELD_TRACE_COLLECTION, - CAP_FIELD_SWEEP_COLLECTION, - CAP_FIELD_REGION_SERIES, - ], - ), - vec![recording.data.sweeps.len()], - vec![channel.unit.symbol.clone()], - "line", - )) + &key, + &channel.name, + capabilities( + id, + &[ + CAP_FIELD_TRACE_COLLECTION, + CAP_FIELD_SWEEP_COLLECTION, + CAP_FIELD_REGION_SERIES, + ], + ), + vec![recording.data.sweeps.len()], + vec![channel.unit.symbol.clone()], + "line", + ) + .with_line_x_unit("s"), + ) }) .collect(), Self::Afm(afm) => { @@ -299,22 +308,23 @@ impl super::Dataset { ]; for (key, name, capability, length) in entries { if let Some(id) = dataset.field_catalog.id_for_key(&key) { - fields.push(descriptor( - id, - &key, - &name, - capabilities(id, &[capability]), - vec![length], - vec![ - if capability == CAP_FIELD_MASS_SPECTRUM { - "m/z" - } else { - "min" - } - .to_owned(), - ], - "line", - )); + let x_unit = if capability == CAP_FIELD_MASS_SPECTRUM { + "m/z" + } else { + "min" + }; + fields.push( + descriptor( + id, + &key, + &name, + capabilities(id, &[capability]), + vec![length], + vec![x_unit.to_owned()], + "line", + ) + .with_line_x_unit(x_unit), + ); } } } @@ -326,45 +336,54 @@ impl super::Dataset { { let key = channel_key(&channel.id.0); if let Some(id) = dataset.field_catalog.id_for_key(&key) { - fields.push(descriptor( - id, - &key, - &channel.description, - capabilities(id, &[CAP_FIELD_MASS_CHROMATOGRAM]), - vec![channel.values.len()], - vec!["min".to_owned(), channel.unit.clone()], - "line", - )); + fields.push( + descriptor( + id, + &key, + &channel.description, + capabilities(id, &[CAP_FIELD_MASS_CHROMATOGRAM]), + vec![channel.values.len()], + vec!["min".to_owned(), channel.unit.clone()], + "line", + ) + .with_line_x_unit("min"), + ); } } for extraction in &dataset.extracted_spectra { let key = extracted_stream_spectrum_key(extraction.id); if let Some(id) = dataset.field_catalog.id_for_key(&key) { - fields.push(descriptor( - id, - &key, - &extraction_title(&dataset.run, extraction), - capabilities(id, &[CAP_FIELD_MASS_SPECTRUM]), - // Aggregated spectra are computed lazily; descriptor - // discovery must remain a metadata-only operation. - vec![0], - vec!["m/z".to_owned()], - "line", - )); + fields.push( + descriptor( + id, + &key, + &extraction_title(&dataset.run, extraction), + capabilities(id, &[CAP_FIELD_MASS_SPECTRUM]), + // Aggregated spectra are computed lazily; descriptor + // discovery must remain a metadata-only operation. + vec![0], + vec!["m/z".to_owned()], + "line", + ) + .with_line_x_unit("m/z"), + ); } } for xic in &dataset.extracted_ion_chromatograms { let key = xic_key(xic.id); if let Some(id) = dataset.field_catalog.id_for_key(&key) { - fields.push(descriptor( - id, - &key, - &xic_title(&dataset.run, xic), - capabilities(id, &[CAP_FIELD_MASS_CHROMATOGRAM]), - vec![xic.intensity.len()], - vec!["min".to_owned()], - "line", - )); + fields.push( + descriptor( + id, + &key, + &xic_title(&dataset.run, xic), + capabilities(id, &[CAP_FIELD_MASS_CHROMATOGRAM]), + vec![xic.intensity.len()], + vec!["min".to_owned()], + "line", + ) + .with_line_x_unit("min"), + ); } } fields @@ -385,15 +404,18 @@ impl super::Dataset { || region.name.clone(), |m| format!("{} — {}", m.label, region.name), ); - Some(descriptor( - id, - &super::xps_region_key(region.id), - &name, - capabilities(id, &[CAP_FIELD_XPS_SPECTRUM]), - vec![region.intensity_cps.len()], - vec!["eV".to_owned()], - "line", - )) + Some( + descriptor( + id, + &super::xps_region_key(region.id), + &name, + capabilities(id, &[CAP_FIELD_XPS_SPECTRUM]), + vec![region.intensity_cps.len()], + vec!["eV".to_owned()], + "line", + ) + .with_line_x_unit("eV"), + ) }) .collect(), } @@ -623,6 +645,19 @@ pub struct FieldDescriptor { pub metadata: FieldMetadata, } +impl FieldDescriptor { + pub(crate) fn with_line_x_unit(mut self, unit: impl Into) -> Self { + self.metadata + .0 + .insert(LINE_X_UNIT_METADATA_KEY.to_owned(), unit.into()); + self + } + + pub fn line_x_unit(&self) -> Option<&str> { + self.metadata.line_x_unit() + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FieldCapabilities(BTreeSet); @@ -651,10 +686,19 @@ impl FieldCapabilities { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FieldMetadata(pub BTreeMap); +const LINE_X_UNIT_METADATA_KEY: &str = "line_x_unit"; + impl FieldMetadata { pub fn recommended_encoding(&self) -> Option<&str> { self.0.get("recommended_encoding").map(String::as_str) } + + pub fn line_x_unit(&self) -> Option<&str> { + self.0 + .get(LINE_X_UNIT_METADATA_KEY) + .map(String::as_str) + .filter(|unit| !unit.is_empty()) + } } /// A creation-time request. It is resolved to a concrete `SeriesEncoding` @@ -696,104 +740,6 @@ pub fn field_peak_magnitude(dataset: &super::Dataset, field: FieldId) -> Option< (peak > 0.0).then_some(peak) } -/// Stable ids of the contour base policies, shared by the default factory and -/// the property catalog so a base chosen either way is the same value. -pub const CONTOUR_BASE_ABSOLUTE: &str = "absolute"; -pub const CONTOUR_BASE_NOISE_FLOOR: &str = "noise_floor"; -pub const CONTOUR_BASE_BACKGROUND_SCALE: &str = "background_scale"; -pub const CONTOUR_BASE_FRACTION_OF_RANGE: &str = "fraction_of_range"; - -/// The conventional lowest level of a peak-anchored ladder, and the fraction the -/// bounded policy starts from. -const CONTOUR_BASE_FRACTION: f64 = 0.04; -/// The conventional distance from the noise or background floor. -const CONTOUR_BASE_MULTIPLIER: f64 = 5.0; -/// The smallest noise scale a σ-anchored base accepts, as a fraction of the -/// field's peak magnitude. -/// -/// This is a calibration, not a convention, and it is the one number in this -/// file that should be re-measured when new evidence arrives. -/// -/// A noise estimator measures thermal noise. A 2D plane with large dynamic -/// range also carries the sampling artefacts of its own strongest feature — -/// indirect-dimension (t₁) noise ridges and residual solvent ridges — whose -/// amplitude scales with that feature rather than with the thermal floor, and -/// which are conventionally quoted at 10⁻³ to 10⁻⁴ of the parent peak. A level -/// below that traces artefacts, not signal. -/// -/// Measured on a 2048 × 8192 ¹H–¹H NOESY (peak 3.304e8, robust σ 1.669e3, so -/// 197,900:1 dynamic range) by counting the grid crossings of a single level -/// swept geometrically: 2.81e6 crossings at 0.001 % of peak, 8.99e5 at 0.004 %, -/// then 7.56e4 at 0.008 % and a smooth halving per octave above that. The knee -/// at ≈ 0.008 % of peak — 16 σ — is where contours stop following the artefact -/// floor, and it agrees with the conventional t₁-noise magnitude. The floor is -/// set at that knee, and the ladder's own 5× multiplier then places the lowest -/// level five artefact-floor units above it, exactly as 5σ places it five -/// thermal-noise units above thermal noise. -/// -/// Re-calibrate if the noise estimator changes what it measures, if the -/// renderer's segment budget changes, or if fields are seen whose artefact floor -/// sits elsewhere. The floor binds only above a dynamic range of 1/this value; -/// below it the estimated scale wins and nothing about resolution changes. -const CONTOUR_NOISE_FLOOR_PEAK_FRACTION: f64 = 1.0e-4; - -pub fn contour_base_kind(policy: &ContourBasePolicy) -> &'static str { - match policy { - ContourBasePolicy::Absolute(_) => CONTOUR_BASE_ABSOLUTE, - ContourBasePolicy::NoiseFloor { .. } => CONTOUR_BASE_NOISE_FLOOR, - ContourBasePolicy::BackgroundScale { .. } => CONTOUR_BASE_BACKGROUND_SCALE, - ContourBasePolicy::FractionOfRange(_) => CONTOUR_BASE_FRACTION_OF_RANGE, - } -} - -/// The canonical parameters of one base policy. -/// -/// Whether a policy *may* be chosen is a capability question answered by the -/// caller; this only says what it looks like when it is. Returns `None` for an -/// unknown id rather than substituting a policy the caller did not ask for. -pub fn contour_base_policy(kind: &str, peak: PeakMagnitude<'_>) -> Option { - let policy = match kind { - CONTOUR_BASE_ABSOLUTE => ContourBasePolicy::Absolute(absolute_base(peak)), - CONTOUR_BASE_NOISE_FLOOR => ContourBasePolicy::NoiseFloor { - multiplier: PositiveFiniteF64::new(CONTOUR_BASE_MULTIPLIER) - .expect("literal multiplier is valid"), - peak_fraction: UnitInterval::new(CONTOUR_NOISE_FLOOR_PEAK_FRACTION) - .expect("literal fraction is valid"), - estimator: EstimatorSelection::Frozen { - estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), - version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, - }, - }, - CONTOUR_BASE_BACKGROUND_SCALE => ContourBasePolicy::BackgroundScale { - multiplier: PositiveFiniteF64::new(CONTOUR_BASE_MULTIPLIER) - .expect("literal multiplier is valid"), - estimator: EstimatorSelection::Frozen { - estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), - version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, - }, - }, - CONTOUR_BASE_FRACTION_OF_RANGE => ContourBasePolicy::FractionOfRange( - UnitInterval::new(CONTOUR_BASE_FRACTION).expect("literal fraction is valid"), - ), - _ => return None, - }; - Some(policy) -} - -/// An absolute base anchored to the field's own peak. -/// -/// A fixed literal cannot serve here: a base of one intensity unit draws nothing -/// at all on any field whose peak is below one, and does so silently, with no -/// control in the panel that explains the blank plot. The peak is the only -/// scale-free anchor available when no capability offers a better one; the -/// literal remains solely as the last resort when even that is unknown. -fn absolute_base(peak: PeakMagnitude<'_>) -> PositiveFiniteF64 { - peak() - .map(|peak| peak * CONTOUR_BASE_FRACTION) - .and_then(PositiveFiniteF64::new) - .unwrap_or_else(|| PositiveFiniteF64::new(1.0).expect("literal base is valid")) -} - #[cfg(test)] #[path = "field_tests.rs"] mod tests; diff --git a/crates/core/src/state/field_contour.rs b/crates/core/src/state/field_contour.rs new file mode 100644 index 0000000..c5dc8a7 --- /dev/null +++ b/crates/core/src/state/field_contour.rs @@ -0,0 +1,100 @@ +use super::PeakMagnitude; +use plotx_figure::{ContourBasePolicy, EstimatorSelection, PositiveFiniteF64, UnitInterval}; + +/// Stable ids of the contour base policies, shared by the default factory and +/// the property catalog so a base chosen either way is the same value. +pub const CONTOUR_BASE_ABSOLUTE: &str = "absolute"; +pub const CONTOUR_BASE_NOISE_FLOOR: &str = "noise_floor"; +pub const CONTOUR_BASE_BACKGROUND_SCALE: &str = "background_scale"; +pub const CONTOUR_BASE_FRACTION_OF_RANGE: &str = "fraction_of_range"; + +/// The conventional lowest level of a peak-anchored ladder, and the fraction the +/// bounded policy starts from. +const CONTOUR_BASE_FRACTION: f64 = 0.04; +/// The conventional distance from the noise or background floor. +const CONTOUR_BASE_MULTIPLIER: f64 = 5.0; +/// The smallest noise scale a σ-anchored base accepts, as a fraction of the +/// field's peak magnitude. +/// +/// This is a calibration, not a convention, and it is the one number in this +/// file that should be re-measured when new evidence arrives. +/// +/// A noise estimator measures thermal noise. A 2D plane with large dynamic +/// range also carries the sampling artefacts of its own strongest feature — +/// indirect-dimension (t₁) noise ridges and residual solvent ridges — whose +/// amplitude scales with that feature rather than with the thermal floor, and +/// which are conventionally quoted at 10⁻³ to 10⁻⁴ of the parent peak. A level +/// below that traces artefacts, not signal. +/// +/// Measured on a 2048 × 8192 ¹H–¹H NOESY (peak 3.304e8, robust σ 1.669e3, so +/// 197,900:1 dynamic range) by counting the grid crossings of a single level +/// swept geometrically: 2.81e6 crossings at 0.001 % of peak, 8.99e5 at 0.004 %, +/// then 7.56e4 at 0.008 % and a smooth halving per octave above that. The knee +/// at ≈ 0.008 % of peak — 16 σ — is where contours stop following the artefact +/// floor, and it agrees with the conventional t₁-noise magnitude. The floor is +/// set at that knee, and the ladder's own 5× multiplier then places the lowest +/// level five artefact-floor units above it, exactly as 5σ places it five +/// thermal-noise units above thermal noise. +/// +/// Re-calibrate if the noise estimator changes what it measures, if the +/// renderer's segment budget changes, or if fields are seen whose artefact floor +/// sits elsewhere. The floor binds only above a dynamic range of 1/this value; +/// below it the estimated scale wins and nothing about resolution changes. +const CONTOUR_NOISE_FLOOR_PEAK_FRACTION: f64 = 1.0e-4; + +pub fn contour_base_kind(policy: &ContourBasePolicy) -> &'static str { + match policy { + ContourBasePolicy::Absolute(_) => CONTOUR_BASE_ABSOLUTE, + ContourBasePolicy::NoiseFloor { .. } => CONTOUR_BASE_NOISE_FLOOR, + ContourBasePolicy::BackgroundScale { .. } => CONTOUR_BASE_BACKGROUND_SCALE, + ContourBasePolicy::FractionOfRange(_) => CONTOUR_BASE_FRACTION_OF_RANGE, + } +} + +/// The canonical parameters of one base policy. +/// +/// Whether a policy *may* be chosen is a capability question answered by the +/// caller; this only says what it looks like when it is. Returns `None` for an +/// unknown id rather than substituting a policy the caller did not ask for. +pub fn contour_base_policy(kind: &str, peak: PeakMagnitude<'_>) -> Option { + let policy = match kind { + CONTOUR_BASE_ABSOLUTE => ContourBasePolicy::Absolute(absolute_base(peak)), + CONTOUR_BASE_NOISE_FLOOR => ContourBasePolicy::NoiseFloor { + multiplier: PositiveFiniteF64::new(CONTOUR_BASE_MULTIPLIER) + .expect("literal multiplier is valid"), + peak_fraction: UnitInterval::new(CONTOUR_NOISE_FLOOR_PEAK_FRACTION) + .expect("literal fraction is valid"), + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + }, + CONTOUR_BASE_BACKGROUND_SCALE => ContourBasePolicy::BackgroundScale { + multiplier: PositiveFiniteF64::new(CONTOUR_BASE_MULTIPLIER) + .expect("literal multiplier is valid"), + estimator: EstimatorSelection::Frozen { + estimator: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_ID.to_owned(), + version: plotx_analysis::robust::DEPLANED_LOCATION_SCALE_VERSION, + }, + }, + CONTOUR_BASE_FRACTION_OF_RANGE => ContourBasePolicy::FractionOfRange( + UnitInterval::new(CONTOUR_BASE_FRACTION).expect("literal fraction is valid"), + ), + _ => return None, + }; + Some(policy) +} + +/// An absolute base anchored to the field's own peak. +/// +/// A fixed literal cannot serve here: a base of one intensity unit draws nothing +/// at all on any field whose peak is below one, and does so silently, with no +/// control in the panel that explains the blank plot. The peak is the only +/// scale-free anchor available when no capability offers a better one; the +/// literal remains solely as the last resort when even that is unknown. +fn absolute_base(peak: PeakMagnitude<'_>) -> PositiveFiniteF64 { + peak() + .map(|peak| peak * CONTOUR_BASE_FRACTION) + .and_then(PositiveFiniteF64::new) + .unwrap_or_else(|| PositiveFiniteF64::new(1.0).expect("literal base is valid")) +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 880e35e..af48c10 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -1,6 +1,7 @@ use crate::actions::{ Action, DatasetProcessingState, PendingCanvasSizeEdit, PendingInspectorEdit, - PendingPageLayoutEdit, PendingProcessingEdit, PendingViewportEdit, + PendingPageLayoutEdit, PendingProcessingEdit, PendingSeriesPresentationEdit, + PendingViewportEdit, }; use crate::export::{ExportDialogState, ExportFormat, ExportSettings}; use crate::{ @@ -105,6 +106,7 @@ mod table_fit; mod table_native; mod table_numeric; mod tile_drop; +mod trace_alignment; mod trace_composer; mod trace_provider; #[cfg(test)] @@ -186,6 +188,7 @@ pub use table_execution::*; pub use table_execution_job::*; pub use table_native::*; pub use tile_drop::*; +pub use trace_alignment::*; pub use ui_drag::*; pub use ui_state::*; pub use units::*; diff --git a/crates/core/src/state/series_binding.rs b/crates/core/src/state/series_binding.rs index 70f74da..7401fe7 100644 --- a/crates/core/src/state/series_binding.rs +++ b/crates/core/src/state/series_binding.rs @@ -110,6 +110,24 @@ impl SeriesBinding { } } + pub fn line_x_shift(&self) -> Option { + match &self.encoding { + plotx_figure::SeriesEncoding::Line(line) => Some(line.x_shift.get()), + _ => None, + } + } + + pub fn set_line_x_shift(&mut self, value: f64) -> bool { + let Some(value) = plotx_figure::FiniteF64::new(value) else { + return false; + }; + let plotx_figure::SeriesEncoding::Line(line) = &mut self.encoding else { + return false; + }; + line.x_shift = value; + true + } + pub fn primary_color(&self) -> Option { match &self.encoding { plotx_figure::SeriesEncoding::Line(line) => Some(line.color.resolve()), diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 87e8b18..1f8c6fd 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -1,6 +1,13 @@ use super::*; use plotx_figure::{ErrorBar, Series}; +struct PreparedLine { + index: usize, + x_bounds: [f64; 2], + series: Vec, + error_bars: Vec, +} + impl PlotxApp { /// Whether a binding has one stackable representation. Item-addressed line /// traces use their field contracts and may cross enclosing data domains; @@ -92,13 +99,16 @@ impl PlotxApp { } else { self.build_full_canvas_figure(primary, &line_chart, size_mm) }; + if let Some(primary_binding) = binding.series.first() { + self.apply_series_binding_style(&mut fig, primary_binding); + } let x_span = (fig.x.max - fig.x.min).abs().max(f64::MIN_POSITIVE); fig.series.clear(); fig.error_bars.clear(); // Build each visible trace's (scaled, optionally normalized) line series, // tracking the global peak the vertical offset scales against. - let mut prepared: Vec<(usize, Vec, Vec)> = Vec::new(); + let mut prepared = Vec::new(); let mut global_peak = 0.0f64; for (i, sb) in binding.series.iter().enumerate() { let Some(dataset) = self.doc.dataset_index(sb.source.resource) else { @@ -116,6 +126,7 @@ impl PlotxApp { self.build_full_canvas_figure(dataset, &line_chart, size_mm) }; self.apply_series_binding_style(&mut part, sb); + let part_x_bounds = [part.x.min, part.x.max]; let mut series = part.series; let mut error_bars = part.error_bars; let peak = series @@ -140,13 +151,22 @@ impl PlotxApp { error_bar.positive *= factor.abs(); } global_peak = global_peak.max(trace_peak); - prepared.push((i, series, error_bars)); + prepared.push(PreparedLine { + index: i, + x_bounds: part_x_bounds, + series, + error_bars, + }); } let stacked = matches!(stack.mode, StackMode::Offset); - let (mut x_min, mut x_max) = (fig.x.min, fig.x.max); + let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY); let (mut y_min, mut y_max) = (fig.y.min, fig.y.max); - for (i, mut series, mut error_bars) in prepared { + for prepared in prepared { + let i = prepared.index; + let part_x_bounds = prepared.x_bounds; + let mut series = prepared.series; + let mut error_bars = prepared.error_bars; let x_off = if stacked { i as f64 * stack.shear_x * x_span } else { @@ -158,6 +178,8 @@ impl PlotxApp { 0.0 }; let active = stack.active == Some(i); + x_min = x_min.min(part_x_bounds[0] + x_off); + x_max = x_max.max(part_x_bounds[1] + x_off); for mut s in series.drain(..) { for p in &mut s.points { p[0] += x_off; @@ -185,8 +207,10 @@ impl PlotxApp { fig.error_bars.push(error_bar); } } - fig.x.min = x_min; - fig.x.max = x_max; + if x_min.is_finite() && x_max.is_finite() { + fig.x.min = x_min; + fig.x.max = x_max; + } fig.y.min = y_min; fig.y.max = y_max; if !binding.primary_visible() { diff --git a/crates/core/src/state/trace_alignment.rs b/crates/core/src/state/trace_alignment.rs new file mode 100644 index 0000000..8b8a644 --- /dev/null +++ b/crates/core/src/state/trace_alignment.rs @@ -0,0 +1,388 @@ +use super::*; +use plotx_analysis::alignment::{PeakPolarity, trace_peak_anchor}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TraceAlignmentMethod { + TraceStart, + PeakInWindow { + lo: f64, + hi: f64, + polarity: PeakPolarity, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TraceAlignmentRequest { + pub canvas: CanvasId, + pub object: ObjectId, + pub reference: SeriesId, + pub method: TraceAlignmentMethod, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TraceAlignmentOutcome { + Align { + anchor: f64, + delta: f64, + resulting_shift: f64, + }, + Reference { + anchor: f64, + }, + Skipped(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TraceAlignmentRow { + pub series: SeriesId, + pub label: String, + pub current_shift: f64, + pub outcome: TraceAlignmentOutcome, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TraceAlignmentPlan { + pub request: TraceAlignmentRequest, + pub x_unit: String, + pub reference_anchor: Option, + pub rows: Vec, +} + +impl TraceAlignmentPlan { + pub fn alignment_count(&self) -> usize { + self.rows + .iter() + .filter(|row| matches!(row.outcome, TraceAlignmentOutcome::Align { .. })) + .count() + } +} + +impl PlotxApp { + /// Resolve the plot targeted by global alignment entry points such as the Ribbon. + pub fn trace_alignment_target(&self) -> Option<(CanvasId, ObjectId)> { + let ci = self.session.active_canvas?; + let canvas = self.doc.canvases.get(ci)?; + let object = if let Some(selected) = canvas.selected_object { + canvas.object(selected)?.plot().map(|_| selected)? + } else { + let mut plots = canvas + .objects + .iter() + .filter(|object| object.plot().is_some()) + .map(|object| object.id); + let only = plots.next()?; + plots.next().is_none().then_some(only)? + }; + self.can_align_plot_traces(canvas.resource_id, object) + .then_some((canvas.resource_id, object)) + } + + pub fn line_series_x_unit(&self, series: &SeriesBinding) -> Option { + line_x_unit(self, series) + } + + pub fn trace_alignment_x_unit( + &self, + canvas: CanvasId, + object: ObjectId, + series: SeriesId, + ) -> Option { + let ci = self.doc.canvas_index(canvas)?; + let plot = self.doc.canvases[ci].object(object)?.plot()?; + let displayed = self.display_binding(plot.display_owner, &plot.binding); + let series = displayed + .series + .iter() + .find(|candidate| candidate.id == series)?; + line_x_unit(self, series) + } + + pub fn default_trace_alignment_reference( + &self, + canvas: CanvasId, + object: ObjectId, + ) -> Option { + let ci = self.doc.canvas_index(canvas)?; + let plot = self.doc.canvases[ci].object(object)?.plot()?; + let displayed = self.display_binding(plot.display_owner, &plot.binding); + displayed.series.iter().find_map(|candidate| { + let unit = eligible_trace_unit(self, candidate)?; + (displayed + .series + .iter() + .filter(|series| eligible_trace_unit(self, series).as_ref() == Some(&unit)) + .count() + >= 2) + .then_some(candidate.id) + }) + } + + pub fn can_align_plot_traces(&self, canvas: CanvasId, object: ObjectId) -> bool { + self.default_trace_alignment_reference(canvas, object) + .is_some() + } + + pub fn plan_trace_alignment( + &mut self, + request: TraceAlignmentRequest, + ) -> Result { + if !trace_alignment_method_valid(request.method) { + return Err("Alignment settings must be finite and the window must have width.".into()); + } + let ci = self + .doc + .canvas_index(request.canvas) + .ok_or_else(|| "The alignment page is no longer available.".to_owned())?; + let (persisted, display_owner, chart, frame) = self.doc.canvases[ci] + .object(request.object) + .and_then(|object| { + object.plot().map(|plot| { + ( + plot.binding.clone(), + plot.display_owner, + plot.chart.clone(), + object.frame, + ) + }) + }) + .ok_or_else(|| "The alignment plot is no longer available.".to_owned())?; + let binding = self.display_binding(display_owner, &persisted); + let reference = binding + .series + .iter() + .find(|series| series.id == request.reference) + .ok_or_else(|| "The reference series is no longer available.".to_owned())?; + if !reference.visible + || !matches!(reference.encoding, plotx_figure::SeriesEncoding::Line(_)) + { + return Err("The reference must be a visible line series.".into()); + } + let reference_unit = line_x_unit(self, reference) + .ok_or_else(|| "The reference trace has no x-axis unit contract.".to_owned())?; + for series in &binding.series { + validate_live_line_source(self, series)?; + } + + let size = [frame.width / MM_TO_PT, frame.height / MM_TO_PT]; + let mut anchors = std::collections::BTreeMap::new(); + for series in binding.series.iter().filter(|series| { + series.visible && matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_)) + }) { + let mut materialized = series.clone(); + materialized.visible = true; + let figure = self.build_binding_figure( + &DataBinding { + series: vec![materialized], + }, + &chart, + &StackSpec::default(), + size, + ); + anchors.insert(series.id, detected_anchor(&figure, request.method)); + } + let reference_anchor = anchors.get(&request.reference).copied().flatten(); + let mut rows = Vec::with_capacity(binding.series.len()); + for series in &binding.series { + let current_shift = series.line_x_shift().unwrap_or(0.0); + let label = alignment_series_label(self, series); + let outcome = if !matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_)) { + TraceAlignmentOutcome::Skipped("Not a line series.".into()) + } else if !series.visible { + TraceAlignmentOutcome::Skipped( + "Hidden series are not aligned automatically.".into(), + ) + } else if line_x_unit(self, series).as_ref() != Some(&reference_unit) { + TraceAlignmentOutcome::Skipped("The x-axis unit differs from the reference.".into()) + } else { + match (reference_anchor, anchors.get(&series.id).copied().flatten()) { + (None, _) => { + TraceAlignmentOutcome::Skipped("The reference has no usable anchor.".into()) + } + (_, None) => TraceAlignmentOutcome::Skipped(match request.method { + TraceAlignmentMethod::TraceStart => "No finite plotted sample.".into(), + TraceAlignmentMethod::PeakInWindow { .. } => { + "No significant peak in the window.".into() + } + }), + (Some(anchor), Some(_)) if series.id == request.reference => { + TraceAlignmentOutcome::Reference { anchor } + } + (Some(reference), Some(anchor)) => { + let delta = reference - anchor; + let resulting_shift = current_shift + delta; + if !delta.is_finite() || !resulting_shift.is_finite() { + return Err("Alignment produced a non-finite shift.".into()); + } + TraceAlignmentOutcome::Align { + anchor, + delta, + resulting_shift, + } + } + } + }; + rows.push(TraceAlignmentRow { + series: series.id, + label, + current_shift, + outcome, + }); + } + Ok(TraceAlignmentPlan { + request, + x_unit: reference_unit, + reference_anchor, + rows, + }) + } + + pub fn apply_trace_alignment( + &mut self, + request: TraceAlignmentRequest, + ) -> Result { + // Always recompute from current absolute shifts. The preview is advisory + // and cannot smuggle stale series identities into a document action. + let plan = self.plan_trace_alignment(request)?; + if plan.alignment_count() == 0 { + return Err("No non-reference series can be aligned.".into()); + } + let ci = self + .doc + .canvas_index(request.canvas) + .ok_or_else(|| "The alignment page is no longer available.".to_owned())?; + let (before, display_owner) = self.doc.canvases[ci] + .object(request.object) + .and_then(CanvasObject::plot) + .map(|plot| (plot.binding.clone(), plot.display_owner)) + .ok_or_else(|| "The alignment plot is no longer available.".to_owned())?; + let mut displayed_after = self.display_binding(display_owner, &before); + for row in &plan.rows { + let TraceAlignmentOutcome::Align { + resulting_shift, .. + } = row.outcome + else { + continue; + }; + let series = displayed_after + .series + .iter_mut() + .find(|series| series.id == row.series) + .ok_or_else(|| "A series changed before alignment could be applied.".to_owned())?; + if !series.set_line_x_shift(resulting_shift) { + return Err("Alignment produced an invalid line shift.".into()); + } + } + let after = self.merge_display_binding(display_owner, &before, displayed_after); + self.execute_action(Action::set_series_presentation( + ci, + request.object, + before, + after, + )); + Ok(plan.alignment_count()) + } +} + +fn trace_alignment_method_valid(method: TraceAlignmentMethod) -> bool { + match method { + TraceAlignmentMethod::TraceStart => true, + TraceAlignmentMethod::PeakInWindow { lo, hi, .. } => { + lo.is_finite() && hi.is_finite() && lo != hi + } + } +} + +fn line_x_unit(app: &PlotxApp, series: &SeriesBinding) -> Option { + app.doc + .dataset_by_id(series.source.resource) + .and_then(|dataset| dataset.field_descriptor(series.source.field)) + .and_then(|descriptor| descriptor.line_x_unit().map(str::to_owned)) +} + +fn eligible_trace_unit(app: &PlotxApp, series: &SeriesBinding) -> Option { + (series.visible && matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_))) + .then(|| line_x_unit(app, series))? +} + +fn validate_live_line_source(app: &PlotxApp, series: &SeriesBinding) -> Result<(), String> { + if !matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_)) { + return Ok(()); + } + let dataset = app + .doc + .dataset_by_id(series.source.resource) + .ok_or_else(|| format!("Series {} references a missing dataset.", series.id))?; + if !dataset.has_field(series.source.field) + || !dataset.supports_encoding(series.source.field, &series.encoding) + { + return Err(format!( + "Series {} references an unavailable line field.", + series.id + )); + } + match ( + series.source.item, + dataset.trace_collection(series.source.field), + ) { + (Some(item), Some(collection)) if collection.item(item).is_some() => Ok(()), + (Some(_), _) => Err(format!("Series {} references a missing trace.", series.id)), + (None, Some(_)) => Err(format!( + "Series {} does not identify a trace item.", + series.id + )), + (None, None) => Ok(()), + } +} + +fn detected_anchor(figure: &plotx_figure::Figure, method: TraceAlignmentMethod) -> Option { + match method { + TraceAlignmentMethod::TraceStart => figure + .series + .iter() + .flat_map(|series| series.points.iter()) + .find_map(|point| (point[0].is_finite() && point[1].is_finite()).then_some(point[0])), + TraceAlignmentMethod::PeakInWindow { lo, hi, polarity } => { + let mut x = Vec::new(); + let mut y = Vec::new(); + for point in figure.series.iter().flat_map(|series| &series.points) { + if point[0].is_finite() && point[1].is_finite() { + x.push(point[0]); + y.push(point[1]); + } + } + trace_peak_anchor(&x, &y, lo, hi, polarity) + } + } +} + +fn alignment_series_label(app: &PlotxApp, series: &SeriesBinding) -> String { + let item = app.series_label(series); + let source = app + .doc + .dataset_by_id(series.source.resource) + .map(Dataset::display_name) + .unwrap_or_else(|| "Missing source".into()); + format!("{source} — {item}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_start_requires_one_finite_plotted_sample() { + let figure = plotx_figure::Figure::new( + "trace", + plotx_figure::Axis::new("x", 0.0, 2.0), + plotx_figure::Axis::new("y", 0.0, 2.0), + ) + .with_series(plotx_figure::Series::line( + "trace", + vec![[0.0, f64::NAN], [1.0, 2.0]], + )); + assert_eq!( + detected_anchor(&figure, TraceAlignmentMethod::TraceStart), + Some(1.0) + ); + } +} diff --git a/crates/core/src/state/trace_alignment_tests.rs b/crates/core/src/state/trace_alignment_tests.rs new file mode 100644 index 0000000..588d48c --- /dev/null +++ b/crates/core/src/state/trace_alignment_tests.rs @@ -0,0 +1,756 @@ +use super::*; + +fn alignment_recording_app() -> (PlotxApp, CanvasId, ObjectId, Vec) { + let mut dataset = recording("pA", None); + let recording = dataset.as_electrophysiology_mut().unwrap(); + recording.processing.gaussian_lowpass_enabled = false; + recording.data.sweeps[1].channels[0] = vec![f64::NAN, 3.0, 4.0]; + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let mut canvas = CanvasDocument::new("alignment".into(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + let mut object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + id, + "Traces".into(), + ); + object.plot_mut().unwrap().mint_series_ids(); + let ids = object + .plot() + .unwrap() + .binding + .series + .iter() + .map(|series| series.id) + .collect(); + canvas.objects.push(object); + canvas.selected_object = Some(id); + let canvas_id = canvas.resource_id; + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.rebuild_canvas(0); + (app, canvas_id, id, ids) +} + +#[test] +fn trace_start_composes_absolute_shifts_as_one_undo_step() { + let (mut app, canvas, object, ids) = alignment_recording_app(); + let source_before = source_bits(&app.doc.datasets[0]); + let binding_before = { + let plot = app.doc.canvases[0] + .object_mut(object) + .unwrap() + .plot_mut() + .unwrap(); + assert!(plot.binding.series[1].set_line_x_shift(0.5)); + plot.binding.clone() + }; + app.rebuild_canvas(0); + let request = TraceAlignmentRequest { + canvas, + object, + reference: ids[0], + method: TraceAlignmentMethod::TraceStart, + }; + let viewport_before = { + let plot = app.doc.canvases[0] + .object_mut(object) + .unwrap() + .plot_mut() + .unwrap(); + plot.viewport.view_x = AxisRange::new(0.00002, 0.00008); + plot.apply_viewport(); + plot.viewport.clone() + }; + let plan = app.plan_trace_alignment(request).unwrap(); + let TraceAlignmentOutcome::Align { + anchor, + delta, + resulting_shift, + } = &plan.rows[1].outcome + else { + panic!("second trace should align: {:?}", plan.rows[1].outcome) + }; + assert!((*anchor - 0.5001).abs() < 1e-12); + assert!((*delta + 0.5001).abs() < 1e-12); + assert!((*resulting_shift + 0.0001).abs() < 1e-12); + + let history = app.session.undo_stack.len(); + assert_eq!(app.apply_trace_alignment(request).unwrap(), 1); + assert_eq!(app.session.undo_stack.len(), history + 1); + assert!(matches!( + app.session.undo_stack.last(), + Some(Action::SetSeriesPresentation { .. }) + )); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .viewport + .view_x, + viewport_before.view_x + ); + let plot = app.doc.canvases[0].object(object).unwrap().plot().unwrap(); + assert_eq!( + plot.binding.series.iter().map(|s| s.id).collect::>(), + ids + ); + assert!((plot.binding.series[1].line_x_shift().unwrap() + 0.0001).abs() < 1e-12); + assert!( + (plot.figure().series[0].points[0][0] - plot.figure().series[1].points[0][0]).abs() < 1e-12 + ); + assert_eq!(source_bits(&app.doc.datasets[0]), source_before); + + let repeat = app.plan_trace_alignment(request).unwrap(); + let TraceAlignmentOutcome::Align { delta, .. } = repeat.rows[1].outcome else { + panic!("second trace should remain alignable") + }; + assert!(delta.abs() < 1e-12); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + binding_before + ); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .viewport + .view_x, + viewport_before.view_x + ); + app.redo(); + assert!( + (app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .series[1] + .line_x_shift() + .unwrap() + + 0.0001) + .abs() + < 1e-12 + ); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .viewport + .view_x, + viewport_before.view_x + ); +} + +#[test] +fn manual_shift_round_trips_in_strict_v1_project() { + let (mut app, _, object, _) = app_for_dataset(recording("pA", None)); + assert!( + app.doc.canvases[0] + .object_mut(object) + .unwrap() + .plot_mut() + .unwrap() + .binding + .series[0] + .set_line_x_shift(0.125) + ); + app.rebuild_canvas(0); + let path = std::env::temp_dir().join(format!( + "plotx-trace-alignment-{}.plotx", + uuid::Uuid::new_v4() + )); + crate::project::save_project(&app, &path, false).unwrap(); + let loaded = crate::project::load_project(&path).unwrap(); + let _ = std::fs::remove_file(path); + assert_eq!( + loaded.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .series[0] + .line_x_shift(), + Some(0.125) + ); +} + +#[test] +fn hidden_and_stale_requests_are_atomic() { + let (mut app, canvas, object, ids) = alignment_recording_app(); + app.doc.canvases[0] + .object_mut(object) + .unwrap() + .plot_mut() + .unwrap() + .binding + .series[1] + .visible = false; + let before = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + let request = TraceAlignmentRequest { + canvas, + object, + reference: ids[0], + method: TraceAlignmentMethod::TraceStart, + }; + let plan = app.plan_trace_alignment(request).unwrap(); + assert!(matches!( + plan.rows[1].outcome, + TraceAlignmentOutcome::Skipped(_) + )); + assert!(app.apply_trace_alignment(request).is_err()); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + before + ); + assert!(app.session.undo_stack.is_empty()); + assert!( + app.plan_trace_alignment(TraceAlignmentRequest { + reference: SeriesId::new(999), + ..request + }) + .is_err() + ); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + before + ); +} + +#[test] +fn manual_shift_moves_single_trace_and_preserves_provider_bounds() { + let dataset = recording("pA", None); + let mut binding = DataBinding { + series: vec![DataBinding::single(&dataset).series[0].clone()], + }; + assert!(binding.series[0].set_line_x_shift(2.5)); + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let build = |app: &mut PlotxApp| { + app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Electrophysiology), + &StackSpec::default(), + [120.0, 80.0], + ) + }; + let figure = build(&mut app); + assert_eq!(figure.series[0].points[0][0], 2.5); + assert_eq!([figure.x.min, figure.x.max], [2.5, 2.5002]); + assert_eq!(build(&mut app).series[0].points[0][0], 2.5); +} + +#[test] +fn continuous_manual_shift_commits_one_presentation_action() { + let (mut app, _, object, _) = alignment_recording_app(); + let before = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + let view_x = { + let plot = app.doc.canvases[0] + .object_mut(object) + .unwrap() + .plot_mut() + .unwrap(); + plot.viewport.view_x = AxisRange::new(0.00002, 0.00008); + plot.apply_viewport(); + plot.viewport.view_x + }; + let history = app.session.undo_stack.len(); + let revision = app.doc.automation_revision; + + app.begin_series_presentation_edit(0, object); + for shift in [0.1, 0.2, 0.3] { + let mut after = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + assert!(after.series[0].set_line_x_shift(shift)); + app.set_series_presentation_value(0, object, &after); + } + assert_eq!(app.session.undo_stack.len(), history); + assert_eq!(app.doc.automation_revision, revision); + app.finish_series_presentation_edit(); + + assert_eq!(app.session.undo_stack.len(), history + 1); + assert_eq!(app.doc.automation_revision, revision + 1); + assert!(matches!( + app.session.undo_stack.last(), + Some(Action::SetSeriesPresentation { .. }) + )); + let after = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + assert_eq!(after.series[0].line_x_shift(), Some(0.3)); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .viewport + .view_x, + view_x + ); + + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + before + ); + app.redo(); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + after + ); +} + +#[test] +fn stacked_shift_bounds_union_each_provider_range() { + let dataset = recording("pA", None); + let mut binding = DataBinding::single(&dataset); + assert!(binding.series[0].set_line_x_shift(-1.0)); + assert!(binding.series[1].set_line_x_shift(2.0)); + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let chart = ChartSpec::default_for(DataDomain::Electrophysiology); + let parts: Vec<_> = binding + .series + .iter() + .map(|series| { + app.build_binding_figure( + &DataBinding { + series: vec![series.clone()], + }, + &chart, + &StackSpec::default(), + [120.0, 80.0], + ) + }) + .collect(); + let stacked = app.build_binding_figure(&binding, &chart, &StackSpec::default(), [120.0, 80.0]); + assert_eq!(stacked.x.min, parts[0].x.min.min(parts[1].x.min)); + assert_eq!(stacked.x.max, parts[0].x.max.max(parts[1].x.max)); +} + +#[test] +fn pseudo_increment_uses_the_same_plot_owned_plan() { + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let field = dataset.field_catalog().id_for_key("nmr.stack").unwrap(); + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let mut canvas = CanvasDocument::new("pseudo alignment".into(), [120.0, 80.0]); + let object = canvas.allocate_object_id(); + let mut plot_object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + object, + "Pseudo traces".into(), + ); + let plot = plot_object.plot_mut().unwrap(); + plot.binding = DataBinding { + series: SeriesBinding::from_field_all(&app.doc.datasets[0], field)[..2].to_vec(), + }; + plot.mint_series_ids(); + let ids: Vec<_> = plot.binding.series.iter().map(|series| series.id).collect(); + assert!(plot.binding.series[1].set_line_x_shift(0.25)); + canvas.objects.push(plot_object); + let canvas_id = canvas.resource_id; + app.doc.canvases.push(canvas); + app.rebuild_canvas(0); + let plan = app + .plan_trace_alignment(TraceAlignmentRequest { + canvas: canvas_id, + object, + reference: ids[0], + method: TraceAlignmentMethod::TraceStart, + }) + .unwrap(); + let TraceAlignmentOutcome::Align { + resulting_shift, .. + } = plan.rows[1].outcome + else { + panic!("pseudo increment should align") + }; + assert!(resulting_shift.abs() < 1e-12); +} + +#[test] +fn peak_window_uses_displayed_coordinates() { + let mut dataset = recording("pA", None); + let recording = dataset.as_electrophysiology_mut().unwrap(); + recording.processing.gaussian_lowpass_enabled = false; + recording.data.sweeps[0].channels[0] = vec![0.0, 0.0, 8.0, 0.0, 0.0, 0.0, 0.0]; + recording.data.sweeps[1].channels[0] = vec![0.0, 0.0, 0.0, 0.0, 0.0, 8.0, 0.0]; + let (mut app, canvas, object, ids) = app_for_dataset(dataset); + let plan = app + .plan_trace_alignment(TraceAlignmentRequest { + canvas, + object, + reference: ids[0], + method: TraceAlignmentMethod::PeakInWindow { + lo: 0.0, + hi: 0.001, + polarity: plotx_analysis::alignment::PeakPolarity::Positive, + }, + }) + .unwrap(); + let TraceAlignmentOutcome::Align { delta, .. } = plan.rows[1].outcome else { + panic!("second peak should align") + }; + assert!((delta + 0.0003).abs() < 1e-12); +} + +#[test] +fn selected_channel_projection_preserves_other_channel_bindings() { + let dataset = multichannel_recording(["mV", "pA"], 1, "channels.abf"); + let (mut app, canvas, object, _) = app_for_dataset(dataset); + let active_field = app.doc.datasets[0].active_trace_collection_field().unwrap(); + let persisted_before = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + let displayed = { + let plot = app.doc.canvases[0].object(object).unwrap().plot().unwrap(); + app.display_binding(plot.display_owner, &plot.binding) + }; + assert_eq!(displayed.series.len(), 2); + assert!( + displayed + .series + .iter() + .all(|series| series.source.field == active_field) + ); + app.apply_trace_alignment(TraceAlignmentRequest { + canvas, + object, + reference: displayed.series[0].id, + method: TraceAlignmentMethod::TraceStart, + }) + .unwrap(); + let persisted_after = &app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding; + for before in persisted_before + .series + .iter() + .filter(|series| series.source.field != active_field) + { + assert_eq!( + persisted_after + .series + .iter() + .find(|series| series.id == before.id), + Some(before) + ); + } +} + +#[test] +fn automatic_alignment_skips_incompatible_x_units() { + let (mut app, canvas, object, ids) = alignment_recording_app(); + let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let field = pseudo.field_catalog().id_for_key("nmr.stack").unwrap(); + let mut extra = SeriesBinding::from_field_all(&pseudo, field)[0].clone(); + extra.id = SeriesId::new(99); + app.doc.datasets.push(pseudo); + app.doc.canvases[0] + .object_mut(object) + .unwrap() + .plot_mut() + .unwrap() + .binding + .series + .push(extra); + let plan = app + .plan_trace_alignment(TraceAlignmentRequest { + canvas, + object, + reference: ids[0], + method: TraceAlignmentMethod::TraceStart, + }) + .unwrap(); + assert!(matches!( + plan.rows.last().unwrap().outcome, + TraceAlignmentOutcome::Skipped(ref reason) if reason.contains("unit differs") + )); +} + +#[test] +fn provider_line_x_units_describe_plotted_x_axes() { + for response_unit in ["pA", "mV"] { + let dataset = recording(response_unit, None); + let field = dataset.active_trace_collection_field().unwrap(); + assert_eq!( + dataset.field_descriptor(field).unwrap().line_x_unit(), + Some("s") + ); + } + let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let field = pseudo.field_catalog().id_for_key("nmr.stack").unwrap(); + assert_eq!( + pseudo.field_descriptor(field).unwrap().line_x_unit(), + Some("ppm") + ); +} + +fn scalar_nmr(source: &str, carrier_ppm: f64) -> Dataset { + Dataset::Nmr(Box::new(NmrDataset::load(plotx_io::NmrData { + points: vec![num_complex::Complex64::new(1.0, 0.0); 8], + domain: plotx_io::Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm, + nucleus: "1H".to_owned(), + source: source.to_owned(), + group_delay: 0.0, + }))) +} + +#[test] +fn ordinary_scalar_line_stack_uses_the_same_alignment_planner() { + let mut app = PlotxApp::new(); + app.doc.datasets.push(scalar_nmr("before", 4.0)); + app.doc.datasets.push(scalar_nmr("after", 5.0)); + + let mut canvas = CanvasDocument::new("scalar alignment".into(), [120.0, 80.0]); + let object = canvas.allocate_object_id(); + let mut plot_object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + object, + "NMR comparison".into(), + ); + let plot = plot_object.plot_mut().unwrap(); + plot.display_owner = None; + plot.binding = DataBinding { + series: app + .doc + .datasets + .iter() + .map(|dataset| DataBinding::single(dataset).series.remove(0)) + .collect(), + }; + plot.mint_series_ids(); + let reference = plot.binding.series[0].id; + assert!( + plot.binding + .series + .iter() + .all(|series| series.source.item.is_none()) + ); + canvas.objects.push(plot_object); + canvas.selected_object = Some(object); + let canvas_id = canvas.resource_id; + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.rebuild_canvas(0); + + let raw_before = app + .doc + .datasets + .iter() + .map(|dataset| { + dataset + .as_nmr() + .unwrap() + .data + .points + .iter() + .map(|point| (point.re.to_bits(), point.im.to_bits())) + .collect::>() + }) + .collect::>(); + let binding_before = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + assert_eq!(app.trace_alignment_target(), Some((canvas_id, object))); + let request = TraceAlignmentRequest { + canvas: canvas_id, + object, + reference, + method: TraceAlignmentMethod::TraceStart, + }; + let plan = app.plan_trace_alignment(request).unwrap(); + assert_eq!(plan.x_unit, "ppm"); + assert_eq!(plan.alignment_count(), 1); + + let history = app.session.undo_stack.len(); + assert_eq!(app.apply_trace_alignment(request).unwrap(), 1); + assert_eq!(app.session.undo_stack.len(), history + 1); + assert!(matches!( + app.session.undo_stack.last(), + Some(Action::SetSeriesPresentation { .. }) + )); + let binding_after = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding + .clone(); + assert_ne!(binding_after, binding_before); + let figure = app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .figure(); + assert!((figure.series[0].points[0][0] - figure.series[1].points[0][0]).abs() < 1e-12); + assert_eq!( + app.doc + .datasets + .iter() + .map(|dataset| { + dataset + .as_nmr() + .unwrap() + .data + .points + .iter() + .map(|point| (point.re.to_bits(), point.im.to_bits())) + .collect::>() + }) + .collect::>(), + raw_before + ); + + let path = std::env::temp_dir().join(format!( + "plotx-scalar-line-alignment-{}.plotx", + uuid::Uuid::new_v4() + )); + crate::project::save_project(&app, &path, false).unwrap(); + let loaded = crate::project::load_project(&path).unwrap(); + let _ = std::fs::remove_file(path); + assert_eq!( + loaded.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + binding_after + ); + + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + binding_before + ); + app.redo(); + assert_eq!( + app.doc.canvases[0] + .object(object) + .unwrap() + .plot() + .unwrap() + .binding, + binding_after + ); +} + +fn app_for_dataset(dataset: Dataset) -> (PlotxApp, CanvasId, ObjectId, Vec) { + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let mut canvas = CanvasDocument::new("alignment".into(), [120.0, 80.0]); + let object = canvas.allocate_object_id(); + let mut plot_object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, 340.0, 220.0), + object, + "Traces".into(), + ); + plot_object.plot_mut().unwrap().mint_series_ids(); + let ids = plot_object + .plot() + .unwrap() + .binding + .series + .iter() + .map(|series| series.id) + .collect(); + canvas.objects.push(plot_object); + let canvas_id = canvas.resource_id; + app.doc.canvases.push(canvas); + app.rebuild_canvas(0); + (app, canvas_id, object, ids) +} + +fn source_bits(dataset: &Dataset) -> Vec { + dataset + .as_electrophysiology() + .unwrap() + .data + .sweeps + .iter() + .flat_map(|sweep| &sweep.channels) + .flat_map(|channel| channel.iter().map(|value| value.to_bits())) + .collect() +} diff --git a/crates/core/src/state/trace_provider_tests.rs b/crates/core/src/state/trace_provider_tests.rs index f47fb30..766ae20 100644 --- a/crates/core/src/state/trace_provider_tests.rs +++ b/crates/core/src/state/trace_provider_tests.rs @@ -786,3 +786,6 @@ fn electrophysiology_trace_figures_drop_non_finite_points() { .all(|value| value.is_finite()) ); } + +#[path = "trace_alignment_tests.rs"] +mod trace_alignment_tests; diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 82a85e0..983eeef 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -14,6 +14,8 @@ pub use task_dock::TaskDockTab; mod trace_composer; pub use trace_composer::{TraceComposerItem, TraceComposerState}; +mod trace_alignment; +pub use trace_alignment::TraceAlignmentDialogState; /// Which sidebar entry an in-progress inline rename targets. #[derive(Clone, Copy, PartialEq, Eq)] @@ -254,6 +256,7 @@ pub struct UiState { pub property_gesture: Option, pub property_text_edits: Vec, pub inspector_edit: Option, + pub series_presentation_edit: Option, /// Pre-edit snapshot for a plot-local axis text/range gesture. pub axis_overrides_before: Option<(usize, ObjectId, AxisOverrides)>, pub canvas_settings: Option, @@ -291,6 +294,7 @@ pub struct UiState { pub processing_template_dialog: Option, pub spectrum_arithmetic_dialog: Option, pub align_spectra_dialog: Option, + pub trace_alignment_dialog: Option, pub trace_composer: Option, pub selection: Selection, pub selection_scope: SelectionScope, @@ -470,6 +474,7 @@ impl Default for UiState { property_gesture: None, property_text_edits: Vec::new(), inspector_edit: None, + series_presentation_edit: None, axis_overrides_before: None, canvas_settings: None, figure_typography_open: false, @@ -496,6 +501,7 @@ impl Default for UiState { processing_template_dialog: None, spectrum_arithmetic_dialog: None, align_spectra_dialog: None, + trace_alignment_dialog: None, trace_composer: None, selection: Selection::None, selection_scope: SelectionScope::default(), diff --git a/crates/core/src/state/ui_state/trace_alignment.rs b/crates/core/src/state/ui_state/trace_alignment.rs new file mode 100644 index 0000000..5056dad --- /dev/null +++ b/crates/core/src/state/ui_state/trace_alignment.rs @@ -0,0 +1,13 @@ +use super::{CanvasId, ObjectId, SeriesId, TraceAlignmentMethod, TraceAlignmentPlan}; + +#[derive(Clone, Debug)] +pub struct TraceAlignmentDialogState { + pub canvas: CanvasId, + pub object: ObjectId, + pub reference: SeriesId, + pub method: TraceAlignmentMethod, + pub peak_window: (f64, f64), + pub peak_polarity: plotx_analysis::alignment::PeakPolarity, + pub plan: Option>, + pub history_mark: (usize, usize, u64), +} diff --git a/crates/core/src/state/xrd.rs b/crates/core/src/state/xrd.rs index ae3a927..4246bff 100644 --- a/crates/core/src/state/xrd.rs +++ b/crates/core/src/state/xrd.rs @@ -75,20 +75,23 @@ impl XrdDataset { pub(crate) fn field_descriptors(&self) -> Vec { self.field_id() .into_iter() - .map(|id| FieldDescriptor { - id, - local_id: "xrd.intensity".to_owned(), - name: "Intensity".to_owned(), - capabilities: FieldCapabilities::new([ - CapabilityId::new(CAP_FIELD_CURVE_1D), - CapabilityId::new(CAP_FIELD_XRD_PATTERN), - ]), - dimensions: vec![self.data.len()], - units: vec!["deg".to_owned(), "a.u.".to_owned()], - metadata: FieldMetadata(BTreeMap::from([( - "recommended_encoding".to_owned(), - "line".to_owned(), - )])), + .map(|id| { + FieldDescriptor { + id, + local_id: "xrd.intensity".to_owned(), + name: "Intensity".to_owned(), + capabilities: FieldCapabilities::new([ + CapabilityId::new(CAP_FIELD_CURVE_1D), + CapabilityId::new(CAP_FIELD_XRD_PATTERN), + ]), + dimensions: vec![self.data.len()], + units: vec!["deg".to_owned(), "a.u.".to_owned()], + metadata: FieldMetadata(BTreeMap::from([( + "recommended_encoding".to_owned(), + "line".to_owned(), + )])), + } + .with_line_x_unit("deg") }) .collect() } diff --git a/crates/figure/src/encoding.rs b/crates/figure/src/encoding.rs index aa644f4..7cf6bc7 100644 --- a/crates/figure/src/encoding.rs +++ b/crates/figure/src/encoding.rs @@ -20,6 +20,31 @@ impl PositiveFiniteF64 { } } +/// A finite signed scalar used by persisted presentation settings. +#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct FiniteF64(f64); + +impl FiniteF64 { + pub fn new(value: f64) -> Option { + value.is_finite().then_some(Self(value)) + } + + pub const fn get(self) -> f64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for FiniteF64 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = f64::deserialize(deserializer)?; + Self::new(value).ok_or_else(|| de::Error::custom("expected a finite value")) + } +} + impl<'de> Deserialize<'de> for PositiveFiniteF64 { fn deserialize(deserializer: D) -> Result where @@ -108,10 +133,13 @@ pub enum EstimatorSelection { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LineEncoding { pub color: ColorSource, pub scale: f64, pub width: PositiveFiniteF32, + /// Plot-owned translation in the x-axis coordinate system. + pub x_shift: FiniteF64, } impl Default for LineEncoding { @@ -121,6 +149,7 @@ impl Default for LineEncoding { scale: 1.0, width: PositiveFiniteF32::new(DEFAULT_DATA_LINE_WIDTH_PT) .expect("literal width is valid"), + x_shift: FiniteF64::default(), } } } @@ -344,4 +373,20 @@ mod tests { .is_ok() ); } + + #[test] + fn strict_line_encoding_requires_one_finite_x_shift() { + let value = serde_json::to_value(LineEncoding::default()).unwrap(); + assert_eq!(value["x_shift"], 0.0); + + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove("x_shift"); + assert!(serde_json::from_value::(missing).is_err()); + + let mut unknown = value.clone(); + unknown["legacy_shift"] = serde_json::json!(1.0); + assert!(serde_json::from_value::(unknown).is_err()); + + assert!(serde_json::from_str::("1e999").is_err()); + } } diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index 5a38eb1..457cb38 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -7,8 +7,8 @@ mod encoding; pub use colormap::ColormapId; pub use encoding::{ ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, - DEFAULT_DATA_LINE_WIDTH_PT, EstimatorSelection, HeatmapSpec, ImageInterpolation, ImageSpec, - LineEncoding, PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, UnitInterval, + DEFAULT_DATA_LINE_WIDTH_PT, EstimatorSelection, FiniteF64, HeatmapSpec, ImageInterpolation, + ImageSpec, LineEncoding, PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, UnitInterval, }; /// An RGB color, 0–255 per channel. diff --git a/docs/src/content/docs/guides/electrophysiology.md b/docs/src/content/docs/guides/electrophysiology.md index 18922f4..1d956f7 100644 --- a/docs/src/content/docs/guides/electrophysiology.md +++ b/docs/src/content/docs/guides/electrophysiology.md @@ -18,6 +18,21 @@ from the selected compatible recording. Use **Show all**, **Hide all**, the row checkboxes, or remove buttons to reduce the stack to the voltages or currents you want to compare. +To align sweep peaks, select the plot and choose **Align Traces…** in the +**Align** group on the **Analyze** tab. You can also choose **Align traces…** +under **Data** in the Object inspector. Choose a reference sweep, then use +**Peak in window** and enter the time window to search. **Positive** finds +upward peaks, **Negative** finds downward peaks, and **Magnitude** compares +their prominence. **Trace start** instead aligns the first finite plotted +sample; it does not detect stimulus onset. + +Review each sweep's **Anchor**, **Delta**, and **Result** before selecting +**Apply**. Hidden sweeps and sweeps without a usable peak are listed as +**Skipped** and remain unchanged. To correct one sweep manually, edit its **X +shift (s)** under **Data**; this is also available on a single-sweep plot. +Automatic and manual shifts are undoable plot settings: they do not alter the +imported samples or dataset exports. + To compare recordings, select two or more compatible recordings in the Data browser and choose **Stack selected data**. The trace composer lists every sweep from each recording's selected channel and starts with all of them diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index af4f639..6edb8ea 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -124,6 +124,24 @@ A single plot frame can display several 1D datasets — superimposed, or stacked with adjustable vertical spacing and 3D shear. 2D datasets combine as a color overlay. +To align line series along x, select the plot and choose **Align Traces…** in +the **Align** group on the **Analyze** tab. The command is available when the +plot contains at least two visible line series with the same x-axis unit. You +can open the same dialog with **Align traces…** under **Data** in the Object +inspector. + +Choose a reference series, then align by **Trace start** (the first finite +plotted sample) or by a peak within an x window. For peak alignment, +**Positive** finds upward peaks, **Negative** finds downward peaks, and +**Magnitude** compares their prominence. Review the **Anchor**, **Delta**, and +**Result** columns before selecting **Apply**. Hidden series, incompatible +series, and series without a usable anchor are listed as **Skipped** and remain +unchanged. + +Alignment preserves the plot's current zoom. To adjust one line manually, edit +its **X shift** under **Data**. Automatic and manual shifts are undoable plot +settings and do not affect source data or data exports. + ## Plot styling and typography PlotX styles plots for print automatically: clean bottom-and-left axes with diff --git a/docs/src/content/docs/guides/pseudo-2d.md b/docs/src/content/docs/guides/pseudo-2d.md index f5a1dbd..b4d72ef 100644 --- a/docs/src/content/docs/guides/pseudo-2d.md +++ b/docs/src/content/docs/guides/pseudo-2d.md @@ -27,6 +27,18 @@ increments; **Cancel** leaves the project unchanged. This remains available while a dataset displays a DOSY map because the composer selects stable stack increments rather than copying data from the displayed map. +To align peaks in a stack on the canvas, select the plot and choose **Align +Traces…** in the **Align** group on the **Analyze** tab. You can also choose +**Align traces…** under **Data** in the Object inspector. Choose a reference +increment and a peak window, then review the proposed shifts before selecting +**Apply**. Hidden increments and increments without a usable peak are listed as +**Skipped** and remain unchanged. **Trace start** is also available when you +need to align the first finite plotted sample instead of a peak. + +For a manual correction, edit **X shift (ppm)** on the increment's line row. +These undoable shifts belong to the plot, so the processed spectra and DOSY or +ILT maps remain unchanged. + ## Workflow 1. Import the pseudo-2D dataset. diff --git a/docs/src/content/docs/zh-cn/guides/electrophysiology.md b/docs/src/content/docs/zh-cn/guides/electrophysiology.md index c3c992e..1b9bb43 100644 --- a/docs/src/content/docs/zh-cn/guides/electrophysiology.md +++ b/docs/src/content/docs/zh-cn/guides/electrophysiology.md @@ -15,6 +15,17 @@ float32、单/多记录通道、定长或变长 sweep、ADC 缩放、通道名 recording 的全部 sweep。可用 **Show all**、**Hide all**、每行的复选框或删除按钮, 把 stack 缩减到需要比较的电压或电流。 +若要对齐 sweep 的峰,请选中图形,然后在 **Analyze** 页签的 **Align** 组中选择 +**Align Traces…**。也可以在对象检查器的 **Data** 中选择 **Align traces…**。 +选择参考 sweep 后,用 **Peak in window** 输入要搜索的时间窗。**Positive** 查找 +向上的峰,**Negative** 查找向下的峰,**Magnitude** 比较两者的突出度。若选择 +**Trace start**,则会对齐第一个有限绘制样本,而不是检测刺激起点。 + +选择 **Apply** 前,请检查每条 sweep 的 **Anchor**、**Delta** 和 **Result**。 +隐藏的 sweep 以及没有可用峰的 sweep 会列为 **Skipped**,并保持不变。若要手动 +校正一条 sweep,可在 **Data** 中编辑其 **X shift (s)**;单 sweep 图中也有此项。 +自动和手动位移都是可撤销的图形设置,不会改动导入样本或数据集导出。 + 若要比较多个 recording,请在 Data 浏览器中选择两个或更多兼容的 recording, 然后选择 **Stack selected data**。trace composer 会列出每个 recording 当前所选 通道中的全部 sweep,并默认全部纳入。可按数据集、sweep 标签或参数值搜索,再用 diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 4047d08..ae49379 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -104,6 +104,20 @@ object**——此后按住 `Alt` 就只为这一次拖放删除空页面。 一个图框可以同时显示多个 1D 数据集——可叠加显示,也可以按可调的垂直 间距与 3D 错切堆叠。2D 数据集则以颜色叠加方式组合。 +若要沿 x 轴对齐线条序列,请选中图形,然后在 **Analyze** 页签的 **Align** 组中 +选择 **Align Traces…**。当图中至少有两条可见线条序列使用相同的 x 轴单位时, +此命令才可用。也可以从对象检查器的 **Data** 中选择 **Align traces…**,打开 +同一个对话框。 + +选择参考序列后,可用 **Trace start**(第一个有限绘制样本)对齐,也可在指定的 +x 区间内按峰对齐。按峰对齐时,**Positive** 查找向上的峰,**Negative** 查找 +向下的峰,**Magnitude** 比较两者的突出度。选择 **Apply** 前,请检查 +**Anchor**、**Delta** 和 **Result** 列。隐藏、不兼容或没有可用对齐位置的序列会 +列为 **Skipped**,并保持不变。 + +对齐会保留图形当前的缩放范围。若要手动调整一条线,可在 **Data** 中编辑其 +**X shift**。自动和手动位移都是可撤销的图形设置,不会改变来源数据或数据导出。 + ## 作图样式与排印 PlotX 自动按印刷习惯设定图形样式:只保留左轴和底轴、向外的短刻度、随 diff --git a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md index be7b171..128f1ae 100644 --- a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md +++ b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md @@ -22,6 +22,15 @@ scales** 中将 **Visibility** 设为 **Show**。图例会使用各增量的梯 即使数据集当前显示 DOSY 图,此功能仍然可用,因为 composer 选择的是稳定的 stack 增量,而不是复制当前图中的数据。 +若要对齐画布上 stack 中的峰,请选中图形,然后在 **Analyze** 页签的 **Align** +组中选择 **Align Traces…**。也可以在对象检查器的 **Data** 中选择 **Align +traces…**。选择参考增量和峰区间后,在选择 **Apply** 前检查建议的位移。隐藏的 +增量以及没有可用峰的增量会列为 **Skipped**,并保持不变。若要按第一个有限绘制 +样本而不是峰对齐,可选择 **Trace start**。 + +若要手动校正,可在增量的线条行中编辑 **X shift (ppm)**。这些可撤销的位移只 +属于图形,因此不会改变处理后的谱图,也不会改变 DOSY 或 ILT 图。 + ## 工作流 1. 导入伪 2D 数据集。