diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 7963d05..2542c5c 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -485,7 +485,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { ), CommandId::StackData => requires( app.stackable_selection().is_some(), - "Select at least two compatible datasets before stacking them.", + "Select at least two compatible datasets. Trace collections such as electrophysiology require compatible axes and units.", ), CommandId::ExtractMassSpectrum => requires( dataset().is_some_and(|dataset| { @@ -668,6 +668,7 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { && app.session.ui.processing_template_dialog.is_none() && app.session.ui.spectrum_arithmetic_dialog.is_none() && app.session.ui.align_spectra_dialog.is_none() + && app.session.ui.trace_composer.is_none() && !app.session.ui.interaction.is_active()); // Activation requirements must not trap an already-active tool after the // dataset context changes: its command remains available for deactivation. diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 1e62410..4a104d1 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -34,6 +34,7 @@ mod switcher; #[cfg(not(target_os = "macos"))] mod title_bar; pub(crate) mod tools; +mod trace_composer; mod windows; use data_sheet::*; @@ -90,6 +91,7 @@ pub fn render( || app.session.ui.processing_template_dialog.is_some() || app.session.ui.spectrum_arithmetic_dialog.is_some() || app.session.ui.align_spectra_dialog.is_some() + || app.session.ui.trace_composer.is_some() || app.session.ui.command_palette.is_some() || app.session.ui.save_project_options || app.session.ui.project_transition.is_some() @@ -170,6 +172,7 @@ pub fn render( processing_templates::processing_template_window(app, &ctx); arithmetic::spectrum_arithmetic_window(app, &ctx); align::align_spectra_window(app, &ctx); + trace_composer::trace_composer_window(app, &ctx); batch_workflow.show(app, &ctx); handle_file_drop(app, &ctx); diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index b787da8..cf9f04b 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -502,9 +502,11 @@ fn render_dataset_node( ui.separator(); if ui .add_enabled(can_stack, egui::Button::new("Stack selected data")) - .on_hover_text("Build a new page stacking the selected datasets") + .on_hover_text( + "Build a new page from compatible datasets. Trace collections such as electrophysiology require compatible axes and units.", + ) .on_disabled_hover_text( - "Select 2 or more datasets of the same type (1D NMR, data tables, or 2D NMR).", + "Select at least two compatible datasets. Trace collections such as electrophysiology require compatible axes and units.", ) .clicked() { diff --git a/crates/app/src/ui/trace_composer.rs b/crates/app/src/ui/trace_composer.rs new file mode 100644 index 0000000..7ec3f05 --- /dev/null +++ b/crates/app/src/ui/trace_composer.rs @@ -0,0 +1,125 @@ +use plotx_core::state::PlotxApp; + +pub(crate) fn trace_composer_window(app: &mut PlotxApp, ctx: &egui::Context) { + let Some(mut state) = app.session.ui.trace_composer.take() else { + return; + }; + let mut create = false; + let mut cancel = false; + let available = ctx.content_rect().size() - egui::vec2(32.0, 48.0); + let size = egui::vec2(760.0, 540.0).min(available).max(egui::vec2( + 300.0_f32.min(available.x), + 260.0_f32.min(available.y), + )); + let modal = super::modal(ctx, "trace_composer_modal", super::ModalKind::Dialog).show(ctx, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + ui.heading("Compose trace stack"); + ui.separator(); + ui.label("Choose the traces to include. You can edit their appearance after creating the plot."); + ui.add_space(4.0); + + ui.horizontal_wrapped(|ui| { + ui.label("Search"); + ui.add( + egui::TextEdit::singleline(&mut state.query) + .hint_text("Dataset, trace, or parameter") + .desired_width(ui.available_width().min(280.0)), + ); + if ui.button("Select all").clicked() { + state.set_all(true); + } + if ui.button("Clear all").clicked() { + state.set_all(false); + } + }); + let normalized_query = state.normalized_query(); + let visible_count = state.visible_count(&normalized_query); + let filtered = !normalized_query.is_empty() && visible_count < state.items.len(); + if filtered { + ui.horizontal(|ui| { + if ui.button("Select filtered").clicked() { + state.set_filtered(&normalized_query, true); + } + if ui.button("Clear filtered").clicked() { + state.set_filtered(&normalized_query, false); + } + }); + } + + ui.add_space(4.0); + let table_width = ui.available_width(); + let trace_width = (table_width * 0.48).max(120.0); + let parameter_width = (table_width - trace_width - 16.0).max(80.0); + egui::ScrollArea::vertical() + .id_salt("trace_composer_items") + .max_height((size.y - 190.0).max(100.0)) + .show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + egui::Grid::new("trace_composer_grid") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + ui.strong("Trace"); + ui.strong("Parameters"); + ui.end_row(); + for item in state + .items + .iter_mut() + .filter(|item| item.matches_normalized_query(&normalized_query)) + { + let trace_name = format!("{} — {}", item.dataset_name, item.label); + ui.add_sized( + [trace_width, 20.0], + egui::Checkbox::new(&mut item.selected, &trace_name), + ) + .on_hover_text(format!("Include {trace_name}")); + let parameters = item + .parameters + .iter() + .map(|(name, value)| format!("{name}: {value}")) + .collect::>() + .join("; "); + ui.add_sized( + [parameter_width, 20.0], + egui::Label::new(¶meters).truncate(), + ) + .on_hover_text(parameters); + ui.end_row(); + } + }); + }); + + ui.add_space(4.0); + ui.label(format!( + "{} of {} traces selected; {} shown", + state.selected_count(), + state.items.len(), + visible_count + )); + ui.separator(); + ui.horizontal(|ui| { + if ui + .add_enabled( + state.selected_count() > 0, + egui::Button::new("Create stack"), + ) + .clicked() + { + create = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if create { + app.session.ui.trace_composer = Some(state); + app.create_trace_composer_stack(); + } else if cancel || modal.should_close() { + app.cancel_trace_composer(); + } else { + app.session.ui.trace_composer = Some(state); + } +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index d533ae7..880e35e 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -105,6 +105,7 @@ mod table_fit; mod table_native; mod table_numeric; mod tile_drop; +mod trace_composer; mod trace_provider; #[cfg(test)] #[path = "trace_provider_tests.rs"] diff --git a/crates/core/src/state/series_binding.rs b/crates/core/src/state/series_binding.rs index b499c64..70f74da 100644 --- a/crates/core/src/state/series_binding.rs +++ b/crates/core/src/state/series_binding.rs @@ -40,47 +40,57 @@ impl SeriesBinding { } 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)], + || { + Self::from_field_item(dataset, field, None, 0) + .into_iter() + .collect() + }, |collection| { collection .items .iter() .enumerate() - .map(|(index, item)| make(Some(item.id), index)) + .filter_map(|(index, item)| { + Self::from_field_item(dataset, field, Some(item.id), index) + }) .collect() }, ) } + pub(crate) fn from_field_item( + dataset: &Dataset, + field: FieldId, + item: Option, + palette_index: usize, + ) -> Option { + let descriptor = dataset.field_descriptor(field)?; + 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[palette_index % OVERLAY_PALETTE.len()], + ); + } + Some(Self { + id: SeriesId::default(), + source: SeriesSource { + resource: dataset.resource_id(), + field, + item, + }, + visible: true, + label: None, + encoding, + }) + } + pub fn with_source(source: SeriesSource) -> Self { Self { id: SeriesId::default(), diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 9222984..87e8b18 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -2,8 +2,9 @@ use super::*; use plotx_figure::{ErrorBar, Series}; impl PlotxApp { - /// Whether every series shares a domain and either that domain supports - /// generic stacking or every source is an item-addressed trace. + /// Whether a binding has one stackable representation. Item-addressed line + /// traces use their field contracts and may cross enclosing data domains; + /// ordinary fields retain the legacy same-domain rules. pub fn series_stackable(&self, binding: &DataBinding) -> bool { let Some(domain) = binding .series @@ -33,9 +34,12 @@ impl PlotxApp { .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) + if item_addressed { + all_lines + } else { + same_domain + && ((all_lines && domain.stack_kind() == Some(StackKind::Line)) || all_contours) + } } /// Combine a stackable binding into one figure. Concrete encodings decide @@ -447,44 +451,97 @@ impl PlotxApp { } } - /// The selected datasets in the Data list if they form a valid stack (≥2 and - /// sharing one stackable domain), in selection order. Drives the "Stack - /// selected data" command's enablement. + /// The selected datasets in the Data list if they form a valid stack (at + /// least two in one domain), in selection order. Trace collections derive + /// applicability from their concrete fields rather than the domain's + /// legacy stack kind. pub fn stackable_selection(&self) -> Option> { let sel = &self.session.ui.data_selection; if sel.len() < 2 { return None; } - let domain = self - .doc - .datasets - .get(*sel.first()?) - .map(Dataset::domain) - .filter(|d| d.stack_kind().is_some())?; - sel.iter() - .all(|&d| self.doc.datasets.get(d).map(Dataset::domain) == Some(domain)) + let trace_count = sel + .iter() + .filter_map(|index| self.doc.datasets.get(*index)) + .filter(|dataset| dataset.active_trace_collection_field().is_some()) + .count(); + if trace_count > 0 { + (trace_count == sel.len() && self.trace_selection_compatible(sel)).then(|| sel.clone()) + } else { + let domain = self.doc.datasets.get(*sel.first()?).map(Dataset::domain)?; + (domain.stack_kind().is_some() + && sel + .iter() + .all(|&d| self.doc.datasets.get(d).map(Dataset::domain) == Some(domain))) .then(|| sel.clone()) + } } /// Build a new page whose single plot stacks the currently multi-selected - /// datasets, as one undoable step. No-op unless the selection is a valid stack. + /// datasets, as one undoable step. Item-addressed sources first open the + /// trace composer; non-trace stacks retain the immediate command behavior. pub fn stack_selected_data(&mut self) { let Some(sel) = self.stackable_selection() else { return; }; - let domain = self.doc.datasets[sel[0]].domain(); - let mut series = sel + let trace_source_count = sel + .iter() + .filter_map(|index| self.doc.datasets.get(*index)) + .filter(|dataset| dataset.active_trace_collection_field().is_some()) + .count(); + if trace_source_count > 0 { + if trace_source_count == sel.len() + && let Some(composer) = self.trace_composer_for_selection(&sel) + { + let item_count = composer.items.len(); + self.session.ui.trace_composer = Some(composer); + self.session.status = format!( + "Choose traces for the new stack. {item_count} items selected by default." + ); + } else { + self.session.status = + "The selected trace collections do not have compatible axes and units." + .to_owned(); + } + return; + } + let series = sel .iter() .filter_map(|&d| self.doc.datasets.get(d)) .flat_map(SeriesBinding::from_dataset_all) .collect::>(); + self.insert_stack_canvas(&sel, series, false); + } + + pub(super) fn insert_stack_canvas( + &mut self, + selection: &[usize], + mut series: Vec, + trace_items: bool, + ) -> bool { + let Some(&primary) = selection.first() else { + return false; + }; + let domain = self.doc.datasets[primary].domain(); 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, + let series_count = binding.series.len(); + let mode = if trace_items { + if !binding + .series + .iter() + .all(|series| matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_))) + { + return false; + } + StackMode::Offset + } else { + match domain.stack_kind() { + Some(StackKind::Field) => StackMode::ColorOverlay, + _ => StackMode::Offset, + } }; let stack = StackSpec { mode, @@ -498,7 +555,7 @@ impl PlotxApp { let frame = ObjectFrame::new(0.0, 0.0, page[0], page[1]); let figure = self.build_binding_figure(&binding, &chart, &stack, canvas.size_mm); let viewport = CanvasViewport::from_figure(&figure); - let panel = PanelMeta::new(self.default_plot_title(sel[0]), frame.width); + let panel = PanelMeta::new(self.default_plot_title(primary), frame.width); let mut plot = PlotObject::new( None, SeriesId::new(0), @@ -524,27 +581,43 @@ impl PlotxApp { kind: CanvasObjectKind::Plot(Box::new(plot)), }); let index = self.doc.canvases.len(); + let canvas_count = self.doc.canvases.len(); self.execute_action(Action::insert_canvas( index, canvas, self.session.active_canvas, )); + if self.doc.canvases.len() != canvas_count + 1 { + return false; + } + if trace_items { + self.reveal_board_frame(FrameRef::Page(index)); + self.session.ui.selection_scope = SelectionScope::CanvasObjects; + self.session.ui.selection_anchors = SelectionAnchors::default(); + self.set_selection(Selection::single(id)); + self.session.ui.requested_inspector_section = Some("inspector.data".to_owned()); + } self.clear_selection(); - self.session.status = format!("Stacked {} datasets on a new page.", sel.len()); + self.session.status = if trace_items { + let trace_word = if series_count == 1 { "trace" } else { "traces" }; + let dataset_word = if selection.len() == 1 { + "dataset" + } else { + "datasets" + }; + format!( + "Stacked {series_count} {trace_word} from {} {dataset_word} on a new page.", + selection.len(), + ) + } else { + format!("Stacked {} datasets on a new page.", selection.len()) + }; + true } } 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()) - }) + dataset.active_trace_collection_field() } fn default_field_encoding_matches( diff --git a/crates/core/src/state/trace_composer.rs b/crates/core/src/state/trace_composer.rs new file mode 100644 index 0000000..8976875 --- /dev/null +++ b/crates/core/src/state/trace_composer.rs @@ -0,0 +1,211 @@ +use super::*; +use crate::automation::{CAP_FIELD_CURVE_1D, CAP_FIELD_TRACE_COLLECTION}; + +impl PlotxApp { + /// Resolve and create the current transient draft without trusting cached + /// sources from the frame in which the dialog opened. + pub fn create_trace_composer_stack(&mut self) { + let Some(composer) = self.session.ui.trace_composer.as_ref() else { + return; + }; + if composer.selected_count() == 0 { + self.session.status = "Select at least one trace to create a stack.".to_owned(); + return; + } + let selected = composer + .items + .iter() + .filter(|item| item.selected) + .collect::>(); + let mut series = Vec::with_capacity(selected.len()); + for item in selected { + let source = item.series.source; + let Some(dataset) = self.doc.dataset_by_id(source.resource) else { + self.session.status = + "A source dataset is no longer available. Review the trace selection." + .to_owned(); + return; + }; + let Some(item_id) = source.item else { + self.session.status = "The trace selection contains a non-item source.".to_owned(); + return; + }; + let Some(collection) = dataset.trace_collection(source.field) else { + self.session.status = + "A selected trace collection is no longer available. Review the selection." + .to_owned(); + return; + }; + if collection.item(item_id).is_none() { + self.session.status = + "A selected trace is no longer available. Review the selection.".to_owned(); + return; + } + let palette_index = collection + .items + .iter() + .position(|item| item.id == item_id) + .unwrap_or(0); + let Some(binding) = + SeriesBinding::from_field_item(dataset, source.field, Some(item_id), palette_index) + else { + self.session.status = + "A selected trace can no longer be rendered. Review the selection.".to_owned(); + return; + }; + series.push(binding); + } + let binding = DataBinding { + series: series.clone(), + }; + if !self.series_stackable(&binding) || !self.trace_stack_fields_compatible(&binding) { + self.session.status = + "The selected traces are no longer compatible for stacking.".to_owned(); + return; + } + let dataset_indices = series + .iter() + .filter_map(|series| self.doc.dataset_index(series.source.resource)) + .fold(Vec::new(), |mut indices, index| { + if !indices.contains(&index) { + indices.push(index); + } + indices + }); + if self.insert_stack_canvas(&dataset_indices, series, true) { + self.session.ui.trace_composer = None; + } + } + + pub fn cancel_trace_composer(&mut self) { + self.session.ui.trace_composer = None; + self.session.status = "Trace stack creation cancelled.".to_owned(); + } + + pub(super) fn trace_composer_for_selection( + &self, + selection: &[usize], + ) -> Option { + let mut items = Vec::new(); + let base_titles = selection + .iter() + .filter_map(|index| self.doc.datasets.get(*index)) + .map(crate::workflow::dataset_title) + .collect::>(); + let title_counts = base_titles.iter().fold( + std::collections::BTreeMap::::new(), + |mut counts, title| { + *counts.entry(title.clone()).or_default() += 1; + counts + }, + ); + let mut title_occurrences = std::collections::BTreeMap::::new(); + for (&index, base_title) in selection.iter().zip(base_titles) { + let dataset = self.doc.datasets.get(index)?; + let occurrence = title_occurrences.entry(base_title.clone()).or_default(); + *occurrence += 1; + let dataset_name = if title_counts.get(&base_title).copied().unwrap_or(0) > 1 { + format!("{base_title} ({occurrence})") + } else { + base_title + }; + let field = dataset.active_trace_collection_field()?; + let collection = dataset.trace_collection(field)?; + let bindings = SeriesBinding::from_field_all(dataset, field); + if bindings.len() != collection.items.len() || bindings.is_empty() { + return None; + } + for (descriptor, series) in collection.items.iter().zip(bindings) { + let label = descriptor + .automatic_label() + .unwrap_or_else(|| collection.axis_quantity.clone()); + let parameters = descriptor + .parameters + .iter() + .map(|parameter| (parameter.name.clone(), parameter.value.formatted())) + .collect(); + items.push(TraceComposerItem::new( + series, + dataset_name.clone(), + label, + parameters, + )); + } + } + let binding = DataBinding { + series: items.iter().map(|item| item.series.clone()).collect(), + }; + (self.series_stackable(&binding) && self.trace_stack_fields_compatible(&binding)).then_some( + TraceComposerState { + items, + query: String::new(), + }, + ) + } + + pub(super) fn trace_selection_compatible(&self, selection: &[usize]) -> bool { + let mut expected_units: Option> = None; + for &index in selection { + let Some(dataset) = self.doc.datasets.get(index) else { + return false; + }; + let Some(field) = dataset.active_trace_collection_field() else { + return false; + }; + let Some(collection) = dataset.trace_collection(field) else { + return false; + }; + let Some(first_item) = collection.items.first() else { + return false; + }; + let Some(descriptor) = dataset.field_descriptor(field) else { + return false; + }; + let Some(binding) = + SeriesBinding::from_field_item(dataset, field, Some(first_item.id), 0) + else { + return false; + }; + if !trace_field_contract_matches(&descriptor, &binding.encoding, &mut expected_units) { + return false; + } + } + expected_units.is_some() + } + + fn trace_stack_fields_compatible(&self, binding: &DataBinding) -> bool { + let mut expected_units: Option> = None; + binding.series.iter().all(|series| { + let Some(descriptor) = self + .doc + .dataset_by_id(series.source.resource) + .and_then(|dataset| dataset.field_descriptor(series.source.field)) + else { + return false; + }; + series.source.item.is_some() + && trace_field_contract_matches(&descriptor, &series.encoding, &mut expected_units) + }) && expected_units.is_some() + } +} + +pub(super) fn trace_field_contract_matches( + descriptor: &FieldDescriptor, + encoding: &plotx_figure::SeriesEncoding, + expected_units: &mut Option>, +) -> bool { + if !descriptor + .capabilities + .supports(&[CAP_FIELD_TRACE_COLLECTION, CAP_FIELD_CURVE_1D]) + || !matches!(encoding, plotx_figure::SeriesEncoding::Line(_)) + { + return false; + } + match expected_units { + Some(units) => *units == descriptor.units, + None => { + *expected_units = Some(descriptor.units.clone()); + true + } + } +} diff --git a/crates/core/src/state/trace_provider.rs b/crates/core/src/state/trace_provider.rs index c055cc3..fda15fe 100644 --- a/crates/core/src/state/trace_provider.rs +++ b/crates/core/src/state/trace_provider.rs @@ -14,6 +14,25 @@ impl Dataset { } } + /// The trace collection currently represented by this dataset's live + /// display. Providers resolve their own display ownership; generic trace + /// workflows only consume the resulting field identity. + pub fn active_trace_collection_field(&self) -> Option { + if let Self::Electrophysiology(recording) = self { + return recording + .field_key(recording.selected_channel) + .and_then(|key| recording.field_catalog.id_for_key(key)); + } + self.default_field_id() + .filter(|field| self.trace_collection(*field).is_some()) + .or_else(|| { + self.field_descriptors() + .into_iter() + .map(|field| field.id) + .find(|field| self.trace_collection(*field).is_some()) + }) + } + pub(super) fn validate_trace_collections(&self) -> Result<(), String> { let catalog = self.field_catalog(); match self { diff --git a/crates/core/src/state/trace_provider_tests.rs b/crates/core/src/state/trace_provider_tests.rs index 16a5f7f..f47fb30 100644 --- a/crates/core/src/state/trace_provider_tests.rs +++ b/crates/core/src/state/trace_provider_tests.rs @@ -116,6 +116,42 @@ fn recording(response_unit: &str, command_unit: Option<&str>) -> Dataset { ))) } +fn multichannel_recording( + response_units: [&str; 2], + selected_channel: usize, + source: &str, +) -> Dataset { + let mut recording = ElectrophysiologyDataset::load(plotx_io::ElectrophysiologyData { + abf_version: "2.9".into(), + sample_rate_hz: 10_000.0, + channels: response_units + .into_iter() + .enumerate() + .map(|(index, unit)| plotx_io::RecordedChannel { + name: format!("Channel {}", index + 1), + unit: plotx_io::ElectricalUnit::from_symbol(unit), + }) + .collect(), + 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: source.into(), + import_warnings: Vec::new(), + }); + recording.selected_channel = selected_channel; + Dataset::Electrophysiology(Box::new(recording)) +} + #[test] fn electrophysiology_trace_labels_prefer_dac_and_fall_back_to_sweep() { let dataset = recording("pA", Some("mV")); @@ -228,8 +264,27 @@ fn stacked_trace_collections_expand_all_items_and_round_trip_visibility() { app.doc.datasets.push(recording("pA", Some("mV"))); app.doc.datasets.push(recording("pA", Some("mV"))); app.focus_datasets(&[0, 1], None); + app.session.ui.selection_scope = SelectionScope::DataList; + app.session.ui.selection_anchors.dataset = Some(app.doc.datasets[0].resource_id()); + app.session.ui.selection_anchors.dataset_lead = Some(app.doc.datasets[1].resource_id()); + app.session.ui.selection_anchors.layer = Some(ObjectId::new(91)); + app.session.ui.selection_anchors.layer_lead = Some(ObjectId::new(92)); + assert_eq!(app.stackable_selection(), Some(vec![0, 1])); app.stack_selected_data(); + let composer = app.session.ui.trace_composer.as_mut().unwrap(); + assert_eq!(composer.selected_count(), 4); + assert_eq!(composer.items[0].dataset_name, "pA.abf (1)"); + assert_eq!(composer.items[2].dataset_name, "pA.abf (2)"); + composer.set_all(false); + composer.items[1].selected = true; + composer.items[3].selected = true; + let expected_sources = vec![ + composer.items[1].series.source, + composer.items[3].series.source, + ]; + app.create_trace_composer_stack(); + let canvas = app.doc.canvases.len() - 1; let object = app.doc.canvases[canvas].objects[0].id; let before = app.doc.canvases[canvas].objects[0] @@ -237,54 +292,85 @@ fn stacked_trace_collections_expand_all_items_and_round_trip_visibility() { .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!(before.series.len(), 2); assert_eq!( + before + .series + .iter() + .map(|series| series.source) + .collect::>(), + expected_sources + ); + assert!( + before + .series + .iter() + .all(|series| app.series_label(series) == "Sweep 2") + ); + assert_ne!(before.series[0].id, before.series[1].id); + assert!( app.doc.canvases[canvas].objects[0] .plot() .unwrap() - .figure() - .series - .len(), - 4 + .next_series_id + .get() + > before + .series + .iter() + .map(|series| series.id.get()) + .max() + .unwrap() + ); + assert_ne!( + before.series[0].primary_color(), + before.series[1].primary_color() ); - - 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() + app.doc.canvases[canvas].objects[0] + .plot() + .unwrap() + .display_owner + .is_none() + ); + assert_eq!(app.session.active_canvas, Some(canvas)); + assert_eq!(app.session.ui.selection, Selection::single(object)); + assert_eq!(app.doc.canvases[canvas].selected_object, Some(object)); + let frame = BoardFrameId::Page(app.doc.canvases[canvas].resource_id); + assert_eq!(app.session.ui.frame_selection, vec![frame]); + assert_eq!(app.session.board_reveal, Some(frame)); + assert_eq!( + app.session.ui.selection_scope, + SelectionScope::CanvasObjects + ); + assert!(app.session.ui.selection_anchors.dataset.is_none()); + assert!(app.session.ui.selection_anchors.dataset_lead.is_none()); + assert!(app.session.ui.selection_anchors.layer.is_none()); + assert!(app.session.ui.selection_anchors.layer_lead.is_none()); + assert_eq!( + app.session.ui.requested_inspector_section.as_deref(), + Some("inspector.data") + ); + assert_eq!( + app.doc.canvases[canvas].objects[0] + .plot() + .unwrap() + .figure() .series - .iter() - .all(|series| series.name == "Sweep 2") + .len(), + 2 ); + app.session.ui.selection = Selection::None; + app.sync_selection_to_active_canvas(); + assert_eq!(app.session.ui.selection, Selection::single(object)); app.undo(); - assert_eq!( - app.doc.canvases[canvas].objects[0].plot().unwrap().binding, - before - ); + assert_eq!(app.doc.canvases.len(), canvas); app.redo(); + app.sync_selection_to_active_canvas(); + assert_eq!(app.session.ui.selection, Selection::single(object)); assert_eq!( app.doc.canvases[canvas].objects[0].plot().unwrap().binding, - after + before ); let path = std::env::temp_dir().join(format!( @@ -295,10 +381,274 @@ fn stacked_trace_collections_expand_all_items_and_round_trip_visibility() { 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.binding, before); assert_eq!(loaded_plot.figure().series.len(), 2); } +#[test] +fn trace_composer_uses_each_recordings_selected_channel() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(multichannel_recording(["mV", "pA"], 1, "before.abf")); + app.doc + .datasets + .push(multichannel_recording(["mV", "pA"], 1, "after.abf")); + let expected_fields = app + .doc + .datasets + .iter() + .map(|dataset| dataset.active_trace_collection_field().unwrap()) + .collect::>(); + app.focus_datasets(&[0, 1], None); + app.stack_selected_data(); + + let composer = app.session.ui.trace_composer.as_ref().unwrap(); + assert_eq!(composer.items.len(), 4); + assert!( + composer.items[..2] + .iter() + .all(|item| item.dataset_name == "before.abf") + ); + assert!( + composer.items[2..] + .iter() + .all(|item| item.dataset_name == "after.abf") + ); + let query = "before.abf"; + assert_eq!(composer.visible_count(query), 2); + assert!( + composer.items[..2] + .iter() + .all(|item| item.series.source.field == expected_fields[0]) + ); + assert!( + composer.items[2..] + .iter() + .all(|item| item.series.source.field == expected_fields[1]) + ); +} + +#[test] +fn pseudo_map_display_composes_the_stable_stack_collection() { + let mut app = PlotxApp::new(); + for _ in 0..2 { + let mut dataset = Nmr2DDataset::load(pseudo_data()); + dataset.display = PseudoDisplay::DosyMap; + app.doc.datasets.push(Dataset::Nmr2D(Box::new(dataset))); + } + let stack_fields = app + .doc + .datasets + .iter() + .map(|dataset| dataset.field_catalog().id_for_key("nmr.stack").unwrap()) + .collect::>(); + app.focus_datasets(&[0, 1], None); + assert_eq!(app.stackable_selection(), Some(vec![0, 1])); + app.stack_selected_data(); + + let composer = app.session.ui.trace_composer.as_ref().unwrap(); + assert_eq!(composer.items.len(), 8); + assert!( + composer.items[..4] + .iter() + .all(|item| item.series.source.field == stack_fields[0]) + ); + assert!( + composer.items[4..] + .iter() + .all(|item| item.series.source.field == stack_fields[1]) + ); +} + +#[test] +fn cancelling_trace_composer_leaves_document_and_selection_unchanged() { + 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); + let before = ( + app.doc.canvases.len(), + app.doc.dirty, + app.doc.edit_generation, + app.doc.project_revision.clone(), + app.session.undo_stack.len(), + app.session.ui.data_selection.clone(), + ); + app.stack_selected_data(); + assert_eq!( + app.session + .ui + .trace_composer + .as_ref() + .unwrap() + .selected_count(), + 4 + ); + app.session + .ui + .trace_composer + .as_mut() + .unwrap() + .set_all(false); + app.create_trace_composer_stack(); + assert!(app.session.ui.trace_composer.is_some()); + assert!(app.session.status.contains("Select at least one trace")); + app.cancel_trace_composer(); + assert!(app.session.ui.trace_composer.is_none()); + assert_eq!( + before, + ( + app.doc.canvases.len(), + app.doc.dirty, + app.doc.edit_generation, + app.doc.project_revision.clone(), + app.session.undo_stack.len(), + app.session.ui.data_selection.clone(), + ) + ); +} + +#[test] +fn stale_trace_composer_source_fails_atomically_and_keeps_the_draft() { + 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(); + app.session.ui.trace_composer.as_mut().unwrap().items[0] + .series + .source + .field = FieldId::new(u64::MAX); + let before = ( + app.doc.canvases.len(), + app.doc.dirty, + app.doc.edit_generation, + app.doc.project_revision.clone(), + app.session.undo_stack.len(), + app.session.ui.data_selection.clone(), + ); + app.create_trace_composer_stack(); + assert!(app.session.ui.trace_composer.is_some()); + assert!(app.session.status.contains("no longer available")); + assert_eq!( + before, + ( + app.doc.canvases.len(), + app.doc.dirty, + app.doc.edit_generation, + app.doc.project_revision.clone(), + app.session.undo_stack.len(), + app.session.ui.data_selection.clone(), + ) + ); +} + +#[test] +fn trace_composer_rejects_incompatible_field_units() { + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(multichannel_recording(["pA", "pA"], 0, "current.abf")); + app.doc + .datasets + .push(multichannel_recording(["mV", "mV"], 0, "voltage.abf")); + app.focus_datasets(&[0, 1], None); + app.stack_selected_data(); + + assert!(app.session.ui.trace_composer.is_none()); + assert!(app.doc.canvases.is_empty()); + assert!(app.stackable_selection().is_none()); + assert_eq!(app.session.ui.data_selection, vec![0, 1]); +} + +#[test] +fn trace_contract_uses_capabilities_concrete_encoding_and_units_not_domain_policy() { + let electrophysiology = recording("pA", Some("mV")); + let electrophysiology_field = electrophysiology.active_trace_collection_field().unwrap(); + let electrophysiology_binding = + SeriesBinding::from_field_all(&electrophysiology, electrophysiology_field) + .into_iter() + .next() + .unwrap(); + let mut electrophysiology_descriptor = electrophysiology + .field_descriptor(electrophysiology_field) + .unwrap(); + electrophysiology_descriptor.metadata = FieldMetadata::default(); + + let pseudo = Dataset::Nmr2D(Box::new(Nmr2DDataset::load(pseudo_data()))); + let pseudo_field = pseudo.active_trace_collection_field().unwrap(); + let pseudo_binding = SeriesBinding::from_field_all(&pseudo, pseudo_field) + .into_iter() + .next() + .unwrap(); + let mut pseudo_descriptor = pseudo.field_descriptor(pseudo_field).unwrap(); + pseudo_descriptor.units = electrophysiology_descriptor.units.clone(); + pseudo_descriptor + .metadata + .0 + .insert("recommended_encoding".into(), "contour".into()); + + let mut units = None; + assert!(super::trace_composer::trace_field_contract_matches( + &electrophysiology_descriptor, + &electrophysiology_binding.encoding, + &mut units, + )); + assert!(super::trace_composer::trace_field_contract_matches( + &pseudo_descriptor, + &pseudo_binding.encoding, + &mut units, + )); + + let mut app = PlotxApp::new(); + app.doc.datasets.push(electrophysiology); + app.doc.datasets.push(pseudo); + let binding = DataBinding { + series: vec![electrophysiology_binding, pseudo_binding], + }; + assert!( + app.series_stackable(&binding), + "item-addressed line applicability must not use enclosing domains" + ); +} + +#[test] +fn trace_stack_forces_offset_even_when_the_primary_domain_is_field_stacked() { + let mut true_2d = pseudo_data(); + true_2d.pseudo_axis = None; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr2D(Box::new(Nmr2DDataset::load(true_2d)))); + app.doc.datasets.push(recording("pA", Some("mV"))); + assert_eq!( + app.doc.datasets[0].domain().stack_kind(), + Some(StackKind::Field) + ); + let field = app.doc.datasets[1].active_trace_collection_field().unwrap(); + let descriptor = app.doc.datasets[1].field_descriptor(field).unwrap(); + assert!(descriptor.capabilities.supports(&[ + crate::automation::CAP_FIELD_TRACE_COLLECTION, + crate::automation::CAP_FIELD_CURVE_1D, + ])); + let series = SeriesBinding::from_field_all(&app.doc.datasets[1], field); + assert!( + series + .iter() + .all(|series| matches!(series.encoding, plotx_figure::SeriesEncoding::Line(_))) + ); + + assert!(app.insert_stack_canvas(&[0, 1], series, true)); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + assert_eq!(plot.stack.mode, StackMode::Offset); + assert_eq!(plot.figure().series.len(), 2); + assert!( + plot.figure().series[1].points[0][1] > 3.0, + "the second raw trace starts at 3.0 and must receive a vertical offset" + ); +} + #[test] fn fixed_prepulse_is_skipped_for_the_varying_abf_test_pulse() { let levels = [-20.0, 0.0, 20.0]; diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 9fba86f..82a85e0 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -12,6 +12,9 @@ pub use xps::{PropertyTextEditState, XpsWorkbenchTab}; mod task_dock; pub use task_dock::TaskDockTab; +mod trace_composer; +pub use trace_composer::{TraceComposerItem, TraceComposerState}; + /// Which sidebar entry an in-progress inline rename targets. #[derive(Clone, Copy, PartialEq, Eq)] pub enum RenameTarget { @@ -288,6 +291,7 @@ pub struct UiState { pub processing_template_dialog: Option, pub spectrum_arithmetic_dialog: Option, pub align_spectra_dialog: Option, + pub trace_composer: Option, pub selection: Selection, pub selection_scope: SelectionScope, pub selection_anchors: SelectionAnchors, @@ -492,6 +496,7 @@ impl Default for UiState { processing_template_dialog: None, spectrum_arithmetic_dialog: None, align_spectra_dialog: None, + trace_composer: None, selection: Selection::None, selection_scope: SelectionScope::default(), selection_anchors: SelectionAnchors::default(), diff --git a/crates/core/src/state/ui_state/trace_composer.rs b/crates/core/src/state/ui_state/trace_composer.rs new file mode 100644 index 0000000..619202d --- /dev/null +++ b/crates/core/src/state/ui_state/trace_composer.rs @@ -0,0 +1,120 @@ +use super::SeriesBinding; + +/// One transient row in the trace composer. The source carries stable typed +/// identities; the remaining text is a presentation snapshot for searching +/// and does not participate in plot creation. +#[derive(Clone, Debug, PartialEq)] +pub struct TraceComposerItem { + pub series: SeriesBinding, + pub dataset_name: String, + pub label: String, + pub parameters: Vec<(String, String)>, + pub selected: bool, + search_blob: String, +} + +impl TraceComposerItem { + pub fn new( + series: SeriesBinding, + dataset_name: String, + label: String, + parameters: Vec<(String, String)>, + ) -> Self { + let mut search_blob = format!("{dataset_name}\n{label}").to_lowercase(); + for (name, value) in ¶meters { + search_blob.push('\n'); + search_blob.push_str(&name.to_lowercase()); + search_blob.push('\n'); + search_blob.push_str(&value.to_lowercase()); + } + Self { + series, + dataset_name, + label, + parameters, + selected: true, + search_blob, + } + } + + pub fn matches_normalized_query(&self, query: &str) -> bool { + query.is_empty() || self.search_blob.contains(query) + } +} + +/// Session-only selection draft used to compose a plot from trace collection +/// items. It is intentionally absent from the project DTOs. +#[derive(Clone, Debug, PartialEq)] +pub struct TraceComposerState { + pub items: Vec, + pub query: String, +} + +impl TraceComposerState { + pub fn normalized_query(&self) -> String { + self.query.trim().to_lowercase() + } + + pub fn selected_count(&self) -> usize { + self.items.iter().filter(|item| item.selected).count() + } + + pub fn visible_count(&self, normalized_query: &str) -> usize { + self.items + .iter() + .filter(|item| item.matches_normalized_query(normalized_query)) + .count() + } + + pub fn set_all(&mut self, selected: bool) { + for item in &mut self.items { + item.selected = selected; + } + } + + pub fn set_filtered(&mut self, normalized_query: &str, selected: bool) { + for item in &mut self.items { + if item.matches_normalized_query(normalized_query) { + item.selected = selected; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{FieldId, SeriesSource}; + + fn item(dataset_name: &str, label: &str, value: &str) -> TraceComposerItem { + TraceComposerItem::new( + SeriesBinding::with_source(SeriesSource { + resource: crate::state::DatasetId::new(), + field: FieldId::new(0), + item: Some(plotx_data::TraceItemId::new()), + }), + dataset_name.into(), + label.into(), + vec![("Level".into(), value.into())], + ) + } + + #[test] + fn bulk_selection_and_filtering_are_unambiguous() { + let mut state = TraceComposerState { + items: vec![ + item("Before", "-90 mV", "-90 mV"), + item("After", "-70 mV", "-70 mV"), + ], + query: "-70".into(), + }; + let query = state.normalized_query(); + assert_eq!(state.visible_count(&query), 1); + state.set_filtered(&query, false); + assert_eq!(state.selected_count(), 1); + state.set_all(false); + assert_eq!(state.selected_count(), 0); + state.set_all(true); + assert_eq!(state.selected_count(), 2); + } +} diff --git a/crates/core/src/workflow/trace_collection.rs b/crates/core/src/workflow/trace_collection.rs index c9f0681..96593ea 100644 --- a/crates/core/src/workflow/trace_collection.rs +++ b/crates/core/src/workflow/trace_collection.rs @@ -7,12 +7,9 @@ pub(super) fn initial_figure( 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 field = dataset + .active_trace_collection_field() + .or_else(|| dataset.default_field_id()); let Some(field) = field else { return fallback; }; diff --git a/docs/src/content/docs/guides/electrophysiology.md b/docs/src/content/docs/guides/electrophysiology.md index 7b32d87..18922f4 100644 --- a/docs/src/content/docs/guides/electrophysiology.md +++ b/docs/src/content/docs/guides/electrophysiology.md @@ -16,9 +16,18 @@ 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 +you want to compare. + +To compare recordings, select two or more compatible recordings in the Data +browser and choose **Stack selected data**. The trace composer lists every +sweep from each recording's selected channel and starts with all of them +included. Search by dataset, sweep label, or parameter value, then use the row +checkboxes or **Select all**, **Clear all**, **Select filtered**, and **Clear +filtered** to choose the comparison. **Create stack** adds one plot containing +the selected sweeps; **Cancel** leaves the project unchanged. + +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 diff --git a/docs/src/content/docs/guides/pseudo-2d.md b/docs/src/content/docs/guides/pseudo-2d.md index 81e6098..f5a1dbd 100644 --- a/docs/src/content/docs/guides/pseudo-2d.md +++ b/docs/src/content/docs/guides/pseudo-2d.md @@ -18,6 +18,15 @@ exact values you want to compare. To identify them on the plot, set increment by its gradient strength, relaxation delay, or imported pseudo-axis value and display unit. +To compare increments from multiple compatible datasets, select the datasets +in the Data browser and choose **Stack selected data**. The trace composer +starts with every increment included. Search by dataset, increment label, or +parameter value and adjust the row checkboxes, or use the global and filtered +selection controls. **Create stack** creates one plot from the chosen +increments; **Cancel** leaves the project unchanged. This remains available +while a dataset displays a DOSY map because the composer selects stable stack +increments rather than copying data from the displayed map. + ## 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 9825d67..c3c992e 100644 --- a/docs/src/content/docs/zh-cn/guides/electrophysiology.md +++ b/docs/src/content/docs/zh-cn/guides/electrophysiology.md @@ -13,9 +13,16 @@ float32、单/多记录通道、定长或变长 sweep、ADC 缩放、通道名 **Data** 中逐条显示或隐藏 sweep。通过某个绘图系列的 **Choose trace…**,可将其 替换为同一 recording 中的另一种刺激;**Add series…** 会一次加入所选兼容 recording 的全部 sweep。可用 **Show all**、**Hide all**、每行的复选框或删除按钮, -把 stack 缩减到需要比较的电压或电流。**Stack selected data** 同样会从每个所选 -recording 的全部 sweep 开始。这些操作只改变图形。在 Dataset tools 的 -**Patch clamp** 中选择记录通道,以及参与区域测量、时间窗统计、IV 表和 +把 stack 缩减到需要比较的电压或电流。 + +若要比较多个 recording,请在 Data 浏览器中选择两个或更多兼容的 recording, +然后选择 **Stack selected data**。trace composer 会列出每个 recording 当前所选 +通道中的全部 sweep,并默认全部纳入。可按数据集、sweep 标签或参数值搜索,再用 +每行的复选框或 **Select all**、**Clear all**、**Select filtered** 和 +**Clear filtered** 确定要比较的曲线。选择 **Create stack** 会生成一张包含所选 +sweep 的图;选择 **Cancel** 不会修改项目。 + +这些操作只改变图形。在 Dataset tools 的 **Patch clamp** 中选择记录通道,以及参与区域测量、时间窗统计、IV 表和 数据导出的 sweep。零相位 Gaussian 低通默认启用,截止频率为 1 kHz。 绘图和分析使用同一处理结果;原始样本不改变,设置会随项目保存。 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 778b100..be7b171 100644 --- a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md +++ b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md @@ -15,6 +15,13 @@ description: DOSY、T1、T2 弛豫分析与曲线拟合。 scales** 中将 **Visibility** 设为 **Show**。图例会使用各增量的梯度强度、 弛豫延迟或导入的伪轴值及显示单位进行标注。 +若要比较多个兼容数据集中的增量,请在 Data 浏览器中选择这些数据集,再选择 +**Stack selected data**。trace composer 默认纳入全部增量。可按数据集、增量标签 +或参数值搜索,并调整每行的复选框,也可以使用全局和筛选结果的批量选择控件。 +选择 **Create stack** 会用选定增量生成一张图;选择 **Cancel** 不会修改项目。 +即使数据集当前显示 DOSY 图,此功能仍然可用,因为 composer 选择的是稳定的 +stack 增量,而不是复制当前图中的数据。 + ## 工作流 1. 导入伪 2D 数据集。