From 0906bee24318c13966a38bb792a3fb2296200160 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 7 Aug 2026 21:50:30 +0800 Subject: [PATCH] feat(core): unify trace collection bindings Add stable item-addressed sources for electrophysiology and pseudo-2D trace collections. Unify stack display, per-series authoring, truthful map fields, and transient analysis selection behind plot-owned bindings. Persist strict v1 references and styling, with aligned user documentation and regression coverage. --- crates/app/src/ui/canvas/mod.rs | 2 + crates/app/src/ui/canvas/navigation_tests.rs | 1 + crates/app/src/ui/object_inspector.rs | 2 +- crates/app/src/ui/object_inspector/data.rs | 123 +++-- crates/app/src/ui/tools/electrophysiology.rs | 42 +- .../src/actions/app_impl/axis_overrides.rs | 5 +- crates/core/src/actions/app_impl/mod.rs | 21 +- .../core/src/actions/tests/stable_identity.rs | 6 + crates/core/src/actions/tests/stack.rs | 22 +- crates/core/src/automation/resources.rs | 1 + crates/core/src/data_export.rs | 31 +- crates/core/src/data_export/tests.rs | 2 +- crates/core/src/export/precheck.rs | 3 + crates/core/src/figures.rs | 2 +- crates/core/src/project/convert_views.rs | 80 +++- crates/core/src/project/dto.rs | 8 +- crates/core/src/project/dto_series_source.rs | 51 ++ .../src/project/electrophysiology_tests.rs | 21 +- crates/core/src/project/field_catalog.rs | 20 + crates/core/src/project/lineage_tests.rs | 1 + crates/core/src/project/pseudo_tests.rs | 60 +++ crates/core/src/project/reference_tests.rs | 66 ++- crates/core/src/project/tests.rs | 37 ++ crates/core/src/properties/axis_tests.rs | 2 +- crates/core/src/properties/object.rs | 40 +- crates/core/src/properties/target.rs | 15 +- crates/core/src/state/app_impl.rs | 48 +- .../src/state/app_impl_analysis_tables.rs | 71 ++- .../core/src/state/app_impl_analysis_tests.rs | 146 +++++- crates/core/src/state/app_impl_figures.rs | 234 ++++++++-- crates/core/src/state/charts.rs | 4 +- crates/core/src/state/dataset_trace.rs | 103 ++++ crates/core/src/state/datasets.rs | 3 + .../datasets/pseudo_display_binding_tests.rs | 305 ++++++++++++ .../core/src/state/datasets/pseudo_tests.rs | 195 +++++++- crates/core/src/state/datasets_2d_figure.rs | 46 ++ crates/core/src/state/document.rs | 6 +- crates/core/src/state/electrophysiology.rs | 201 ++++++-- crates/core/src/state/field.rs | 105 +++-- crates/core/src/state/field_catalog.rs | 221 ++++++++- crates/core/src/state/field_payload.rs | 45 +- crates/core/src/state/mass_spec_tests.rs | 2 + crates/core/src/state/mod.rs | 13 +- crates/core/src/state/plot_object.rs | 7 + crates/core/src/state/pseudo_map_field.rs | 72 +++ crates/core/src/state/series_binding.rs | 67 ++- crates/core/src/state/stack.rs | 242 ++++++++-- crates/core/src/state/table.rs | 3 + crates/core/src/state/trace_provider.rs | 77 +++ crates/core/src/state/trace_provider_tests.rs | 438 ++++++++++++++++++ crates/core/src/workflow.rs | 55 ++- crates/core/src/workflow/mass_spec_layout.rs | 1 + crates/core/src/workflow/trace_collection.rs | 62 +++ crates/data/src/id.rs | 20 + crates/data/src/lib.rs | 2 + crates/data/src/trace_collection.rs | 161 +++++++ .../content/docs/guides/electrophysiology.md | 34 +- docs/src/content/docs/guides/pseudo-2d.md | 10 + .../docs/zh-cn/guides/electrophysiology.md | 32 +- .../content/docs/zh-cn/guides/pseudo-2d.md | 8 + 60 files changed, 3325 insertions(+), 378 deletions(-) create mode 100644 crates/core/src/project/dto_series_source.rs create mode 100644 crates/core/src/state/datasets/pseudo_display_binding_tests.rs create mode 100644 crates/core/src/state/pseudo_map_field.rs create mode 100644 crates/core/src/state/trace_provider.rs create mode 100644 crates/core/src/state/trace_provider_tests.rs create mode 100644 crates/core/src/workflow/trace_collection.rs create mode 100644 crates/data/src/trace_collection.rs diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index ddaff8d..408da4e 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -635,6 +635,7 @@ mod tests { Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); let viewport = CanvasViewport::from_figure(&figure); PlotObject::new( + None, plotx_core::state::SeriesId::new(1), plotx_core::state::DataBinding { series: Vec::new() }, plotx_core::state::ChartSpec::default(), @@ -669,6 +670,7 @@ mod tests { Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); let viewport = CanvasViewport::from_figure(&figure); PlotObject::new( + None, plotx_core::state::SeriesId::new(1), plotx_core::state::DataBinding { series: Vec::new() }, plotx_core::state::ChartSpec::default(), diff --git a/crates/app/src/ui/canvas/navigation_tests.rs b/crates/app/src/ui/canvas/navigation_tests.rs index 937bb7f..c2e07c6 100644 --- a/crates/app/src/ui/canvas/navigation_tests.rs +++ b/crates/app/src/ui/canvas/navigation_tests.rs @@ -24,6 +24,7 @@ fn zoomed_plot_fixture() -> (PlotxApp, ObjectId, PlotRect) { visible: true, group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, plotx_core::state::SeriesId::new(1), plotx_core::state::DataBinding { series: Vec::new() }, plotx_core::state::ChartSpec::default(), diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index b6b4602..a827f44 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -17,7 +17,7 @@ use geometry::geometry_section; use plotx_core::actions::{Action, PendingInspectorEdit}; use plotx_core::state::{ CanvasObject, DataBinding, Dataset, MM_TO_PT, OVERLAY_PALETTE, ObjectFrame, ObjectId, PlotxApp, - SeriesBinding, StackSpec, + StackSpec, }; use plotx_figure::Color; diff --git a/crates/app/src/ui/object_inspector/data.rs b/crates/app/src/ui/object_inspector/data.rs index ac11d66..d310ae0 100644 --- a/crates/app/src/ui/object_inspector/data.rs +++ b/crates/app/src/ui/object_inspector/data.rs @@ -5,19 +5,52 @@ use super::*; /// Binding edits rebuild through `SetDataBinding`; stack-layout edits through /// `SetStackSpec`. pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: &mut Ui) { - let Some((binding, stack)) = app.doc.canvases[ci] + let Some((persisted_binding, display_owner, stack)) = app.doc.canvases[ci] .object(object) .and_then(|o| o.plot()) - .map(|p| (p.binding.clone(), p.stack)) + .map(|p| (p.binding.clone(), p.display_owner, p.stack)) else { return; }; + let binding = app.display_binding(display_owner, &persisted_binding); let is_stack = binding.series.len() > 1 && app.series_stackable(&binding); + let multiple_datasets = binding.dataset_ids().len() > 1; let count = binding.series.len(); let mut next_binding: Option = None; let mut next_stack: Option = None; + if is_stack { + ui.horizontal(|ui| { + if ui + .add_enabled( + binding.series.iter().any(|series| !series.visible), + egui::Button::new("Show all"), + ) + .clicked() + { + let mut b = binding.clone(); + for series in &mut b.series { + series.visible = true; + } + next_binding = Some(b); + } + if ui + .add_enabled( + binding.series.iter().any(|series| series.visible), + egui::Button::new("Hide all"), + ) + .clicked() + { + let mut b = binding.clone(); + for series in &mut b.series { + series.visible = false; + } + next_binding = Some(b); + } + }); + } for (i, sb) in binding.series.iter().enumerate() { + let item_options = app.series_item_options(sb); ui.horizontal(|ui| { if is_stack { let mut visible = sb.visible; @@ -39,12 +72,17 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: .primary_color() .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); swatch(ui, color); - let name = app - .doc - .dataset_index(sb.source.resource) - .and_then(|index| app.doc.datasets.get(index)) - .map(Dataset::display_name) - .unwrap_or_default(); + let item_name = app.series_label(sb); + let name = if multiple_datasets { + let dataset = app + .doc + .dataset_by_id(sb.source.resource) + .map(Dataset::display_name) + .unwrap_or_default(); + format!("{dataset} — {item_name}") + } else { + item_name + }; let label = if i == 0 { format!("{name} (primary)") } else { @@ -64,6 +102,23 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: } else { ui.label(label); } + if item_options.len() > 1 { + egui::ComboBox::from_id_salt(("object_series_item", object, sb.id)) + .selected_text("Choose trace…") + .show_ui(ui, |ui| { + for (item, option_label) in &item_options { + if ui + .selectable_label(sb.source.item == Some(*item), option_label) + .clicked() + { + let mut b = binding.clone(); + b.series[i].source.item = Some(*item); + next_binding = Some(b); + ui.close(); + } + } + }); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if count > 1 && ui.small_button(icon::X).on_hover_text("Remove").clicked() { let mut b = binding.clone(); @@ -115,12 +170,7 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: } let candidates = app.stack_candidates(&binding); - if binding - .primary_dataset() - .and_then(|id| app.doc.dataset_by_id(id)) - .map(Dataset::domain) - .is_some_and(|d| d.stack_kind().is_some()) - { + if app.series_stackable(&binding) { if candidates.is_empty() { ui.weak("No other datasets to stack."); } else { @@ -128,26 +178,33 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: .selected_text("Add series…") .show_ui(ui, |ui| { for di in &candidates { - let label = app.doc.datasets[*di].display_name(); + let dataset_name = app.doc.datasets[*di].display_name(); + let options = app.stack_candidate_series_options(&binding, *di); + let label = if options.len() > 1 { + format!("{dataset_name} — All traces ({})", options.len()) + } else { + dataset_name + }; if ui.selectable_label(false, label).clicked() { let mut b = binding.clone(); - let Some(series_id) = app - .doc - .canvases - .get_mut(ci) - .and_then(|canvas| canvas.object_mut(object)) - .and_then(|object| object.plot_mut()) - .map(|plot| plot.allocate_series_id()) - else { - continue; - }; - if let Some(mut series) = - SeriesBinding::from_dataset(&app.doc.datasets[*di]) - { + for mut series in options { + let color = app.next_stack_color(&b); + let Some(series_id) = app + .doc + .canvases + .get_mut(ci) + .and_then(|canvas| canvas.object_mut(object)) + .and_then(|object| object.plot_mut()) + .map(|plot| plot.allocate_series_id()) + else { + continue; + }; series.id = series_id; + series.set_primary_color(color); b.series.push(series); - next_binding = Some(b); } + next_binding = Some(b); + ui.close(); } } }); @@ -159,7 +216,13 @@ pub(super) fn data_section(app: &mut PlotxApp, ci: usize, object: ObjectId, ui: if let Some(after) = next_binding && after != binding { - app.execute_action(Action::set_data_binding(ci, object, binding, after)); + let after = app.merge_display_binding(display_owner, &persisted_binding, after); + app.execute_action(Action::set_data_binding( + ci, + object, + persisted_binding, + after, + )); app.session.status = "Updated plot data.".to_owned(); } else if let Some(after) = next_stack && after != stack diff --git a/crates/app/src/ui/tools/electrophysiology.rs b/crates/app/src/ui/tools/electrophysiology.rs index c7b1636..d32d959 100644 --- a/crates/app/src/ui/tools/electrophysiology.rs +++ b/crates/app/src/ui/tools/electrophysiology.rs @@ -21,6 +21,7 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui return false; }; let mut dirty = false; + let mut invocation_changed = false; ui.label(crate::typography::headline("Recording")); ui.label(format!( @@ -37,6 +38,7 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui ui.colored_label(ui.visuals().warn_fg_color, warning); } } + let mut selected_channel = recording.selected_channel; ComboBox::from_label("Recorded channel") .selected_text( recording @@ -50,31 +52,54 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui for (index, channel) in recording.data.channels.iter().enumerate() { dirty |= ui .selectable_value( - &mut recording.selected_channel, + &mut selected_channel, index, format!("{} ({})", channel.name, channel.unit.symbol), ) .changed(); } }); + recording.set_selected_channel(selected_channel); ui.separator(); ui.label(crate::typography::headline("Sweeps")); + let item_ids = recording + .trace_items() + .iter() + .map(|item| item.id) + .collect::>(); + let mut selected_ids = recording + .invocation + .analysis_selection + .clone() + .unwrap_or_else(|| item_ids.clone()); ui.horizontal(|ui| { if ui.button("Select all").clicked() { - recording.selected_sweeps.fill(true); - dirty = true; + selected_ids.clone_from(&item_ids); + invocation_changed = true; } if ui.button("Clear").clicked() { - recording.selected_sweeps.fill(false); - dirty = true; + selected_ids.clear(); + invocation_changed = true; } }); ui.horizontal_wrapped(|ui| { - for (index, selected) in recording.selected_sweeps.iter_mut().enumerate() { - dirty |= ui.checkbox(selected, (index + 1).to_string()).changed(); + for (index, item) in item_ids.iter().enumerate() { + let mut selected = selected_ids.contains(item); + if ui + .checkbox(&mut selected, (index + 1).to_string()) + .changed() + { + if selected { + selected_ids.push(*item); + } else { + selected_ids.retain(|id| id != item); + } + invocation_changed = true; + } } }); + recording.invocation.analysis_selection = Some(selected_ids); ui.separator(); ui.label(crate::typography::headline("Processing")); @@ -245,6 +270,9 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui Err(error) => app.session.status = error.to_string(), } } + if invocation_changed { + app.apply_electrophysiology_invocation_edit(di); + } dirty } diff --git a/crates/core/src/actions/app_impl/axis_overrides.rs b/crates/core/src/actions/app_impl/axis_overrides.rs index 8e60331..73baa6d 100644 --- a/crates/core/src/actions/app_impl/axis_overrides.rs +++ b/crates/core/src/actions/app_impl/axis_overrides.rs @@ -71,7 +71,7 @@ impl PlotxApp { after: &AxisOverrides, ) { let after = after.clone().normalized(); - let Some((before, binding, chart, stack, projections, frame)) = self + let Some((before, owner, binding, chart, stack, projections, frame)) = self .doc .canvases .get(canvas) @@ -80,6 +80,7 @@ impl PlotxApp { object.plot().map(|plot| { ( plot.axis_overrides.clone(), + plot.display_owner, plot.binding.clone(), plot.chart.clone(), plot.stack, @@ -117,7 +118,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - self.build_object_figure(&binding, &chart, &stack, &projections, size) + self.build_object_figure(owner, &binding, &chart, &stack, &projections, size) }); let Some(plot) = self diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 3cad604..95e5d41 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -153,6 +153,7 @@ impl PlotxApp { }; o.frame = frame; if let Some(plot) = o.plot() { + let owner = plot.display_owner; let binding = plot.binding.clone(); let chart = plot.chart.clone(); let stack = plot.stack; @@ -161,7 +162,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let fig = self.build_object_figure(&binding, &chart, &stack, &projections, size); + let fig = self.build_object_figure(owner, &binding, &chart, &stack, &projections, size); self.apply_viewport_to_plot_object(canvas, object, fig); } } @@ -376,7 +377,7 @@ impl PlotxApp { } fn rebuild_plot_presentation(&mut self, canvas: usize, object: ObjectId) { - let Some((binding, chart, stack, projections, frame, previous_contours)) = self + let Some((owner, binding, chart, stack, projections, frame, previous_contours)) = self .doc .canvases .get(canvas) @@ -384,6 +385,7 @@ impl PlotxApp { .and_then(|object| { let plot = object.plot()?; Some(( + plot.display_owner, plot.binding.clone(), plot.chart.clone(), plot.stack, @@ -399,7 +401,8 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let mut figure = self.build_object_figure(&binding, &chart, &stack, &projections, size); + let mut figure = + self.build_object_figure(owner, &binding, &chart, &stack, &projections, size); if figure.contours.len() < previous_contours.len() { figure.contours = previous_contours; } @@ -433,6 +436,7 @@ impl PlotxApp { return; }; plot.binding = binding.clone(); + let owner = plot.display_owner; let chart = plot.chart.clone(); let stack = plot.stack; let projections = plot.projections.clone(); @@ -441,7 +445,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let fig = self.build_object_figure(binding, &chart, &stack, &projections, size); + let fig = self.build_object_figure(owner, binding, &chart, &stack, &projections, size); if let Some(plot) = self .doc .canvases @@ -477,6 +481,7 @@ impl PlotxApp { return; }; plot.chart = chart.clone(); + let owner = plot.display_owner; let binding = plot.binding.clone(); let stack = plot.stack; let projections = plot.projections.clone(); @@ -485,7 +490,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let fig = self.build_object_figure(&binding, chart, &stack, &projections, size); + let fig = self.build_object_figure(owner, &binding, chart, &stack, &projections, size); if let Some(plot) = self .doc .canvases @@ -517,6 +522,7 @@ impl PlotxApp { return; }; plot.stack = *stack; + let owner = plot.display_owner; let binding = plot.binding.clone(); let chart = plot.chart.clone(); let projections = plot.projections.clone(); @@ -525,7 +531,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let fig = self.build_object_figure(&binding, &chart, stack, &projections, size); + let fig = self.build_object_figure(owner, &binding, &chart, stack, &projections, size); if let Some(plot) = self .doc .canvases @@ -557,6 +563,7 @@ impl PlotxApp { return; }; plot.projections = projections.clone(); + let owner = plot.display_owner; let binding = plot.binding.clone(); let chart = plot.chart.clone(); let stack = plot.stack; @@ -565,7 +572,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let fig = self.build_object_figure(&binding, &chart, &stack, projections, size); + let fig = self.build_object_figure(owner, &binding, &chart, &stack, projections, size); if let Some(plot) = self .doc .canvases diff --git a/crates/core/src/actions/tests/stable_identity.rs b/crates/core/src/actions/tests/stable_identity.rs index ccb7df6..be96a4b 100644 --- a/crates/core/src/actions/tests/stable_identity.rs +++ b/crates/core/src/actions/tests/stable_identity.rs @@ -25,6 +25,7 @@ fn dataset_delete_undo_restores_identity_and_persistent_references() { .push(SeriesBinding::with_source(SeriesSource { resource: inserted_id, field: FieldId::default(), + item: None, })); plot.projections.top = AxisProjection { source: ProjectionSource::Attached(inserted_id), @@ -80,14 +81,17 @@ fn canvas_dataset_ids_follow_first_appearance_and_page_indices_follow_document_o SeriesBinding::with_source(SeriesSource { resource: ids[2], field: FieldId::default(), + item: None, }), SeriesBinding::with_source(SeriesSource { resource: ids[0], field: FieldId::default(), + item: None, }), SeriesBinding::with_source(SeriesSource { resource: ids[2], field: FieldId::default(), + item: None, }), ]; let object_id = canvas.allocate_object_id(); @@ -125,6 +129,7 @@ fn series_reorder_preserves_ids_and_only_changes_order() { let mut series = SeriesBinding::with_source(SeriesSource { resource: second_id, field: FieldId::default(), + item: None, }); series.id = id; plot.binding.series.push(series); @@ -174,6 +179,7 @@ fn step_and_series_allocators_do_not_rollback_with_undo() { let mut series = SeriesBinding::with_source(SeriesSource { resource: second_id, field: FieldId::default(), + item: None, }); series.id = series_id; after.series.push(series); diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index 276397f..d4a0bbc 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -94,12 +94,15 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { signed_grid, )))); let (a, b) = (app.doc.datasets.len() - 2, app.doc.datasets.len() - 1); - let binding = DataBinding { + let mut binding = DataBinding { series: vec![ SeriesBinding::from_dataset(&app.doc.datasets[a]).unwrap(), SeriesBinding::from_dataset(&app.doc.datasets[b]).unwrap(), ], }; + for (index, series) in binding.series.iter_mut().enumerate() { + series.set_primary_color(crate::state::OVERLAY_PALETTE[index]); + } let chart = ChartSpec::default_for(DataDomain::Nmr2d); let stack = StackSpec { mode: StackMode::ColorOverlay, @@ -134,8 +137,8 @@ fn field_overlay_stacks_two_2d_contours_in_distinct_colors() { "each signed dataset contributes both signs" ); assert_ne!( - fig.contours[0].color, fig.contours[1].color, - "a signed field keeps its positive and negative contours distinct" + fig.contours[0].color, fig.contours[2].color, + "each dataset uses its persisted positive-contour color" ); } @@ -155,6 +158,19 @@ fn plain_then_ctrl_click_selects_two_datasets_for_stacking() { assert_eq!(app.stackable_selection(), Some(vec![0, 1])); assert!(app.stackable_selection().is_some(), "can_stack is true"); assert_eq!(app.active_dataset(), Some(1)); + + app.stack_selected_data(); + let plot = app.doc.canvases.last().unwrap().objects[0].plot().unwrap(); + assert_ne!( + plot.binding.series[0].primary_color(), + plot.binding.series[1].primary_color(), + "stack creation persists a plot-position palette" + ); + assert_ne!( + plot.figure().series[0].color, + plot.figure().series[1].color, + "the persisted palette reaches the rendered traces" + ); } #[test] diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index c61c78b..3044707 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -43,6 +43,7 @@ pub const CAP_PROCESSING_SCHEME: &str = "processing.scheme"; pub const CAP_PROPERTY_CATALOG: &str = "properties.catalog"; pub const CAP_FIELD_SCALAR_GRID_2D_REGULAR: &str = "field.scalar_grid_2d.regular"; pub const CAP_FIELD_CURVE_1D: &str = "field.curve_1d"; +pub const CAP_FIELD_TRACE_COLLECTION: &str = "field.trace_collection"; pub const CAP_FIELD_COLORED_RASTER_2D: &str = "field.colored_raster_2d"; pub const CAP_FIELD_SIGNED: &str = "field.signed"; pub const CAP_FIELD_NOISE_SCALE: &str = "field.noise_scale"; diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index 044fe44..364e05f 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -190,19 +190,14 @@ fn processed_data_available(dataset: &Dataset) -> bool { .channels .get(recording.selected_channel) .is_some() - && recording - .selected_sweeps - .iter() - .enumerate() - .any(|(index, selected)| { - *selected - && recording.data.sweeps.get(index).is_some_and(|sweep| { - sweep - .channels - .get(recording.selected_channel) - .is_some_and(|trace| !trace.is_empty()) - }) + && recording.selected_sweep_indices().into_iter().any(|index| { + recording.data.sweeps.get(index).is_some_and(|sweep| { + sweep + .channels + .get(recording.selected_channel) + .is_some_and(|trace| !trace.is_empty()) }) + }) } Dataset::Table(_) => false, Dataset::Afm(_) => false, @@ -524,13 +519,11 @@ fn capture_processed(dataset: &Dataset) -> Result format!("{} ({})", channel.name, channel.unit.symbol) }; let mut traces = Vec::new(); - for (index, selected) in recording.selected_sweeps.iter().copied().enumerate() { - if selected { - traces.push(( - index + 1, - recording.processed_trace(index, recording.selected_channel)?, - )); - } + for index in recording.selected_sweep_indices() { + traces.push(( + index + 1, + recording.processed_trace(index, recording.selected_channel)?, + )); } Ok(SnapshotData::Electrophysiology { sample_rate_hz: recording.data.sample_rate_hz, diff --git a/crates/core/src/data_export/tests.rs b/crates/core/src/data_export/tests.rs index 730c4d8..cb76a2c 100644 --- a/crates/core/src/data_export/tests.rs +++ b/crates/core/src/data_export/tests.rs @@ -370,7 +370,7 @@ fn electrophysiology_export_includes_only_selected_sweeps_and_pads_short_ones() }; let mut recording = crate::state::ElectrophysiologyDataset::load(data); recording.processing.gaussian_lowpass_enabled = false; - recording.selected_sweeps = vec![true, false]; + recording.invocation.analysis_selection = Some(vec![recording.trace_items()[0].id]); let dataset = Dataset::Electrophysiology(Box::new(recording)); let value = DataExportSnapshot::capture( &dataset, diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index e38357e..ea173eb 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -280,6 +280,7 @@ mod tests { visible: true, group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, crate::state::SeriesId::new(1), DataBinding { series: Vec::new() }, ChartSpec::default(), @@ -322,6 +323,7 @@ mod tests { visible: true, group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, crate::state::SeriesId::new(1), DataBinding { series: Vec::new() }, ChartSpec::default(), @@ -364,6 +366,7 @@ mod tests { visible: true, group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, crate::state::SeriesId::new(1), DataBinding { series: Vec::new() }, ChartSpec::default(), diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 312d907..2348de7 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -215,7 +215,7 @@ pub fn build_stack_figure(stack: &StackSpectrum) -> Figure { fig } -fn axis_label(nucleus: &str) -> String { +pub(crate) fn axis_label(nucleus: &str) -> String { let mut formatted = String::new(); let mut chars = nucleus.chars().peekable(); while chars.peek().is_some_and(char::is_ascii_digit) { diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index fada637..de8e8f4 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -1,5 +1,5 @@ use super::axis_overrides::AxisOverridesDto; -use super::field_catalog::validate_series; +use super::field_catalog::validate_series_source; use super::*; use crate::state::SeriesId; use crate::state::{AxisProjection, AxisProjections, ProjectionSource}; @@ -148,11 +148,18 @@ pub fn canvas_to_view( Dataset::Xrd(_) => "line_plot", Dataset::Xps(_) => "line_plot", }; + let mut ids = std::collections::BTreeSet::new(); let series = plot .binding .series .iter() .map(|sb| { + if !ids.insert(sb.id) { + return Err(ProjectError::Invalid(format!( + "view {view_id} plot {} has duplicate series id {}", + object.id, sb.id + ))); + } let dataset = datasets .iter() .find(|dataset| dataset.resource_id() == sb.source.resource) @@ -162,16 +169,26 @@ pub fn canvas_to_view( object.id, sb.source.resource )) })?; - validate_series( + validate_series_source( dataset, sb.source.field, + sb.source.item, &sb.encoding, &format!("view {view_id} plot {} series {}", object.id, sb.id), )?; Ok(SeriesBindingDto { id: sb.id.get(), - input: format!("recipe_{}", dataset.resource_id()), - field: sb.source.field.get(), + source: match sb.source.item { + Some(item) => SeriesSourceDto::TraceItem { + input: format!("recipe_{}", dataset.resource_id()), + field: sb.source.field.get(), + item, + }, + None => SeriesSourceDto::Field { + input: format!("recipe_{}", dataset.resource_id()), + field: sb.source.field.get(), + }, + }, label: sb.label.clone(), encoding: sb.encoding.clone(), visible: sb.visible, @@ -181,7 +198,10 @@ pub fn canvas_to_view( let stack = (plot.stack != StackSpec::default()) .then(|| StackDto::from_spec(&plot.stack)); Ok(ViewCanvasObject { - input: format!("recipe_{}", primary_dataset.resource_id()), + input: plot + .display_owner + .map(|owner| format!("recipe_{owner}")) + .unwrap_or_default(), next_series_id: plot.next_series_id.get(), series, chart_type: Some(plot.chart.type_id.clone()), @@ -314,17 +334,26 @@ pub fn view_to_canvas( ))); } let mut series = Vec::with_capacity(view_object.series.len()); + let mut ids = std::collections::BTreeSet::new(); for sb in &view_object.series { - let index = resolve(&sb.input)?; + if !ids.insert(sb.id) { + return Err(ProjectError::Invalid(format!( + "view {view_id} plot {} has duplicate series id {}", + view_object.id, sb.id + ))); + } + let (input, stored_field, item) = sb.source.parts(); + let index = resolve(input)?; let dataset = app.doc.datasets.get(index).ok_or_else(|| { ProjectError::Invalid(format!( "view {view_id} references unavailable dataset index {index}" )) })?; - let field = crate::state::FieldId::new(sb.field); - validate_series( + let field = crate::state::FieldId::new(stored_field); + validate_series_source( dataset, field, + item, &sb.encoding, &format!("view {view_id} series {}", sb.id), )?; @@ -333,6 +362,7 @@ pub fn view_to_canvas( source: crate::state::SeriesSource { resource: dataset.resource_id(), field, + item, }, label: sb.label.clone(), encoding: sb.encoding.clone(), @@ -340,6 +370,11 @@ pub fn view_to_canvas( }); } let binding = DataBinding { series }; + let display_owner = if view_object.input.is_empty() { + None + } else { + Some(app.doc.datasets[resolve(&view_object.input)?].resource_id()) + }; let stack = view_object .stack .clone() @@ -405,9 +440,23 @@ pub fn view_to_canvas( let mut figure = match &view_object.snapshot { Some(snapshot) if !map_unavailable => read_json(zip, &snapshot.figure) .unwrap_or_else(|_| { - app.build_object_figure(&binding, &chart, &stack, &projections, size_mm) + app.build_object_figure( + display_owner, + &binding, + &chart, + &stack, + &projections, + size_mm, + ) }), - _ => app.build_object_figure(&binding, &chart, &stack, &projections, size_mm), + _ => app.build_object_figure( + display_owner, + &binding, + &chart, + &stack, + &projections, + size_mm, + ), }; let axis_overrides = view_object .axis_overrides @@ -420,8 +469,14 @@ pub fn view_to_canvas( // here or the rebuilt figure would lose both. let snapshot_backed = view_object.snapshot.is_some() && !map_unavailable; let derived_axes = if snapshot_backed { - let derived = - app.build_object_figure(&binding, &chart, &stack, &projections, size_mm); + let derived = app.build_object_figure( + display_owner, + &binding, + &chart, + &stack, + &projections, + size_mm, + ); crate::state::DerivedAxes::from_figure(&derived) } else { crate::state::DerivedAxes::from_figure(&figure) @@ -449,6 +504,7 @@ pub fn view_to_canvas( .map(PanelDto::into_panel) .unwrap_or_else(|| PanelMeta::new(app.default_plot_title(di), frame.width)); CanvasObjectKind::Plot(Box::new(PlotObject::from_materialized_figure( + display_owner, SeriesId::new(view_object.next_series_id), binding, chart, diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index ff7561a..d3a80c3 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -1,5 +1,8 @@ use super::axis_overrides::AxisOverridesDto; use super::*; +#[path = "dto_series_source.rs"] +mod dto_series_source; +pub use dto_series_source::SeriesSourceDto; #[derive(Serialize, Deserialize)] pub struct Manifest { @@ -414,13 +417,12 @@ fn caption_visible_default() -> bool { } #[derive(Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] pub struct SeriesBindingDto { /// Owner-local series identity. It is mandatory because a persisted series /// is an addressable component, not a position in an overlay list. pub id: u64, - pub input: String, - /// Dataset-local stable field identity. - pub field: u64, + pub source: SeriesSourceDto, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, /// The complete, concrete document encoding. Auto is creation-only and diff --git a/crates/core/src/project/dto_series_source.rs b/crates/core/src/project/dto_series_source.rs new file mode 100644 index 0000000..a3db51b --- /dev/null +++ b/crates/core/src/project/dto_series_source.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum SeriesSourceDto { + Field { + input: String, + field: u64, + }, + TraceItem { + input: String, + field: u64, + item: plotx_data::TraceItemId, + }, +} + +impl SeriesSourceDto { + pub fn parts(&self) -> (&str, u64, Option) { + match self { + Self::Field { input, field } => (input, *field, None), + Self::TraceItem { input, field, item } => (input, *field, Some(*item)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tagged_source_rejects_unknown_fields_and_malformed_variants() { + assert!( + serde_json::from_value::(serde_json::json!({ + "kind": "field", "input": "recipe_x", "field": 1, "item": "extra" + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "kind": "trace_item", "input": "recipe_x", "field": 1 + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "kind": "unknown", "input": "recipe_x", "field": 1 + })) + .is_err() + ); + } +} diff --git a/crates/core/src/project/electrophysiology_tests.rs b/crates/core/src/project/electrophysiology_tests.rs index 44ec790..b7f0a19 100644 --- a/crates/core/src/project/electrophysiology_tests.rs +++ b/crates/core/src/project/electrophysiology_tests.rs @@ -69,11 +69,18 @@ fn project_roundtrip_preserves_raw_data_and_settings() { name: "Current".to_owned(), unit: plotx_io::ElectricalUnit::from_symbol("pA"), }], - sweeps: vec![plotx_io::Sweep { - start_time_s: 0.0, - channels: vec![vec![1.0, -2.0, -4.0, 1.0]], - commands: vec![command], - }], + sweeps: vec![ + plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![1.0, -2.0, -4.0, 1.0]], + commands: vec![command.clone()], + }, + plotx_io::Sweep { + start_time_s: 1.0, + channels: vec![vec![2.0, -3.0, -5.0, 2.0]], + commands: vec![command], + }, + ], protocol: Some("vc".to_owned()), source: "cell1/test.abf".to_owned(), import_warnings: Vec::new(), @@ -81,6 +88,8 @@ fn project_roundtrip_preserves_raw_data_and_settings() { let mut recording = crate::state::ElectrophysiologyDataset::load(data); recording.metadata.cell_id = "cell-42".to_owned(); recording.processing.cutoff_hz = 750.0; + let selected_item = recording.trace_items()[1].id; + recording.invocation.analysis_selection = Some(vec![selected_item]); recording .region_analysis .regions @@ -109,6 +118,8 @@ fn project_roundtrip_preserves_raw_data_and_settings() { assert_eq!(recording.data.sweeps[0].commands[0].samples[1], -90.0); assert_eq!(recording.metadata.cell_id, "cell-42"); assert_eq!(recording.processing.cutoff_hz, 750.0); + assert!(recording.invocation.analysis_selection.is_none()); + assert_eq!(recording.selected_sweep_indices(), vec![0, 1]); assert_eq!(recording.region_analysis.regions.len(), 1); assert_eq!(recording.region_analysis.regions[0].name, "transient"); assert_eq!( diff --git a/crates/core/src/project/field_catalog.rs b/crates/core/src/project/field_catalog.rs index 525c25c..9fbf0e5 100644 --- a/crates/core/src/project/field_catalog.rs +++ b/crates/core/src/project/field_catalog.rs @@ -38,6 +38,26 @@ pub(super) fn validate_series( Ok(()) } +pub(super) fn validate_series_source( + dataset: &Dataset, + field: crate::state::FieldId, + item: Option, + encoding: &plotx_figure::SeriesEncoding, + context: &str, +) -> Result<()> { + validate_series(dataset, field, encoding, context)?; + match (item, dataset.trace_collection(field)) { + (Some(item), Some(collection)) if collection.item(item).is_some() => Ok(()), + (Some(item), _) => Err(ProjectError::Invalid(format!( + "{context} references unknown trace item {item}" + ))), + (None, Some(_)) => Err(ProjectError::Invalid(format!( + "{context} addresses a trace collection as a scalar field" + ))), + (None, None) => Ok(()), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/project/lineage_tests.rs b/crates/core/src/project/lineage_tests.rs index 8a5b8cb..86f024e 100644 --- a/crates/core/src/project/lineage_tests.rs +++ b/crates/core/src/project/lineage_tests.rs @@ -68,6 +68,7 @@ fn region_provenance_without_lineage_stays_unlinked() { table.provenance = Some(TableProvenance { source_resource, source_field, + members: None, regions: vec![RegionColumnProvenance { region: RegionId::new(1), column, diff --git a/crates/core/src/project/pseudo_tests.rs b/crates/core/src/project/pseudo_tests.rs index c3057b6..86b6406 100644 --- a/crates/core/src/project/pseudo_tests.rs +++ b/crates/core/src/project/pseudo_tests.rs @@ -148,6 +148,59 @@ fn rewrite_project(path: &Path, mut edit: impl FnMut(&str, &mut Vec) -> bool std::fs::rename(tmp, path).unwrap(); } +fn pseudo_project_with_view(name: &str) -> PathBuf { + let mut app = PlotxApp::new(); + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy_2d()))); + let canvas = crate::workflow::build_default_canvas(&dataset, "strict-pseudo"); + app.doc.datasets.push(dataset); + app.doc.canvases.push(canvas); + let path = temp_project(name); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + path +} + +#[test] +fn load_rejects_duplicate_series_ids_scalar_collections_and_unknown_source_fields() { + let cases = ["duplicate", "scalar", "unknown-field"]; + for case in cases { + let path = pseudo_project_with_view(case); + rewrite_project(&path, |name, bytes| { + if !name.starts_with("views/") || !name.ends_with(".json") { + return true; + } + let mut view: serde_json::Value = serde_json::from_slice(bytes).unwrap(); + let series = view["objects"][0]["series"].as_array_mut().unwrap(); + match case { + "duplicate" => series.push(series[0].clone()), + "scalar" => { + let source = series[0]["source"].as_object_mut().unwrap(); + source.insert("kind".into(), "field".into()); + source.remove("item"); + } + "unknown-field" => { + series[0]["source"]["unexpected"] = serde_json::json!(true); + } + _ => unreachable!(), + } + *bytes = serde_json::to_vec_pretty(&view).unwrap(); + true + }); + let error = match load_project(&path) { + Ok(_) => panic!("{case} project unexpectedly loaded"), + Err(error) => error.to_string(), + }; + let _ = std::fs::remove_file(path); + let expected = match case { + "duplicate" => "duplicate series id", + "scalar" => "trace collection as a scalar field", + "unknown-field" => "unknown field", + _ => unreachable!(), + }; + assert!(error.contains(expected), "{case}: {error}"); + } +} + fn assert_f64_bits_equal(actual: &[f64], expected: &[f64]) { assert_eq!( actual @@ -447,6 +500,13 @@ fn a_snapshot_is_not_replayed_when_the_stored_map_could_not_be_restored() { crate::state::DEFAULT_CANVAS_SIZE_MM, ); app.execute_action(action); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.poll_compute(); + assert!(!app.compute_busy(), "DOSY contour build did not settle"); let path = temp_project("dosy-snapshot-bypass"); let _ = std::fs::remove_file(&path); diff --git a/crates/core/src/project/reference_tests.rs b/crates/core/src/project/reference_tests.rs index 90cddce..43aa8ca 100644 --- a/crates/core/src/project/reference_tests.rs +++ b/crates/core/src/project/reference_tests.rs @@ -41,13 +41,14 @@ fn save_rejects_missing_primary_and_series_datasets() { let mut missing_series = sample_app(); let missing = DatasetId::new(); - first_plot_mut(&mut missing_series) - .binding - .series - .push(SeriesBinding::with_source(crate::state::SeriesSource { - resource: missing, - field: crate::state::FieldId::default(), - })); + let plot = first_plot_mut(&mut missing_series); + let mut series = SeriesBinding::with_source(crate::state::SeriesSource { + resource: missing, + field: crate::state::FieldId::default(), + item: None, + }); + series.id = plot.allocate_series_id(); + plot.binding.series.push(series); let error = save_error(&missing_series, "missing-series-dataset"); assert!( error.contains(&format!("missing series dataset {missing}")), @@ -55,6 +56,54 @@ fn save_rejects_missing_primary_and_series_datasets() { ); } +#[test] +fn save_rejects_unknown_items_and_duplicate_owner_local_series_ids() { + let mut unknown_item = sample_app(); + let collection = plotx_data::TraceCollectionId::derived(b"missing", b"collection"); + first_plot_mut(&mut unknown_item).binding.series[0] + .source + .item = Some(plotx_data::TraceItemId::derived(collection, b"item")); + let error = save_error(&unknown_item, "unknown-trace-item"); + assert!(error.contains("unknown trace item"), "{error}"); + + let mut duplicate = sample_app(); + let repeated = first_plot_mut(&mut duplicate).binding.series[0].clone(); + first_plot_mut(&mut duplicate).binding.series.push(repeated); + let error = save_error(&duplicate, "duplicate-series-id"); + assert!(error.contains("duplicate series id"), "{error}"); + + let recording = Dataset::Electrophysiology(Box::new( + crate::state::ElectrophysiologyDataset::load(plotx_io::ElectrophysiologyData { + abf_version: "test".into(), + sample_rate_hz: 1_000.0, + channels: vec![plotx_io::RecordedChannel { + name: "A".into(), + unit: plotx_io::ElectricalUnit::from_symbol("pA"), + }], + sweeps: vec![plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![0.0, 1.0]], + commands: Vec::new(), + }], + protocol: None, + source: "scalar-collection.abf".into(), + import_warnings: Vec::new(), + }), + )); + let canvas = crate::workflow::build_default_canvas(&recording, "scalar-collection.abf"); + let mut scalar_collection = PlotxApp::new(); + scalar_collection.doc.datasets.push(recording); + scalar_collection.doc.canvases.push(canvas); + first_plot_mut(&mut scalar_collection).binding.series[0] + .source + .item = None; + let error = save_error(&scalar_collection, "scalar-trace-collection"); + assert!( + error.contains("trace collection as a scalar field"), + "{error}" + ); +} + #[test] fn save_rejects_missing_attached_projection_dataset() { let mut app = sample_app(); @@ -167,8 +216,7 @@ fn loading_a_maximum_series_id_reports_exhaustion() { "input": recipe, "series": [{ "id": u64::MAX, - "input": recipe, - "field": 0, + "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}} }], "frame": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0 }, diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index ec07b48..0ea655b 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -482,6 +482,43 @@ fn project_roundtrip_preserves_pseudo2d_metadata() { assert!((meta.shape_factor - 1.0 / 3.0).abs() < 1e-12); } +#[test] +fn project_roundtrip_preserves_trace_item_sources_and_visibility() { + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(synthetic_dosy_2d()))); + let mut app = PlotxApp::new(); + app.doc.canvases.push(crate::workflow::build_default_canvas( + &dataset, + "synthetic DOSY", + )); + app.doc.datasets.push(dataset); + let before = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + assert!(before.binding.series.len() > 1); + before.binding.series[1].visible = false; + let sources = before + .binding + .series + .iter() + .map(|series| series.source.item) + .collect::>(); + + let path = temp_project("trace-item-sources"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let after = loaded.doc.canvases[0].objects[0].plot().unwrap(); + assert_eq!( + after + .binding + .series + .iter() + .map(|series| series.source.item) + .collect::>(), + sources + ); + assert!(!after.binding.series[1].visible); +} + #[test] fn project_roundtrip_preserves_authoring_objects() { use crate::state::{CanvasObject, CanvasObjectKind, ShapeKind, ShapeObject, TextBox}; diff --git a/crates/core/src/properties/axis_tests.rs b/crates/core/src/properties/axis_tests.rs index f9745f0..ddae99a 100644 --- a/crates/core/src/properties/axis_tests.rs +++ b/crates/core/src/properties/axis_tests.rs @@ -123,7 +123,7 @@ fn guide_visibility_is_a_persistent_undoable_plot_override() { .unwrap() .figure() .guide_visibility, - plotx_figure::GuideVisibility::Auto + plotx_figure::GuideVisibility::Hide ); } diff --git a/crates/core/src/properties/object.rs b/crates/core/src/properties/object.rs index 4c4e0d8..fc16030 100644 --- a/crates/core/src/properties/object.rs +++ b/crates/core/src/properties/object.rs @@ -123,17 +123,20 @@ fn plot_context<'a>( let plot = object.plot().ok_or_else(|| { PropertyError::NotApplicable("This property belongs to a plot object.".to_owned()) })?; - let series = plot.binding.series.first().ok_or_else(|| { - PropertyError::NotApplicable("The plot has no primary series.".to_owned()) - })?; - let dataset = app - .doc - .dataset_by_id(series.source.resource) + let series = app + .display_binding(plot.display_owner, &plot.binding) + .series + .first() + .map(|series| series.source) .ok_or_else(|| { - PropertyError::UnknownTarget(format!("{canvas}/{}", series.source.resource)) + PropertyError::NotApplicable("The plot has no primary series.".to_owned()) })?; + let dataset = app + .doc + .dataset_by_id(series.resource) + .ok_or_else(|| PropertyError::UnknownTarget(format!("{canvas}/{}", series.resource)))?; let capabilities = dataset - .field_descriptor(series.source.field) + .field_descriptor(series.field) .map(|field| field.capabilities) .unwrap_or_default(); Ok((plot, dataset, dataset.domain(), capabilities)) @@ -222,12 +225,20 @@ fn plot_value( let schema = resolved_schema(definition, &capabilities); match definition.id { STACK_MODE | STACK_SPACING_Y | STACK_SHEAR_X | STACK_NORMALIZE => { - if plot.binding.series.len() <= 1 || !app.series_stackable(&plot.binding) { + let binding = app.display_binding(plot.display_owner, &plot.binding); + if binding.series.len() <= 1 || !app.series_stackable(&binding) { return Err(PropertyError::NotApplicable( "Stack settings require a stackable plot with multiple series.".to_owned(), )); } - let kind = domain.stack_kind(); + let kind = + if binding.series.iter().all(|series| { + matches!(series.encoding, plotx_figure::SeriesEncoding::Contour(_)) + }) { + Some(StackKind::Field) + } else { + Some(StackKind::Line) + }; if definition.id != STACK_MODE && (kind != Some(StackKind::Line) || plot.stack.mode != StackMode::Offset) { @@ -335,13 +346,13 @@ fn read_series( .object(context.object) .and_then(|object| object.plot()) .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; - if plot.binding.series.len() <= 1 || !app.series_stackable(&plot.binding) { + let binding = app.display_binding(plot.display_owner, &plot.binding); + if binding.series.len() <= 1 || !app.series_stackable(&binding) { return Err(PropertyError::NotApplicable( "Series visibility is available only on stackable multi-series plots.".to_owned(), )); } - let visible = plot - .binding + let visible = binding .series .iter() .find(|series| series.id == context.series) @@ -369,7 +380,8 @@ fn edit_series( .object(context.object) .and_then(|object| object.plot()) .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; - if plot.binding.series.len() <= 1 || !app.series_stackable(&plot.binding) { + let binding = app.display_binding(plot.display_owner, &plot.binding); + if binding.series.len() <= 1 || !app.series_stackable(&binding) { return Err(PropertyError::NotApplicable( "Series visibility is available only on stackable multi-series plots.".to_owned(), )); diff --git a/crates/core/src/properties/target.rs b/crates/core/src/properties/target.rs index aeb5f47..4fa819b 100644 --- a/crates/core/src/properties/target.rs +++ b/crates/core/src/properties/target.rs @@ -169,6 +169,17 @@ pub(crate) fn series_context_unchecked<'a>( .object(object) .and_then(|object| object.plot()) .ok_or_else(|| PropertyError::UnknownTarget(target.resource.id.clone()))?; + if !app + .display_binding(plot.display_owner, &plot.binding) + .series + .iter() + .any(|binding| binding.id == series) + { + return Err(PropertyError::UnknownTarget(format!( + "{}/series/{series}", + target.resource.id + ))); + } let binding = plot .binding .series @@ -270,9 +281,9 @@ pub(crate) fn series_targets(app: &PlotxApp, canvas: usize, object: ObjectId) -> .object(object) .and_then(|object| object.plot()) .map(|plot| { - plot.binding + app.display_binding(plot.display_owner, &plot.binding) .series - .iter() + .into_iter() .map(|series| TargetRef { resource: resource.clone(), component: Some(ComponentRef::Series(series.id)), diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 99740e5..3a6e617 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -1,4 +1,14 @@ use super::*; + +type PlotRebuild = ( + ObjectId, + Option, + DataBinding, + ChartSpec, + StackSpec, + AxisProjections, + ObjectFrame, +); use crate::operation::OperationHistory; use plotx_processing::{ProjectionMode, SliceKind}; @@ -103,13 +113,15 @@ impl PlotxApp { /// projections survive every rebuild. pub fn build_object_figure( &mut self, + display_owner: Option, binding: &DataBinding, chart: &ChartSpec, stack: &StackSpec, projections: &AxisProjections, size_mm: [f32; 2], ) -> Figure { - let mut fig = self.build_binding_figure(binding, chart, stack, size_mm); + let effective = self.display_binding(display_owner, binding); + let mut fig = self.build_binding_figure(&effective, chart, stack, size_mm); if let Some(dataset) = binding .primary_dataset() .and_then(|id| self.doc.dataset_index(id)) @@ -214,7 +226,9 @@ impl PlotxApp { }) else { return object; }; - let figure = self.build_object_figure(&binding, &chart, &stack, &projections, size_mm); + let owner = object.plot().and_then(|plot| plot.display_owner); + let figure = + self.build_object_figure(owner, &binding, &chart, &stack, &projections, size_mm); if let Some(plot) = object.plot_mut() { plot.adopt_rebuilt_figure(figure); } @@ -252,10 +266,11 @@ impl PlotxApp { }) .collect(); for id in ids { - let (binding, chart, stack, projections, frame) = { + let (owner, binding, chart, stack, projections, frame) = { let object = self.doc.canvases[ci].object(id).unwrap(); let plot = object.plot().unwrap(); ( + plot.display_owner, plot.binding.clone(), plot.chart.clone(), plot.stack, @@ -264,7 +279,8 @@ impl PlotxApp { ) }; let size = [frame.width / MM_TO_PT, frame.height / MM_TO_PT]; - let fig = self.build_object_figure(&binding, &chart, &stack, &projections, size); + let fig = + self.build_object_figure(owner, &binding, &chart, &stack, &projections, size); self.apply_viewport_to_plot_object(ci, id, fig); } } @@ -587,11 +603,21 @@ impl PlotxApp { self.mark_document_dirty(); return; } + if let Some(recording) = self.doc.datasets[dataset].as_electrophysiology_mut() { + recording.refresh_trace_collections(); + } self.rebuild_canvases_for(dataset); self.sync_region_table(dataset); self.mark_document_dirty(); } + /// Refresh analysis products affected by a transient sweep selection + /// without treating that invocation input as a document edit. + pub fn apply_electrophysiology_invocation_edit(&mut self, _dataset: usize) { + // Invocation selection is session state. Linked tables are refreshed + // only by persistent source edits or explicit creation/export. + } + /// Like [`Self::apply_dataset_edit`] but re-runs the FFT first — the live path /// for dragging a time-domain step parameter, where the cached base changes. pub fn apply_dataset_retransform(&mut self, dataset: usize) { @@ -609,20 +635,14 @@ impl PlotxApp { } pub fn rebuild_canvas(&mut self, ci: usize) { - let items: Vec<( - ObjectId, - DataBinding, - ChartSpec, - StackSpec, - AxisProjections, - ObjectFrame, - )> = self.doc.canvases[ci] + let items: Vec = self.doc.canvases[ci] .objects .iter() .filter_map(|object| { object.plot().map(|plot| { ( object.id, + plot.display_owner, plot.binding.clone(), plot.chart.clone(), plot.stack, @@ -632,9 +652,9 @@ impl PlotxApp { }) }) .collect(); - for (id, binding, chart, stack, projections, frame) in items { + for (id, owner, binding, chart, stack, projections, frame) in items { let size = [frame.width / MM_TO_PT, frame.height / MM_TO_PT]; - let fig = self.build_object_figure(&binding, &chart, &stack, &projections, size); + let fig = self.build_object_figure(owner, &binding, &chart, &stack, &projections, size); self.apply_viewport_to_plot_object(ci, id, fig); } } diff --git a/crates/core/src/state/app_impl_analysis_tables.rs b/crates/core/src/state/app_impl_analysis_tables.rs index 0d9687d..74d58ee 100644 --- a/crates/core/src/state/app_impl_analysis_tables.rs +++ b/crates/core/src/state/app_impl_analysis_tables.rs @@ -141,7 +141,11 @@ impl PlotxApp { /// Build a fresh series table from any field that exposes ordered 1D /// members. Rows follow the member ruler; every region becomes one column. - fn build_region_table(&self, dataset: usize) -> Result { + fn build_region_table( + &self, + dataset: usize, + persisted_members: Option<(FieldId, &[plotx_data::TraceItemId])>, + ) -> Result { let source = self .doc .datasets @@ -187,12 +191,26 @@ impl PlotxApp { ) } Dataset::Electrophysiology(recording) => { - let selected = recording - .selected_sweeps - .iter() - .enumerate() - .filter_map(|(index, selected)| (*selected).then_some(index)) - .collect::>(); + let selected = if let Some((member_field, members)) = persisted_members { + let collection = recording + .field_catalog + .trace_collection(member_field) + .ok_or_else(|| { + "The recording channel is no longer available.".to_owned() + })?; + members + .iter() + .map(|member| { + collection + .items + .iter() + .position(|item| item.id == *member) + .ok_or_else(|| "A linked sweep is no longer available.".to_owned()) + }) + .collect::, _>>()? + } else { + recording.selected_sweep_indices() + }; let signal_unit = recording .data .channels @@ -283,9 +301,30 @@ impl PlotxApp { .as_ref() .map(DiffusionConstants::from_meta); } + let members = match source { + Dataset::Electrophysiology(recording) => { + let collection = recording + .field_key(recording.selected_channel) + .and_then(|key| recording.field_catalog.id_for_key(key)) + .and_then(|field| recording.field_catalog.trace_collection(field)); + collection.map(|collection| { + let indices = if let Some((_, members)) = persisted_members { + return members.to_vec(); + } else { + recording.selected_sweep_indices() + }; + indices + .into_iter() + .filter_map(|index| collection.items.get(index).map(|item| item.id)) + .collect() + }) + } + _ => None, + }; table.provenance = Some(TableProvenance { source_resource, source_field, + members, regions: region_provenance, }); Ok(table) @@ -308,7 +347,17 @@ impl PlotxApp { let Some(tj) = self.region_table_index(source) else { return; }; - let table = match self.build_region_table(source) { + let provenance = self.doc.datasets[tj] + .as_table() + .and_then(|table| table.provenance.as_ref()) + .cloned(); + let persisted_members = provenance.as_ref().and_then(|provenance| { + provenance + .members + .as_deref() + .map(|members| (provenance.source_field, members)) + }); + let table = match self.build_region_table(source, persisted_members) { Ok(table) => table, Err(error) => { self.session.status = error; @@ -334,13 +383,13 @@ impl PlotxApp { } if self.doc.datasets[dataset] .as_electrophysiology() - .is_some_and(|recording| !recording.selected_sweeps.iter().any(|selected| *selected)) + .is_some_and(|recording| recording.selected_sweep_indices().is_empty()) { self.session.status = "Select at least one sweep before building a region table.".to_owned(); return; } - let table = match self.build_region_table(dataset) { + let table = match self.build_region_table(dataset, None) { Ok(table) => table, Err(error) => { self.session.status = error; @@ -377,7 +426,7 @@ impl PlotxApp { /// Place an independent, unlinked snapshot of the current region values as a /// new table (no provenance), so later region edits leave it untouched. pub fn freeze_region_table(&mut self, dataset: usize) { - let mut tds = match self.build_region_table(dataset) { + let mut tds = match self.build_region_table(dataset, None) { Ok(table) => table, Err(error) => { self.session.status = error; diff --git a/crates/core/src/state/app_impl_analysis_tests.rs b/crates/core/src/state/app_impl_analysis_tests.rs index 45db765..f67b8e4 100644 --- a/crates/core/src/state/app_impl_analysis_tests.rs +++ b/crates/core/src/state/app_impl_analysis_tests.rs @@ -204,10 +204,56 @@ fn electrophysiology_edits_keep_the_live_region_table_synchronized() { metric: Some(RegionMetric::Height), }); recording.region_analysis.next_region_id = RegionId::new(1); + let dataset = Dataset::Electrophysiology(Box::new(recording)); + let canvas = crate::workflow::build_default_canvas(&dataset, "synthetic.abf"); let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Electrophysiology(Box::new(recording))); + app.doc.datasets.push(dataset); + app.doc.canvases.push(canvas); + let channel_a_field = app.doc.datasets[0] + .as_electrophysiology() + .unwrap() + .field_key(0) + .and_then(|key| app.doc.datasets[0].field_catalog().id_for_key(key)) + .unwrap(); + let pinned = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series + .iter() + .find(|series| series.source.field == channel_a_field) + .unwrap() + .clone(); + let pinned_source = pinned.source; + let owner_id = app.doc.datasets[0].resource_id(); + let live_a = app.display_binding( + Some(owner_id), + &app.doc.canvases[0].objects[0].plot().unwrap().binding, + ); + assert!( + !live_a.series.is_empty() + && live_a + .series + .iter() + .all(|series| series.source.field == channel_a_field) + ); + let channel_a_ids = live_a + .series + .iter() + .map(|series| series.id) + .collect::>(); + let pinned_id = app.doc.canvases[0].allocate_object_id(); + let mut pinned_plot = app.build_plot_object( + 0, + ObjectFrame::new(5.0, 5.0, 100.0, 60.0), + pinned_id, + "Pinned channel A".into(), + ); + pinned_plot.plot_mut().unwrap().display_owner = None; + pinned_plot.plot_mut().unwrap().binding = DataBinding { + series: vec![pinned], + }; + app.doc.canvases[0].objects.push(pinned_plot); app.create_region_table(0); let values = |app: &PlotxApp| { @@ -223,30 +269,106 @@ fn electrophysiology_edits_keep_the_live_region_table_synchronized() { .unwrap() .selected_channel = 1; app.apply_dataset_edit(0); + assert_eq!( + app.doc.canvases[0].objects[1] + .plot() + .unwrap() + .binding + .series[0] + .source, + pinned_source, + "an independently addressed channel item must not follow the live channel" + ); let channel_b = values(&app); assert_ne!(channel_a, channel_b); + let recording = app.doc.datasets[0].as_electrophysiology().unwrap(); + let selected_field = recording + .field_key(1) + .and_then(|key| recording.field_catalog.id_for_key(key)) + .unwrap(); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + let binding_before_invocation = plot.binding.clone(); + assert!( + plot.binding + .series + .iter() + .any(|series| series.source.field == selected_field) + ); + let live_b = app.display_binding(Some(owner_id), &plot.binding); + assert!( + !live_b.series.is_empty() + && live_b + .series + .iter() + .all(|series| series.source.field == selected_field), + "the inspector/rendering projection follows only the selected channel" + ); + assert_eq!( + plot.binding + .series + .iter() + .filter(|series| series.source.field == channel_a_field) + .map(|series| series.id) + .collect::>(), + channel_a_ids, + "switching channels retains the inactive channel's stable authored identities" + ); + let selected = app.doc.datasets[0] + .as_electrophysiology() + .unwrap() + .trace_items()[1] + .id; app.doc.datasets[0] .as_electrophysiology_mut() .unwrap() - .selected_sweeps[0] = false; - app.apply_dataset_edit(0); - assert_eq!(values(&app).len(), 1); + .invocation + .analysis_selection = Some(vec![selected]); + app.doc.dirty = false; + let table_before_invocation = values(&app); + let serialized_before = + serde_json::to_value(app.doc.datasets[0].as_electrophysiology().unwrap()).unwrap(); + app.apply_electrophysiology_invocation_edit(0); + assert_eq!(values(&app), table_before_invocation); + assert_eq!( + serde_json::to_value(app.doc.datasets[0].as_electrophysiology().unwrap()).unwrap(), + serialized_before + ); + assert_eq!( + app.doc.canvases[0].objects[0].plot().unwrap().binding, + binding_before_invocation + ); + assert!( + !app.doc.dirty, + "changing a transient sweep selection must not dirty the document" + ); - let raw = values(&app); let recording = app.doc.datasets[0].as_electrophysiology_mut().unwrap(); recording.processing.gaussian_lowpass_enabled = true; recording.processing.cutoff_hz = 1.0; app.apply_dataset_edit(0); - assert_ne!(values(&app), raw); + let after_persistent_edit = values(&app); + assert_eq!(after_persistent_edit.len(), 2); + assert!(app.doc.dirty); + assert_eq!( + app.doc.canvases[0].objects[1] + .plot() + .unwrap() + .binding + .series[0] + .source, + pinned_source + ); app.doc.datasets[0] .as_electrophysiology_mut() .unwrap() - .selected_sweeps - .fill(false); - app.apply_dataset_edit(0); - assert!(values(&app).is_empty()); + .invocation + .analysis_selection = Some(Vec::new()); + app.doc.dirty = false; + app.apply_electrophysiology_invocation_edit(0); + assert_eq!(values(&app), after_persistent_edit); + assert!(!app.doc.dirty); } fn electrophysiology_region_app(samples: Vec, metric: RegionMetric) -> PlotxApp { diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 5540ca2..1d105b6 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -3,6 +3,90 @@ use plotx_figure::{Color, Figure, RangeAnnotation}; use std::sync::Arc; impl PlotxApp { + /// Project a live plot's persisted binding onto its current owner field. + /// + /// Alternate fields belonging to the owner remain persisted so switching + /// back restores their authored state. Series explicitly added from other + /// datasets are plot-owned and remain visible in encoding-compatible modes. + pub fn display_binding(&self, owner: Option, binding: &DataBinding) -> DataBinding { + let Some((resource, field)) = self.live_display_source(owner) else { + return binding.clone(); + }; + let mut owner_series = binding + .series + .iter() + .filter(|series| series.source.resource == resource && series.source.field == field) + .cloned() + .collect::>(); + let recommended = self + .doc + .dataset_by_id(resource) + .and_then(|dataset| dataset.field_descriptor(field)) + .and_then(|field| field.metadata.recommended_encoding().map(str::to_owned)); + let owner_encoding = owner_series.first().map(|series| series.encoding.clone()); + owner_series.extend( + binding + .series + .iter() + .filter(|series| series.source.resource != resource) + .filter(|series| { + owner_encoding.as_ref().map_or_else( + || { + recommended.as_deref().is_some_and(|recommended| { + encoding_matches_recommended(&series.encoding, recommended) + }) + }, + |owner| same_encoding_kind(owner, &series.encoding), + ) + }) + .cloned(), + ); + DataBinding { + series: owner_series, + } + } + + /// Merge edits made against [`Self::display_binding`] into the full + /// persisted binding without discarding inactive owner fields. + pub fn merge_display_binding( + &self, + owner: Option, + persisted: &DataBinding, + displayed: DataBinding, + ) -> DataBinding { + let Some((_resource, _field)) = self.live_display_source(owner) else { + return displayed; + }; + let previous_display = self.display_binding(owner, persisted); + let previous_ids = previous_display + .series + .iter() + .map(|series| series.id) + .collect::>(); + let allowed = self.display_binding(owner, &displayed); + let mut series = allowed.series.into_iter().collect::>(); + series.extend( + persisted + .series + .iter() + .filter(|series| !previous_ids.contains(&series.id)) + .cloned(), + ); + DataBinding { series } + } + + fn live_display_source(&self, owner: Option) -> Option<(DatasetId, FieldId)> { + let dataset = owner.and_then(|id| self.doc.dataset_by_id(id))?; + let field = match dataset { + Dataset::Nmr2D(data) if !data.is_true_2d() => dataset.default_field_id(), + Dataset::Electrophysiology(recording) => recording + .field_key(recording.selected_channel) + .and_then(|key| recording.field_catalog.id_for_key(key)), + _ => None, + }?; + Some((dataset.resource_id(), field)) + } + /// Build a dataset's figure through the chart registry: resolve `chart`'s /// type for the dataset's domain (falling back to the domain default when the /// recorded id doesn't apply), then dispatch to its builder. The default chart @@ -55,23 +139,35 @@ impl PlotxApp { stack: &StackSpec, size_mm: [f32; 2], ) -> Figure { + if !binding.series.iter().any(|series| series.visible) { + return self.normalize_binding_figure( + Figure::new( + "", + plotx_figure::Axis::new("x", 0.0, 1.0), + plotx_figure::Axis::new("y", 0.0, 1.0), + ), + size_mm, + ); + } if binding.series.len() > 1 && self.series_stackable(binding) { self.build_stacked_figure(binding, stack, size_mm) } else { if let Some(series) = binding.series.first() && self.series_uses_encoded_curve(series) { - let figure = self + let mut figure = self .build_encoded_series_figure(series) .unwrap_or_else(|| unsupported_series_figure(series)); + self.apply_series_binding_style(&mut figure, series); return self.normalize_binding_figure(figure, size_mm); } if let Some(series) = binding.series.first() && !matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_)) { - let figure = self + let mut figure = self .build_encoded_series_figure(series) .unwrap_or_else(|| unsupported_series_figure(series)); + self.apply_series_binding_style(&mut figure, series); return self.normalize_binding_figure(figure, size_mm); } let Some(primary_id) = binding.primary_dataset() else { @@ -94,53 +190,8 @@ impl PlotxApp { // recolours the built traces, so it survives figure rebuilds and export. // Applied before the line-fit overlays so those keep their own colours; // stacked figures never get overlays (each trace stays a single series). - if let Some(line) = binding - .series - .first() - .and_then(|series| match &series.encoding { - plotx_figure::SeriesEncoding::Line(line) => Some(line), - plotx_figure::SeriesEncoding::Contour(_) - | plotx_figure::SeriesEncoding::Heatmap(_) - | plotx_figure::SeriesEncoding::Image(_) => None, - }) - { - let color = line.color.resolve(); - let semantic_colors = fig.series_colors_are_semantic; - for series in &mut fig.series { - if !semantic_colors { - series.color = color; - } - series.width = line.width.get(); - for point in &mut series.points { - point[1] *= line.scale; - } - } - for error_bar in &mut fig.error_bars { - if !semantic_colors { - error_bar.color = color; - } - error_bar.center[1] *= line.scale; - error_bar.negative *= line.scale.abs(); - error_bar.positive *= line.scale.abs(); - } - // Bar/box bodies live in `polygons` and must follow the traces. - // Value-mapped figures (heatmap cells, colormap surfaces, pie - // wedges) keep their own colours — one override would erase the - // encoding they carry. - if !semantic_colors - && fig.heatmap.is_none() - && fig.axis_frame != plotx_figure::AxisFrame::Hidden - { - let background = fig.background; - for polygon in &mut fig.polygons { - polygon.fill = color; - if let Some((stroke, _)) = &mut polygon.stroke - && *stroke != background - { - *stroke = color; - } - } - } + if let Some(series) = binding.series.first() { + self.apply_series_binding_style(&mut fig, series); } // Stored fits are curves in the table's native x/y space; every // other table chart (histogram, box, heatmap, …) draws in different @@ -180,8 +231,55 @@ impl PlotxApp { figure } + pub(super) fn apply_series_binding_style(&self, figure: &mut Figure, binding: &SeriesBinding) { + let plotx_figure::SeriesEncoding::Line(line) = &binding.encoding else { + return; + }; + let color = line.color.resolve(); + let semantic_colors = figure.series_colors_are_semantic; + for series in &mut figure.series { + if let Some(label) = &binding.label { + series.name = label.clone(); + } + if !semantic_colors { + series.color = color; + } + series.width = line.width.get(); + for point in &mut series.points { + point[1] *= line.scale; + } + } + for error_bar in &mut figure.error_bars { + if !semantic_colors { + error_bar.color = color; + } + error_bar.width = line.width.get(); + error_bar.center[1] *= line.scale; + error_bar.negative *= line.scale.abs(); + error_bar.positive *= line.scale.abs(); + } + if !semantic_colors + && figure.heatmap.is_none() + && figure.axis_frame != plotx_figure::AxisFrame::Hidden + { + let background = figure.background; + for polygon in &mut figure.polygons { + polygon.fill = color; + if let Some((stroke, width)) = &mut polygon.stroke + && *stroke != background + { + *stroke = color; + *width = line.width.get(); + } + } + } + } + pub(super) fn build_encoded_series_figure(&mut self, series: &SeriesBinding) -> Option
{ let dataset = self.doc.dataset_by_id(series.source.resource)?; + if let Some(item) = series.source.item { + return dataset.trace_item_figure(series.source.field, item); + } if !dataset.supports_encoding(series.source.field, &series.encoding) { return None; } @@ -294,6 +392,9 @@ impl PlotxApp { } pub(super) fn series_uses_encoded_curve(&self, series: &SeriesBinding) -> bool { + if series.source.item.is_some() { + return true; + } self.doc .dataset_by_id(series.source.resource) .and_then(|dataset| dataset.field_descriptor(series.source.field)) @@ -322,6 +423,41 @@ impl PlotxApp { } } +fn same_encoding_kind( + left: &plotx_figure::SeriesEncoding, + right: &plotx_figure::SeriesEncoding, +) -> bool { + matches!( + (left, right), + ( + plotx_figure::SeriesEncoding::Line(_), + plotx_figure::SeriesEncoding::Line(_) + ) | ( + plotx_figure::SeriesEncoding::Contour(_), + plotx_figure::SeriesEncoding::Contour(_) + ) | ( + plotx_figure::SeriesEncoding::Heatmap(_), + plotx_figure::SeriesEncoding::Heatmap(_) + ) | ( + plotx_figure::SeriesEncoding::Image(_), + plotx_figure::SeriesEncoding::Image(_) + ) + ) +} + +fn encoding_matches_recommended( + encoding: &plotx_figure::SeriesEncoding, + recommended: &str, +) -> bool { + matches!( + (encoding, recommended), + (plotx_figure::SeriesEncoding::Line(_), "line") + | (plotx_figure::SeriesEncoding::Contour(_), "contour") + | (plotx_figure::SeriesEncoding::Heatmap(_), "heatmap") + | (plotx_figure::SeriesEncoding::Image(_), "image") + ) +} + /// What the user is told while a field's derived work is still running. /// /// Contour geometry and the estimates it depends on are computed off the diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index f4b557e..0088f5c 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -156,7 +156,7 @@ static CHART_TYPES: &[ChartDescriptor] = &[ recommended_domains: &[DataDomain::Electrophysiology], required_capabilities: &[ crate::automation::CAP_FIELD_CURVE_1D, - crate::automation::CAP_FIELD_SWEEP_COLLECTION, + crate::automation::CAP_FIELD_TRACE_COLLECTION, ], needs_column: false, build: build_electrophysiology, @@ -189,7 +189,7 @@ static CHART_TYPES: &[ChartDescriptor] = &[ recommended_domains: &[DataDomain::PseudoNmr], required_capabilities: &[ crate::automation::CAP_FIELD_CURVE_1D, - crate::automation::CAP_FIELD_NMR_STACK, + crate::automation::CAP_FIELD_TRACE_COLLECTION, ], needs_column: false, build: build_nmr_2d, diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index f002fde..48b69d8 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -1,6 +1,109 @@ use super::{Dataset, Trace1d}; impl Dataset { + pub fn trace_item_figure( + &self, + field: super::FieldId, + item: plotx_data::TraceItemId, + ) -> Option { + let collection = self.trace_collection(field)?; + let index = collection.items.iter().position(|entry| entry.id == item)?; + let label = collection.item(item)?.automatic_label()?; + match self { + Self::Nmr2D(data) => { + let plotx_processing::Processed2D::Stack(stack) = &data.processed else { + return None; + }; + let values = stack.traces.get(index)?; + let points = stack + .ppm + .iter() + .copied() + .zip(values.iter().map(|value| value.re)) + .map(|(x, y)| [x, y]) + .collect::>(); + let (x0, x1) = stack.ppm_bounds(); + let (mut y0, mut y1) = points + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), point| { + (lo.min(point[1]), hi.max(point[1])) + }); + if !y0.is_finite() || y0 == y1 { + y0 = -0.5; + y1 = 0.5; + } + let x_name = if stack.direct_domain == plotx_io::Domain::Frequency { + crate::figures::axis_label(&stack.direct.nucleus) + } else { + "Time (s)".to_owned() + }; + let x_axis = plotx_figure::Axis::new(x_name, x0, x1) + .reversed(stack.direct_domain == plotx_io::Domain::Frequency); + Some( + plotx_figure::Figure::new( + "", + x_axis, + plotx_figure::Axis::new("Intensity", y0, y1), + ) + .with_series(plotx_figure::Series::line(label, points)), + ) + } + Self::Electrophysiology(data) => { + let channel = (0..data.data.channels.len()).find(|&channel| { + data.field_key(channel) + .and_then(|key| data.field_catalog.id_for_key(key)) + == Some(field) + })?; + let ys = data.processed_trace(index, channel).ok()?; + let points = ys + .iter() + .enumerate() + .filter_map(|(i, y)| { + y.is_finite() + .then_some([i as f64 / data.data.sample_rate_hz, *y]) + }) + .collect::>(); + let (mut y0, mut y1) = ys + .iter() + .copied() + .filter(|value| value.is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), value| { + (lo.min(value), hi.max(value)) + }); + if !y0.is_finite() || y0 == y1 { + y0 = -0.5; + y1 = 0.5; + } + let meta = data.data.channels.get(channel)?; + Some( + plotx_figure::Figure::new( + "", + plotx_figure::Axis::new( + "Time (s)", + 0.0, + ys.len() as f64 / data.data.sample_rate_hz, + ), + plotx_figure::Axis::new( + format!("{} ({})", meta.name, meta.unit.symbol), + y0, + y1, + ), + ) + .with_series(plotx_figure::Series::line(label, points)), + ) + } + _ => None, + } + } + + pub fn trace_item_label( + &self, + field: super::FieldId, + item: plotx_data::TraceItemId, + ) -> Option { + self.trace_collection(field)?.item(item)?.automatic_label() + } + pub fn trace_x_unit(&self) -> String { match self { Self::Nmr(data) => match data.output_domain() { diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 10d548e..112df39 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -255,6 +255,7 @@ impl Nmr2DDataset { version: 1, }), ); + attach_pseudo_trace_collection(&mut field_catalog, &data); let mut result = Self { resource_id: DatasetId::new(), field_catalog, @@ -461,5 +462,7 @@ fn set_pipeline_pivot_frac(pipe: &mut AxisPipeline, frac: f64) { } } +#[cfg(test)] +mod pseudo_display_binding_tests; #[cfg(test)] mod pseudo_tests; diff --git a/crates/core/src/state/datasets/pseudo_display_binding_tests.rs b/crates/core/src/state/datasets/pseudo_display_binding_tests.rs new file mode 100644 index 0000000..582b78f --- /dev/null +++ b/crates/core/src/state/datasets/pseudo_display_binding_tests.rs @@ -0,0 +1,305 @@ +use super::pseudo_tests::{synthetic_dosy, wait_for_compute}; +use super::*; + +#[test] +fn live_binding_projects_the_current_field_and_keeps_external_series() { + let mut owner = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(owner.build_dosy_map()); + owner.display = PseudoDisplay::Stack; + let mut external = Nmr2DDataset::load(synthetic_dosy(1.5e-9)); + assert!(external.build_dosy_map()); + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(owner))); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(external))); + let canvas = crate::workflow::build_default_canvas(&app.doc.datasets[0], "Owner"); + app.doc.canvases.push(canvas); + + let owner_id = app.doc.datasets[0].resource_id(); + let owner_stack = app.doc.datasets[0] + .field_catalog() + .id_for_key("nmr.stack") + .unwrap(); + let initial_collection = app.display_binding( + Some(owner_id), + &app.doc.canvases[0].objects[0].plot().unwrap().binding, + ); + let retained = initial_collection.series[1].clone(); + assert_eq!(retained.primary_color(), Some(OVERLAY_PALETTE[1])); + let raw = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .clone(); + let retained_binding = app.merge_display_binding( + Some(owner_id), + &raw, + DataBinding { + series: vec![retained], + }, + ); + app.doc.canvases[0].objects[0].plot_mut().unwrap().binding = retained_binding; + let initial = app.display_binding( + Some(owner_id), + &app.doc.canvases[0].objects[0].plot().unwrap().binding, + ); + assert_eq!(initial.series.len(), 1); + assert_eq!(app.stack_candidates(&initial), vec![1]); + let mut external = app + .stack_candidate_series(&initial, 1) + .expect("a map-displaying pseudo dataset still contributes a trace item"); + assert!(external.source.item.is_some()); + external.id = app.doc.canvases[0].objects[0] + .plot_mut() + .unwrap() + .allocate_series_id(); + let external_color = app.next_stack_color(&initial); + external.set_primary_color(external_color); + assert_ne!(external.primary_color(), initial.series[0].primary_color()); + let persisted_before = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .clone(); + let mut edited = initial; + edited.series.push(external.clone()); + let stored = app.merge_display_binding(Some(owner_id), &persisted_before, edited); + app.doc.canvases[0].objects[0].plot_mut().unwrap().binding = stored.clone(); + let displayed = app.display_binding(Some(owner_id), &stored); + assert!( + displayed + .series + .iter() + .any(|series| series.id == external.id) + ); + assert_eq!(external.primary_color(), Some(external_color)); + assert!(displayed.series.iter().all(|series| { + series.source.resource != owner_id || series.source.field == owner_stack + })); + let targets = app.series_targets(0, app.doc.canvases[0].objects[0].id); + assert_eq!(targets.len(), displayed.series.len()); + let hidden_id = displayed.series[0].id; + let target = app + .series_target(0, app.doc.canvases[0].objects[0].id, hidden_id) + .unwrap(); + let commit = app + .plan_property_write( + crate::properties::object::SERIES_VISIBLE, + &[target], + &crate::properties::PropertyValue::Bool(false), + ) + .unwrap(); + assert_eq!(app.commit_property(commit), 1); + assert!( + !app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series + .iter() + .find(|series| series.id == hidden_id) + .unwrap() + .visible + ); + app.undo(); + assert!( + app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .series + .iter() + .find(|series| series.id == hidden_id) + .unwrap() + .visible + ); + let figure = app.build_object_figure( + Some(owner_id), + &stored, + &ChartSpec::default_for(DataDomain::PseudoNmr), + &StackSpec::default(), + &AxisProjections::default(), + [120.0, 80.0], + ); + assert_eq!(figure.series.len(), displayed.series.len()); + assert_ne!(figure.series[0].color, figure.series[1].color); + + app.set_pseudo_display(0, PseudoDisplay::DosyMap); + let map_display = app.display_binding(Some(owner_id), &stored); + assert_eq!(map_display.series.len(), 1); + assert_eq!(map_display.series[0].source.resource, owner_id); + assert!(matches!( + map_display.series[0].encoding, + plotx_figure::SeriesEncoding::Contour(_) + )); + wait_for_compute(&mut app); + assert!( + !app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .figure() + .contours + .is_empty() + ); + app.set_pseudo_display(0, PseudoDisplay::Stack); + assert_eq!(app.display_binding(Some(owner_id), &stored), displayed); + + let external_only = DataBinding { + series: vec![external.clone()], + }; + let merged = app.merge_display_binding(Some(owner_id), &stored, external_only.clone()); + assert_eq!(app.display_binding(Some(owner_id), &merged), external_only); + assert!(merged.series.iter().any(|series| { + series.source.resource == owner_id && series.source.field != owner_stack + })); + let empty = app.merge_display_binding(Some(owner_id), &stored, DataBinding { series: vec![] }); + assert!( + app.display_binding(Some(owner_id), &empty) + .series + .is_empty() + ); + let empty_figure = app.build_object_figure( + Some(owner_id), + &empty, + &ChartSpec::default_for(DataDomain::PseudoNmr), + &StackSpec::default(), + &AxisProjections::default(), + [120.0, 80.0], + ); + assert!(empty_figure.series.is_empty() && empty_figure.contours.is_empty()); + + app.doc.canvases[0].objects[0].plot_mut().unwrap().binding = stored.clone(); + let path = std::env::temp_dir().join(format!( + "plotx-live-external-series-{}.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); + let loaded_plot = loaded.doc.canvases[0].objects[0].plot().unwrap(); + assert_eq!(loaded_plot.binding, stored); + assert_eq!( + loaded.display_binding(loaded_plot.display_owner, &loaded_plot.binding), + displayed + ); + assert_eq!(loaded_plot.figure().series.len(), displayed.series.len()); +} + +#[test] +fn dosy_map_honors_non_default_contour_levels_and_style() { + use plotx_figure::{ + Color, ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, + PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, + }; + + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(dataset.build_dosy_map()); + let peak = dosy_scalar_grid(dataset.dosy_map.as_ref().unwrap()) + .values + .iter() + .copied() + .fold(0.0_f32, f32::max) as f64; + let field = dataset.field_catalog.id_for_key("nmr.dosy_map").unwrap(); + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + let mut binding = DataBinding { + series: SeriesBinding::from_field_all(&app.doc.datasets[0], field), + }; + let color = Color::rgb(17, 93, 201); + binding.series[0].encoding = SeriesEncoding::Contour(ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(peak * 0.35).unwrap()), + count: 2, + ratio: PositiveFiniteF64::new(1.4).unwrap(), + }, + negative: None, + style: ContourStyle { + positive_color: ColorSource::Explicit(color), + negative_color: ColorSource::Explicit(Color::rgb(200, 30, 30)), + width: PositiveFiniteF32::new(2.75).unwrap(), + }, + }); + let build = |app: &mut PlotxApp, binding: &DataBinding| { + app.build_binding_figure( + binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ) + }; + assert!(build(&mut app, &binding).contours.is_empty()); + wait_for_compute(&mut app); + let styled = build(&mut app, &binding); + assert!(!styled.contours.is_empty()); + assert!(styled.contours.iter().all(|contour| contour.color == color)); + assert!(styled.contours.iter().all(|contour| contour.width == 2.75)); + + let SeriesEncoding::Contour(spec) = &mut binding.series[0].encoding else { + unreachable!() + }; + spec.positive.base = ContourBasePolicy::Absolute(PositiveFiniteF64::new(peak * 2.0).unwrap()); + app.session.status.clear(); + assert!(build(&mut app, &binding).contours.is_empty()); + assert!(app.session.status.contains("threshold")); +} + +#[test] +fn ilt_map_honors_non_default_contour_style() { + use plotx_figure::{ + Color, ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, + PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, + }; + + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(dataset.build_ilt_map(IltParams { + lambda: 1e-2, + d_min: 1e-10, + d_max: 1e-8, + n_grid: 32, + })); + let peak = dataset + .ilt_map + .as_ref() + .unwrap() + .amp + .iter() + .flatten() + .copied() + .fold(0.0_f64, f64::max); + let field = dataset.field_catalog.id_for_key("nmr.ilt_map").unwrap(); + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + let mut binding = DataBinding { + series: SeriesBinding::from_field_all(&app.doc.datasets[0], field), + }; + let color = Color::rgb(31, 151, 87); + binding.series[0].encoding = SeriesEncoding::Contour(ContourSpec { + positive: ContourLevelSpec { + base: ContourBasePolicy::Absolute(PositiveFiniteF64::new(peak * 0.35).unwrap()), + count: 2, + ratio: PositiveFiniteF64::new(1.4).unwrap(), + }, + negative: None, + style: ContourStyle { + positive_color: ColorSource::Explicit(color), + negative_color: ColorSource::Explicit(Color::rgb(180, 40, 40)), + width: PositiveFiniteF32::new(3.25).unwrap(), + }, + }); + let pending = app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ); + assert!(pending.contours.is_empty()); + wait_for_compute(&mut app); + let styled = app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ); + assert!(!styled.contours.is_empty()); + assert!(styled.contours.iter().all(|contour| contour.color == color)); + assert!(styled.contours.iter().all(|contour| contour.width == 3.25)); +} diff --git a/crates/core/src/state/datasets/pseudo_tests.rs b/crates/core/src/state/datasets/pseudo_tests.rs index 95179c0..059f955 100644 --- a/crates/core/src/state/datasets/pseudo_tests.rs +++ b/crates/core/src/state/datasets/pseudo_tests.rs @@ -6,6 +6,19 @@ use plotx_io::{ AxisSource, DiffusionMeta, Dim, Domain, NmrData2D, PseudoAxis, PseudoKind, QuadMode, }; +pub(super) fn wait_for_compute(app: &mut PlotxApp) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.poll_compute(); + assert!( + !app.compute_busy(), + "field job did not settle before deadline" + ); +} + fn dim(nucleus: &str) -> Dim { Dim { spectral_width_hz: 4000.0, @@ -18,7 +31,7 @@ fn dim(nucleus: &str) -> Dim { // A synthetic DOSY array: one decaying resonance whose amplitude follows a // Stejskal–Tanner decay with a known D across 16 linear gradient steps. -fn synthetic_dosy(d_true: f64) -> NmrData2D { +pub(super) fn synthetic_dosy(d_true: f64) -> NmrData2D { let (cols, rows) = (256usize, 16usize); let meta = DiffusionMeta { gamma: 2.675_222e8, @@ -167,6 +180,43 @@ fn dataset_builds_ilt_dosy_map() { assert!(ds.ilt_map.is_some(), "ILT result should remain cached"); } +#[test] +fn pseudo_map_fields_are_truthful_scalar_grids_with_map_encodings() { + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(dataset.build_dosy_map()); + assert!(dataset.build_ilt_map(IltParams { + lambda: 1e-2, + d_min: 1e-10, + d_max: 1e-8, + n_grid: 32, + })); + let dataset = Dataset::Nmr2D(Box::new(dataset)); + let fields = dataset.field_descriptors(); + assert!(fields.iter().any(|field| field.local_id == "nmr.stack")); + for key in ["nmr.dosy_map", "nmr.ilt_map"] { + let field = fields.iter().find(|field| field.local_id == key).unwrap(); + assert!(matches!( + dataset.field_payload(field.id), + Some(FieldPayload::ScalarGrid2D(ScalarGrid2D { rows, cols, ref values, .. })) + if rows > 1 && cols > 1 && values.len() == rows * cols + )); + let binding = SeriesBinding::from_field_all(&dataset, field.id); + assert!(matches!( + binding[0].encoding, + plotx_figure::SeriesEncoding::Contour(_) + )); + assert!(dataset.supports_encoding(field.id, &binding[0].encoding)); + assert_eq!( + field + .metadata + .0 + .get("recommended_encoding") + .map(String::as_str), + Some("contour") + ); + } +} + /// Both maps can be cached at once, so the figure cache must be keyed by method. /// A single shared slot would serve whichever figure was built last for whichever /// method the display happens to select — an ILT contour labelled per-column DOSY. @@ -249,6 +299,149 @@ fn persisted_display_and_method_changes_mark_the_document_dirty() { assert!(app.doc.dirty, "changing the persisted method must be dirty"); } +#[test] +fn switching_an_existing_stack_canvas_to_dosy_rebuilds_it_as_a_map() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); + assert!(dataset.build_dosy_map()); + dataset.display = PseudoDisplay::Stack; + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + + let mut canvas = CanvasDocument::new("DOSY".to_owned(), [120.0, 80.0]); + let [width, height] = canvas.size_pt(); + let object = app.build_plot_object( + 0, + ObjectFrame::new(0.0, 0.0, width, height), + canvas.allocate_object_id(), + "DOSY".to_owned(), + ); + assert!(object.plot().unwrap().figure().heatmap.is_none()); + canvas.objects.push(object); + app.doc.canvases.push(canvas); + + { + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.binding.series.swap(0, 1); + let series = &mut plot.binding.series[0]; + series.label = Some("authored increment".to_owned()); + if let plotx_figure::SeriesEncoding::Line(line) = &mut series.encoding { + line.scale = 1.75; + line.width = plotx_figure::PositiveFiniteF32::new(2.25).unwrap(); + line.color = plotx_figure::ColorSource::Explicit(plotx_figure::Color::rgb(12, 34, 56)); + } + } + + let authored_stack = { + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + let stack_field = app.doc.datasets[0] + .field_catalog() + .id_for_key("nmr.stack") + .unwrap(); + DataBinding { + series: plot + .binding + .series + .iter() + .filter(|series| series.source.field == stack_field) + .cloned() + .collect(), + } + }; + let extracted = DataBinding { + series: vec![authored_stack.series[0].clone()], + }; + app.set_pseudo_display(0, PseudoDisplay::DosyMap); + wait_for_compute(&mut app); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + let display_field = app.doc.datasets[0] + .field_catalog() + .id_for_key("nmr.dosy_map") + .unwrap(); + assert!( + plot.binding + .series + .iter() + .any(|series| { series.source.field == display_field && series.source.item.is_none() }) + ); + let figure = plot.figure(); + assert!( + figure.heatmap.is_some() || !figure.contours.is_empty(), + "the rebuilt canvas must show the selected DOSY map" + ); + + let trace = app.build_binding_figure( + &extracted, + &ChartSpec::default_for(DataDomain::Nmr2d), + &StackSpec::default(), + [120.0, 80.0], + ); + assert_eq!(trace.series.len(), 1, "an extracted item remains a trace"); + assert!(trace.heatmap.is_none() && trace.contours.is_empty()); + + let independent_id = app.doc.canvases[0].allocate_object_id(); + let mut independent = app.build_plot_object( + 0, + ObjectFrame::new(4.0, 4.0, 100.0, 60.0), + independent_id, + "Extracted increment".to_owned(), + ); + { + let plot = independent.plot_mut().unwrap(); + plot.display_owner = None; + plot.binding = extracted.clone(); + } + app.doc.canvases[0].objects.push(independent); + app.rebuild_canvas(0); + assert_eq!( + app.doc.canvases[0].objects[1] + .plot() + .unwrap() + .figure() + .series + .len(), + 1 + ); + + let default_binding_before_save = app.doc.canvases[0].objects[0] + .plot() + .unwrap() + .binding + .clone(); + let path = std::env::temp_dir().join(format!( + "plotx-pseudo-extracted-{}.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); + let loaded_plot = loaded.doc.canvases[0].objects[1].plot().unwrap(); + assert_eq!( + loaded.doc.canvases[0].objects[0].plot().unwrap().binding, + default_binding_before_save, + "palette colors and all stack authoring state must round-trip" + ); + assert_eq!(loaded_plot.display_owner, None); + assert_eq!(loaded_plot.binding, extracted); + assert_eq!(loaded_plot.figure().series.len(), 1); + + app.set_pseudo_display(0, PseudoDisplay::Stack); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + let stack_field = app.doc.datasets[0] + .field_catalog() + .id_for_key("nmr.stack") + .unwrap(); + let restored = DataBinding { + series: plot + .binding + .series + .iter() + .filter(|series| series.source.field == stack_field) + .cloned() + .collect(), + }; + assert_eq!(restored, authored_stack); +} + #[test] fn processing_invalidation_explains_the_stack_fallback() { let mut dataset = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); diff --git a/crates/core/src/state/datasets_2d_figure.rs b/crates/core/src/state/datasets_2d_figure.rs index 4af68a3..df2558e 100644 --- a/crates/core/src/state/datasets_2d_figure.rs +++ b/crates/core/src/state/datasets_2d_figure.rs @@ -69,6 +69,11 @@ impl Nmr2DDataset { field: &FieldDescriptor, encoding: &SeriesEncoding, ) -> Option
{ + if matches!(encoding, SeriesEncoding::Contour(_)) + && matches!(field.local_id.as_str(), "nmr.dosy_map" | "nmr.ilt_map") + { + return self.map_contour_base(field.id); + } let Processed2D::Ft(spectrum) = &self.processed else { return None; }; @@ -101,6 +106,9 @@ impl Nmr2DDataset { geometry: &ContourGeometry, style: &ContourStyle, ) -> Option
{ + if let Some(figure) = self.map_contour_base(field) { + return Some(apply_contour_geometry(figure, geometry, style)); + } let Processed2D::Ft(spectrum) = &self.processed else { return None; }; @@ -117,6 +125,44 @@ impl Nmr2DDataset { style, )) } + + fn map_contour_base(&self, field: FieldId) -> Option
{ + let map_axes = if self.field_catalog.id_for_key("nmr.dosy_map") == Some(field) { + let grid = dosy_scalar_grid(self.dosy_map.as_ref()?); + let bounds = |axis: &AxisSampling| match axis { + AxisSampling::Linear { start, end } => Some((*start, *end)), + AxisSampling::Explicit(values) => Some((*values.first()?, *values.last()?)), + }; + let (x0, x1) = bounds(&grid.x)?; + let (y0, y1) = bounds(&grid.y)?; + Some(("DOSY", x0, x1, y0, y1)) + } else if self.field_catalog.id_for_key("nmr.ilt_map") == Some(field) { + let map = self.ilt_map.as_ref()?; + Some(( + "DOSY (ILT)", + *map.ppm.first()?, + *map.ppm.last()?, + map.d_grid.first()?.max(f64::MIN_POSITIVE).log10(), + map.d_grid.last()?.max(f64::MIN_POSITIVE).log10(), + )) + } else { + return None; + }; + let (title, x0, x1, y0, y1) = map_axes?; + Some( + Figure::new( + format!("{title} — {}", self.data.source), + Axis::new( + format!("{} chemical shift (ppm)", self.data.direct.nucleus), + x0.min(x1), + x0.max(x1), + ) + .reversed(true), + Axis::new("log10(D / (m2/s))", y0.min(y1), y0.max(y1)).reversed(true), + ) + .with_axis_frame(AxisFrame::Box), + ) + } } fn nmr_axes(spectrum: &plotx_processing::Spectrum2D) -> (Axis, Axis) { diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index a9901b9..4dc2572 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -228,9 +228,11 @@ pub struct DataBinding { impl DataBinding { pub fn single(dataset: &Dataset) -> Self { - Self { - series: SeriesBinding::from_dataset(dataset).into_iter().collect(), + let mut series = SeriesBinding::from_dataset_all(dataset); + for (index, binding) in series.iter_mut().enumerate() { + binding.id = SeriesId::new(index as u64); } + Self { series } } pub fn primary_dataset(&self) -> Option { diff --git a/crates/core/src/state/electrophysiology.rs b/crates/core/src/state/electrophysiology.rs index 93440cd..5e34e67 100644 --- a/crates/core/src/state/electrophysiology.rs +++ b/crates/core/src/state/electrophysiology.rs @@ -84,7 +84,10 @@ pub struct ElectrophysiologyDataset { pub name: Option, pub metadata: RecordingMetadata, pub processing: ElectrophysiologyProcessing, - pub selected_sweeps: Vec, + /// Per-invocation sweep selection. It is UI/runtime state, not part of a + /// recording or a saved project. + #[serde(skip, default)] + pub invocation: ElectrophysiologyInvocationState, pub selected_channel: usize, pub stimulus: Option, pub lineage: Option, @@ -92,9 +95,81 @@ pub struct ElectrophysiologyDataset { pub peak_mode: PeakMode, } +#[derive(Clone, Debug, Default)] +pub struct ElectrophysiologyInvocationState { + pub analysis_selection: Option>, +} + +pub(crate) struct ResolvedAbfStimulus { + pub values: Vec, + pub quantity: ElectricalQuantity, + pub unit: String, + pub name: String, +} + +pub(crate) fn command_level(command: &plotx_io::CommandWaveform) -> f64 { + command + .samples + .iter() + .copied() + .find(|value| value.is_finite() && (*value - command.holding_level).abs() > f64::EPSILON) + .unwrap_or(command.holding_level) +} + +/// Resolve one experimental command level per sweep. Across multiple sweeps, +/// the earliest sample that varies between sweeps identifies the test epoch; +/// this deliberately skips fixed holding and prepulse epochs. +pub(crate) fn resolve_abf_stimulus(data: &ElectrophysiologyData) -> Option { + let command_count = data.sweeps.iter().map(|sweep| sweep.commands.len()).min()?; + for command_index in 0..command_count { + let commands = data + .sweeps + .iter() + .map(|sweep| &sweep.commands[command_index]) + .collect::>(); + let sample_count = commands.iter().map(|command| command.samples.len()).min()?; + if let Some(sample) = (0..sample_count).find(|&sample| { + let (lo, hi) = + commands + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), command| { + let value = command.samples[sample]; + (lo.min(value), hi.max(value)) + }); + lo.is_finite() && hi.is_finite() && hi - lo > f64::EPSILON * hi.abs().max(1.0) + }) { + let first = commands[0]; + return Some(ResolvedAbfStimulus { + values: commands + .iter() + .map(|command| command.samples[sample]) + .collect(), + quantity: first.unit.quantity, + unit: first.unit.symbol.clone(), + name: first.name.clone(), + }); + } + } + + let commands = data + .sweeps + .iter() + .map(|sweep| sweep.commands.first()) + .collect::>>()?; + let first = commands.first()?; + Some(ResolvedAbfStimulus { + values: commands + .iter() + .map(|command| command_level(command)) + .collect(), + quantity: first.unit.quantity, + unit: first.unit.symbol.clone(), + name: first.name.clone(), + }) +} + impl ElectrophysiologyDataset { pub fn load(data: ElectrophysiologyData) -> Self { - let selected_sweeps = vec![true; data.sweeps.len()]; let stimulus = data .sweeps .iter() @@ -117,6 +192,11 @@ impl ElectrophysiologyDataset { let field_keys = crate::state::electrophysiology_channel_keys(&data); let mut field_catalog = crate::state::electrophysiology_field_catalog_for_keys(&field_keys); field_catalog.attach_provenance(&data.source, None); + crate::state::attach_electrophysiology_trace_collections( + &mut field_catalog, + &data, + stimulus.as_ref(), + ); Self { resource_id: new_resource_id(), field_catalog, @@ -125,7 +205,7 @@ impl ElectrophysiologyDataset { name: None, metadata, processing: ElectrophysiologyProcessing::default(), - selected_sweeps, + invocation: ElectrophysiologyInvocationState::default(), selected_channel: 0, stimulus, lineage: None, @@ -165,6 +245,74 @@ impl ElectrophysiologyDataset { .map_err(|source| ElectrophysiologyAnalysisError::Processing { sweep, source }) } + pub fn trace_items(&self) -> &[plotx_data::TraceItemDescriptor] { + self.field_key(self.selected_channel) + .and_then(|key| self.field_catalog.id_for_key(key)) + .and_then(|field| self.field_catalog.trace_collection(field)) + .map(|collection| collection.items.as_slice()) + .unwrap_or(&[]) + } + + pub fn selected_sweep_indices(&self) -> Vec { + match &self.invocation.analysis_selection { + None => (0..self.data.sweeps.len()).collect(), + Some(selected) => self + .trace_items() + .iter() + .enumerate() + .filter_map(|(index, item)| selected.contains(&item.id).then_some(index)) + .collect(), + } + } + + pub fn set_selected_channel(&mut self, channel: usize) { + if channel == self.selected_channel || channel >= self.data.channels.len() { + return; + } + let selected_indices = self.invocation.analysis_selection.as_ref().map(|selected| { + self.trace_items() + .iter() + .enumerate() + .filter_map(|(index, item)| selected.contains(&item.id).then_some(index)) + .collect::>() + }); + self.selected_channel = channel; + self.invocation.analysis_selection = selected_indices.map(|indices| { + let items = self.trace_items(); + indices + .into_iter() + .filter_map(|index| items.get(index).map(|item| item.id)) + .collect() + }); + } + + pub fn refresh_trace_collections(&mut self) { + let fields = self + .field_keys() + .iter() + .filter_map(|key| key.as_deref()) + .filter_map(|key| self.field_catalog.id_for_key(key)) + .collect::>(); + let overrides = fields + .iter() + .filter_map(|field| self.field_catalog.trace_collection(*field)) + .flat_map(|collection| collection.items.iter()) + .filter_map(|item| item.label_override.clone().map(|label| (item.id, label))) + .collect::>(); + crate::state::attach_electrophysiology_trace_collections( + &mut self.field_catalog, + &self.data, + self.stimulus.as_ref(), + ); + for field in fields { + if let Some(collection) = self.field_catalog.trace_collection_mut(field) { + for item in &mut collection.items { + item.label_override = overrides.get(&item.id).cloned(); + } + } + } + } + pub fn figure(&self) -> Figure { let channel = self.data.channels.get(self.selected_channel); let unit = channel.map(|c| c.unit.symbol.as_str()).unwrap_or(""); @@ -172,10 +320,7 @@ impl ElectrophysiologyDataset { let mut ymin = f64::INFINITY; let mut ymax = f64::NEG_INFINITY; let mut traces = Vec::new(); - for (index, selected) in self.selected_sweeps.iter().copied().enumerate() { - if !selected { - continue; - } + for index in self.selected_sweep_indices() { // The chart builder contract has no error channel. A sweep that fails // to filter is dropped here, but the same failure is reported with its // cause the moment the user builds a statistics or IV table, and the @@ -254,33 +399,9 @@ impl ElectrophysiologyDataset { .ok_or(ElectrophysiologyAnalysisError::UnconfirmedStimulus)?; match &definition.protocol { StimulusProtocol::FromAbf => { - let commands: Option> = self - .data - .sweeps - .iter() - .map(|sweep| sweep.commands.first()) - .collect(); - let commands = - commands.ok_or(ElectrophysiologyAnalysisError::UnconfirmedStimulus)?; - let quantity = commands - .first() - .ok_or(ElectrophysiologyAnalysisError::UnconfirmedStimulus)? - .unit - .quantity; - Ok(( - commands - .iter() - .map(|command| { - command - .samples - .iter() - .copied() - .find(|value| (*value - command.holding_level).abs() > f64::EPSILON) - .unwrap_or(command.holding_level) - }) - .collect(), - quantity, - )) + let resolved = resolve_abf_stimulus(&self.data) + .ok_or(ElectrophysiologyAnalysisError::UnconfirmedStimulus)?; + Ok((resolved.values, resolved.quantity)) } StimulusProtocol::VoltageStep { start_mv, step_mv, .. @@ -351,10 +472,7 @@ pub fn build_window_statistics_table( let mut peaks = Vec::new(); let mut means = Vec::new(); let mut peak_times = Vec::new(); - for (index, selected) in recording.selected_sweeps.iter().copied().enumerate() { - if !selected { - continue; - } + for index in recording.selected_sweep_indices() { let values = recording.processed_trace(index, channel)?; let stats = electrophysiology::window_statistics( &values, @@ -404,12 +522,7 @@ pub fn build_iv_table( )?; *slot = trace; } - let selected: Vec = recording - .selected_sweeps - .iter() - .enumerate() - .filter_map(|(index, selected)| (*selected).then_some(index)) - .collect(); + let selected = recording.selected_sweep_indices(); let result = electrophysiology::build_iv( &processed, channel, &selected, window, mode, &stimulus, quantity, )?; diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index fe601b1..a38a1a9 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -9,8 +9,8 @@ use crate::automation::{ CAP_FIELD_FORCE_CURVE, CAP_FIELD_LOCATION_SCALE, CAP_FIELD_MASS_CHROMATOGRAM, CAP_FIELD_MASS_SPECTRUM, CAP_FIELD_NMR_CONTOUR, CAP_FIELD_NMR_SIGNAL, CAP_FIELD_NMR_STACK, CAP_FIELD_NOISE_SCALE, CAP_FIELD_REGION_SERIES, CAP_FIELD_SCALAR_GRID_2D_REGULAR, - CAP_FIELD_SIGNED, CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_TABLE, CAP_FIELD_XPS_SPECTRUM, - CapabilityId, + 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, @@ -19,9 +19,7 @@ use plotx_figure::{ use std::collections::{BTreeMap, BTreeSet}; impl super::Dataset { - /// Describes the stable child fields a dataset currently exposes. This is a - /// data adapter, not an encoding registry: callers decide applicability - /// solely from the returned capabilities. + /// Describes stable child fields and their encoding capabilities. pub fn field_descriptors(&self) -> Vec { let capabilities = |id: FieldId, extra: &[&str]| { // Capabilities are derived from the field's actual representation, @@ -124,29 +122,61 @@ impl super::Dataset { .flatten() .collect() } - Self::Nmr2D(nmr) => nmr - .field_catalog - .id_for_key("nmr.stack") - .into_iter() - .map(|id| { - descriptor( + Self::Nmr2D(nmr) => { + let plotx_processing::Processed2D::Stack(stack) = &nmr.processed else { + unreachable!("pseudo 2D is stack") + }; + 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(id, &[CAP_FIELD_NMR_STACK, CAP_FIELD_REGION_SERIES]), - vec![nmr.data.cols], - vec![match &nmr.processed { - plotx_processing::Processed2D::Stack(spectrum) => { - domain_unit(spectrum.direct_domain) - } - plotx_processing::Processed2D::Ft(_) => { - unreachable!("pseudo 2D is 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", - ) - }) - .collect(), + )); + } + if let Some(id) = nmr.field_catalog.id_for_key("nmr.dosy_map") { + let dimensions = nmr.dosy_map.as_ref().map_or_else( + || vec![0, 0], + |_| vec![super::DOSY_GRID_ROWS, super::DOSY_GRID_COLS], + ); + fields.push(descriptor( + id, + "nmr.dosy_map", + "DOSY map", + capabilities(id, &[CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR]), + dimensions, + vec!["log10(m2/s)".to_owned(), domain_unit(stack.direct_domain)], + "contour", + )); + } + if let Some(id) = nmr.field_catalog.id_for_key("nmr.ilt_map") { + let dimensions = nmr + .ilt_map + .as_ref() + .map_or_else(|| vec![0, 0], |map| vec![map.d_grid.len(), map.ppm.len()]); + fields.push(descriptor( + id, + "nmr.ilt_map", + "ILT map", + capabilities(id, &[CAP_FIELD_BOUNDED, CAP_FIELD_SCALAR_GRID_2D_REGULAR]), + dimensions, + vec!["log10(m2/s)".to_owned(), domain_unit(stack.direct_domain)], + "contour", + )); + } + fields + } Self::Table(table) => { let Ok(row_count) = usize::try_from(table.typed_state.envelope.revision.snapshot.row_count) @@ -182,7 +212,14 @@ impl super::Dataset { id, &key, &channel.name, - capabilities(id, &[CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_REGION_SERIES]), + 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", @@ -363,6 +400,19 @@ impl super::Dataset { } pub fn default_field_id(&self) -> Option { + if let Self::Nmr2D(dataset) = self + && !dataset.is_true_2d() + { + let key = match dataset.display { + super::PseudoDisplay::Stack => "nmr.stack", + super::PseudoDisplay::DosyMap => match dataset.dosy_method { + super::DosyMethod::MonoExp if dataset.dosy_map.is_some() => "nmr.dosy_map", + super::DosyMethod::Ilt(_) if dataset.ilt_map.is_some() => "nmr.ilt_map", + _ => "nmr.stack", + }, + }; + return dataset.field_catalog.id_for_key(key); + } self.field_descriptors().first().map(|field| field.id) } @@ -480,7 +530,8 @@ impl super::Dataset { /// accepted, so a decoder change cannot silently retarget a series. pub fn validate_field_catalog(&self) -> Result<(), String> { let catalog = self.field_catalog(); - catalog.validate_for_keys(self.all_field_keys()) + catalog.validate_for_keys(self.all_field_keys())?; + self.validate_trace_collections() } pub(super) fn field_catalog(&self) -> &FieldCatalog { @@ -505,6 +556,8 @@ impl super::Dataset { "nmr.real".to_owned(), "nmr.magnitude".to_owned(), "nmr.stack".to_owned(), + "nmr.dosy_map".to_owned(), + "nmr.ilt_map".to_owned(), ], Self::Table(_) => vec!["table.default_series".to_owned()], Self::Electrophysiology(dataset) => (0..dataset.data.channels.len()) diff --git a/crates/core/src/state/field_catalog.rs b/crates/core/src/state/field_catalog.rs index 8240135..bd53597 100644 --- a/crates/core/src/state/field_catalog.rs +++ b/crates/core/src/state/field_catalog.rs @@ -1,4 +1,8 @@ use super::{FieldAlgorithmProvenance, FieldId, FieldProvenance}; +use plotx_data::{ + TraceCollectionCatalog, TraceCollectionId, TraceItemDescriptor, TraceItemId, + TraceItemParameter, TraceParameterValue, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -7,7 +11,7 @@ use std::sync::Arc; /// Dataset-owned allocator and persisted lookup table for field child resources. /// The key is supplied by the provider and identifies the actual channel/plane; /// the numeric `FieldId` is only an owner-local reference, never an array index. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FieldCatalog { next_id: u64, key_to_id: BTreeMap, @@ -18,6 +22,7 @@ pub struct FieldCatalog { /// Persisted source and algorithm provenance for each stable child field. /// Runtime `FieldVersion` deliberately does not live here. provenance: BTreeMap, + trace_collections: BTreeMap, } impl FieldCatalog { @@ -27,6 +32,7 @@ impl FieldCatalog { key_to_id: BTreeMap::new(), retired_key_to_id: BTreeMap::new(), provenance: BTreeMap::new(), + trace_collections: BTreeMap::new(), }; for key in keys { catalog.activate_key(key); @@ -38,6 +44,25 @@ impl FieldCatalog { self.key_to_id.get(key).copied() } + pub fn trace_collection(&self, field: FieldId) -> Option<&TraceCollectionCatalog> { + self.trace_collections.get(&field) + } + + pub(crate) fn trace_collection_mut( + &mut self, + field: FieldId, + ) -> Option<&mut TraceCollectionCatalog> { + self.trace_collections.get_mut(&field) + } + + pub(crate) fn set_trace_collection( + &mut self, + field: FieldId, + collection: TraceCollectionCatalog, + ) { + self.trace_collections.insert(field, collection); + } + pub(crate) fn provenance_for(&self, id: FieldId) -> Option<&FieldProvenance> { self.provenance.get(&id) } @@ -158,10 +183,184 @@ impl FieldCatalog { { return Err("field catalog does not carry provenance for every field".to_owned()); } + let mut collection_ids = BTreeSet::new(); + for (field, collection) in &self.trace_collections { + if !ids.contains(field) { + return Err(format!("trace collection references unknown field {field}")); + } + if !collection_ids.insert(collection.id) { + return Err(format!( + "field catalog contains duplicate trace collection id {}", + collection.id + )); + } + collection.validate()?; + } Ok(()) } } +pub(crate) fn pseudo_axis_display_scale(unit: &str) -> f64 { + match unit { + "ms" => 1e3, + "us" | "µs" => 1e6, + "ns" => 1e9, + "mT/m" => 1e3, + "G/cm" => 1e2, + "G/mm" => 10.0, + _ => 1.0, + } +} + +pub(crate) fn attach_pseudo_trace_collection( + catalog: &mut FieldCatalog, + data: &plotx_io::NmrData2D, +) { + let Some(field) = catalog.id_for_key("nmr.stack") else { + return; + }; + let count = data.rows; + let collection = TraceCollectionId::derived(data.source.as_bytes(), b"nmr.stack"); + let (quantity, unit) = data.pseudo_axis.as_ref().map_or(("Increment", ""), |axis| { + ( + match axis.kind { + plotx_io::PseudoKind::Gradient => "Gradient strength", + plotx_io::PseudoKind::Delay => "Relaxation delay", + plotx_io::PseudoKind::Generic => axis.name.as_str(), + }, + axis.unit.as_str(), + ) + }); + let scale = pseudo_axis_display_scale(unit); + let items = (0..count) + .map(|index| { + let value = data + .pseudo_axis + .as_ref() + .and_then(|axis| axis.values.get(index)) + .copied() + .unwrap_or((index + 1) as f64) + * scale; + TraceItemDescriptor { + id: TraceItemId::derived(collection, &(index as u64).to_le_bytes()), + parameters: vec![TraceItemParameter { + key: "axis_value".into(), + name: quantity.into(), + value: TraceParameterValue::Number { + value, + unit: unit.into(), + }, + }], + primary_label_parameter: "axis_value".into(), + label_override: None, + } + }) + .collect(); + catalog.set_trace_collection( + field, + TraceCollectionCatalog { + id: collection, + axis_quantity: quantity.into(), + axis_unit: unit.into(), + items, + }, + ); +} + +pub(crate) fn attach_electrophysiology_trace_collections( + catalog: &mut FieldCatalog, + data: &plotx_io::ElectrophysiologyData, + stimulus: Option<&super::StimulusDefinition>, +) { + let abf_stimulus = super::resolve_abf_stimulus(data); + let fields = (0..data.channels.len()) + .filter_map(|channel| { + let key = electrophysiology_data_channel_key(data, channel)?; + Some((key.clone(), catalog.id_for_key(&key)?)) + }) + .collect::>(); + for (key, field) in fields { + let collection = TraceCollectionId::derived(data.source.as_bytes(), key.as_bytes()); + let items = data + .sweeps + .iter() + .enumerate() + .map(|(index, _sweep)| { + let mut parameters = vec![TraceItemParameter { + key: "sweep".into(), + name: "Sweep".into(), + value: TraceParameterValue::Text { + value: format!("Sweep {}", index + 1), + }, + }]; + let resolved_level = abf_stimulus + .as_ref() + .and_then(|resolved| { + resolved + .values + .get(index) + .map(|level| (resolved.name.clone(), resolved.unit.clone(), *level)) + }) + .or_else(|| { + _sweep.commands.first().map(|command| { + ( + command.name.clone(), + command.unit.symbol.clone(), + super::command_level(command), + ) + }) + }); + let primary = if let Some((name, unit, level)) = resolved_level { + parameters.push(TraceItemParameter { + key: "abf_stimulus".into(), + name, + value: TraceParameterValue::Number { value: level, unit }, + }); + "abf_stimulus" + } else if let Some((value, unit)) = stimulus + .filter(|definition| definition.confirmed) + .and_then(|definition| match definition.protocol { + super::StimulusProtocol::VoltageStep { + start_mv, step_mv, .. + } => Some((start_mv + index as f64 * step_mv, "mV")), + super::StimulusProtocol::CurrentStep { + start_pa, step_pa, .. + } => Some((start_pa + index as f64 * step_pa, "pA")), + _ => None, + }) + { + parameters.push(TraceItemParameter { + key: "stimulus_template".into(), + name: "Command".into(), + value: TraceParameterValue::Number { + value, + unit: unit.into(), + }, + }); + "stimulus_template" + } else { + "sweep" + }; + TraceItemDescriptor { + id: TraceItemId::derived(collection, &(index as u64).to_le_bytes()), + parameters, + primary_label_parameter: primary.into(), + label_override: None, + } + }) + .collect(); + catalog.set_trace_collection( + field, + TraceCollectionCatalog { + id: collection, + axis_quantity: "Sweep".into(), + axis_unit: String::new(), + items, + }, + ); + } +} + pub(crate) fn nmr_field_catalog() -> FieldCatalog { FieldCatalog::for_keys(["nmr.real".to_owned()]) } @@ -171,6 +370,8 @@ pub(crate) fn nmr2d_field_catalog() -> FieldCatalog { "nmr.real".to_owned(), "nmr.magnitude".to_owned(), "nmr.stack".to_owned(), + "nmr.dosy_map".to_owned(), + "nmr.ilt_map".to_owned(), ]) } @@ -374,4 +575,22 @@ mod tests { assert_eq!(catalog.id_for_key("second"), Some(second)); assert!(catalog.retired_key_to_id.is_empty()); } + + #[test] + fn every_supported_pseudo_axis_unit_converts_from_si() { + let cases = [ + ("s", 1.0), + ("ms", 1e3), + ("us", 1e6), + ("µs", 1e6), + ("ns", 1e9), + ("T/m", 1.0), + ("mT/m", 1e3), + ("G/cm", 1e2), + ("G/mm", 10.0), + ]; + for (unit, expected) in cases { + assert_eq!(super::pseudo_axis_display_scale(unit), expected, "{unit}"); + } + } } diff --git a/crates/core/src/state/field_payload.rs b/crates/core/src/state/field_payload.rs index 5cb64c9..00ffc5c 100644 --- a/crates/core/src/state/field_payload.rs +++ b/crates/core/src/state/field_payload.rs @@ -328,7 +328,9 @@ fn nmr_field_payload(dataset: &super::Nmr2DDataset, id: FieldId) -> Option None, + plotx_processing::Processed2D::Stack(_) => { + pseudo_map_grid(dataset, id).map(FieldPayload::ScalarGrid2D) + } } } @@ -355,8 +357,47 @@ fn nmr_field_representation( { Some(FieldRepresentation::Curve1D) } - plotx_processing::Processed2D::Stack(_) => None, + plotx_processing::Processed2D::Stack(_) => { + pseudo_map_grid(dataset, id).map(|grid| FieldRepresentation::ScalarGrid2D { + rows: grid.rows, + cols: grid.cols, + values: grid.values.len(), + x_linear: matches!(grid.x, AxisSampling::Linear { .. }), + y_linear: matches!(grid.y, AxisSampling::Linear { .. }), + }) + } + } +} + +fn pseudo_map_grid(dataset: &super::Nmr2DDataset, id: FieldId) -> Option { + if dataset.field_catalog.id_for_key("nmr.ilt_map") == Some(id) { + let map = dataset.ilt_map.as_ref()?; + let rows = map.d_grid.len(); + let cols = map.ppm.len(); + let mut values = vec![0.0_f32; rows * cols]; + for (column, amplitudes) in map.amp.iter().enumerate().take(cols) { + for (row, amplitude) in amplitudes.iter().enumerate().take(rows) { + values[row * cols + column] = *amplitude as f32; + } + } + return Some(ScalarGrid2D { + values: Arc::from(values), + rows, + cols, + x: axis_sampling(&map.ppm), + y: axis_sampling( + &map.d_grid + .iter() + .map(|value| value.max(f64::MIN_POSITIVE).log10()) + .collect::>(), + ), + }); + } + if dataset.field_catalog.id_for_key("nmr.dosy_map") == Some(id) { + let map = dataset.dosy_map.as_ref()?; + return Some(super::dosy_scalar_grid(map)); } + None } pub(crate) fn nmr_scalar_grid( diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index d6dca61..090585e 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -188,6 +188,7 @@ fn mixed_or_cross_dataset_chromatogram_overlays_do_not_declare_interactions() { .push(SeriesBinding::with_source(SeriesSource { resource: dataset_id, field: spectrum, + item: None, })); assert!(app.plot_interaction_descriptor(0, object).is_none()); @@ -218,6 +219,7 @@ fn mixed_or_cross_dataset_chromatogram_overlays_do_not_declare_interactions() { .push(SeriesBinding::with_source(SeriesSource { resource: other_id, field: other_field, + item: None, })); assert!(app.plot_interaction_descriptor(0, object).is_none()); } diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index f4ffbb2..d533ae7 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -86,6 +86,7 @@ mod peaks; mod peaks2d; mod plot_interaction; mod plot_object; +mod pseudo_map_field; mod region; mod selection; mod series_binding; @@ -104,6 +105,10 @@ mod table_fit; mod table_native; mod table_numeric; mod tile_drop; +mod trace_provider; +#[cfg(test)] +#[path = "trace_provider_tests.rs"] +mod trace_provider_tests; mod ui_drag; mod ui_state; mod units; @@ -141,9 +146,10 @@ pub(crate) use field_catalog::{ reset_afm_channel_key_computations, }; pub(crate) use field_catalog::{ - afm_channel_keys, afm_field_catalog_for_keys, electrophysiology_channel_key, - electrophysiology_channel_keys, electrophysiology_field_catalog_for_keys, nmr_field_catalog, - nmr2d_field_catalog, table_field_catalog, + afm_channel_keys, afm_field_catalog_for_keys, attach_electrophysiology_trace_collections, + attach_pseudo_trace_collection, electrophysiology_channel_key, electrophysiology_channel_keys, + electrophysiology_field_catalog_for_keys, nmr_field_catalog, nmr2d_field_catalog, + table_field_catalog, }; pub use field_defaults::*; pub(crate) use field_payload::nmr_scalar_grid; @@ -163,6 +169,7 @@ pub use peaks::*; pub use peaks2d::*; pub use plot_interaction::*; pub use plot_object::*; +pub(crate) use pseudo_map_field::{DOSY_GRID_COLS, DOSY_GRID_ROWS, dosy_scalar_grid}; pub use region::*; pub use selection::*; pub use series_binding::*; diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index 832668a..11b44e3 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -6,6 +6,9 @@ use plotx_figure::{Figure, FigureTypography}; #[derive(Clone)] pub struct PlotObject { + /// Dataset whose current display/channel choice this default view follows. + /// Independent plots carry `None` and keep their item-addressed sources. + pub display_owner: Option, /// Persistent high-water mark for owner-local series identities. This is /// deliberately outside `binding`, which actions may replace wholesale. pub next_series_id: SeriesId, @@ -29,6 +32,7 @@ pub struct PlotObject { impl PlotObject { #[allow(clippy::too_many_arguments)] pub fn new( + display_owner: Option, next_series_id: SeriesId, binding: DataBinding, chart: ChartSpec, @@ -41,6 +45,7 @@ impl PlotObject { ) -> Self { let derived_axes = DerivedAxes::from_figure(&figure); Self { + display_owner, next_series_id, binding, chart, @@ -58,6 +63,7 @@ impl PlotObject { /// viewport state while retaining the separately rebuilt automatic axes. #[allow(clippy::too_many_arguments)] pub(crate) fn from_materialized_figure( + display_owner: Option, next_series_id: SeriesId, binding: DataBinding, chart: ChartSpec, @@ -70,6 +76,7 @@ impl PlotObject { panel: PanelMeta, ) -> Self { Self { + display_owner, next_series_id, binding, chart, diff --git a/crates/core/src/state/pseudo_map_field.rs b/crates/core/src/state/pseudo_map_field.rs new file mode 100644 index 0000000..578167d --- /dev/null +++ b/crates/core/src/state/pseudo_map_field.rs @@ -0,0 +1,72 @@ +use super::{AxisSampling, ScalarGrid2D}; +use plotx_analysis::diffusion::DiffusionMap; +use std::sync::Arc; + +pub(crate) const DOSY_GRID_COLS: usize = 512; +pub(crate) const DOSY_GRID_ROWS: usize = 300; + +/// Materialize the scalar grid represented by the mono-exponential DOSY field. +/// This matches the Gaussian deposition used by the figure builder. +pub(crate) fn dosy_scalar_grid(map: &DiffusionMap) -> ScalarGrid2D { + let fitted = map + .ppm + .iter() + .zip(&map.d) + .zip(&map.amp) + .filter_map(|((&ppm, &d), &)| { + (d.is_finite() && d > 0.0).then_some((ppm, d.log10(), amp)) + }) + .collect::>(); + let (ppm_lo, ppm_hi) = map + .ppm + .iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &ppm| { + (lo.min(ppm), hi.max(ppm)) + }); + let (mut logd_lo, mut logd_hi) = fitted.iter().fold( + (f64::INFINITY, f64::NEG_INFINITY), + |(lo, hi), &(_, logd, _)| (lo.min(logd), hi.max(logd)), + ); + if fitted.is_empty() { + (logd_lo, logd_hi) = (-10.5, -8.5); + } else { + logd_lo -= 0.5; + logd_hi += 0.5; + } + let x_span = (ppm_hi - ppm_lo).max(f64::MIN_POSITIVE); + let y_span = (logd_hi - logd_lo).max(f64::MIN_POSITIVE); + let mut values = vec![0.0_f32; DOSY_GRID_COLS * DOSY_GRID_ROWS]; + for (ppm, logd, amplitude) in fitted { + let cx = ((ppm - ppm_lo) / x_span * (DOSY_GRID_COLS - 1) as f64).round() as isize; + let cy = ((logd - logd_lo) / y_span * (DOSY_GRID_ROWS - 1) as f64).round() as isize; + for dy in -9..=9 { + let row = cy + dy; + if !(0..DOSY_GRID_ROWS as isize).contains(&row) { + continue; + } + for dx in -4..=4 { + let col = cx + dx; + if !(0..DOSY_GRID_COLS as isize).contains(&col) { + continue; + } + let weight = (-(dx as f64).powi(2) / (2.0 * 1.5_f64.powi(2)) + - (dy as f64).powi(2) / (2.0 * 3.0_f64.powi(2))) + .exp(); + values[row as usize * DOSY_GRID_COLS + col as usize] += (amplitude * weight) as f32; + } + } + } + ScalarGrid2D { + values: Arc::from(values), + rows: DOSY_GRID_ROWS, + cols: DOSY_GRID_COLS, + x: AxisSampling::Linear { + start: ppm_lo, + end: ppm_hi, + }, + y: AxisSampling::Linear { + start: logd_lo, + end: logd_hi, + }, + } +} diff --git a/crates/core/src/state/series_binding.rs b/crates/core/src/state/series_binding.rs index 8a31b88..b499c64 100644 --- a/crates/core/src/state/series_binding.rs +++ b/crates/core/src/state/series_binding.rs @@ -1,6 +1,6 @@ use super::{ - Dataset, DatasetId, FieldId, PresentationProfile, RequestedChart, SeriesId, default_encoding, - field_peak_magnitude, + Dataset, DatasetId, FieldId, OVERLAY_PALETTE, PresentationProfile, RequestedChart, SeriesId, + default_encoding, field_peak_magnitude, }; use plotx_figure::Color; @@ -10,6 +10,7 @@ use plotx_figure::Color; pub struct SeriesSource { pub resource: DatasetId, pub field: FieldId, + pub item: Option, } /// One overlaid series and its concrete visual encoding. Encoding-specific @@ -25,27 +26,59 @@ pub struct SeriesBinding { } impl SeriesBinding { - /// Materialize a complete source and encoding from the dataset's actual - /// default field. This is the only production constructor for a new series. + /// Materialize the first canonical source for callers that add one series. pub fn from_dataset(dataset: &Dataset) -> Option { - let field = dataset.default_field_id()?; - let descriptor = dataset.field_descriptor(field)?; - Some(Self { - id: SeriesId::default(), - source: SeriesSource { - resource: dataset.resource_id(), - field, - }, - visible: true, - label: None, - encoding: default_encoding( + Self::from_dataset_all(dataset).into_iter().next() + } + + /// Expand the dataset's default field into canonical item-addressed series. + pub fn from_dataset_all(dataset: &Dataset) -> Vec { + let Some(field) = dataset.default_field_id() else { + return Vec::new(); + }; + Self::from_field_all(dataset, field) + } + + pub(crate) fn from_field_all(dataset: &Dataset, field: FieldId) -> Vec { + let Some(descriptor) = dataset.field_descriptor(field) else { + return Vec::new(); + }; + let make = |item, index: usize| { + let mut encoding = default_encoding( &descriptor.capabilities, &descriptor.metadata, RequestedChart::Auto, &PresentationProfile::default(), &|| field_peak_magnitude(dataset, field), - ), - }) + ); + if let plotx_figure::SeriesEncoding::Line(line) = &mut encoding { + line.color = plotx_figure::ColorSource::Explicit( + OVERLAY_PALETTE[index % OVERLAY_PALETTE.len()], + ); + } + Self { + id: SeriesId::default(), + source: SeriesSource { + resource: dataset.resource_id(), + field, + item, + }, + visible: true, + label: None, + encoding, + } + }; + dataset.trace_collection(field).map_or_else( + || vec![make(None, 0)], + |collection| { + collection + .items + .iter() + .enumerate() + .map(|(index, item)| make(Some(item.id), index)) + .collect() + }, + ) } pub fn with_source(source: SeriesSource) -> Self { diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index a9e07aa..9222984 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -2,8 +2,8 @@ use super::*; use plotx_figure::{ErrorBar, Series}; impl PlotxApp { - /// Whether every dataset in `binding` shares one stackable domain (hence one - /// [`StackKind`]), so they can be combined into a single overlay/stack figure. + /// Whether every series shares a domain and either that domain supports + /// generic stacking or every source is an item-addressed trace. pub fn series_stackable(&self, binding: &DataBinding) -> bool { let Some(domain) = binding .series @@ -14,33 +14,47 @@ impl PlotxApp { else { return false; }; - domain.stack_kind().is_some() - && binding.series.iter().all(|s| { - self.doc - .dataset_index(s.source.resource) - .and_then(|index| self.doc.datasets.get(index)) - .map(Dataset::domain) - == Some(domain) - }) + let same_domain = binding.series.iter().all(|s| { + self.doc + .dataset_index(s.source.resource) + .and_then(|index| self.doc.datasets.get(index)) + .map(Dataset::domain) + == Some(domain) + }); + let item_addressed = binding + .series + .iter() + .all(|series| series.source.item.is_some()); + let all_lines = binding + .series + .iter() + .all(|series| matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_))); + let all_contours = binding + .series + .iter() + .all(|series| matches!(series.encoding, plotx_figure::SeriesEncoding::Contour(_))); + same_domain + && ((all_lines && (domain.stack_kind() == Some(StackKind::Line) || item_addressed)) + || all_contours) } - /// Combine a stackable binding into one figure. Dispatches on the primary's - /// [`StackKind`] and the stack `mode`: Line kinds overlay/offset traces; the - /// Field kind overlays each dataset's 2D contour in a distinct colour. + /// Combine a stackable binding into one figure. Concrete encodings decide + /// whether this is a contour overlay or a line stack; the enclosing domain + /// may expose both kinds of field. pub fn build_stacked_figure( &mut self, binding: &DataBinding, stack: &StackSpec, size_mm: [f32; 2], ) -> Figure { - let primary = binding - .primary_dataset() - .and_then(|id| self.doc.dataset_index(id)) - .expect("validated data binding has a primary dataset"); - let domain = self.doc.datasets[primary].domain(); - match (domain.stack_kind(), stack.mode) { - (Some(StackKind::Field), _) => self.build_contour_overlay(binding, size_mm), - _ => self.build_line_stack(binding, stack, size_mm), + let contour_overlay = binding + .series + .iter() + .all(|series| matches!(series.encoding, plotx_figure::SeriesEncoding::Contour(_))); + if contour_overlay { + self.build_contour_overlay(binding, size_mm) + } else { + self.build_line_stack(binding, stack, size_mm) } } @@ -90,25 +104,25 @@ impl PlotxApp { continue; } let encoded_curve = self.series_uses_encoded_curve(sb); - let part = if encoded_curve { + let mut part = if encoded_curve { self.build_encoded_series_figure(sb) .map(|figure| self.normalize_binding_figure(figure, size_mm)) .unwrap_or_else(|| self.build_full_canvas_figure(dataset, &line_chart, size_mm)) } else { self.build_full_canvas_figure(dataset, &line_chart, size_mm) }; + self.apply_series_binding_style(&mut part, sb); let mut series = part.series; let mut error_bars = part.error_bars; let peak = series .iter() .flat_map(|s| s.points.iter()) .fold(0.0f64, |m, p| m.max(p[1].abs())); - let factor = sb.line_scale() - * if stack.normalize && peak > 0.0 { - 1.0 / peak - } else { - 1.0 - }; + let factor = if stack.normalize && peak > 0.0 { + 1.0 / peak + } else { + 1.0 + }; let mut trace_peak = 0.0f64; for s in &mut series { for p in &mut s.points { @@ -129,11 +143,6 @@ impl PlotxApp { let (mut x_min, mut x_max) = (fig.x.min, fig.x.max); let (mut y_min, mut y_max) = (fig.y.min, fig.y.max); for (i, mut series, mut error_bars) in prepared { - let sb = &binding.series[i]; - let color = sb - .primary_color() - .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); - let label = self.series_label(sb); let x_off = if stacked { i as f64 * stack.shear_x * x_span } else { @@ -154,8 +163,6 @@ impl PlotxApp { y_min = y_min.min(p[1]); y_max = y_max.max(p[1]); } - s.color = color; - s.name = label.clone(); if active { s.width = s.width.max(1.0) * 2.0; } @@ -164,7 +171,6 @@ impl PlotxApp { for mut error_bar in error_bars.drain(..) { error_bar.center[0] += x_off; error_bar.center[1] += y_off; - error_bar.color = color; if active { error_bar.width = error_bar.width.max(1.0) * 2.0; } @@ -186,9 +192,9 @@ impl PlotxApp { } /// Field-kind stacking (`ColorOverlay`): overlay every selected 2D dataset's - /// contour on one canvas, each recoloured from the palette (or its per-series - /// override), merging the datasets' x/y ranges. The primary supplies the axis - /// labels and orientation; hidden series are skipped. + /// contour using its persisted per-series style, merging the datasets' x/y + /// ranges. The primary supplies the axis labels and orientation; hidden + /// series are skipped. fn build_contour_overlay(&mut self, binding: &DataBinding, size_mm: [f32; 2]) -> Figure { let chart = ChartSpec::default_for(DataDomain::Nmr2d); let primary = binding @@ -250,32 +256,135 @@ impl PlotxApp { sb.label.clone().unwrap_or_else(|| { self.doc .dataset_by_id(sb.source.resource) - .map(Dataset::display_name) + .map(|dataset| { + sb.source + .item + .and_then(|item| dataset.trace_item_label(sb.source.field, item)) + .unwrap_or_else(|| dataset.display_name()) + }) .unwrap_or_default() }) } /// Dataset indices eligible to stack onto `binding`: other datasets of the - /// same stackable domain not already bound. Empty when the plot's primary is - /// not a stackable domain. + /// same stackable domain not already bound. Item-addressed trace collections + /// can be line-stacked across datasets even when their enclosing domain also + /// exposes non-line fields. pub fn stack_candidates(&self, binding: &DataBinding) -> Vec { let Some(domain) = binding .primary_dataset() .and_then(|id| self.doc.dataset_by_id(id)) .map(Dataset::domain) - .filter(|d| d.stack_kind().is_some()) else { return Vec::new(); }; + if domain.stack_kind().is_none() + && !binding + .series + .iter() + .all(|series| series.source.item.is_some()) + { + return Vec::new(); + } let bound = binding.dataset_ids(); + let item_addressed = binding + .series + .iter() + .all(|series| series.source.item.is_some()); (0..self.doc.datasets.len()) .filter(|di| { self.doc.datasets.get(*di).map(Dataset::domain) == Some(domain) && !bound.contains(&self.doc.datasets[*di].resource_id()) + && (!item_addressed + || trace_collection_field(&self.doc.datasets[*di]).is_some()) + && (item_addressed + || binding.series.first().is_some_and(|source| { + default_field_encoding_matches( + &self.doc.datasets[*di], + &source.encoding, + ) + })) }) .collect() } + /// Materialize every source compatible with the binding being extended. + /// Item-addressed plots expose the whole trace collection even when that + /// dataset's own live display currently selects a scalar map. + pub fn stack_candidate_series_options( + &self, + binding: &DataBinding, + dataset: usize, + ) -> Vec { + let Some(dataset) = self.doc.datasets.get(dataset) else { + return Vec::new(); + }; + if binding + .series + .iter() + .all(|series| series.source.item.is_some()) + { + let Some(field) = trace_collection_field(dataset) else { + return Vec::new(); + }; + return SeriesBinding::from_field_all(dataset, field); + } + SeriesBinding::from_dataset_all(dataset) + } + + /// Materialize the first compatible source for non-interactive callers. + pub fn stack_candidate_series( + &self, + binding: &DataBinding, + dataset: usize, + ) -> Option { + self.stack_candidate_series_options(binding, dataset) + .into_iter() + .next() + } + + /// Labels and stable IDs available when retargeting one item-addressed + /// series. The plot keeps its own ID and styling when its source item changes. + pub fn series_item_options( + &self, + series: &SeriesBinding, + ) -> Vec<(plotx_data::TraceItemId, String)> { + let Some(collection) = self + .doc + .dataset_by_id(series.source.resource) + .and_then(|dataset| dataset.trace_collection(series.source.field)) + else { + return Vec::new(); + }; + collection + .items + .iter() + .enumerate() + .map(|(index, item)| { + let label = item + .automatic_label() + .unwrap_or_else(|| format!("{} {}", collection.axis_quantity, index + 1)); + (item.id, label) + }) + .collect() + } + + /// Choose a default for a newly inserted series without changing any + /// existing authored colors. Prefer an unused palette entry; cycling is the + /// unavoidable fallback only after the plot already uses the full palette. + pub fn next_stack_color(&self, binding: &DataBinding) -> plotx_figure::Color { + OVERLAY_PALETTE + .iter() + .copied() + .find(|color| { + binding + .series + .iter() + .all(|series| series.primary_color() != Some(*color)) + }) + .unwrap_or(OVERLAY_PALETTE[binding.series.len() % OVERLAY_PALETTE.len()]) + } + /// The focused dataset: the lead (last) element of the selection set. Drives the /// object inspector, secondary-sidebar tools, analysis, breadcrumb and shortcuts. /// The single source of truth — a stored active dataset can no longer disagree @@ -364,13 +473,15 @@ impl PlotxApp { return; }; let domain = self.doc.datasets[sel[0]].domain(); - let binding = DataBinding { - series: sel - .iter() - .filter_map(|&d| self.doc.datasets.get(d)) - .filter_map(SeriesBinding::from_dataset) - .collect(), - }; + let mut series = sel + .iter() + .filter_map(|&d| self.doc.datasets.get(d)) + .flat_map(SeriesBinding::from_dataset_all) + .collect::>(); + for (index, series) in series.iter_mut().enumerate() { + series.set_primary_color(OVERLAY_PALETTE[index % OVERLAY_PALETTE.len()]); + } + let binding = DataBinding { series }; let mode = match domain.stack_kind() { Some(StackKind::Field) => StackMode::ColorOverlay, _ => StackMode::Offset, @@ -389,6 +500,7 @@ impl PlotxApp { let viewport = CanvasViewport::from_figure(&figure); let panel = PanelMeta::new(self.default_plot_title(sel[0]), frame.width); let mut plot = PlotObject::new( + None, SeriesId::new(0), binding, chart, @@ -421,3 +533,33 @@ impl PlotxApp { self.session.status = format!("Stacked {} datasets on a new page.", sel.len()); } } + +fn trace_collection_field(dataset: &Dataset) -> Option { + dataset + .default_field_id() + .filter(|field| dataset.trace_collection(*field).is_some()) + .or_else(|| { + dataset + .field_descriptors() + .into_iter() + .map(|field| field.id) + .find(|field| dataset.trace_collection(*field).is_some()) + }) +} + +fn default_field_encoding_matches( + dataset: &Dataset, + encoding: &plotx_figure::SeriesEncoding, +) -> bool { + let recommended = dataset + .default_field_id() + .and_then(|field| dataset.field_descriptor(field)) + .and_then(|field| field.metadata.recommended_encoding().map(str::to_owned)); + matches!( + (encoding, recommended.as_deref()), + (plotx_figure::SeriesEncoding::Line(_), Some("line")) + | (plotx_figure::SeriesEncoding::Contour(_), Some("contour")) + | (plotx_figure::SeriesEncoding::Heatmap(_), Some("heatmap")) + | (plotx_figure::SeriesEncoding::Image(_), Some("image")) + ) +} diff --git a/crates/core/src/state/table.rs b/crates/core/src/state/table.rs index fb46091..ada0f3f 100644 --- a/crates/core/src/state/table.rs +++ b/crates/core/src/state/table.rs @@ -101,6 +101,9 @@ pub struct ModelInstanceBinding { pub struct TableProvenance { pub source_resource: String, pub source_field: FieldId, + /// Stable collection members captured by explicit table creation. `None` + /// means the source is not an item collection. + pub members: Option>, pub regions: Vec, } diff --git a/crates/core/src/state/trace_provider.rs b/crates/core/src/state/trace_provider.rs new file mode 100644 index 0000000..c055cc3 --- /dev/null +++ b/crates/core/src/state/trace_provider.rs @@ -0,0 +1,77 @@ +use super::{Dataset, FieldId}; + +impl Dataset { + pub fn trace_collection(&self, field: FieldId) -> Option<&plotx_data::TraceCollectionCatalog> { + match self { + Self::Nmr(data) => data.field_catalog.trace_collection(field), + Self::Table(data) => data.field_catalog.trace_collection(field), + Self::Nmr2D(data) => data.field_catalog.trace_collection(field), + Self::Electrophysiology(data) => data.field_catalog.trace_collection(field), + Self::Afm(data) => data.field_catalog.trace_collection(field), + Self::MassSpec(data) => data.field_catalog.trace_collection(field), + Self::Xrd(data) => data.field_catalog.trace_collection(field), + Self::Xps(data) => data.field_catalog.trace_collection(field), + } + } + + pub(super) fn validate_trace_collections(&self) -> Result<(), String> { + let catalog = self.field_catalog(); + match self { + Self::Nmr2D(dataset) => { + let field = catalog + .id_for_key("nmr.stack") + .ok_or_else(|| "NMR stack field is missing".to_owned())?; + let collection = catalog.trace_collection(field).ok_or_else(|| { + "NMR stack field is missing its trace collection catalog".to_owned() + })?; + if collection.items.len() != dataset.data.rows { + return Err( + "NMR trace collection item count does not match the acquisition".to_owned(), + ); + } + } + Self::Electrophysiology(dataset) => { + for field in self + .field_descriptors() + .into_iter() + .map(|descriptor| descriptor.id) + { + let collection = catalog.trace_collection(field).ok_or_else(|| format!("electrophysiology field {field} is missing its trace collection catalog"))?; + if collection.items.len() != dataset.data.sweeps.len() { + return Err(format!( + "electrophysiology field {field} trace item count does not match its sweeps" + )); + } + } + if let Some(selected) = &dataset.invocation.analysis_selection { + let collection = dataset + .field_key(dataset.selected_channel) + .and_then(|key| catalog.id_for_key(key)) + .and_then(|field| catalog.trace_collection(field)) + .ok_or_else(|| { + "electrophysiology analysis selection has no channel collection" + .to_owned() + })?; + let unique = selected + .iter() + .copied() + .collect::>(); + if unique.len() != selected.len() { + return Err( + "electrophysiology analysis selection contains duplicate item IDs" + .to_owned(), + ); + } + if !selected.iter().all(|item| collection.item(*item).is_some()) { + return Err( + "electrophysiology analysis selection contains an unknown item ID" + .to_owned(), + ); + } + } + } + _ => {} + } + Ok(()) + } +} diff --git a/crates/core/src/state/trace_provider_tests.rs b/crates/core/src/state/trace_provider_tests.rs new file mode 100644 index 0000000..16a5f7f --- /dev/null +++ b/crates/core/src/state/trace_provider_tests.rs @@ -0,0 +1,438 @@ +use super::*; + +fn pseudo_data() -> plotx_io::NmrData2D { + let dimension = plotx_io::Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: "1H".into(), + group_delay: 0.0, + }; + plotx_io::NmrData2D { + data: vec![num_complex::Complex64::new(1.0, 0.5); 16], + rows: 4, + cols: 4, + domain: plotx_io::Domain::Frequency, + direct: dimension.clone(), + indirect: dimension, + quad: plotx_io::QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: Some(plotx_io::PseudoAxis { + name: "Gradient".into(), + kind: plotx_io::PseudoKind::Gradient, + values: vec![0.02, 0.04, 0.06, 0.08], + unit: "mT/m".into(), + source: plotx_io::AxisSource::EmbeddedList, + }), + diffusion: None, + nus: None, + source: "stable pseudo".into(), + } +} + +#[test] +fn pseudo_trace_items_keep_identity_and_format_display_units() { + let mut dataset = Nmr2DDataset::load(pseudo_data()); + let field = dataset.field_catalog.id_for_key("nmr.stack").unwrap(); + let before = dataset + .field_catalog + .trace_collection(field) + .unwrap() + .items + .iter() + .map(|item| item.id) + .collect::>(); + assert_eq!( + dataset.field_catalog.trace_collection(field).unwrap().items[0] + .automatic_label() + .as_deref(), + Some("20 mT/m") + ); + dataset.rebuild(); + assert_eq!( + before, + dataset + .field_catalog + .trace_collection(field) + .unwrap() + .items + .iter() + .map(|item| item.id) + .collect::>() + ); + let object = crate::workflow::build_plot_object( + &Dataset::Nmr2D(Box::new(dataset)), + 0, + ObjectFrame::new(0.0, 0.0, 400.0, 300.0), + ObjectId::new(1), + "Pseudo".into(), + ); + assert_eq!( + object.plot().unwrap().axis_overrides.guide_visibility, + Some(plotx_figure::GuideVisibility::Hide) + ); +} + +fn recording(response_unit: &str, command_unit: Option<&str>) -> Dataset { + let commands = command_unit + .map(|unit| { + vec![plotx_io::CommandWaveform { + name: "Command".into(), + unit: plotx_io::ElectricalUnit::from_symbol(unit), + holding_level: if unit == "mV" { -80.0 } else { 0.0 }, + samples: if unit == "mV" { + vec![-80.0, -60.0] + } else { + vec![0.0, 40.0] + }, + }] + }) + .unwrap_or_default(); + Dataset::Electrophysiology(Box::new(ElectrophysiologyDataset::load( + plotx_io::ElectrophysiologyData { + abf_version: "2.9".into(), + sample_rate_hz: 10_000.0, + channels: vec![plotx_io::RecordedChannel { + name: "Response".into(), + unit: plotx_io::ElectricalUnit::from_symbol(response_unit), + }], + sweeps: vec![ + plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![1.0, 2.0]], + commands, + }, + plotx_io::Sweep { + start_time_s: 1.0, + channels: vec![vec![3.0]], + commands: Vec::new(), + }, + ], + protocol: None, + source: format!("{response_unit}.abf"), + import_warnings: Vec::new(), + }, + ))) +} + +#[test] +fn electrophysiology_trace_labels_prefer_dac_and_fall_back_to_sweep() { + let dataset = recording("pA", Some("mV")); + let field = dataset.default_field_id().unwrap(); + let collection = dataset.trace_collection(field).unwrap(); + assert_eq!( + collection.items[0].automatic_label().as_deref(), + Some("-60 mV") + ); + assert_eq!( + collection.items[1].automatic_label().as_deref(), + Some("Sweep 2") + ); + let binding = DataBinding::single(&dataset); + assert_eq!(binding.series.len(), 2); + assert!(binding.series.iter().all(|series| { + dataset + .trace_item_figure(field, series.source.item.unwrap()) + .unwrap() + .series + .len() + == 1 + })); + let object = crate::workflow::build_plot_object( + &dataset, + 0, + ObjectFrame::new(0.0, 0.0, 400.0, 300.0), + ObjectId::new(1), + "Recording".into(), + ); + assert_eq!( + object.plot().unwrap().axis_overrides.guide_visibility, + Some(plotx_figure::GuideVisibility::Hide) + ); + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let mut binding = binding; + binding.series[0].visible = false; + let figure = app.build_binding_figure( + &binding, + &ChartSpec::default_for(DataDomain::Electrophysiology), + &StackSpec::default(), + [120.0, 80.0], + ); + assert_eq!( + figure.series.len(), + 1, + "hiding one binding must hide only that sweep" + ); + let current = recording("mV", Some("pA")); + let current_field = current.default_field_id().unwrap(); + assert_eq!( + current.trace_collection(current_field).unwrap().items[0] + .automatic_label() + .as_deref(), + Some("40 pA") + ); +} + +#[test] +fn initial_multichannel_recording_figure_uses_only_the_selected_channel() { + let data = plotx_io::ElectrophysiologyData { + abf_version: "2.9".into(), + sample_rate_hz: 10_000.0, + channels: vec![ + plotx_io::RecordedChannel { + name: "A".into(), + unit: plotx_io::ElectricalUnit::from_symbol("pA"), + }, + plotx_io::RecordedChannel { + name: "B".into(), + unit: plotx_io::ElectricalUnit::from_symbol("pA"), + }, + ], + sweeps: vec![ + plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![1.0, 2.0], vec![10.0, 20.0]], + commands: Vec::new(), + }, + plotx_io::Sweep { + start_time_s: 1.0, + channels: vec![vec![3.0, 4.0], vec![30.0, 40.0]], + commands: Vec::new(), + }, + ], + protocol: None, + source: "multichannel.abf".into(), + import_warnings: Vec::new(), + }; + let mut recording = ElectrophysiologyDataset::load(data); + recording.selected_channel = 1; + let object = crate::workflow::build_plot_object( + &Dataset::Electrophysiology(Box::new(recording)), + 0, + ObjectFrame::new(0.0, 0.0, 400.0, 300.0), + ObjectId::new(1), + "Recording".into(), + ); + let plot = object.plot().unwrap(); + assert_eq!(plot.binding.series.len(), 4); + assert_eq!(plot.figure().series.len(), 2); + assert_eq!(plot.figure().y.label, "B (pA)"); + assert!(plot.figure().series[0].points[0][1] > 5.0); +} + +#[test] +fn stacked_trace_collections_expand_all_items_and_round_trip_visibility() { + let mut app = PlotxApp::new(); + app.doc.datasets.push(recording("pA", Some("mV"))); + app.doc.datasets.push(recording("pA", Some("mV"))); + app.focus_datasets(&[0, 1], None); + app.stack_selected_data(); + + let canvas = app.doc.canvases.len() - 1; + let object = app.doc.canvases[canvas].objects[0].id; + let before = app.doc.canvases[canvas].objects[0] + .plot() + .unwrap() + .binding + .clone(); + assert_eq!(before.series.len(), 4); + assert_eq!(app.series_label(&before.series[0]), "-60 mV"); + assert_eq!(app.series_label(&before.series[1]), "Sweep 2"); + assert_eq!(app.series_label(&before.series[2]), "-60 mV"); + assert_eq!(app.series_label(&before.series[3]), "Sweep 2"); + assert_eq!( + app.doc.canvases[canvas].objects[0] + .plot() + .unwrap() + .figure() + .series + .len(), + 4 + ); + + let options = app.stack_candidate_series_options(&before, 1); + assert_eq!(options.len(), 2); + assert_eq!(app.series_label(&options[1]), "Sweep 2"); + assert_eq!(app.series_item_options(&before.series[2]).len(), 2); + let mut after = before.clone(); + for series in &mut after.series { + series.visible = app.series_label(series) == "Sweep 2"; + } + app.execute_action(crate::actions::Action::set_data_binding( + canvas, + object, + before.clone(), + after.clone(), + )); + + let plot = app.doc.canvases[canvas].objects[0].plot().unwrap(); + assert_eq!(plot.figure().series.len(), 2); + assert!( + plot.figure() + .series + .iter() + .all(|series| series.name == "Sweep 2") + ); + + app.undo(); + assert_eq!( + app.doc.canvases[canvas].objects[0].plot().unwrap().binding, + before + ); + app.redo(); + assert_eq!( + app.doc.canvases[canvas].objects[0].plot().unwrap().binding, + after + ); + + let path = std::env::temp_dir().join(format!( + "plotx-selected-trace-item-{}.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); + let loaded_plot = loaded.doc.canvases[canvas].objects[0].plot().unwrap(); + assert_eq!(loaded_plot.binding, after); + assert_eq!(loaded_plot.figure().series.len(), 2); +} + +#[test] +fn fixed_prepulse_is_skipped_for_the_varying_abf_test_pulse() { + let levels = [-20.0, 0.0, 20.0]; + let data = plotx_io::ElectrophysiologyData { + abf_version: "2.9".into(), + sample_rate_hz: 10_000.0, + channels: vec![plotx_io::RecordedChannel { + name: "Response".into(), + unit: plotx_io::ElectricalUnit::from_symbol("pA"), + }], + sweeps: levels + .iter() + .enumerate() + .map(|(index, level)| plotx_io::Sweep { + start_time_s: index as f64, + channels: vec![vec![0.0; 6]], + commands: vec![plotx_io::CommandWaveform { + name: "Command".into(), + unit: plotx_io::ElectricalUnit::from_symbol("mV"), + holding_level: -80.0, + samples: vec![-80.0, -40.0, -40.0, *level, *level, -80.0], + }], + }) + .collect(), + protocol: None, + source: "prepulse.abf".into(), + import_warnings: Vec::new(), + }; + let recording = ElectrophysiologyDataset::load(data); + let labels = recording + .trace_items() + .iter() + .map(|item| item.automatic_label().unwrap()) + .collect::>(); + assert_eq!(labels, ["-20 mV", "0 mV", "20 mV"]); + let (stimulus, quantity) = recording.stimulus_values().unwrap(); + assert_eq!(stimulus, levels); + assert_eq!(quantity, plotx_io::ElectricalQuantity::Voltage); +} + +#[test] +fn single_and_multi_item_materialization_apply_identical_line_style() { + let dataset = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let field = dataset.field_catalog().id_for_key("nmr.stack").unwrap(); + let mut bindings = SeriesBinding::from_field_all(&dataset, field); + for binding in bindings.iter_mut().take(2) { + binding.label = Some("styled".into()); + if let plotx_figure::SeriesEncoding::Line(line) = &mut binding.encoding { + line.scale = 2.0; + line.width = plotx_figure::PositiveFiniteF32::new(3.25).unwrap(); + line.color = plotx_figure::ColorSource::Explicit(plotx_figure::Color::rgb(7, 19, 31)); + } + } + let mut app = PlotxApp::new(); + app.doc.datasets.push(dataset); + let chart = ChartSpec::default_for(DataDomain::Nmr2d); + let single = app.build_binding_figure( + &DataBinding { + series: vec![bindings[0].clone()], + }, + &chart, + &StackSpec::default(), + [120.0, 80.0], + ); + let multi = app.build_binding_figure( + &DataBinding { + series: bindings[..2].to_vec(), + }, + &chart, + &StackSpec::default(), + [120.0, 80.0], + ); + for figure in [&single, &multi] { + assert!(figure.series.iter().all(|series| { + series.name == "styled" + && series.width == 3.25 + && series.color == plotx_figure::Color::rgb(7, 19, 31) + })); + } + assert_ne!( + SeriesBinding::from_field_all(&app.doc.datasets[0], field)[0].primary_color(), + SeriesBinding::from_field_all(&app.doc.datasets[0], field)[1].primary_color(), + "initial palette colors belong to the bindings" + ); +} + +#[test] +fn confirming_a_stimulus_template_refreshes_trace_labels() { + let mut dataset = recording("pA", None); + let field = dataset.default_field_id().unwrap(); + let recording = dataset.as_electrophysiology_mut().unwrap(); + assert_eq!( + recording.trace_items()[0].automatic_label().as_deref(), + Some("Sweep 1") + ); + recording.stimulus = Some(StimulusDefinition { + protocol: StimulusProtocol::VoltageStep { + holding_mv: -80.0, + start_mv: -60.0, + step_mv: 10.0, + start_s: 0.1, + end_s: 0.2, + }, + source: StimulusSource::User, + confirmed: true, + }); + recording.refresh_trace_collections(); + assert_eq!( + recording + .field_catalog + .trace_collection(field) + .unwrap() + .items[0] + .automatic_label() + .as_deref(), + Some("-60 mV") + ); +} + +#[test] +fn electrophysiology_trace_figures_drop_non_finite_points() { + 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![1.0, f64::NAN, f64::INFINITY, 2.0]; + let field = dataset.default_field_id().unwrap(); + let item = dataset.trace_collection(field).unwrap().items[0].id; + let figure = dataset.trace_item_figure(field, item).unwrap(); + assert_eq!(figure.series[0].points.len(), 2); + assert!( + figure.series[0] + .points + .iter() + .flatten() + .all(|value| value.is_finite()) + ); +} diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 64005ac..5aa45c4 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -6,7 +6,8 @@ use crate::export::{ use crate::state::{ AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, DEFAULT_CANVAS_SIZE_MM, DataBinding, Dataset, MM_TO_PT, Nmr2DDataset, NmrDataset, - ObjectFrame, ObjectId, PanelMeta, PlotObject, PlotxApp, StackSpec, default_chart_type, + ObjectFrame, ObjectId, PanelMeta, PlotObject, PlotxApp, StackMode, StackSpec, + default_chart_type, }; use plotx_figure::{Axis, Figure}; use plotx_io::{Acquisition, DataFormat, Domain, LoadWarning, LoadWarningCode, Provenance}; @@ -15,6 +16,8 @@ use std::path::{Path, PathBuf}; use std::time::Duration; #[path = "workflow/mass_spec_layout.rs"] mod mass_spec_layout; +#[path = "workflow/trace_collection.rs"] +mod trace_collection; #[path = "workflow/xps.rs"] mod xps; pub const INSPECTION_SCHEMA: &str = "plotx.inspect.v1"; @@ -338,7 +341,26 @@ pub fn build_dataset_figure(dataset: &Dataset, chart: &ChartSpec, size_mm: [f32; } fn default_binding(dataset: &Dataset) -> DataBinding { - DataBinding::single(dataset) + let fields = match dataset { + Dataset::Nmr2D(data) if !data.is_true_2d() => ["nmr.stack", "nmr.dosy_map", "nmr.ilt_map"] + .into_iter() + .filter_map(|key| data.field_catalog.id_for_key(key)) + .collect::>(), + Dataset::Electrophysiology(_) => dataset + .field_descriptors() + .into_iter() + .map(|field| field.id) + .collect(), + _ => return DataBinding::single(dataset), + }; + let mut series = fields + .into_iter() + .flat_map(|field| crate::state::SeriesBinding::from_field_all(dataset, field)) + .collect::>(); + for (index, binding) in series.iter_mut().enumerate() { + binding.id = crate::state::SeriesId::new(index as u64); + } + DataBinding { series } } pub fn build_plot_object( @@ -354,14 +376,34 @@ pub fn build_plot_object( { chart.type_id = "afm_force_curve".to_owned(); } - let figure = build_dataset_figure(dataset, &chart, size_mm); + let binding = default_binding(dataset); + let figure = trace_collection::initial_figure( + dataset, + &binding, + size_mm, + build_dataset_figure(dataset, &chart, size_mm), + ); let viewport = CanvasViewport::from_figure(&figure); let panel = PanelMeta::new(dataset_title(dataset), frame.width); let axis_overrides = AxisOverrides { lock_aspect: matches!(dataset, Dataset::Nmr2D(dataset) if dataset.is_true_2d()) .then_some(figure.lock_aspect), + guide_visibility: (matches!(dataset, Dataset::Electrophysiology(_) | Dataset::Nmr2D(_))) + .then_some(plotx_figure::GuideVisibility::Hide), ..AxisOverrides::default() }; + let stack = if binding + .series + .iter() + .any(|series| series.source.item.is_some()) + { + StackSpec { + mode: StackMode::Offset, + ..StackSpec::default() + } + } else { + StackSpec::default() + }; CanvasObject { id, name, @@ -370,10 +412,11 @@ pub fn build_plot_object( visible: true, group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( - crate::state::SeriesId::new(1), - default_binding(dataset), + Some(dataset.resource_id()), + crate::state::SeriesId::new(binding.series.len() as u64), + binding, chart, - StackSpec::default(), + stack, AxisProjections::default(), axis_overrides, figure, diff --git a/crates/core/src/workflow/mass_spec_layout.rs b/crates/core/src/workflow/mass_spec_layout.rs index 718a55d..04920d0 100644 --- a/crates/core/src/workflow/mass_spec_layout.rs +++ b/crates/core/src/workflow/mass_spec_layout.rs @@ -47,6 +47,7 @@ pub(super) fn configure_fields( let mut series = SeriesBinding::with_source(SeriesSource { resource: dataset.resource_id(), field: *field, + item: None, }); series.set_primary_color(palette[index % palette.len()]); series.label = Some(label.clone()); diff --git a/crates/core/src/workflow/trace_collection.rs b/crates/core/src/workflow/trace_collection.rs new file mode 100644 index 0000000..c9f0681 --- /dev/null +++ b/crates/core/src/workflow/trace_collection.rs @@ -0,0 +1,62 @@ +use crate::state::{DataBinding, Dataset, MM_TO_PT, OVERLAY_PALETTE, StackSpec}; +use plotx_figure::Figure; + +pub(super) fn initial_figure( + dataset: &Dataset, + binding: &DataBinding, + size_mm: [f32; 2], + fallback: Figure, +) -> Figure { + let field = match dataset { + Dataset::Electrophysiology(recording) => recording + .field_key(recording.selected_channel) + .and_then(|key| recording.field_catalog.id_for_key(key)), + _ => dataset.default_field_id(), + }; + let Some(field) = field else { + return fallback; + }; + let active = binding + .series + .iter() + .filter(|series| series.source.resource == dataset.resource_id()) + .filter(|series| series.source.field == field) + .collect::>(); + if !active.iter().any(|series| series.source.item.is_some()) { + return fallback; + } + let parts = active + .into_iter() + .filter_map(|series| dataset.trace_item_figure(series.source.field, series.source.item?)) + .collect::>(); + let Some(first) = parts.first() else { + return fallback; + }; + let mut figure = first.clone(); + figure.series.clear(); + let peak = parts + .iter() + .flat_map(|part| &part.series) + .flat_map(|series| &series.points) + .fold(0.0_f64, |peak, point| peak.max(point[1].abs())); + let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY); + for (index, part) in parts.into_iter().enumerate() { + for mut series in part.series { + for point in &mut series.points { + point[1] += index as f64 * StackSpec::default().spacing_y * peak; + y_min = y_min.min(point[1]); + y_max = y_max.max(point[1]); + } + series.color = OVERLAY_PALETTE[index % OVERLAY_PALETTE.len()]; + figure.series.push(series); + } + } + if y_min.is_finite() && y_max.is_finite() { + figure.y.min = y_min; + figure.y.max = y_max; + } + figure.series_colors_are_semantic = true; + figure.width = size_mm[0] * MM_TO_PT; + figure.height = size_mm[1] * MM_TO_PT; + figure +} diff --git a/crates/data/src/id.rs b/crates/data/src/id.rs index 4f0716f..36f0927 100644 --- a/crates/data/src/id.rs +++ b/crates/data/src/id.rs @@ -51,6 +51,26 @@ uuid_id!(RevisionId); uuid_id!(RowId); uuid_id!(ColumnId); uuid_id!(OperationId); +uuid_id!(TraceCollectionId); +uuid_id!(TraceItemId); + +impl TraceCollectionId { + pub fn derived(source: &[u8], discriminator: &[u8]) -> Self { + Self(deterministic_uuid( + b"plotx.trace-collection.v1", + &[source, discriminator], + )) + } +} + +impl TraceItemId { + pub fn derived(collection: TraceCollectionId, discriminator: &[u8]) -> Self { + Self(deterministic_uuid( + b"plotx.trace-item.v1", + &[collection.as_bytes(), discriminator], + )) + } +} impl RowId { pub fn derived(operation: OperationId, inputs: &[RowId], discriminator: &[u8]) -> Self { diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 75f60c6..959e687 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -27,6 +27,7 @@ mod snapshot; mod source; #[doc(hidden)] pub mod storage; +mod trace_collection; mod typecheck; mod unit_registry; @@ -45,6 +46,7 @@ pub use schema::*; pub use snapshot::*; pub use source::*; pub use storage::*; +pub use trace_collection::*; pub use typecheck::*; pub use unit_registry::*; diff --git a/crates/data/src/trace_collection.rs b/crates/data/src/trace_collection.rs new file mode 100644 index 0000000..0948dc4 --- /dev/null +++ b/crates/data/src/trace_collection.rs @@ -0,0 +1,161 @@ +use crate::{TraceCollectionId, TraceItemId}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TraceParameterValue { + Number { value: f64, unit: String }, + Text { value: String }, +} + +impl TraceParameterValue { + pub fn formatted(&self) -> String { + match self { + Self::Number { value, unit } => { + let value = format!("{value:.6}") + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned(); + if unit.is_empty() { + value + } else { + format!("{value} {unit}") + } + } + Self::Text { value } => value.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TraceItemParameter { + pub key: String, + pub name: String, + pub value: TraceParameterValue, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TraceItemDescriptor { + pub id: TraceItemId, + pub parameters: Vec, + pub primary_label_parameter: String, + pub label_override: Option, +} + +impl TraceItemDescriptor { + pub fn automatic_label(&self) -> Option { + self.label_override.clone().or_else(|| { + self.parameters + .iter() + .find(|parameter| parameter.key == self.primary_label_parameter) + .map(|parameter| parameter.value.formatted()) + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TraceCollectionCatalog { + pub id: TraceCollectionId, + pub axis_quantity: String, + pub axis_unit: String, + pub items: Vec, +} + +impl TraceCollectionCatalog { + pub fn validate(&self) -> Result<(), String> { + if self.axis_quantity.trim().is_empty() { + return Err("trace collection axis quantity is empty".to_owned()); + } + let mut ids = BTreeSet::new(); + for item in &self.items { + if !ids.insert(item.id) { + return Err(format!( + "trace collection contains duplicate item id {}", + item.id + )); + } + let mut keys = BTreeSet::new(); + for parameter in &item.parameters { + if parameter.key.trim().is_empty() || !keys.insert(parameter.key.as_str()) { + return Err(format!( + "trace item {} has empty or duplicate parameter keys", + item.id + )); + } + if let TraceParameterValue::Number { value, .. } = parameter.value + && !value.is_finite() + { + return Err(format!("trace item {} has a non-finite parameter", item.id)); + } + } + if !keys.contains(item.primary_label_parameter.as_str()) { + return Err(format!( + "trace item {} has an unknown primary label parameter", + item.id + )); + } + } + Ok(()) + } + + pub fn item(&self, id: TraceItemId) -> Option<&TraceItemDescriptor> { + self.items.iter().find(|item| item.id == id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(id: TraceItemId) -> TraceItemDescriptor { + TraceItemDescriptor { + id, + parameters: vec![TraceItemParameter { + key: "level".into(), + name: "Level".into(), + value: TraceParameterValue::Number { + value: -60.0, + unit: "mV".into(), + }, + }], + primary_label_parameter: "level".into(), + label_override: None, + } + } + + #[test] + fn validation_rejects_duplicate_items_and_unknown_primary_parameters() { + let collection_id = TraceCollectionId::new(); + let item_id = TraceItemId::derived(collection_id, b"one"); + let mut collection = TraceCollectionCatalog { + id: collection_id, + axis_quantity: "Sweep".into(), + axis_unit: String::new(), + items: vec![item(item_id), item(item_id)], + }; + assert!( + collection + .validate() + .unwrap_err() + .contains("duplicate item") + ); + collection.items.truncate(1); + collection.items[0].primary_label_parameter = "missing".into(); + assert!( + collection + .validate() + .unwrap_err() + .contains("unknown primary") + ); + } + + #[test] + fn explicit_label_override_wins_over_the_primary_parameter() { + let collection = TraceCollectionId::new(); + let mut descriptor = item(TraceItemId::derived(collection, b"one")); + assert_eq!(descriptor.automatic_label().as_deref(), Some("-60 mV")); + descriptor.label_override = Some("Threshold".into()); + assert_eq!(descriptor.automatic_label().as_deref(), Some("Threshold")); + } +} diff --git a/docs/src/content/docs/guides/electrophysiology.md b/docs/src/content/docs/guides/electrophysiology.md index 48f0e56..7b32d87 100644 --- a/docs/src/content/docs/guides/electrophysiology.md +++ b/docs/src/content/docs/guides/electrophysiology.md @@ -11,16 +11,30 @@ waveforms. ABF1 and compressed ABF2 data are not currently supported. ## Sweeps and filtering The default chart overlays every sweep from the selected channel against time. -Use **Patch clamp** in Dataset tools to select or clear individual sweeps and -choose the recorded channel. The optional zero-phase Gaussian low-pass is -enabled at 1 kHz by default. It affects charts and analysis consistently; raw -samples remain unchanged and the setting is saved in the project. +Select the plot, then use **Data** in the Object inspector to show or hide +individual sweeps. Use **Choose trace…** on a plot series to replace it with a +different stimulus from the same recording. **Add series…** adds every sweep +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. **Stack selected data** likewise starts with all sweeps +from every selected recording. These changes affect only the plot. In Dataset +tools, use **Patch clamp** to choose the recorded channel and the sweeps +included in region measurements, window statistics, IV tables, and data +exports. The optional zero-phase Gaussian low-pass is enabled at 1 kHz by +default. It affects charts and analysis consistently; raw samples remain +unchanged and the setting is saved in the project. -Sweep names share the plot legend. To recover plot area, select the plot and -set **Visibility** to **Hide** under **Legend & scales** in the Object -inspector. **Legend size** and **Legend text color** under **Figure typography** -style legends throughout the document. With **Select** active, drag the legend -to a clear part of the plot; double-click it to restore automatic placement. +The legend is hidden for a newly imported recording. To identify sweeps on the +plot, set **Visibility** to **Show** under **Legend & scales**. A sweep with an +ABF DAC waveform is labeled with its command value and unit. Otherwise, PlotX +uses a confirmed voltage-step or current-step template when available, then +falls back to `Sweep n`. The command unit does not depend on the recorded +response channel. In a multi-epoch protocol, PlotX uses the command epoch that +varies between sweeps, so a fixed prepulse does not replace the test-pulse +value. **Legend size** and **Legend text color** under **Figure +typography** style legends throughout the document. With **Select** active, +drag the legend to a clear part of the plot; double-click it to restore +automatic placement. ## Regions and window statistics @@ -33,6 +47,8 @@ recording in the Data browser without adding a second board frame. Use **Fit curves**, **View data**, or **Back to regions** in the Curve Fit task card. The data opens read-only; choose **Save Snapshot** in the Regions task card, or **Save editable snapshot** in the data sheet, to create an independent table. +The synchronized table keeps the sweep membership captured when you create it; +changing the current analysis selection does not rewrite existing table rows. For peak, average, and peak-time values, open **Patch clamp**. PlotX uses the selected region, or the first region in the list when none is selected. Choose diff --git a/docs/src/content/docs/guides/pseudo-2d.md b/docs/src/content/docs/guides/pseudo-2d.md index 3a4b360..81e6098 100644 --- a/docs/src/content/docs/guides/pseudo-2d.md +++ b/docs/src/content/docs/guides/pseudo-2d.md @@ -8,6 +8,16 @@ varies. PlotX reads that varying parameter from the acquisition parameters on import: a gradient-strength series marks the dataset as DOSY, a delay series as relaxation (T1 or T2 — which one is your choice of fit model). +The initial stack contains one plot series per increment. Select the plot and +use **Data** in the Object inspector to show or hide individual increments. +Use **Choose trace…** to replace a series with another increment from the same +dataset. **Add series…** adds every increment from a compatible dataset. Use +**Show all**, **Hide all**, the row checkboxes, or remove buttons to keep the +exact values you want to compare. To identify them on the plot, set +**Visibility** to **Show** under **Legend & scales**. The legend labels each +increment by its gradient strength, relaxation delay, or imported pseudo-axis +value and display unit. + ## 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 f57f23c..9825d67 100644 --- a/docs/src/content/docs/zh-cn/guides/electrophysiology.md +++ b/docs/src/content/docs/zh-cn/guides/electrophysiology.md @@ -9,17 +9,24 @@ float32、单/多记录通道、定长或变长 sweep、ADC 缩放、通道名 ## Sweep 与滤波 -默认图表按时间叠加所选通道的全部 sweep。在 Dataset tools 的 -**Patch clamp** 中可以全选、清空或单独启用 sweep,并切换记录通道。 -零相位 Gaussian 低通默认启用,截止频率为 1 kHz。绘图和分析使用同一 -处理结果;原始样本不改变,设置会随项目保存。 - -各 sweep 的名称共用图内图例。若要释放数据区空间,请选中该图,并在对象 -检查器的 **Legend & scales** 中把 **Visibility** 设为 **Hide**。 -**Figure typography** 中的 -**Legend size** 和 **Legend text color** 统一设定文档内所有图例的样式。 -启用 **Select** 工具后可把图例拖到不遮挡曲线的位置;双击图例即可恢复 -自动放置。 +默认图表按时间叠加所选通道的全部 sweep。选中图形后,可在对象检查器的 +**Data** 中逐条显示或隐藏 sweep。通过某个绘图系列的 **Choose trace…**,可将其 +替换为同一 recording 中的另一种刺激;**Add series…** 会一次加入所选兼容 +recording 的全部 sweep。可用 **Show all**、**Hide all**、每行的复选框或删除按钮, +把 stack 缩减到需要比较的电压或电流。**Stack selected data** 同样会从每个所选 +recording 的全部 sweep 开始。这些操作只改变图形。在 Dataset tools 的 +**Patch clamp** 中选择记录通道,以及参与区域测量、时间窗统计、IV 表和 +数据导出的 sweep。零相位 Gaussian 低通默认启用,截止频率为 1 kHz。 +绘图和分析使用同一处理结果;原始样本不改变,设置会随项目保存。 + +新导入记录的图例默认隐藏。若要在图中识别 sweep,请在 **Legend & scales** +中将 **Visibility** 设为 **Show**。如果某条 sweep 含有 ABF DAC 波形,图例会 +使用命令值及其单位;否则,PlotX 会优先使用已确认的电压阶跃或电流阶跃模板, +再回退为 `Sweep n`。命令单位不受记录响应通道影响。对于多 epoch 协议, +PlotX 使用各 sweep 之间发生变化的命令 epoch,因此固定的预脉冲不会取代测试 +脉冲值。**Figure typography** 中的 **Legend size** 和 **Legend text color** +统一设定文档内所有图例的样式。 +启用 **Select** 工具后可把图例拖到不遮挡曲线的位置;双击图例即可恢复自动放置。 ## 区域与时间窗统计 @@ -30,7 +37,8 @@ Min 或 Mean,再选择 **View extracted curves**。PlotX 会选中散点图并 但不会另占一个画板框架。在 Curve Fit 任务卡片中使用 **Fit curves**、 **View data** 或 **Back to regions**。数据以只读方式打开;若要生成独立表, 可在 Regions 任务卡片中选择 **Save Snapshot**,也可在数据表中选择 -**Save editable snapshot**。 +**Save editable snapshot**。同步表会保留创建时选定的 sweep;之后改变当前 +分析选择不会改写已有表行。 若要得到峰值、平均值和峰值时间,请打开 **Patch clamp**。PlotX 使用当前选中的 区域;若未选中区域,则使用列表中的第一个区域。在 **Peak mode** 下选择 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 ae5af20..778b100 100644 --- a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md +++ b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md @@ -7,6 +7,14 @@ description: DOSY、T1、T2 弛豫分析与曲线拟合。 中读取这个变化参数:梯度强度系列标记为 DOSY,延迟系列标记为弛豫 (T1 还是 T2 由你选择的拟合模型决定)。 +初始堆叠图中,每个增量对应一个绘图系列。选中图形后,可在对象检查器的 +**Data** 中逐条显示或隐藏增量。通过 **Choose trace…** 可把某个系列替换为同一 +数据集中的另一条增量;**Add series…** 会一次加入兼容数据集的全部增量。可用 +**Show all**、**Hide all**、每行的复选框或删除按钮,只保留要比较的确切数值。 +若要在图中识别这些增量,请在 **Legend & +scales** 中将 **Visibility** 设为 **Show**。图例会使用各增量的梯度强度、 +弛豫延迟或导入的伪轴值及显示单位进行标注。 + ## 工作流 1. 导入伪 2D 数据集。