From c26e5fd20fe9ae0283c8a36d6d2bac54f909037c Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 13 Nov 2026 22:26:57 +0800 Subject: [PATCH] feat(app): organize datasets and locate their plots --- crates/app/src/ui/canvas/image_painting.rs | 58 ++- crates/app/src/ui/command_exec.rs | 15 + crates/app/src/ui/commands.rs | 20 +- crates/app/src/ui/commands/helpers.rs | 26 ++ crates/app/src/ui/commands/identity.rs | 15 + crates/app/src/ui/commands/roster.rs | 2 + crates/app/src/ui/data_navigation.rs | 384 ++++++++++++++++++ crates/app/src/ui/data_navigation_tests.rs | 64 +++ crates/app/src/ui/dataset_editor.rs | 223 ++++++++++ crates/app/src/ui/feedback_banner.rs | 3 + crates/app/src/ui/file_dialogs.rs | 6 +- .../src/ui/file_dialogs/archive_feedback.rs | 7 +- .../ui/file_dialogs/archive_feedback_tests.rs | 19 +- .../file_dialogs/import_navigation_tests.rs | 17 +- crates/app/src/ui/import_feedback.rs | 50 +++ crates/app/src/ui/import_feedback_tests.rs | 76 +++- crates/app/src/ui/mod.rs | 4 + crates/app/src/ui/primary_sidebar.rs | 69 ++-- .../src/ui/primary_sidebar/data_browser.rs | 356 +++++----------- .../ui/primary_sidebar/data_browser_tests.rs | 278 +++++++++++++ .../src/ui/primary_sidebar/dataset_rows.rs | 118 ++++++ .../app/src/ui/primary_sidebar/selection.rs | 13 +- .../app/src/ui/primary_sidebar/thumbnails.rs | 115 +++++- crates/app/src/ui/result_follow.rs | 1 + crates/core/src/actions/app_impl/apply.rs | 6 + crates/core/src/actions/app_impl/revert.rs | 8 + crates/core/src/actions/app_impl/validate.rs | 2 +- crates/core/src/actions/mod.rs | 7 + crates/core/src/figures.rs | 2 + crates/core/src/project/dto.rs | 5 + crates/core/src/project/lineage_tests.rs | 1 + crates/core/src/project/mod.rs | 2 + crates/core/src/project/typed_table.rs | 1 + crates/core/src/state/afm.rs | 4 + crates/core/src/state/app_impl_figures.rs | 6 + crates/core/src/state/data_import.rs | 10 +- crates/core/src/state/data_import_tests.rs | 9 +- crates/core/src/state/dataset_organization.rs | 218 ++++++++++ crates/core/src/state/datasets.rs | 4 + crates/core/src/state/datasets_2d_figure.rs | 2 + crates/core/src/state/electrophysiology.rs | 2 + crates/core/src/state/log_axes_tests.rs | 1 + crates/core/src/state/mass_spec.rs | 2 + crates/core/src/state/mod.rs | 2 + crates/core/src/state/plot_object.rs | 1 + crates/core/src/state/stack.rs | 1 + crates/core/src/state/table.rs | 2 + crates/core/src/state/ui_state.rs | 4 + crates/core/src/state/xas.rs | 2 + crates/core/src/state/xps.rs | 2 + crates/core/src/state/xrd.rs | 2 + crates/figure/src/lib.rs | 2 + crates/render/src/screen_tests.rs | 1 + .../src/content/docs/guides/importing-data.md | 6 + .../content/docs/guides/organizing-data.md | 30 ++ .../docs/zh-cn/guides/importing-data.md | 5 + .../docs/zh-cn/guides/organizing-data.md | 23 ++ 57 files changed, 1938 insertions(+), 376 deletions(-) create mode 100644 crates/app/src/ui/data_navigation.rs create mode 100644 crates/app/src/ui/data_navigation_tests.rs create mode 100644 crates/app/src/ui/dataset_editor.rs create mode 100644 crates/app/src/ui/primary_sidebar/data_browser_tests.rs create mode 100644 crates/app/src/ui/primary_sidebar/dataset_rows.rs create mode 100644 crates/core/src/state/dataset_organization.rs diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs index 73d60f22..af478c06 100644 --- a/crates/app/src/ui/canvas/image_painting.rs +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -79,19 +79,45 @@ pub(super) fn paint_document( ) { let canvas = &app.doc.canvases[ci]; let mut document = canvas_document(app, ci); + let highlight = crate::ui::data_navigation::highlight(app, painter.ctx(), canvas.resource_id); + let mut outline = None; + let accent = plotium_figure::Color::rgb(221, 82, 16); let previews: Vec<_> = canvas .objects .iter() .map(|object| { let plot = object.plot()?; - let preview = super::super::nmr_phase_preview::contours( - app, - painter.ctx(), - object.dataset()?, - object.id, - )?; + let preview = object.dataset().and_then(|dataset| { + super::super::nmr_phase_preview::contours(app, painter.ctx(), dataset, object.id) + }); + let target = highlight.filter(|target| target.plot == object.id); + if preview.is_none() && target.is_none() { + return None; + } let mut figure = plot.figure().clone(); - figure.contours = preview.contours.clone(); + if let Some(preview) = preview { + figure.contours = preview.contours.clone(); + } + if let Some(target) = target { + let origin = Some(plotium_figure::SeriesOrigin(target.series.get())); + let mut accented = false; + for series in figure.series.iter_mut().filter(|s| s.origin == origin) { + accented = true; + series.color = accent; + series.width = (series.width * 2.0).max(2.0); + series.appearance.marker_fill = Some(accent); + } + for contour in figure.contours.iter_mut().filter(|s| s.origin == origin) { + accented = true; + contour.color = accent; + contour.width = (contour.width * 2.0).max(2.0); + } + if !accented { + outline = canvas + .content_page_frame(object.id) + .map(|frame| frame.rect()); + } + } Some(figure) }) .collect(); @@ -117,6 +143,24 @@ pub(super) fn paint_document( }, detail, ); + if let Some(frame) = outline { + let transform = BoardTransform::from_board(app.session.board, rect); + let screen = transform.board_rect_screen(PlotRect::new( + bp[0] + frame.left, + bp[1] + frame.top, + frame.width, + frame.height, + )); + painter.rect_stroke( + screen, + 0.0, + egui::Stroke::new( + 4.0_f32, + egui::Color32::from_rgb(accent.r, accent.g, accent.b), + ), + egui::StrokeKind::Inside, + ); + } } fn loading_image_placeholder( diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 3bd4035b..b3c089fa 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -95,6 +95,21 @@ fn execute_inner( } CommandId::ImportTable => super::file_dialogs::import_delimited_table(app), CommandId::ImportResults => super::import_feedback::open_results(app, ctx, None), + CommandId::EditDatasetMetadata(dataset) => super::dataset_editor::open(app, ctx, dataset), + CommandId::LocateDatasetPlot(dataset) => { + if let Some(dataset) = dataset.or_else(|| { + app.active_dataset() + .and_then(|i| app.doc.datasets.get(i)) + .map(|d| d.resource_id()) + }) { + app.session.primary_sidebar_visible = true; + app.session.view = plotium_core::state::PrimaryView::Data; + super::data_navigation::locate_dataset(app, ctx, dataset); + } + } + CommandId::GoToDatasetPlot(target) => super::data_navigation::navigate(app, ctx, target), + CommandId::RevealDatasetPlot(target) => super::data_navigation::reveal(app, ctx, target), + CommandId::ViewImportBatch(batch) => super::import_feedback::view_batch(app, ctx, batch), CommandId::RetryImportBatch(batch) => { if let Some(retry) = app.retry_failed_import(batch) { super::import_feedback::open_results(app, ctx, Some(retry)); diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index cc407cb2..5e620d1f 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -51,6 +51,11 @@ pub enum CommandId { HelpManual, ImportTable, ImportResults, + EditDatasetMetadata(Option), + LocateDatasetPlot(Option), + GoToDatasetPlot(super::data_navigation::Destination), + RevealDatasetPlot(super::data_navigation::Destination), + ViewImportBatch(plotium_core::state::ImportBatchId), RetryImportBatch(plotium_core::state::ImportBatchId), RecomputeScientificTask(plotium_core::state::ActivityId), ImportImage, @@ -198,13 +203,12 @@ pub struct CommandDescriptor { pub fn catalog(app: &PlotiumApp) -> Vec { command_ids(app.session.recent_files.len()) .into_iter() - .chain( - app.session - .data_imports - .batches() - .iter() - .map(|batch| CommandId::RetryImportBatch(batch.id)), - ) + .chain(app.session.data_imports.batches().iter().flat_map(|batch| { + [ + CommandId::RetryImportBatch(batch.id), + CommandId::ViewImportBatch(batch.id), + ] + })) .chain(super::scientific_task_feedback::commands(app)) .map(|id| { debug_assert!(!id.stable_id().is_empty()); @@ -286,6 +290,8 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { !app.session.data_imports.batches().is_empty(), "Import data before viewing import results.", ), + CommandId::EditDatasetMetadata(_) | CommandId::LocateDatasetPlot(_) | CommandId::GoToDatasetPlot(_) + | CommandId::RevealDatasetPlot(_) | CommandId::ViewImportBatch(_) => helpers::data_navigation_gate(app, id), CommandId::RetryImportBatch(batch) => requires( app.session.data_imports.batches().iter().any(|item| { item.id == batch diff --git a/crates/app/src/ui/commands/helpers.rs b/crates/app/src/ui/commands/helpers.rs index f4d5b3cd..2e0964a7 100644 --- a/crates/app/src/ui/commands/helpers.rs +++ b/crates/app/src/ui/commands/helpers.rs @@ -166,3 +166,29 @@ pub(super) fn palette_available(app: &PlotiumApp, id: super::CommandId) -> bool && app.session.ui.trace_composer.is_none() && !app.session.ui.interaction.is_active()) } + +pub(super) fn data_navigation_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'static str> { + match id { + CommandId::EditDatasetMetadata(Some(dataset)) => requires( + app.doc.dataset_index(dataset).is_some(), + "Choose an available dataset.", + ), + CommandId::EditDatasetMetadata(None) | CommandId::LocateDatasetPlot(None) => requires( + app.active_dataset().is_some(), + "Select data in the Data List first.", + ), + CommandId::LocateDatasetPlot(Some(_)) => Ok(()), + CommandId::GoToDatasetPlot(target) => requires( + target.hidden(app).is_some(), + "Choose an available plot series.", + ), + CommandId::RevealDatasetPlot(target) => requires( + target.hidden(app) == Some(true), + "Choose a hidden plot series to reveal.", + ), + CommandId::ViewImportBatch(batch) => { + crate::ui::import_feedback::view_batch_gate(app, batch) + } + _ => unreachable!("not a data navigation command"), + } +} diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 2968a951..00a5d783 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -16,6 +16,11 @@ impl CommandId { pub fn stable_id(self) -> String { match self { Self::OpenRecent(i) => format!("file.open_recent.{i}"), + Self::EditDatasetMetadata(_) => "data.edit_metadata".into(), + Self::LocateDatasetPlot(_) => "data.locate_plot".into(), + Self::GoToDatasetPlot(_) => "data.go_to_plot".into(), + Self::RevealDatasetPlot(_) => "data.reveal_plot".into(), + Self::ViewImportBatch(id) => format!("file.view_import.{id}"), Self::RetryImportBatch(id) => format!("file.retry_import.{id}"), Self::RecomputeScientificTask(id) => format!("analysis.recompute_task.{}", id.0), Self::NewCanvas(i) => format!("file.new_canvas.{i}"), @@ -81,6 +86,16 @@ pub(super) fn command_identity( CommandId::HelpManual => plain("User Manual", Some(icon::BOOK_OPEN)), CommandId::ImportTable => plain("Import Table…", Some(icon::FILE_CSV)), CommandId::ImportResults => plain("Import Results", Some(icon::LIST_CHECKS)), + CommandId::EditDatasetMetadata(_) => plain("Edit experiment and tags…", None), + CommandId::LocateDatasetPlot(_) => plain("Locate plot", None), + CommandId::GoToDatasetPlot(target) => plain( + &target + .label(app) + .unwrap_or_else(|| "Plot unavailable".into()), + None, + ), + CommandId::RevealDatasetPlot(_) => plain("Reveal", None), + CommandId::ViewImportBatch(id) => plain(&format!("View this import — Batch {id}"), None), CommandId::RetryImportBatch(id) => plain( &format!("Retry Failed Imports — Batch {id}"), Some(icon::ARROWS_CLOCKWISE), diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index 4b3235fb..42092edf 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -44,6 +44,8 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::ImportTiffPages, CommandId::PasteImage, CommandId::CancelDataImport, + CommandId::EditDatasetMetadata(None), + CommandId::LocateDatasetPlot(None), CommandId::CancelImageImport, CommandId::ReplaceImage, CommandId::PasteTable, diff --git a/crates/app/src/ui/data_navigation.rs b/crates/app/src/ui/data_navigation.rs new file mode 100644 index 00000000..254b1eb9 --- /dev/null +++ b/crates/app/src/ui/data_navigation.rs @@ -0,0 +1,384 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + sync::Arc, +}; + +use plotium_core::{ + actions::Action, + state::{ + BoardFitItem, BoardFitTarget, BoardFrameId, CanvasId, Dataset, DatasetId, ObjectId, + PlotiumApp, SeriesId, + }, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Destination { + pub canvas: CanvasId, + pub plot: ObjectId, + pub series: SeriesId, +} + +impl Destination { + fn resolve( + self, + app: &PlotiumApp, + ) -> Option<( + usize, + &plotium_core::state::CanvasObject, + &plotium_core::state::SeriesBinding, + )> { + let ci = app.doc.canvas_index(self.canvas)?; + let object = app.doc.canvases[ci].object(self.plot)?; + let series = object + .plot()? + .binding + .series + .iter() + .find(|series| series.id == self.series)?; + Some((ci, object, series)) + } + + pub(super) fn hidden(self, app: &PlotiumApp) -> Option { + let (ci, object, series) = self.resolve(app)?; + let canvas = &app.doc.canvases[ci]; + Some( + !series.visible + || !object.visible + || canvas + .parent_panel(self.plot) + .and_then(|id| canvas.panel(id)) + .is_some_and(|p| !p.visible), + ) + } + + pub(super) fn label(self, app: &PlotiumApp) -> Option { + let (ci, object, series) = self.resolve(app)?; + let canvas = &app.doc.canvases[ci]; + let panel = canvas + .parent_panel(self.plot) + .and_then(|id| canvas.panel(id)); + let title = if object.name.is_empty() { + "Plot" + } else { + &object.name + }; + Some(format!( + "Canvas {}: {} / {} / {} #{} / {} #{}{}", + ci + 1, + canvas.name, + panel.map_or("Unpanelled", |p| &p.name), + title, + self.plot.get(), + app.series_label(series), + self.series.get(), + if self.hidden(app)? { " (hidden)" } else { "" } + )) + } +} + +#[derive(Default)] +pub(super) struct PlotIndex { + occurrences: HashMap>, + pub summaries: HashMap, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct CanvasComposition { + pub compact: String, + pub details: String, + pub datasets: Vec, +} + +impl PlotIndex { + fn build(app: &PlotiumApp) -> Self { + let mut result = Self::default(); + let datasets: HashMap<_, _> = app + .doc + .datasets + .iter() + .map(|d| (d.resource_id(), d)) + .collect(); + for canvas in &app.doc.canvases { + let mut seen = BTreeSet::new(); + for object in &canvas.objects { + let Some(plot) = object.plot() else { continue }; + for series in &plot.binding.series { + seen.insert(series.source.resource); + result + .occurrences + .entry(series.source.resource) + .or_default() + .push(Destination { + canvas: canvas.resource_id, + plot: object.id, + series: series.id, + }); + } + } + result.summaries.insert( + canvas.resource_id, + composition(seen.iter().filter_map(|id| datasets.get(id).copied())), + ); + } + result + } + + pub fn occurrences(&self, dataset: DatasetId) -> &[Destination] { + self.occurrences.get(&dataset).map_or(&[], Vec::as_slice) + } +} + +pub(super) fn index(app: &PlotiumApp, ctx: &egui::Context) -> Arc { + let key = egui::Id::new(( + "dataset_plot_index", + app.session.project_context.to_string(), + )); + let revision = app.doc.edit_generation; + ctx.data_mut(|data| { + if let Some((old, index)) = data.get_temp::<(u64, Arc)>(key) + && old == revision + { + return index; + } + let index = Arc::new(PlotIndex::build(app)); + data.insert_temp(key, (revision, index.clone())); + index + }) +} + +fn composition<'a>(datasets: impl Iterator) -> CanvasComposition { + let mut types = BTreeMap::<&str, BTreeMap>::new(); + let mut details = Vec::new(); + let mut ids = Vec::new(); + for dataset in datasets { + ids.push(dataset.resource_id()); + let category = dataset.compact_category(); + let experiment = dataset.experiment_label(); + let experiments = types.entry(category).or_default(); + if let Some(label) = experiment { + experiments.entry(label.to_lowercase()).or_insert(label); + } + details.push(format!( + "{} — {}{}", + dataset.browser_name(), + category, + experiment.map_or_else(String::new, |name| format!(" · {name}")) + )); + } + details.sort_unstable(); + let count = details.len(); + let mut compact = match types.len() { + 0 => "No plotted data".to_owned(), + 1 => types + .first_key_value() + .map_or_else(String::new, |(category, labels)| { + if (1..=2).contains(&labels.len()) { + format!( + "{category} · {}", + labels.values().copied().collect::>().join(" / ") + ) + } else { + (*category).to_owned() + } + }), + 2 => types.keys().copied().collect::>().join(" + "), + n => format!("{n} data types"), + }; + if count > 1 { + compact.push_str(&format!(" · {count} datasets")); + } + CanvasComposition { + compact, + details: details.join("\n"), + datasets: ids, + } +} + +fn key(app: &PlotiumApp, name: &str) -> egui::Id { + egui::Id::new((name, app.session.project_context.to_string())) +} + +pub(super) fn locate_dataset(app: &mut PlotiumApp, ctx: &egui::Context, dataset: DatasetId) { + let index = index(app, ctx); + let chooser = key(app, "plot_destinations"); + ctx.data_mut(|data| data.remove::(chooser)); + match index.occurrences(dataset) { + [] => app.session.status = "Not plotted.".into(), + [one] if one.hidden(app) == Some(false) => navigate(app, ctx, *one), + [one] => { + ctx.data_mut(|data| data.insert_temp(chooser, dataset)); + navigate(app, ctx, *one); + } + _ => { + ctx.data_mut(|data| data.insert_temp(chooser, dataset)); + } + } +} + +pub(super) fn destinations_window(app: &mut PlotiumApp, ctx: &egui::Context) { + use super::commands::{self, CommandId}; + + let id = key(app, "plot_destinations"); + let Some(dataset) = ctx.data(|data| data.get_temp::(id)) else { + return; + }; + let Some(di) = app.doc.dataset_index(dataset) else { + ctx.data_mut(|data| data.remove::(id)); + return; + }; + let index = index(app, ctx); + let mut open = true; + let mut chosen = None; + egui::Window::new("Plot destinations") + .id(id) + .open(&mut open) + .default_width(420.0) + .show(ctx, |ui| { + ui.label(app.doc.datasets[di].browser_name()); + let occurrences = index.occurrences(dataset); + if occurrences.is_empty() { + ui.weak("No plots remain. Close this window and choose another dataset."); + } + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + for &target in occurrences { + ui.horizontal_wrapped(|ui| { + for command in [ + Some(CommandId::GoToDatasetPlot(target)), + (target.hidden(app) == Some(true)) + .then_some(CommandId::RevealDatasetPlot(target)), + ] + .into_iter() + .flatten() + { + let descriptor = commands::describe(app, command); + if ui + .add_enabled( + descriptor.enabled, + egui::Button::new(descriptor.label).wrap(), + ) + .on_disabled_hover_text( + descriptor.disabled_reason.unwrap_or_default(), + ) + .clicked() + { + chosen = Some(command); + } + } + }); + } + }); + }); + if let Some(command) = chosen { + commands::execute_without_clipboard(command, app, ctx); + // Keep Reveal available after locating a hidden series. + open = matches!(command, CommandId::GoToDatasetPlot(target) if target.hidden(app) == Some(true)); + } + if !open { + ctx.data_mut(|data| data.remove::(id)); + } +} + +pub(super) fn navigate(app: &mut PlotiumApp, ctx: &egui::Context, target: Destination) { + let Some((ci, _, _)) = target.resolve(app) else { + return; + }; + let item = BoardFitItem::Content { + canvas: target.canvas, + content: target.plot, + }; + let page = &app.doc.canvases[ci]; + let fit = if plotium_core::state::board_fit_item_rect(app, item).is_some() { + BoardFitTarget::Items(vec![item]) + } else if let Some(frame) = page.content_page_frame(target.plot) { + let rect = frame.rect(); + let [x, y] = page.board_pos; + BoardFitTarget::Region([ + x + rect.left, + y + rect.top, + x + rect.right(), + y + rect.bottom(), + ]) + } else { + BoardFitTarget::Frame(BoardFrameId::Page(target.canvas)) + }; + app.session.active_canvas = Some(ci); + app.select_object(ci, target.plot); + app.session.ui.frame_selection = vec![BoardFrameId::Page(target.canvas)]; + super::canvas::request_board_fit_target(app, ctx, fit); + let until = ctx.input(|input| input.time) + 2.5; + ctx.data_mut(|data| data.insert_temp(key(app, "located_series"), (target, until))); + app.session.status = if target.hidden(app) == Some(true) { + "Located a hidden series. Use Reveal to show it." + } else { + "Located the plot series." + } + .into(); +} + +pub(super) fn reveal(app: &mut PlotiumApp, ctx: &egui::Context, target: Destination) { + let Some((ci, object, _)) = target.resolve(app) else { + return; + }; + let canvas = &app.doc.canvases[ci]; + let mut actions = Vec::new(); + if let Some(panel) = canvas.parent_panel(target.plot) + && canvas.panel(panel).is_some_and(|p| !p.visible) + { + let mut after = plotium_core::actions::PanelState::of(canvas); + if let Some(p) = after.panels.iter_mut().find(|p| p.id == panel) { + p.visible = true; + } + actions.push(Action::ReplacePanelState { + canvas: ci, + before: plotium_core::actions::PanelState::of(canvas), + after, + }); + } + if !object.visible { + actions.push(Action::set_object_flags( + ci, + target.plot, + (false, object.locked), + (true, object.locked), + )); + } + if let Some(plot) = object.plot() { + let mut after = plot.binding.clone(); + if let Some(series) = after.series.iter_mut().find(|s| s.id == target.series) + && !series.visible + { + series.visible = true; + actions.push(Action::set_series_presentation( + target.canvas, + target.plot, + plot.binding.clone(), + after, + )); + } + } + if !actions.is_empty() { + app.execute_action(Action::Composite(actions)); + } + navigate(app, ctx, target); +} + +pub(super) fn highlight( + app: &PlotiumApp, + ctx: &egui::Context, + canvas: CanvasId, +) -> Option { + let (target, until) = + ctx.data(|data| data.get_temp::<(Destination, f64)>(key(app, "located_series")))?; + let now = ctx.input(|input| input.time); + if now >= until || target.canvas != canvas || target.hidden(app) != Some(false) { + return None; + } + ctx.request_repaint_after(std::time::Duration::from_secs_f64(until - now)); + Some(target) +} + +#[cfg(test)] +#[path = "data_navigation_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/data_navigation_tests.rs b/crates/app/src/ui/data_navigation_tests.rs new file mode 100644 index 00000000..042f2c69 --- /dev/null +++ b/crates/app/src/ui/data_navigation_tests.rs @@ -0,0 +1,64 @@ +use super::*; +use plotium_core::state::{PrimaryView, SeriesBinding, ViewportMode}; + +#[test] +fn secondary_series_navigation_preserves_context_and_hidden_state() { + let mut app = PlotiumApp::new_with_settings(Default::default()); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/jcamp-ppm.dx"); + app.load_from(&fixture); + app.load_from(&fixture); + let canvas = app.doc.canvases[0].resource_id; + let first = app.doc.canvases[0].objects[0].id; + let dataset = app.doc.datasets[1].resource_id(); + let mut series = SeriesBinding::from_dataset(&app.doc.datasets[1]).unwrap(); + series.id = SeriesId::new(1); + series.visible = false; + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.next_series_id = SeriesId::new(2); + plot.binding.series.push(series); + let panel_id = app.doc.canvases[0].parent_panel(first).unwrap(); + let panel = app.doc.canvases[0].panel_mut(panel_id).unwrap(); + panel.name = "Overlay panel".into(); + panel.visible = false; + let second = PlotIndex::build(&app).occurrences(dataset)[1]; + let index = PlotIndex::build(&app); + let target = index.occurrences(dataset)[0]; + assert_eq!( + target, + Destination { + canvas, + plot: first, + series: SeriesId::new(1) + } + ); + assert_ne!(target.label(&app), second.label(&app)); + assert!(target.label(&app).unwrap().contains("Overlay panel")); + assert!(target.label(&app).unwrap().contains("hidden")); + let summary = &index.summaries[&canvas]; + assert!(summary.compact.ends_with("2 datasets")); + app.session.view = PrimaryView::Data; + app.session.ui.data_browser_filter = "sample search".into(); + let selection = app.session.ui.data_selection.clone(); + let generation = app.doc.edit_generation; + let ctx = crate::typography::test_context(); + navigate(&mut app, &ctx, target); + assert_eq!(target.hidden(&app), Some(true)); + assert!(matches!( + app.session.viewport_mode, + ViewportMode::Fit(BoardFitTarget::Region(_)) + )); + assert_eq!(app.session.active_canvas, Some(0)); + assert_eq!(app.session.ui.selection.object(), Some(first)); + assert_eq!(app.session.ui.data_selection, selection); + assert!(app.session.view == PrimaryView::Data); + assert_eq!(app.session.ui.data_browser_filter, "sample search"); + assert_eq!(app.doc.edit_generation, generation); + assert_eq!(&PlotIndex::build(&app).summaries[&canvas], summary); + + reveal(&mut app, &ctx, target); + assert_eq!(target.hidden(&app), Some(false), "{}", app.session.status); + assert_eq!(highlight(&app, &ctx, canvas), Some(target)); + app.undo(); + assert_eq!(target.hidden(&app), Some(true)); +} diff --git a/crates/app/src/ui/dataset_editor.rs b/crates/app/src/ui/dataset_editor.rs new file mode 100644 index 00000000..c9a3e4de --- /dev/null +++ b/crates/app/src/ui/dataset_editor.rs @@ -0,0 +1,223 @@ +use std::collections::BTreeMap; + +use egui::{Context, Ui}; +use plotium_core::state::{DatasetId, OrganizationEdit, PlotiumApp}; + +#[derive(Clone)] +struct Editor { + targets: Vec, + experiment: String, + tag: String, +} + +fn key(app: &PlotiumApp) -> egui::Id { + egui::Id::new(("dataset_metadata", app.session.project_context.to_string())) +} + +pub(super) fn open(app: &PlotiumApp, ctx: &Context, dataset: Option) { + let targets: Vec<_> = if let Some(dataset) = dataset { + vec![dataset] + } else { + app.session + .ui + .data_selection + .iter() + .filter_map(|&index| app.doc.datasets.get(index)) + .map(|d| d.resource_id()) + .collect() + }; + let experiment = if let [id] = targets.as_slice() { + app.doc + .dataset_index(*id) + .and_then(|i| app.doc.datasets[i].experiment_label()) + .unwrap_or_default() + .to_owned() + } else { + String::new() + }; + ctx.data_mut(|data| { + data.insert_temp( + key(app), + Editor { + targets, + experiment, + tag: String::new(), + }, + ) + }); +} + +pub(super) fn window(app: &mut PlotiumApp, ctx: &Context) { + let id = key(app); + let Some(mut editor) = ctx.data(|data| data.get_temp::(id)) else { + return; + }; + editor + .targets + .retain(|&id| app.doc.dataset_index(id).is_some()); + let mut open = !editor.targets.is_empty(); + if open { + egui::Window::new("Edit experiment and tags") + .id(id) + .open(&mut open) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(360.0) + .show(ui, |ui| editor.render(app, ui)); + }); + } + ctx.data_mut(|data| { + if open { + data.insert_temp(id, editor); + } else { + data.remove::(id); + } + }); +} + +impl Editor { + fn render(&mut self, app: &mut PlotiumApp, ui: &mut Ui) { + let count = self.targets.len(); + let targets: Vec<_> = self + .targets + .iter() + .filter_map(|&id| app.doc.dataset_index(id)) + .map(|i| &app.doc.datasets[i]) + .collect(); + let Some(first) = targets.first() else { return }; + ui.heading(if count == 1 { + first.browser_name() + } else { + format!("{count} datasets") + }); + let label = first.experiment_label(); + let mixed = targets + .iter() + .any(|dataset| dataset.experiment_label() != label); + ui.label(if mixed { + "Current experiment: Mixed values".into() + } else { + format!("Current experiment: {}", label.unwrap_or("None")) + }); + let imported = (count == 1).then(|| { + first + .acquisition_identity() + .acquisition + .as_deref() + .unwrap_or_default() + .trim() + .to_owned() + }); + if let Some(imported) = &imported { + ui.weak(format!( + "Imported name: {}", + if imported.is_empty() { + "None" + } else { + imported + } + )); + } + let current = (count == 1).then(|| label.unwrap_or_default().to_owned()); + let has_override = targets + .iter() + .any(|d| d.organization().experiment_override().is_some()); + let suggest_nmr = targets.iter().any(|d| d.compact_category() == "NMR"); + let mut tags = BTreeMap::::new(); + for dataset in &targets { + for tag in dataset.organization().tags() { + tags.entry(tag.to_lowercase()) + .or_insert_with(|| (tag.clone(), 0)) + .1 += 1; + } + } + let label = ui.label("Experiment display name"); + ui.horizontal(|ui| { + let field = ui + .text_edit_singleline(&mut self.experiment) + .labelled_by(label.id); + if suggest_nmr { + egui::ComboBox::from_id_salt("experiment_suggestions") + .selected_text("Suggestions") + .show_ui(ui, |ui| { + for name in ["COSY", "HMQC", "HNCA", "HNCO", "HSQC", "NOESY", "TOCSY"] { + if ui.button(name).clicked() { + self.experiment = name.into(); + ui.close(); + } + } + }); + } + let enter = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); + let value = self.experiment.trim(); + let can_set = !value.is_empty() && current.as_deref() != Some(value); + if (ui + .add_enabled(can_set, egui::Button::new("Set name")) + .clicked() + || enter) + && can_set + { + app.edit_dataset_organization( + &self.targets, + OrganizationEdit::Experiment(Some(&self.experiment)), + ); + } + }); + if ui + .add_enabled(has_override, egui::Button::new("Use imported names")) + .clicked() + { + app.edit_dataset_organization(&self.targets, OrganizationEdit::Experiment(None)); + self.experiment = imported.unwrap_or_default(); + } + ui.separator(); + let label = ui.label("Add tag"); + let mut project_tags = BTreeMap::new(); + let query = self.tag.trim().to_lowercase(); + for dataset in &app.doc.datasets { + for tag in dataset.organization().tags() { + let key = tag.to_lowercase(); + if key.contains(&query) { + project_tags.entry(key).or_insert_with(|| tag.clone()); + } + } + } + ui.horizontal(|ui| { + let field = ui.text_edit_singleline(&mut self.tag).labelled_by(label.id); + egui::ComboBox::from_id_salt("tag_completion") + .selected_text("Project tags") + .show_ui(ui, |ui| { + for tag in project_tags.values() { + if ui.button(tag).clicked() { + self.tag.clone_from(tag); + ui.close(); + } + } + if project_tags.is_empty() { + ui.weak("No matching tags"); + } + }); + let enter = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)); + if ui + .add_enabled(!self.tag.trim().is_empty(), egui::Button::new("Add")) + .clicked() + || enter + { + app.edit_dataset_organization(&self.targets, OrganizationEdit::AddTag(&self.tag)); + self.tag.clear(); + } + }); + ui.horizontal_wrapped(|ui| { + for (tag, uses) in tags.values() { + let label = if *uses == count { + format!("Remove {tag}") + } else { + format!("Remove {tag} ({uses}/{count})") + }; + if ui.small_button(label).clicked() { + app.edit_dataset_organization(&self.targets, OrganizationEdit::RemoveTag(tag)); + } + } + }); + } +} diff --git a/crates/app/src/ui/feedback_banner.rs b/crates/app/src/ui/feedback_banner.rs index ecf491e6..13799739 100644 --- a/crates/app/src/ui/feedback_banner.rs +++ b/crates/app/src/ui/feedback_banner.rs @@ -102,6 +102,9 @@ pub(super) fn feedback_banner(app: &mut PlotiumApp, ui: &mut Ui, dark: bool) { app.session.ui.dismissed_feedback_order = completion_order; } } + if let Some(batch) = batch { + import_feedback::view_batch_button(app, ui, batch); + } if ui.button("Details").clicked() { if let Some(batch) = batch { commands::execute_without_clipboard( diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 98ecb282..f5e7d079 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -404,11 +404,7 @@ where } else { import_navigation::preserve(app, import) }; - let batch = - app.record_completed_user_import_with_operations(path, items, vec![preview.report.id]); - if empty && !single { - app.request_completed_import_open(batch); - } + app.record_completed_user_import_with_operations(path, items, vec![preview.report.id]); app.session.record_operation(preview.report); // Noting the file persists preferences, and a failed write reports itself // through the status line. Recording the import first keeps that diff --git a/crates/app/src/ui/file_dialogs/archive_feedback.rs b/crates/app/src/ui/file_dialogs/archive_feedback.rs index 8938b3f5..e896013a 100644 --- a/crates/app/src/ui/file_dialogs/archive_feedback.rs +++ b/crates/app/src/ui/file_dialogs/archive_feedback.rs @@ -19,7 +19,6 @@ fn load_with( ) { app.cancel_import_open_intent(); let before = app.doc.datasets.len(); - let empty = before == 0 && app.doc.canvases.is_empty(); let previous_order = app .session .operation_history @@ -80,11 +79,7 @@ fn load_with( } } let loaded = items.iter().any(|item| item.result.is_ok()); - let batch = - app.record_completed_user_import_with_operations(path.to_owned(), items, operation_ids); - if empty { - app.request_completed_import_open(batch); - } + app.record_completed_user_import_with_operations(path.to_owned(), items, operation_ids); if loaded { note_recent(app, path); } diff --git a/crates/app/src/ui/file_dialogs/archive_feedback_tests.rs b/crates/app/src/ui/file_dialogs/archive_feedback_tests.rs index 794dcc11..d4f48588 100644 --- a/crates/app/src/ui/file_dialogs/archive_feedback_tests.rs +++ b/crates/app/src/ui/file_dialogs/archive_feedback_tests.rs @@ -1,5 +1,5 @@ use super::*; -use plotium_core::state::{ImportOpenTarget, PrimaryView}; +use plotium_core::state::PrimaryView; fn load_fixture(app: &mut PlotiumApp, count: usize) { let fixture = @@ -42,7 +42,7 @@ fn archive_batch_keeps_existing_workspace_and_records_each_native_result() { } #[test] -fn first_archive_opens_single_result_or_combined_workspace() { +fn first_archive_keeps_navigation_and_records_results() { for count in [1, 2] { let mut app = PlotiumApp::new_with_settings(Default::default()); load_with( @@ -52,17 +52,10 @@ fn first_archive_opens_single_result_or_combined_workspace() { |_, _| {}, ); assert!(app.session.active_canvas.is_none()); - match app.take_import_open_target().unwrap() { - ImportOpenTarget::Dataset(target) => { - assert_eq!(count, 1); - assert_eq!(target.dataset, app.doc.datasets[0].resource_id()); - } - ImportOpenTarget::Workspace(targets) => { - assert_eq!(count, 2); - assert_eq!(targets.len(), count); - } - ImportOpenTarget::Batch(_) => panic!("imports do not open results dialogs"), - } + assert!(app.take_import_open_target().is_none()); + assert!(app.session.ui.data_selection.is_empty()); + let batch = app.session.data_imports.batches().back().unwrap(); + assert_eq!(batch.loaded(), count); } } diff --git a/crates/app/src/ui/file_dialogs/import_navigation_tests.rs b/crates/app/src/ui/file_dialogs/import_navigation_tests.rs index c18e81ee..10198d75 100644 --- a/crates/app/src/ui/file_dialogs/import_navigation_tests.rs +++ b/crates/app/src/ui/file_dialogs/import_navigation_tests.rs @@ -1,7 +1,5 @@ use super::*; -use plotium_core::state::{ - BoardFitTarget, BoardFrameId, ImportOpenTarget, PrimaryView, ViewportMode, -}; +use plotium_core::state::{BoardFitTarget, BoardFrameId, PrimaryView, ViewportMode}; fn preview(app: &mut PlotiumApp, count: usize) { let mut candidates = Vec::new(); @@ -87,7 +85,7 @@ fn appending_tables_preserves_navigation_drafts_and_original_undo_selection() { } #[test] -fn first_multiple_tables_request_one_workspace_without_selecting_last_table() { +fn first_multiple_tables_keep_navigation_and_record_the_batch() { let mut app = PlotiumApp::new_with_settings(Default::default()); preview(&mut app, 2); commit(&mut app); @@ -95,11 +93,10 @@ fn first_multiple_tables_request_one_workspace_without_selecting_last_table() { assert!(app.session.active_canvas.is_none()); assert!(app.session.ui.sheet_open.is_none()); assert!(app.session.ui.data_selection.is_empty()); - let Some(ImportOpenTarget::Workspace(targets)) = app.take_import_open_target() else { - panic!("multi-table import should open its workspace"); - }; - assert_eq!(targets.len(), 2); - for (target, dataset) in targets.iter().zip(&app.doc.datasets) { - assert_eq!(target.dataset, dataset.resource_id()); + assert!(app.take_import_open_target().is_none()); + let batch = app.session.data_imports.batches().back().unwrap(); + assert_eq!(batch.loaded(), 2); + for (item, dataset) in batch.items.iter().zip(&app.doc.datasets) { + assert_eq!(item.result.as_ref().unwrap().dataset, dataset.resource_id()); } } diff --git a/crates/app/src/ui/import_feedback.rs b/crates/app/src/ui/import_feedback.rs index 161982b5..4a4c4d56 100644 --- a/crates/app/src/ui/import_feedback.rs +++ b/crates/app/src/ui/import_feedback.rs @@ -208,6 +208,51 @@ fn open_target(app: &mut PlotiumApp, ui: &Ui, target: &ImportTarget) -> bool { true } +pub(super) fn view_batch_button(app: &mut PlotiumApp, ui: &mut Ui, batch: ImportBatchId) -> bool { + let command = CommandId::ViewImportBatch(batch); + let descriptor = commands::describe(app, command); + if ui + .add_enabled(descriptor.enabled, egui::Button::new("View this import")) + .on_disabled_hover_text(descriptor.disabled_reason.unwrap_or_default()) + .clicked() + { + commands::execute_without_clipboard(command, app, ui.ctx()); + true + } else { + false + } +} + +pub(super) fn view_batch_gate(app: &PlotiumApp, id: ImportBatchId) -> Result<(), &'static str> { + let batch = app + .session + .data_imports + .batches() + .iter() + .find(|batch| batch.id == id) + .ok_or("Choose a recent import batch.")?; + if batch.items.iter().any(|item| { + item.result + .as_ref() + .is_ok_and(|target| app.doc.dataset_index(target.dataset).is_some()) + }) { + Ok(()) + } else if batch.loaded() > 0 { + Err("Restore a removed dataset or import it again to view these results.") + } else if matches!(batch.phase, ImportPhase::Complete | ImportPhase::Cancelled) { + Err("Import a dataset successfully before viewing its results.") + } else { + Err("Wait for a dataset to finish importing.") + } +} + +pub(super) fn view_batch(app: &mut PlotiumApp, ctx: &Context, batch: ImportBatchId) { + app.session.ui.data_browser_batch = Some(batch); + app.session.view = PrimaryView::Data; + app.session.primary_sidebar_visible = true; + ctx.data_mut(|data| data.remove::(window_id(app))); +} + pub(crate) fn results_window(app: &mut PlotiumApp, ctx: &Context) { let id = window_id(app); let mut state = ctx.data_mut(|data| data.get_temp::(id).unwrap_or_default()); @@ -219,6 +264,7 @@ pub(crate) fn results_window(app: &mut PlotiumApp, ctx: &Context) { state.selected = batches.back().map(|batch| batch.id); } let mut retry = None; + let mut viewed = false; egui::Window::new("Import results") .id(id) .open(&mut state.open) @@ -267,6 +313,9 @@ pub(crate) fn results_window(app: &mut PlotiumApp, ctx: &Context) { return; }; ui.label(summary(batch)); + if view_batch_button(app, ui, batch.id) { + viewed = true; + } if ui .link("Warnings and details in operation history") .clicked() @@ -327,6 +376,7 @@ pub(crate) fn results_window(app: &mut PlotiumApp, ctx: &Context) { } }); }); + state.open &= !viewed; ctx.data_mut(|data| data.insert_temp(id, state)); if let Some(command) = retry { commands::execute_without_clipboard(command, app, ctx); diff --git a/crates/app/src/ui/import_feedback_tests.rs b/crates/app/src/ui/import_feedback_tests.rs index 9fed105f..cc0d3d32 100644 --- a/crates/app/src/ui/import_feedback_tests.rs +++ b/crates/app/src/ui/import_feedback_tests.rs @@ -20,7 +20,7 @@ fn queue_fixture(app: &mut PlotiumApp) { } #[test] -fn first_native_import_opens_its_result_while_hovering() { +fn first_native_import_does_not_navigate_while_hovering() { let mut app = app(); queue_fixture(&mut app); cancel_on_input( @@ -34,17 +34,13 @@ fn first_native_import_opens_its_result_while_hovering() { assert!(app.session.active_canvas.is_none()); let ctx = crate::typography::test_context(); let _ = ctx.run_ui(Default::default(), |ui| apply_open_target(&mut app, ui)); - assert_eq!(app.session.active_canvas, Some(0)); - assert_eq!( - app.session.ui.frame_selection, - vec![plotium_core::state::BoardFrameId::Page( - app.doc.canvases[0].resource_id - )] - ); + assert!(app.session.active_canvas.is_none()); + assert!(app.session.ui.frame_selection.is_empty()); + assert!(app.session.ui.data_selection.is_empty()); } #[test] -fn first_native_batch_shows_all_spectra_together_without_results_window() { +fn first_native_batch_waits_for_explicit_view_action() { let mut app = app(); let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../io/tests/fixtures/nmr/jcamp-ppm.dx"); @@ -54,20 +50,19 @@ fn first_native_batch_shows_all_spectra_together_without_results_window() { assert!(app.session.active_canvas.is_none()); let ctx = crate::typography::test_context(); let _ = ctx.run_ui(Default::default(), |ui| apply_open_target(&mut app, ui)); - assert_eq!(app.session.active_canvas, Some(0)); - assert_eq!(app.session.ui.data_selection, vec![0, 1]); + assert!(app.session.active_canvas.is_none()); + assert!(app.session.ui.data_selection.is_empty()); assert!(app.session.ui.frame_selection.is_empty()); assert!(app.session.board_reveal.is_none()); - assert_eq!( - app.session.viewport_mode, - plotium_core::state::ViewportMode::Fit(plotium_core::state::BoardFitTarget::AllFrames) - ); - assert!( - !ctx.data_mut(|data| data - .get_temp::(window_id(&app)) - .unwrap_or_default()) - .open - ); + let batch = app.session.data_imports.batches().back().unwrap().id; + let before = app.session.viewport_mode.clone(); + app.session.ui.data_browser_filter = "existing query".into(); + commands::execute_without_clipboard(CommandId::ViewImportBatch(batch), &mut app, &ctx); + assert_eq!(app.session.ui.data_browser_batch, Some(batch)); + assert!(app.session.view == PrimaryView::Data); + assert_eq!(app.session.ui.data_browser_filter, "existing query"); + assert_eq!(app.session.viewport_mode, before); + assert!(app.session.ui.data_selection.is_empty()); let first = app.doc.canvases[0].board_pos; let second = app.doc.canvases[1].board_pos; assert_ne!(first, second); @@ -106,6 +101,45 @@ fn results_command_is_discoverable_and_requires_an_import() { ); } +#[test] +fn view_batch_requires_live_results_and_identifies_the_batch() { + let mut app = app(); + let pending = app.queue_user_data_import("pending".into(), || Ok(Vec::new())); + let pending = CommandId::ViewImportBatch(pending); + let pending_reason = commands::describe(&app, pending).disabled_reason; + assert_eq!( + pending_reason, + Some("Wait for a dataset to finish importing.") + ); + + app.new_table_dataset(); + let item = ImportItem { + path: "sample".into(), + result: Ok(ImportTarget { + dataset: app.doc.datasets[0].resource_id(), + canvas: None, + }), + }; + let first = app.record_completed_user_import("first".into(), vec![item.clone()]); + let second = app.record_completed_user_import("second".into(), vec![item]); + let command = CommandId::ViewImportBatch(first); + let first_desc = commands::describe(&app, command); + let second_desc = commands::describe(&app, CommandId::ViewImportBatch(second)); + assert!(first_desc.enabled && second_desc.enabled); + assert_ne!(first_desc.label, second_desc.label); + + app.undo(); + assert!(app.doc.datasets.is_empty()); + let removed = commands::describe(&app, command); + assert!(!removed.enabled); + assert!(removed.disabled_reason.is_some()); + assert_ne!(removed.disabled_reason, pending_reason); + commands::execute_without_clipboard(command, &mut app, &Context::default()); + assert_eq!(app.session.ui.data_browser_batch, None); + app.redo(); + assert!(commands::describe(&app, command).enabled); +} + #[test] fn result_links_resolve_identity_and_do_not_retarget_deleted_data() { let mut app = app(); diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index aea7ce29..6f3ca205 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -16,7 +16,9 @@ mod command_exec; mod command_palette; pub(crate) mod commands; mod data_export_dialog; +mod data_navigation; mod data_sheet; +mod dataset_editor; mod diagnostics; mod export_dialog; mod feedback_banner; @@ -202,6 +204,7 @@ pub fn render( // Same for an armed Reference pick: collapsing its editor or switching // datasets must not leave a live click trap on the plot. app.sync_reference_pick(); + dataset_editor::window(app, ui.ctx()); data_sheet_dock(app, ui, dark); egui::CentralPanel::default() .frame(egui::Frame::new().fill(workspace_fill(dark)).inner_margin( @@ -243,6 +246,7 @@ pub fn render( file_dialogs::table_import_preview_window(app, &ctx); settings_window(app, &ctx, agent_connections); command_palette::command_palette_window(app, clipboard_table_paste, &ctx); + data_navigation::destinations_window(app, &ctx); menus::about_window(app, &ctx); quit_confirm_window(app, &ctx); diagnostic_history_window(app, &ctx); diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index fbd0252e..06435f2a 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -7,10 +7,12 @@ use plotium_core::state::{ }; mod board_views; mod data_browser; +mod dataset_rows; mod layer_controls; mod layers_tree; pub(crate) mod selection; mod thumbnails; +use super::data_navigation::{self, PlotIndex}; use board_views::board_views_section; use data_browser::{AnalysisItem, AnalysisKind, DataTree, DatasetNode}; use selection::*; @@ -30,6 +32,7 @@ pub fn render(app: &mut PlotiumApp, ui: &mut Ui) { )) .desired_width(f32::INFINITY), ); + dataset_rows::filters(app, ui); ui.add_space(4.0); } if app.session.view == PrimaryView::Canvas && !app.doc.canvases.is_empty() { @@ -38,6 +41,11 @@ pub fn render(app: &mut PlotiumApp, ui: &mut Ui) { .show_inside(ui, |ui| board_views_section(app, ui)); } egui::ScrollArea::vertical() + .id_salt(( + "primary_list", + app.session.view == PrimaryView::Data, + app.session.project_context.to_string(), + )) .auto_shrink([false, false]) .show(ui, |ui| match app.session.view { PrimaryView::Canvas => canvas_list(app, ui), @@ -89,6 +97,7 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { ui.weak("No canvases yet. Opening data creates one automatically."); return; } + let index = data_navigation::index(app, ui.ctx()); let mut select: Option<(usize, SelectModifiers)> = None; let mut open_settings: Option = None; let mut delete: Option = None; @@ -101,7 +110,7 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { Some(RenameState { target: RenameTarget::Canvas(i), .. }) if *i == ci ); if renaming { - let row = thumbnails::canvas_row(app, ci, ui, true, ""); + let row = thumbnails::canvas_row(app, ci, ui, true, "", None); let mut rect = row.rect.shrink2(egui::vec2(6.0, 12.0)); rect.min.x += 80.0; let mut editor = ui.new_child(egui::UiBuilder::new().max_rect(rect)); @@ -115,7 +124,8 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { } let name = app.doc.canvases[ci].name.clone(); let selected = crate::ui::canvas::frame_is_selected(app, FrameRef::Page(ci)); - let resp = thumbnails::canvas_row(app, ci, ui, selected, &name); + let summary = index.summaries.get(&app.doc.canvases[ci].resource_id); + let resp = thumbnails::canvas_row(app, ci, ui, selected, &name, summary); if resp.clicked() { claim_list_keyboard_focus(ui, &resp); select = Some((ci, select_modifiers(ui))); @@ -140,7 +150,6 @@ fn canvas_list(app: &mut PlotiumApp, ui: &mut Ui) { ui.close(); } }); - resp.on_hover_text("Double-click to rename"); } if let Some(ci) = app.session.active_canvas && ci < app.doc.canvases.len() @@ -208,7 +217,8 @@ fn data_list(app: &mut PlotiumApp, ui: &mut Ui) { } data_browser::reveal_active_path(app); let query = app.session.ui.data_browser_filter.clone(); - let filtering = !query.trim().is_empty(); + let filtering = data_browser::filtering(app); + let index = data_navigation::index(app, ui.ctx()); let tree = DataTree::build(app).filtered(app, &query); if tree.roots.is_empty() { ui.weak("No matching data or analysis results."); @@ -223,7 +233,7 @@ fn data_list(app: &mut PlotiumApp, ui: &mut Ui) { ui, node, 0, - filtering, + (&index, filtering), &mut rename_rendered, &mut event, ); @@ -248,10 +258,11 @@ fn render_dataset_node( ui: &mut Ui, node: &DatasetNode, depth: usize, - filtering: bool, + display: (&PlotIndex, bool), rename_rendered: &mut bool, event: &mut Option, ) { + let (index, filtering) = display; let di = node.dataset; let has_children = !node.analysis.is_empty() || !node.derived.is_empty(); let open = filtering || !app.session.ui.data_browser_collapsed_datasets.contains(&di); @@ -310,15 +321,12 @@ fn render_dataset_node( .on_hover_text(freshness.reason); } } - let mut resp = ui.selectable_label(selected, app.doc.datasets[di].display_name()); - if let Some(tooltip) = data_browser::sources_tooltip(app, di) { - resp = resp.on_hover_text(tooltip); - } else { - resp = resp.on_hover_text(format!( - "{} · Double-click to open", - app.doc.datasets[di].kind_label() - )); + if !node.matches_filter { + ui.weak(app.doc.datasets[di].browser_name()).on_hover_text("Lineage context"); + return; } + let mut resp = ui.add(egui::Button::selectable(selected, app.doc.datasets[di].browser_name()).truncate()); + resp = resp.on_hover_text_at_pointer(format!("{}\n{}", app.doc.datasets[di].browser_name(), dataset_rows::details(app, di))); if resp.clicked() { claim_list_keyboard_focus(ui, &resp); *event = Some(BrowserEvent::SelectDataset(di, select_modifiers(ui))); @@ -330,10 +338,9 @@ fn render_dataset_node( *event = Some(BrowserEvent::OpenSheet(di)); } - let can_stack = app.stackable_selection().is_some(); - let source_target = provenance_source_frame(app, di); - let chart_target = plotium_core::state::page_frame_showing_dataset(app, di); resp.context_menu(|ui| { + let can_stack = app.stackable_selection().is_some(); + let source_target = provenance_source_frame(app, di); if ui.button("Open data sheet").clicked() { *event = Some(BrowserEvent::OpenSheet(di)); ui.close(); @@ -342,6 +349,13 @@ fn render_dataset_node( *event = Some(BrowserEvent::StartRename(di)); ui.close(); } + if dataset_rows::button( + app, + ui, + super::commands::CommandId::EditDatasetMetadata(Some(app.doc.datasets[di].resource_id())), + ) { + ui.close(); + } if app.doc.datasets[di].lineage().is_some() && ui.button("Reveal sources").clicked() { *event = Some(BrowserEvent::RevealSources(di)); ui.close(); @@ -373,7 +387,7 @@ fn render_dataset_node( *event = Some(BrowserEvent::Stack); ui.close(); } - if source_target.is_some() || chart_target.is_some() { + if source_target.is_some() { ui.separator(); } if let Some(frame) = source_target @@ -382,14 +396,12 @@ fn render_dataset_node( *event = Some(BrowserEvent::Jump(frame)); ui.close(); } - if let Some(frame) = chart_target - && ui.button("Jump to plot").clicked() - { - *event = Some(BrowserEvent::Jump(frame)); - ui.close(); - } + }); }); + if node.matches_filter && !renaming { + dataset_rows::summary(app, ui, di, depth, index); + } if !open || node.cycle_cut { return; } @@ -397,7 +409,7 @@ fn render_dataset_node( render_analysis_group(app, ui, node, depth + 1, filtering, event); } if !node.derived.is_empty() { - render_derived_group(app, ui, node, depth + 1, filtering, rename_rendered, event); + render_derived_group(app, ui, node, depth + 1, display, rename_rendered, event); } } fn render_analysis_group( @@ -455,11 +467,12 @@ fn render_derived_group( ui: &mut Ui, node: &DatasetNode, depth: usize, - filtering: bool, + display: (&PlotIndex, bool), rename_rendered: &mut bool, event: &mut Option, ) { let di = node.dataset; + let (_, filtering) = display; let open = filtering || !app.session.ui.data_browser_collapsed_derived.contains(&di); ui.horizontal(|ui| { ui.add_space(depth as f32 * 12.0); @@ -479,7 +492,7 @@ fn render_derived_group( }); if open { for child in &node.derived { - render_dataset_node(app, ui, child, depth + 1, filtering, rename_rendered, event); + render_dataset_node(app, ui, child, depth + 1, display, rename_rendered, event); } } } @@ -515,7 +528,7 @@ fn apply_browser_event( app.focus_single(di); app.session.ui.rename = Some(RenameState { target: RenameTarget::Data(di), - buffer: app.doc.datasets[di].display_name(), + buffer: app.doc.datasets[di].browser_name(), focus: true, }); } diff --git a/crates/app/src/ui/primary_sidebar/data_browser.rs b/crates/app/src/ui/primary_sidebar/data_browser.rs index 4a27b3b8..9fec6356 100644 --- a/crates/app/src/ui/primary_sidebar/data_browser.rs +++ b/crates/app/src/ui/primary_sidebar/data_browser.rs @@ -12,6 +12,7 @@ pub(super) struct DatasetNode { pub dataset: usize, pub linked_reference: bool, pub cycle_cut: bool, + pub matches_filter: bool, pub analysis: Vec, pub derived: Vec, } @@ -125,15 +126,44 @@ impl DataTree { } pub fn filtered(&self, app: &PlotiumApp, query: &str) -> Self { - let query = query.trim().to_lowercase(); - if query.is_empty() { + let terms: Vec<_> = query.split_whitespace().map(str::to_lowercase).collect(); + let tag = app.session.ui.data_browser_tag.as_deref(); + let batch = app.session.ui.data_browser_batch; + if terms.is_empty() && tag.is_none() && batch.is_none() { return self.clone(); } + let mut batch_members = HashSet::new(); + let mut paths = HashMap::new(); + for import in app.session.data_imports.batches() { + for item in &import.items { + if let Ok(target) = &item.result { + if Some(import.id) == batch { + batch_members.insert(target.dataset); + } + if !terms.is_empty() { + paths.insert(target.dataset, item.path.to_string_lossy()); + } + } + } + } + let matches: Vec<_> = app + .doc + .datasets + .iter() + .map(|dataset| { + let path = paths.get(&dataset.resource_id()); + let eligible = (batch.is_none() || batch_members.contains(&dataset.resource_id())) + && tag.is_none_or(|tag| dataset.organization().has_tag(tag)); + let query_match = + eligible && matches_dataset(dataset, &terms, path.map(|p| p.as_ref())); + (eligible, query_match) + }) + .collect(); Self { roots: self .roots .iter() - .filter_map(|node| filter_node(node, app, &query)) + .filter_map(|node| filter_node(node, &matches, &terms)) .collect(), } } @@ -141,8 +171,14 @@ impl DataTree { /// Dataset rows in the exact order the current tree renders them. Linked /// references are one logical selectable item and therefore appear once. pub(super) fn visible_datasets(&self, app: &PlotiumApp, filtering: bool) -> Vec { - fn visit(node: &DatasetNode, app: &PlotiumApp, filtering: bool, out: &mut Vec) { - if !out.contains(&node.dataset) { + fn visit( + node: &DatasetNode, + app: &PlotiumApp, + filtering: bool, + out: &mut Vec, + seen: &mut HashSet, + ) { + if node.matches_filter && seen.insert(node.dataset) { out.push(node.dataset); } let dataset_open = filtering @@ -159,14 +195,15 @@ impl DataTree { .contains(&node.dataset); if dataset_open && derived_open && !node.cycle_cut { for child in &node.derived { - visit(child, app, filtering, out); + visit(child, app, filtering, out, seen); } } } let mut visible = Vec::new(); + let mut seen = HashSet::new(); for root in &self.roots { - visit(root, app, filtering, &mut visible); + visit(root, app, filtering, &mut visible, &mut seen); } visible } @@ -192,6 +229,7 @@ fn build_node( dataset: di, linked_reference: is_linked(app, di), cycle_cut: true, + matches_filter: true, analysis: Vec::new(), derived: Vec::new(), }; @@ -207,6 +245,7 @@ fn build_node( dataset: di, linked_reference: is_linked(app, di), cycle_cut: false, + matches_filter: true, analysis: analysis_items(&app.doc.datasets[di]), derived, } @@ -315,42 +354,77 @@ fn analysis_items(dataset: &Dataset) -> Vec { result } -fn filter_node(node: &DatasetNode, app: &PlotiumApp, query: &str) -> Option { - let dataset = &app.doc.datasets[node.dataset]; - let lineage_label = dataset - .lineage() - .map(|lineage| lineage.kind.label()) - .unwrap_or("Original data"); - let self_matches = [ - dataset.display_name(), - dataset.kind_label().to_owned(), - dataset.summary(), - lineage_label.to_owned(), - ] - .iter() - .any(|text| text.to_lowercase().contains(query)); +pub(super) fn filtering(app: &PlotiumApp) -> bool { + !app.session.ui.data_browser_filter.trim().is_empty() + || app.session.ui.data_browser_tag.is_some() + || app.session.ui.data_browser_batch.is_some() +} - if self_matches { - return Some(node.clone()); +fn matches_dataset(dataset: &Dataset, terms: &[String], import_path: Option<&str>) -> bool { + if terms.is_empty() { + return true; } + let identity = dataset.acquisition_identity(); + let summary = dataset.summary(); + let fields = [ + dataset.name().as_deref().unwrap_or(""), + dataset.compact_category(), + dataset.kind_label(), + summary.as_str(), + dataset.dimension_label().unwrap_or(""), + dataset.experiment_label().unwrap_or(""), + dataset.source_identifier(), + &identity.source_label, + identity.subject.as_deref().unwrap_or(""), + import_path.unwrap_or(""), + dataset + .lineage() + .map_or("Original data", |l| l.kind.label()), + ] + .into_iter() + .chain(dataset.organization().tags().iter().map(String::as_str)) + .chain( + dataset + .as_table() + .into_iter() + .flat_map(|table| &table.import_sources) + .filter_map(|source| source.name.as_deref()), + ) + .map(str::to_lowercase) + .collect::>(); + terms + .iter() + .all(|term| fields.iter().any(|field| field.contains(term))) +} + +fn filter_node( + node: &DatasetNode, + matches: &[(bool, bool)], + terms: &[String], +) -> Option { + let (eligible, self_matches) = matches[node.dataset]; let analysis: Vec<_> = node .analysis .iter() .filter(|item| { - item.label.to_lowercase().contains(query) - || item.kind.type_label().to_lowercase().contains(query) + eligible + && (self_matches || { + let text = format!("{} {}", item.label, item.kind.type_label()).to_lowercase(); + terms.iter().all(|term| text.contains(term)) + }) }) .cloned() .collect(); let derived: Vec<_> = node .derived .iter() - .filter_map(|child| filter_node(child, app, query)) + .filter_map(|child| filter_node(child, matches, terms)) .collect(); - (!analysis.is_empty() || !derived.is_empty()).then_some(DatasetNode { + (self_matches || !analysis.is_empty() || !derived.is_empty()).then_some(DatasetNode { dataset: node.dataset, linked_reference: node.linked_reference, cycle_cut: node.cycle_cut, + matches_filter: eligible && (self_matches || !analysis.is_empty()), analysis, derived, }) @@ -409,229 +483,5 @@ pub(super) fn sources_tooltip(app: &PlotiumApp, di: usize) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use plotium_core::state::{ - CurveFitReference, DatasetId, DatasetLineage, DerivationKind, FloatSeries, LineShapeKind, - MultipletPatternKind, NmrDataset, PeakMark, PeakOrigin, StoredLineFit, StoredMultiplet, - materialized_float_series_table, - }; - use plotium_core::{DisplayModeLabel, IntegralResult}; - use plotium_io::{Domain, NmrData}; - - fn root(name: &str) -> Dataset { - let mut dataset = NmrDataset::load_with_pipeline( - NmrData { - points: vec![1.0.into(), 0.0.into()], - domain: Domain::Frequency, - spectral_width_hz: 1.0, - observe_freq_mhz: 1.0, - carrier_ppm: 0.0, - nucleus: "1H".into(), - source: name.into(), - group_delay: 0.0, - }, - Some(plotium_processing::AxisPipeline { steps: Vec::new() }), - Some(false), - ) - .unwrap(); - dataset.name = Some(name.into()); - Dataset::Nmr(Box::new(dataset)) - } - - fn derived(name: &str, kind: DerivationKind, sources: &[DatasetId]) -> Dataset { - let mut table = materialized_float_series_table( - ("x".into(), "".into(), vec![Some(0.0)]), - Vec::new(), - "plotium.test.derived-table.v1", - ) - .unwrap(); - table.name = Some(name.into()); - table.lineage = Some(DatasetLineage::new(kind, sources.iter().copied())); - Dataset::Table(Box::new(table)) - } - - #[test] - fn builds_deep_multi_source_references_in_stable_order() { - let mut app = PlotiumApp::new(); - app.doc.datasets = vec![root("A"), root("B")]; - let roots = [ - app.doc.datasets[0].resource_id(), - app.doc.datasets[1].resource_id(), - ]; - app.doc - .datasets - .push(derived("AB", DerivationKind::SpectrumArithmetic, &roots)); - let ab = app.doc.datasets[2].resource_id(); - app.doc - .datasets - .push(derived("deep", DerivationKind::LineFitTable, &[ab])); - let tree = DataTree::build(&app); - assert_eq!( - tree.roots.iter().map(|n| n.dataset).collect::>(), - vec![0, 1] - ); - assert_eq!(tree.roots[0].derived[0].dataset, 2); - assert!(tree.roots[0].derived[0].linked_reference); - assert_eq!(tree.roots[0].derived[0].derived[0].dataset, 3); - assert_eq!(tree.roots[1].derived[0].dataset, 2); - } - - #[test] - fn filtering_keeps_ancestor_path() { - let mut app = PlotiumApp::new(); - app.doc.datasets = vec![root("source")]; - let source = app.doc.datasets[0].resource_id(); - app.doc - .datasets - .push(derived("result", DerivationKind::LineFitTable, &[source])); - let filtered = DataTree::build(&app).filtered(&app, "peak fit table"); - assert_eq!(filtered.roots.len(), 1); - assert_eq!(filtered.roots[0].dataset, 0); - assert_eq!(filtered.roots[0].derived[0].dataset, 1); - } - - #[test] - fn visible_dataset_order_follows_the_rendered_lineage_tree() { - let mut app = PlotiumApp::new(); - app.doc.datasets = vec![root("A"), root("B")]; - let source = app.doc.datasets[0].resource_id(); - app.doc - .datasets - .push(derived("A child", DerivationKind::Projection, &[source])); - - let tree = DataTree::build(&app); - assert_eq!(tree.visible_datasets(&app, false), vec![0, 2, 1]); - - app.session.ui.data_browser_collapsed_derived.insert(0); - assert_eq!(tree.visible_datasets(&app, false), vec![0, 1]); - } - - #[test] - fn cycles_are_cut_and_remain_accessible() { - let mut app = PlotiumApp::new(); - app.doc.datasets = vec![root("A"), root("B")]; - let ids = [ - app.doc.datasets[0].resource_id(), - app.doc.datasets[1].resource_id(), - ]; - app.doc.datasets[0].set_lineage(Some(DatasetLineage::new(DerivationKind::Slice, [ids[1]]))); - app.doc.datasets[1].set_lineage(Some(DatasetLineage::new( - DerivationKind::Projection, - [ids[0]], - ))); - let tree = DataTree::build(&app); - assert!(!tree.roots.is_empty()); - assert!(tree.roots[0].derived[0].derived[0].cycle_cut); - } - - #[test] - fn enumerates_stable_analysis_ids_and_fitted_columns() { - let mut spectrum = root("analysis"); - let nmr = spectrum.as_nmr_mut().unwrap(); - nmr.peaks.marks.push(PeakMark { - id: 11, - x: 2.5, - y: 1.0, - origin: PeakOrigin::Manual, - label: Some("solvent".to_owned()), - }); - nmr.integrals.push(IntegralResult { - id: 12, - start_ppm: 1.0, - end_ppm: 2.0, - area: 1.0, - normalized_area: 1.0, - mode: DisplayModeLabel::Real, - reference_value: None, - }); - nmr.line_fits.push(StoredLineFit { - id: 13, - lo: 1.0, - hi: 2.0, - shape: LineShapeKind::Lorentzian, - peaks: Vec::new(), - offset: 0.0, - offset_sigma: None, - r2: 0.99, - }); - nmr.multiplets.push(StoredMultiplet { - id: 14, - lo: 1.0, - hi: 2.0, - center_ppm: 1.5, - pattern: MultipletPatternKind::Singlet, - j_values: Vec::new(), - area: 1.0, - peak_ppm: vec![1.5], - }); - - let table = materialized_float_series_table( - ("x".into(), "".into(), vec![Some(0.0)]), - vec![FloatSeries { - name: "decay".to_owned(), - unit: String::new(), - values: vec![Some(1.0)], - uncertainty: None, - fit: Some(CurveFitReference { - analysis_id: 0, - instance_id: "column-0".into(), - response: "y".into(), - }), - }], - "plotium.test.analysis-table.v1", - ) - .unwrap(); - - let mut app = PlotiumApp::new(); - app.doc.datasets = vec![spectrum, Dataset::Table(Box::new(table))]; - let tree = DataTree::build(&app); - let kinds: Vec<_> = tree.roots[0] - .analysis - .iter() - .map(|item| item.kind.clone()) - .collect(); - assert_eq!( - kinds, - vec![ - AnalysisKind::Peak(11), - AnalysisKind::Integral(12), - AnalysisKind::LineFit(13), - AnalysisKind::Multiplet(14), - ] - ); - assert_eq!( - tree.roots[1].analysis[0].kind, - AnalysisKind::CurveFitResponse( - app.doc.datasets[1].as_table().unwrap().series_bindings[0].value_column - ) - ); - } - - #[test] - fn external_focus_reveals_ancestor_branches() { - let mut app = PlotiumApp::new(); - app.doc.datasets = vec![root("source")]; - let source = app.doc.datasets[0].resource_id(); - app.doc - .datasets - .push(derived("child", DerivationKind::Slice, &[source])); - let child = app.doc.datasets[1].resource_id(); - app.doc.datasets.push(derived( - "grandchild", - DerivationKind::LineFitTable, - &[child], - )); - app.session - .ui - .data_browser_collapsed_datasets - .extend([0, 1, 2]); - app.session.ui.data_browser_collapsed_derived.extend([0, 1]); - app.focus_single(2); - - reveal_active_path(&mut app); - - assert!(app.session.ui.data_browser_collapsed_datasets.is_empty()); - assert!(app.session.ui.data_browser_collapsed_derived.is_empty()); - } -} +#[path = "data_browser_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/primary_sidebar/data_browser_tests.rs b/crates/app/src/ui/primary_sidebar/data_browser_tests.rs new file mode 100644 index 00000000..72b83063 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/data_browser_tests.rs @@ -0,0 +1,278 @@ +use super::*; +use plotium_core::state::{ + CurveFitReference, DatasetId, DatasetLineage, DerivationKind, FloatSeries, LineShapeKind, + MultipletPatternKind, NmrDataset, PeakMark, PeakOrigin, StoredLineFit, StoredMultiplet, + materialized_float_series_table, +}; +use plotium_core::{DisplayModeLabel, IntegralResult}; +use plotium_io::{Domain, NmrData}; + +fn root(name: &str) -> Dataset { + let mut dataset = NmrDataset::load_with_pipeline( + NmrData { + points: vec![1.0.into(), 0.0.into()], + domain: Domain::Frequency, + spectral_width_hz: 1.0, + observe_freq_mhz: 1.0, + carrier_ppm: 0.0, + nucleus: "1H".into(), + source: name.into(), + group_delay: 0.0, + }, + Some(plotium_processing::AxisPipeline { steps: Vec::new() }), + Some(false), + ) + .unwrap(); + dataset.name = Some(name.into()); + Dataset::Nmr(Box::new(dataset)) +} + +fn derived(name: &str, kind: DerivationKind, sources: &[DatasetId]) -> Dataset { + let mut table = materialized_float_series_table( + ("x".into(), "".into(), vec![Some(0.0)]), + Vec::new(), + "plotium.test.derived-table.v1", + ) + .unwrap(); + table.name = Some(name.into()); + table.lineage = Some(DatasetLineage::new(kind, sources.iter().copied())); + Dataset::Table(Box::new(table)) +} + +#[test] +fn builds_deep_multi_source_references_in_stable_order() { + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("A"), root("B")]; + let roots = [ + app.doc.datasets[0].resource_id(), + app.doc.datasets[1].resource_id(), + ]; + app.doc + .datasets + .push(derived("AB", DerivationKind::SpectrumArithmetic, &roots)); + let ab = app.doc.datasets[2].resource_id(); + app.doc + .datasets + .push(derived("deep", DerivationKind::LineFitTable, &[ab])); + let tree = DataTree::build(&app); + assert_eq!( + tree.roots.iter().map(|n| n.dataset).collect::>(), + vec![0, 1] + ); + assert_eq!(tree.roots[0].derived[0].dataset, 2); + assert!(tree.roots[0].derived[0].linked_reference); + assert_eq!(tree.roots[0].derived[0].derived[0].dataset, 3); + assert_eq!(tree.roots[1].derived[0].dataset, 2); +} + +#[test] +fn filtering_keeps_ancestor_path() { + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("source")]; + let source = app.doc.datasets[0].resource_id(); + app.doc + .datasets + .push(derived("result", DerivationKind::LineFitTable, &[source])); + let filtered = DataTree::build(&app).filtered(&app, "peak fit table"); + assert_eq!(filtered.roots.len(), 1); + assert_eq!(filtered.roots[0].dataset, 0); + assert_eq!(filtered.roots[0].derived[0].dataset, 1); +} + +#[test] +fn visible_dataset_order_follows_the_rendered_lineage_tree() { + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("A"), root("B")]; + let source = app.doc.datasets[0].resource_id(); + app.doc + .datasets + .push(derived("A child", DerivationKind::Projection, &[source])); + + let tree = DataTree::build(&app); + assert_eq!(tree.visible_datasets(&app, false), vec![0, 2, 1]); + + app.session.ui.data_browser_collapsed_derived.insert(0); + assert_eq!(tree.visible_datasets(&app, false), vec![0, 1]); +} + +#[test] +fn cycles_are_cut_and_remain_accessible() { + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("A"), root("B")]; + let ids = [ + app.doc.datasets[0].resource_id(), + app.doc.datasets[1].resource_id(), + ]; + app.doc.datasets[0].set_lineage(Some(DatasetLineage::new(DerivationKind::Slice, [ids[1]]))); + app.doc.datasets[1].set_lineage(Some(DatasetLineage::new( + DerivationKind::Projection, + [ids[0]], + ))); + let tree = DataTree::build(&app); + assert!(!tree.roots.is_empty()); + assert!(tree.roots[0].derived[0].derived[0].cycle_cut); +} + +#[test] +fn enumerates_stable_analysis_ids_and_fitted_columns() { + let mut spectrum = root("analysis"); + let nmr = spectrum.as_nmr_mut().unwrap(); + nmr.peaks.marks.push(PeakMark { + id: 11, + x: 2.5, + y: 1.0, + origin: PeakOrigin::Manual, + label: Some("solvent".to_owned()), + }); + nmr.integrals.push(IntegralResult { + id: 12, + start_ppm: 1.0, + end_ppm: 2.0, + area: 1.0, + normalized_area: 1.0, + mode: DisplayModeLabel::Real, + reference_value: None, + }); + nmr.line_fits.push(StoredLineFit { + id: 13, + lo: 1.0, + hi: 2.0, + shape: LineShapeKind::Lorentzian, + peaks: Vec::new(), + offset: 0.0, + offset_sigma: None, + r2: 0.99, + }); + nmr.multiplets.push(StoredMultiplet { + id: 14, + lo: 1.0, + hi: 2.0, + center_ppm: 1.5, + pattern: MultipletPatternKind::Singlet, + j_values: Vec::new(), + area: 1.0, + peak_ppm: vec![1.5], + }); + + let table = materialized_float_series_table( + ("x".into(), "".into(), vec![Some(0.0)]), + vec![FloatSeries { + name: "decay".to_owned(), + unit: String::new(), + values: vec![Some(1.0)], + uncertainty: None, + fit: Some(CurveFitReference { + analysis_id: 0, + instance_id: "column-0".into(), + response: "y".into(), + }), + }], + "plotium.test.analysis-table.v1", + ) + .unwrap(); + + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![spectrum, Dataset::Table(Box::new(table))]; + let tree = DataTree::build(&app); + let kinds: Vec<_> = tree.roots[0] + .analysis + .iter() + .map(|item| item.kind.clone()) + .collect(); + assert_eq!( + kinds, + vec![ + AnalysisKind::Peak(11), + AnalysisKind::Integral(12), + AnalysisKind::LineFit(13), + AnalysisKind::Multiplet(14), + ] + ); + assert_eq!( + tree.roots[1].analysis[0].kind, + AnalysisKind::CurveFitResponse( + app.doc.datasets[1].as_table().unwrap().series_bindings[0].value_column + ) + ); +} + +#[test] +fn external_focus_reveals_ancestor_branches() { + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("source")]; + let source = app.doc.datasets[0].resource_id(); + app.doc + .datasets + .push(derived("child", DerivationKind::Slice, &[source])); + let child = app.doc.datasets[1].resource_id(); + app.doc.datasets.push(derived( + "grandchild", + DerivationKind::LineFitTable, + &[child], + )); + app.session + .ui + .data_browser_collapsed_datasets + .extend([0, 1, 2]); + app.session.ui.data_browser_collapsed_derived.extend([0, 1]); + app.focus_single(2); + + reveal_active_path(&mut app); + + assert!(app.session.ui.data_browser_collapsed_datasets.is_empty()); + assert!(app.session.ui.data_browser_collapsed_derived.is_empty()); +} + +#[test] +fn search_matches_scientific_summary_alongside_name() { + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("sample"), root("control")]; + let tree = DataTree::build(&app); + assert_eq!( + tree.filtered(&app, "sample 1.0 MHZ") + .visible_datasets(&app, true), + vec![0] + ); +} + +#[test] +fn search_crosses_fields_and_combines_with_batch_and_tag() { + use plotium_core::state::{ImportItem, ImportTarget, OrganizationEdit}; + let mut app = PlotiumApp::new(); + app.doc.datasets = vec![root("Protein_A_011"), root("Protein_A_012")]; + let id = app.doc.datasets[1].resource_id(); + let mut identity = app.doc.datasets[1].acquisition_identity().clone(); + identity.acquisition = Some("HSQC".into()); + app.doc.datasets[1].set_acquisition_identity(identity); + let batch = app.record_completed_user_import( + "Batch folder".into(), + vec![ImportItem { + path: "batch-12/acquisition.dx".into(), + result: Ok(ImportTarget { + dataset: id, + canvas: None, + }), + }], + ); + let tree = DataTree::build(&app); + assert_eq!( + tree.filtered(&app, "protein HSQC 1d BATCH-12") + .visible_datasets(&app, true), + vec![1] + ); + crate::ui::import_feedback::view_batch(&mut app, &egui::Context::default(), batch); + assert!(app.session.view == plotium_core::state::PrimaryView::Data); + assert!(app.session.ui.data_selection.is_empty()); + assert_eq!( + tree.filtered(&app, "nmr").visible_datasets(&app, true), + vec![1] + ); + app.edit_dataset_organization(&[id], OrganizationEdit::Experiment(Some("HNCO"))); + app.edit_dataset_organization(&[id], OrganizationEdit::AddTag("Protein A")); + app.session.ui.data_browser_tag = Some("protein a".into()); + assert_eq!( + tree.filtered(&app, "HNCO 1d").visible_datasets(&app, true), + vec![1] + ); + assert!(tree.filtered(&app, "HSQC").roots.is_empty()); +} diff --git a/crates/app/src/ui/primary_sidebar/dataset_rows.rs b/crates/app/src/ui/primary_sidebar/dataset_rows.rs new file mode 100644 index 00000000..3e7080d3 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/dataset_rows.rs @@ -0,0 +1,118 @@ +use egui::Ui; +use plotium_core::state::PlotiumApp; + +use crate::ui::{ + commands::{self, CommandId}, + data_navigation::PlotIndex, +}; + +pub(super) fn filters(app: &mut PlotiumApp, ui: &mut Ui) { + if app.session.ui.data_browser_batch.is_some_and(|id| { + !app.session + .data_imports + .batches() + .iter() + .any(|b| b.id == id) + }) { + app.session.ui.data_browser_batch = None; + } + ui.horizontal_wrapped(|ui| { + if let Some(batch) = app.session.ui.data_browser_batch + && ui + .small_button(format!("Import {batch} ×")) + .on_hover_text("Remove import filter") + .clicked() + { + app.session.ui.data_browser_batch = None; + } + if let Some(tag) = &app.session.ui.data_browser_tag + && ui + .small_button(format!("{tag} ×")) + .on_hover_text("Remove tag filter") + .clicked() + { + app.session.ui.data_browser_tag = None; + } + button(app, ui, CommandId::EditDatasetMetadata(None)); + }); +} + +pub(super) fn details(app: &PlotiumApp, di: usize) -> String { + let dataset = &app.doc.datasets[di]; + let mut text = format!( + "{} · {} +Source: {}", + dataset.dimension_label().unwrap_or(dataset.kind_label()), + dataset.summary(), + dataset.source_identifier() + ); + if let Some(experiment) = dataset.acquisition_identity().acquisition.as_deref() { + text.push_str(&format!( + " +Imported experiment: {experiment}" + )); + } + if let Some(lineage) = super::data_browser::sources_tooltip(app, di) { + text.push_str(&format!( + " +{lineage}" + )); + } + text.push_str( + " +Double-click to open; right-click to rename", + ); + text +} + +pub(super) fn summary( + app: &mut PlotiumApp, + ui: &mut Ui, + di: usize, + depth: usize, + index: &PlotIndex, +) { + let dataset = &app.doc.datasets[di]; + let id = dataset.resource_id(); + let occurrences = index.occurrences(id); + let summary = dataset.experiment_label().map_or_else( + || dataset.compact_category().to_owned(), + |name| format!("{} · {name}", dataset.compact_category()), + ); + let tags = dataset.organization().tags().to_vec(); + ui.horizontal_wrapped(|ui| { + ui.add_space(depth as f32 * 12.0 + 18.0); + ui.weak(summary); + for tag in tags { + if ui + .small_button(&tag) + .on_hover_text("Filter by this tag") + .clicked() + { + app.session.ui.data_browser_tag = Some(tag); + } + } + if occurrences.is_empty() { + ui.weak("Not plotted"); + } else { + button(app, ui, CommandId::LocateDatasetPlot(Some(id))); + } + }); +} + +pub(super) fn button(app: &mut PlotiumApp, ui: &mut Ui, command: CommandId) -> bool { + let descriptor = commands::describe(app, command); + if ui + .add_enabled( + descriptor.enabled, + egui::Button::new(descriptor.label).small().wrap(), + ) + .on_disabled_hover_text(descriptor.disabled_reason.unwrap_or_default()) + .clicked() + { + commands::execute_without_clipboard(command, app, ui.ctx()); + true + } else { + false + } +} diff --git a/crates/app/src/ui/primary_sidebar/selection.rs b/crates/app/src/ui/primary_sidebar/selection.rs index 133df08c..0db65aa8 100644 --- a/crates/app/src/ui/primary_sidebar/selection.rs +++ b/crates/app/src/ui/primary_sidebar/selection.rs @@ -132,13 +132,10 @@ pub(crate) fn select_dataset( dataset: usize, modifiers: SelectModifiers, ) { - let table_frame = app.doc.datasets[dataset].as_table().and_then(|table| { - if table.board_sheet_visible() { - Some(plotium_core::state::FrameRef::Sheet(dataset)) - } else { - plotium_core::state::page_frame_showing_dataset(app, dataset) - } - }); + let table_frame = app.doc.datasets[dataset] + .as_table() + .filter(|table| table.board_sheet_visible()) + .map(|_| plotium_core::state::FrameRef::Sheet(dataset)); app.session.ui.selection_scope = plotium_core::state::SelectionScope::DataList; select_dataset_range(app, visible, dataset, modifiers); app.session.ui.data_browser_selected_node = Some(format!("dataset:{dataset}")); @@ -266,7 +263,7 @@ pub(crate) fn handle_keyboard_selection(app: &mut PlotiumApp, ctx: &egui::Contex } SelectionScope::DataList if !app.doc.datasets.is_empty() => { let query = app.session.ui.data_browser_filter.clone(); - let filtering = !query.trim().is_empty(); + let filtering = super::data_browser::filtering(app); let visible = super::data_browser::DataTree::build(app) .filtered(app, &query) .visible_datasets(app, filtering); diff --git a/crates/app/src/ui/primary_sidebar/thumbnails.rs b/crates/app/src/ui/primary_sidebar/thumbnails.rs index 00e2c84c..bf3fa53c 100644 --- a/crates/app/src/ui/primary_sidebar/thumbnails.rs +++ b/crates/app/src/ui/primary_sidebar/thumbnails.rs @@ -1,12 +1,18 @@ use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, vec2}; use plotium_core::state::PlotiumApp; +use crate::ui::{ + commands::{self, CommandId}, + data_navigation::CanvasComposition, +}; + pub(super) fn canvas_row( - app: &PlotiumApp, + app: &mut PlotiumApp, ci: usize, ui: &mut Ui, selected: bool, name: &str, + summary: Option<&CanvasComposition>, ) -> Response { let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 64.0), Sense::hover()); let response = ui.interact( @@ -73,6 +79,109 @@ pub(super) fn canvas_row( egui::TextStyle::Button, ); let label = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), text_rect); - painter.galley(label.min, galley, visuals.text_color()); - response.on_hover_text(name) + if let Some(summary) = summary { + const EDIT_SIZE: f32 = 18.0; + const EDIT_GAP: f32 = 4.0; + let editable = !summary.datasets.is_empty(); + let summary_width = text_rect.width() - if editable { EDIT_SIZE + EDIT_GAP } else { 0.0 }; + let summary_rect = text_rect.translate(vec2(0.0, 9.0)); + painter.galley(label.min - vec2(0.0, 9.0), galley, visuals.text_color()); + let galley = egui::WidgetText::from(&summary.compact).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + summary_width.max(0.0), + egui::TextStyle::Small, + ); + let line = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), summary_rect); + painter.galley(line.min, galley, ui.visuals().weak_text_color()); + if editable { + let edit_left = (line.right() + EDIT_GAP).min(text_rect.right() - EDIT_SIZE); + let edit_rect = Rect::from_min_size( + egui::pos2(edit_left, line.center().y - EDIT_SIZE * 0.5), + vec2(EDIT_SIZE, EDIT_SIZE), + ); + edit_experiment_button( + app, + ui, + response.id.with("edit_experiment"), + edit_rect, + summary, + ); + } + response.on_hover_text(format!("{name}\n{}\n{}", summary.compact, summary.details)) + } else { + painter.galley(label.min, galley, visuals.text_color()); + response.on_hover_text(name) + } +} + +fn edit_experiment_button( + app: &mut PlotiumApp, + ui: &mut Ui, + id: egui::Id, + rect: Rect, + summary: &CanvasComposition, +) { + let label = "Edit experiment and tags"; + let response = ui.interact(rect, id, Sense::click()); + response.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label) + }); + let visuals = ui.style().interact(&response); + let color = + if response.hovered() || response.is_pointer_button_down_on() || response.has_focus() { + ui.painter() + .rect_filled(rect, visuals.corner_radius, visuals.weak_bg_fill); + visuals.text_color() + } else { + ui.visuals().weak_text_color() + }; + ui.painter().text( + rect.center(), + Align2::CENTER_CENTER, + egui_phosphor::regular::PENCIL_SIMPLE, + egui::FontId::proportional(12.0), + color, + ); + let response = response.on_hover_text(label); + if let [dataset] = summary.datasets.as_slice() { + if response.clicked() { + commands::execute_without_clipboard( + CommandId::EditDatasetMetadata(Some(*dataset)), + app, + ui.ctx(), + ); + } + } else { + egui::Popup::menu(&response).id(id.with("menu")).show(|ui| { + ui.label("Choose a plotted dataset to edit"); + egui::ScrollArea::vertical() + .max_height(240.0) + .show(ui, |ui| { + for &dataset in &summary.datasets { + let Some(di) = app.doc.dataset_index(dataset) else { + continue; + }; + let data = &app.doc.datasets[di]; + let name = data.browser_name(); + let label = data.experiment_label().map_or_else( + || name.clone(), + |experiment| format!("{name} · {experiment}"), + ); + if ui + .button(label) + .on_hover_text(data.source_identifier()) + .clicked() + { + commands::execute_without_clipboard( + CommandId::EditDatasetMetadata(Some(dataset)), + app, + ui.ctx(), + ); + ui.close(); + } + } + }); + }); + } } diff --git a/crates/app/src/ui/result_follow.rs b/crates/app/src/ui/result_follow.rs index 338a569d..debe2da3 100644 --- a/crates/app/src/ui/result_follow.rs +++ b/crates/app/src/ui/result_follow.rs @@ -188,6 +188,7 @@ pub(super) fn pause_for_command(app: &mut PlotiumApp, command: CommandId) { | CommandId::HelpManual | CommandId::CheckUpdates | CommandId::ImportResults + | CommandId::ViewImportBatch(_) | CommandId::ClearRecentFiles | CommandId::SaveProject | CommandId::TogglePrimarySidebar diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index 001ce673..47aeb4d7 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -261,6 +261,12 @@ impl PlotiumApp { c.panel_labels_visible = *after; } } + Action::SetDatasetOrganization { dataset, after, .. } => { + let index = dataset_index!(*dataset); + if let Some(d) = self.doc.datasets.get_mut(index) { + *d.organization_mut() = after.clone(); + } + } Action::RenameDataset { dataset, after, .. } => { let dataset = dataset_index!(*dataset); if let Some(d) = self.doc.datasets.get_mut(dataset) { diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index 27ba78b3..7753280a 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -232,6 +232,14 @@ impl PlotiumApp { c.panel_labels_visible = *before; } } + Action::SetDatasetOrganization { + dataset, before, .. + } => { + let index = dataset_index!(*dataset); + if let Some(d) = self.doc.datasets.get_mut(index) { + *d.organization_mut() = before.clone(); + } + } Action::RenameDataset { dataset, before, .. } => { diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index 1c5aac44..dc8f42e3 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -107,7 +107,7 @@ pub(super) fn validate_action( validate_action(app, child, shape)?; } } - Action::RenameDataset { dataset, .. } => { + Action::RenameDataset { dataset, .. } | Action::SetDatasetOrganization { dataset, .. } => { if !shape.has_dataset(app, *dataset) { return Err(ActionApplyError::StaleTarget(format!("dataset {dataset}"))); } diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 68e08655..8a170702 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -415,6 +415,11 @@ pub enum Action { before: bool, after: bool, }, + SetDatasetOrganization { + dataset: DatasetId, + before: crate::state::DatasetOrganization, + after: crate::state::DatasetOrganization, + }, RenameDataset { dataset: DatasetId, before: Option, @@ -664,6 +669,7 @@ impl Action { Self::DeleteDataset { .. } => "delete dataset", Self::CreatePlot { .. } => "create native plot", Self::ReplacePanelState { .. } => "edit panel structure", + Self::SetDatasetOrganization { .. } => "edit dataset experiment and tags", Self::DeleteFigureTargets { .. } => "delete figure targets", Self::Composite(actions) => actions .iter() @@ -735,6 +741,7 @@ impl Action { Self::SetCanvasCaption { before, after, .. } => before == after, Self::SetPanelLabelStyle { before, after, .. } => before == after, Self::SetPanelLabelsVisible { before, after, .. } => before == after, + Self::SetDatasetOrganization { before, after, .. } => before == after, Self::RenameDataset { before, after, .. } => before == after, Self::SetCurveFitAnalyses { before, after, .. } => before == after, Self::EditTable { delta, .. } => delta.is_empty(), diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 5d9bdda0..d0e8ec09 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -371,6 +371,7 @@ pub fn build_dosy_figure_cancellable( &grid, NY, NX, ppm_lo, ppm_hi, logd_lo, logd_hi, &levels, cancelled, )?; fig = fig.with_contour(Contour { + origin: None, segments, color: contour.style.positive_color.resolve(), width: contour.style.width.get(), @@ -452,6 +453,7 @@ pub fn build_ilt_figure_cancellable( cancelled, )?; fig = fig.with_contour(Contour { + origin: None, segments, color: contour.style.positive_color.resolve(), width: contour.style.width.get(), diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index 903c7877..2e427569 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -630,6 +630,11 @@ pub struct BoardViewDto { #[derive(Serialize, Deserialize)] pub struct DatasetBinding { + #[serde( + default, + skip_serializing_if = "crate::state::DatasetOrganization::is_empty" + )] + pub organization: crate::state::DatasetOrganization, pub data: String, pub recipe: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/core/src/project/lineage_tests.rs b/crates/core/src/project/lineage_tests.rs index 20440187..c4a33e54 100644 --- a/crates/core/src/project/lineage_tests.rs +++ b/crates/core/src/project/lineage_tests.rs @@ -99,6 +99,7 @@ fn lineage_resolution_rejects_missing_self_and_cycles() { ] }; let binding = |data: &str, sources: &[&str]| DatasetBinding { + organization: Default::default(), data: data.to_owned(), recipe: format!("recipe_{data}"), derivation: (!sources.is_empty()).then(|| DerivationDto { diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index f1301421..e8f82350 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -387,6 +387,7 @@ fn save_project_impl( None }; bindings.push(DatasetBinding { + organization: dataset.organization().clone(), data: data_id, recipe: recipe_id, derivation, @@ -559,6 +560,7 @@ pub fn load_project(path: &Path) -> Result { dataset.set_resource_id(binding.data.parse().map_err(|_| { ProjectError::Invalid(format!("dataset has invalid stable id {}", binding.data)) })?); + *dataset.organization_mut() = binding.organization.clone(); let di = app.doc.datasets.len(); app.doc.datasets.push(dataset); app.session diff --git a/crates/core/src/project/typed_table.rs b/crates/core/src/project/typed_table.rs index 00b03118..428d2cad 100644 --- a/crates/core/src/project/typed_table.rs +++ b/crates/core/src/project/typed_table.rs @@ -241,6 +241,7 @@ pub(crate) fn table_dataset_from_v1( ProjectError::Invalid(format!("table has invalid stable id {}", data.id)) })?, field_catalog: sidecar.field_catalog, + organization: Default::default(), acquisition_identity: super::convert::read_acquisition_identity(data)?, provenance: sidecar.provenance, meta: sidecar.meta, diff --git a/crates/core/src/state/afm.rs b/crates/core/src/state/afm.rs index c255db55..710753b5 100644 --- a/crates/core/src/state/afm.rs +++ b/crates/core/src/state/afm.rs @@ -7,6 +7,7 @@ pub struct AfmDataset { pub resource_id: DatasetId, /// Persisted mapping from stable channel keys to dataset-local field ids. pub field_catalog: FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub data: Arc, /// Immutable keys calculated with the loaded raster data, never serialized. @@ -29,6 +30,7 @@ impl AfmDataset { Self { resource_id: DatasetId::new(), field_catalog, + organization: Default::default(), acquisition_identity, data: Arc::new(data), image_field_keys, @@ -94,6 +96,7 @@ impl AfmDataset { let mut figure = self.contour_base_figure(field)?; if !geometry.positive.is_empty() { figure.contours.push(Contour { + origin: None, segments: geometry.positive.as_ref().to_vec(), color: style.positive_color.resolve(), width: style.width.get(), @@ -101,6 +104,7 @@ impl AfmDataset { } if !geometry.negative.is_empty() { figure.contours.push(Contour { + origin: None, segments: geometry.negative.as_ref().to_vec(), color: style.negative_color.resolve(), width: style.width.get(), diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 371fad37..1ad4699a 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -301,6 +301,9 @@ impl PlotiumApp { .build_encoded_series_figure(series) .unwrap_or_else(|| unsupported_series_figure(series)); self.apply_series_binding_style(&mut figure, series); + for contour in &mut figure.contours { + contour.origin = Some(plotium_figure::SeriesOrigin(series.id.get())); + } return self.normalize_binding_figure(figure, size_mm); } if let Some(series) = binding.series.first() @@ -310,6 +313,9 @@ impl PlotiumApp { .build_encoded_series_figure(series) .unwrap_or_else(|| unsupported_series_figure(series)); self.apply_series_binding_style(&mut figure, series); + for contour in &mut figure.contours { + contour.origin = Some(plotium_figure::SeriesOrigin(series.id.get())); + } return self.normalize_binding_figure(figure, size_mm); } let Some(primary_id) = binding.primary_dataset() else { diff --git a/crates/core/src/state/data_import.rs b/crates/core/src/state/data_import.rs index 00fdeb57..aa6d7a7d 100644 --- a/crates/core/src/state/data_import.rs +++ b/crates/core/src/state/data_import.rs @@ -286,8 +286,7 @@ impl PlotiumApp { self.session.data_imports.finish(id); } } - /// Only an explicit first import may continue by opening its results. Any - /// later intentional input cancels that continuation, even if selection returns. + /// Enqueue a user import without changing the workspace when it finishes. pub fn queue_user_data_import( &mut self, recent: PathBuf, @@ -306,12 +305,7 @@ impl PlotiumApp { discover: impl FnOnce() -> Result + Send + 'static, ) -> ImportBatchId { self.cancel_import_open_intent(); - let open = self.doc.datasets.is_empty() && self.doc.canvases.is_empty(); - let id = self.enqueue_import(recent, Box::new(discover)); - if open { - self.session.data_imports.open_intent = Some(id); - } - id + self.enqueue_import(recent, Box::new(discover)) } /// Background callers never request navigation when their import completes. pub fn queue_data_import( diff --git a/crates/core/src/state/data_import_tests.rs b/crates/core/src/state/data_import_tests.rs index dfffa35a..1119ebac 100644 --- a/crates/core/src/state/data_import_tests.rs +++ b/crates/core/src/state/data_import_tests.rs @@ -26,10 +26,7 @@ fn discovered_source_failure_does_not_discard_successful_files() { std::path::Path::new("unreadable-folder") ); assert!(!batch.retryable); - assert!(matches!( - app.take_import_open_target(), - Some(ImportOpenTarget::Dataset(_)) - )); + assert!(app.take_import_open_target().is_none()); } use crate::state::XrdDataset; @@ -375,12 +372,12 @@ fn first_batch_opens_all_successes_together_and_all_failure_never_navigates() { } #[test] -fn new_user_import_cancels_previous_continuation_and_existing_content_never_opens() { +fn user_imports_never_queue_automatic_navigation() { let mut app = PlotiumApp::new_with_settings(Default::default()); let first = app.queue_user_data_import("first".into(), || Ok(Vec::new())); let second = app.queue_user_data_import("second".into(), || Ok(Vec::new())); assert_ne!(first, second); - assert_eq!(app.session.data_imports.open_intent, Some(second)); + assert!(app.session.data_imports.open_intent.is_none()); app.install_prepared_import(std::path::Path::new("existing"), Ok(prepared())); app.queue_user_data_import("append".into(), || Ok(Vec::new())); assert!(app.session.data_imports.open_intent.is_none()); diff --git a/crates/core/src/state/dataset_organization.rs b/crates/core/src/state/dataset_organization.rs new file mode 100644 index 00000000..bba5a111 --- /dev/null +++ b/crates/core/src/state/dataset_organization.rs @@ -0,0 +1,218 @@ +use super::{Dataset, DatasetId, PlotiumApp}; +use crate::actions::Action; +use serde::{Deserialize, Serialize}; + +/// User labels only. Acquisition facts and processing never read these values. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DatasetOrganization { + experiment_override: Option, + tags: Vec, +} + +impl DatasetOrganization { + pub fn is_empty(&self) -> bool { + self.experiment_override.is_none() && self.tags.is_empty() + } + + pub fn experiment_override(&self) -> Option<&str> { + self.experiment_override.as_deref() + } + + pub fn tags(&self) -> &[String] { + &self.tags + } + + pub fn has_tag(&self, tag: &str) -> bool { + let key = tag.to_lowercase(); + self.tags + .iter() + .any(|existing| existing.to_lowercase() == key) + } +} + +pub enum OrganizationEdit<'a> { + Experiment(Option<&'a str>), + AddTag(&'a str), + RemoveTag(&'a str), +} + +impl Dataset { + pub fn organization(&self) -> &DatasetOrganization { + match self { + Self::Nmr(d) => &d.organization, + Self::Nmr2D(d) => &d.organization, + Self::Table(d) => &d.organization, + Self::Electrophysiology(d) => &d.organization, + Self::Afm(d) => &d.organization, + Self::MassSpec(d) => &d.organization, + Self::Xrd(d) => &d.organization, + Self::Xps(d) => &d.organization, + Self::Xas(d) => &d.organization, + } + } + + pub fn organization_mut(&mut self) -> &mut DatasetOrganization { + match self { + Self::Nmr(d) => &mut d.organization, + Self::Nmr2D(d) => &mut d.organization, + Self::Table(d) => &mut d.organization, + Self::Electrophysiology(d) => &mut d.organization, + Self::Afm(d) => &mut d.organization, + Self::MassSpec(d) => &mut d.organization, + Self::Xrd(d) => &mut d.organization, + Self::Xps(d) => &mut d.organization, + Self::Xas(d) => &mut d.organization, + } + } + + pub fn experiment_label(&self) -> Option<&str> { + self.organization().experiment_override().or_else(|| { + self.acquisition_identity() + .acquisition + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + } + + pub fn browser_name(&self) -> String { + self.name().unwrap_or_else(|| { + let identity = self.acquisition_identity(); + identity + .subject + .clone() + .unwrap_or_else(|| identity.source_label.clone()) + }) + } + + pub fn compact_category(&self) -> &'static str { + match self { + Self::Nmr(_) | Self::Nmr2D(_) => "NMR", + Self::Table(_) => "Table", + _ => self.kind_label(), + } + } + + pub fn source_identifier(&self) -> &str { + match self { + Self::Nmr(d) => d.data.source(), + Self::Nmr2D(d) => &d.data.source, + Self::Electrophysiology(d) => &d.data.source, + Self::Afm(d) => &d.data.source, + Self::MassSpec(d) => &d.run.source, + Self::Xrd(d) => &d.data.source, + Self::Xps(d) => &d.experiment.source, + Self::Xas(d) => &d.scan.source, + Self::Table(_) => &self.acquisition_identity().source_label, + } + } + + pub fn dimension_label(&self) -> Option<&'static str> { + match self { + Self::Nmr(_) => Some("1D"), + Self::Nmr2D(data) if data.volume.is_some() => Some("3D"), + Self::Nmr2D(data) if data.is_pseudo() => Some("Pseudo-2D"), + Self::Nmr2D(_) => Some("2D"), + _ => None, + } + } +} + +impl PlotiumApp { + pub fn edit_dataset_organization(&mut self, targets: &[DatasetId], edit: OrganizationEdit<'_>) { + let mut seen = std::collections::HashSet::new(); + let actions = targets + .iter() + .copied() + .filter(|id| seen.insert(*id)) + .filter_map(|dataset| { + let before = self + .doc + .datasets + .get(self.doc.dataset_index(dataset)?)? + .organization() + .clone(); + let mut after = before.clone(); + match edit { + OrganizationEdit::Experiment(value) => { + after.experiment_override = value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + } + OrganizationEdit::AddTag(tag) => { + let tag = tag.trim(); + if !tag.is_empty() && !after.has_tag(tag) { + after.tags.push(tag.to_owned()); + } + } + OrganizationEdit::RemoveTag(tag) => { + let tag = tag.trim().to_lowercase(); + after.tags.retain(|value| value.to_lowercase() != tag); + } + } + (before != after).then_some(Action::SetDatasetOrganization { + dataset, + before, + after, + }) + }) + .collect::>(); + if !actions.is_empty() { + self.execute_action(Action::Composite(actions)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_metadata_round_trips_and_does_not_replace_acquisition_identity() { + let mut app = PlotiumApp::new_with_settings(Default::default()); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/jcamp-ppm.dx"); + app.load_from(&fixture); + app.load_from(&fixture); + let ids: Vec<_> = app.doc.datasets.iter().map(Dataset::resource_id).collect(); + let mut imported = app.doc.datasets[0].acquisition_identity().clone(); + imported.acquisition = Some("Imported protocol".into()); + app.doc.datasets[0].set_acquisition_identity(imported.clone()); + app.edit_dataset_organization(&ids[..1], OrganizationEdit::AddTag("Sample")); + app.edit_dataset_organization(&ids[1..], OrganizationEdit::AddTag("Control")); + app.edit_dataset_organization(&ids, OrganizationEdit::AddTag(" Batch ")); + app.edit_dataset_organization(&ids, OrganizationEdit::AddTag("batch")); + app.edit_dataset_organization(&ids, OrganizationEdit::AddTag(" ")); + app.undo(); + assert_eq!(app.doc.datasets[0].organization().tags(), ["Sample"]); + assert_eq!(app.doc.datasets[1].organization().tags(), ["Control"]); + app.redo(); + app.edit_dataset_organization(&ids, OrganizationEdit::RemoveTag("sample")); + assert_eq!(app.doc.datasets[0].organization().tags(), ["Batch"]); + assert_eq!( + app.doc.datasets[1].organization().tags(), + ["Control", "Batch"] + ); + app.undo(); + app.edit_dataset_organization(&ids, OrganizationEdit::Experiment(Some("HNCO"))); + assert_eq!(app.doc.datasets[0].dimension_label(), Some("1D")); + assert_eq!(app.doc.datasets[0].acquisition_identity(), &imported); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("metadata.plotium"); + crate::project::save_project(&app, &path, false).unwrap(); + let mut loaded = crate::project::load_project(&path).unwrap(); + assert_eq!(loaded.doc.datasets[0].experiment_label(), Some("HNCO")); + assert_eq!( + loaded.doc.datasets[0].organization().tags(), + ["Sample", "Batch"] + ); + assert_eq!(loaded.doc.datasets[0].acquisition_identity(), &imported); + loaded.edit_dataset_organization(&ids, OrganizationEdit::Experiment(None)); + assert_eq!( + loaded.doc.datasets[0].experiment_label(), + Some("Imported protocol") + ); + } +} diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 72c30bc4..500f4661 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -36,6 +36,7 @@ pub struct NmrDataset { pub native_base: plotium_io::nmr_view::NmrSource, pub native_processed: plotium_io::nmr_view::NmrSource, pub phase_reports: Vec, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub base: Processed1D, pub pipeline: AxisPipeline, @@ -126,6 +127,7 @@ impl NmrDataset { resource_id: DatasetId::new(), field_catalog, data, + organization: Default::default(), acquisition_identity, native_base: base.source, native_processed: processed.source, @@ -268,6 +270,7 @@ pub struct Nmr2DDataset { pub nus_request: Option, /// Import diagnostic when automatic reconstruction could not produce a spectrum. pub reconstruction_warning: Option, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub params: Params2D, /// Persistent owner-local allocator shared by both axes. @@ -436,6 +439,7 @@ impl Nmr2DDataset { resource_id: DatasetId::new(), field_catalog, data: Arc::new(data), + organization: Default::default(), acquisition_identity, native_base: base.source, native_processed: output.source, diff --git a/crates/core/src/state/datasets_2d_figure.rs b/crates/core/src/state/datasets_2d_figure.rs index c4741c41..2601caba 100644 --- a/crates/core/src/state/datasets_2d_figure.rs +++ b/crates/core/src/state/datasets_2d_figure.rs @@ -236,6 +236,7 @@ pub(crate) fn apply_contour_geometry( ) -> Figure { if !geometry.positive.is_empty() { figure.contours.push(Contour { + origin: None, segments: geometry.positive.as_ref().to_vec(), color: style.positive_color.resolve(), width: style.width.get(), @@ -243,6 +244,7 @@ pub(crate) fn apply_contour_geometry( } if !geometry.negative.is_empty() { figure.contours.push(Contour { + origin: None, segments: geometry.negative.as_ref().to_vec(), color: style.negative_color.resolve(), width: style.width.get(), diff --git a/crates/core/src/state/electrophysiology.rs b/crates/core/src/state/electrophysiology.rs index 1a024937..01231f8f 100644 --- a/crates/core/src/state/electrophysiology.rs +++ b/crates/core/src/state/electrophysiology.rs @@ -77,6 +77,7 @@ pub struct ElectrophysiologyDataset { pub resource_id: DatasetId, /// Persisted mapping from stable channel keys to dataset-local field ids. pub field_catalog: FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub data: ElectrophysiologyData, /// Calculated from loaded samples and omitted from project metadata. @@ -203,6 +204,7 @@ impl ElectrophysiologyDataset { Self { resource_id: new_resource_id(), field_catalog, + organization: Default::default(), acquisition_identity, data, field_keys: OnceLock::from(field_keys), diff --git a/crates/core/src/state/log_axes_tests.rs b/crates/core/src/state/log_axes_tests.rs index b51f9a63..1d9e26f5 100644 --- a/crates/core/src/state/log_axes_tests.rs +++ b/crates/core/src/state/log_axes_tests.rs @@ -27,6 +27,7 @@ fn only_curve_plots_offer_log_axes() { assert_eq!(log_scale_unavailable(&categorical, false), None); let mut contour = line.clone(); contour.contours.push(plotium_figure::Contour { + origin: None, segments: vec![], color: plotium_figure::Color::BLACK, width: 1.0, diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index 8168ac84..3761116f 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -71,6 +71,7 @@ pub struct ExtractedMassSpectrum { pub struct MassSpecDataset { pub resource_id: DatasetId, pub field_catalog: FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub run: MassSpecRun, pub name: Option, @@ -162,6 +163,7 @@ impl MassSpecDataset { Self { resource_id: DatasetId::new(), field_catalog, + organization: Default::default(), acquisition_identity, run, name: None, diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 420c2aeb..f4b80d11 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -72,10 +72,12 @@ mod cursors; mod data_import; mod dataset_delete; mod dataset_identity; +mod dataset_organization; pub use data_import::{ DataImports, ImportBatch, ImportBatchId, ImportDiscovery, ImportItem, ImportOpenTarget, ImportPhase, ImportTarget, }; +pub use dataset_organization::*; mod curve_bars; mod curve_layout; pub(crate) use curve_bars::bar_edge_color; diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index 56806fb0..55489633 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -495,6 +495,7 @@ mod tests { .collect(), )); figure.contours.push(Contour { + origin: None, segments: vec![[[0.0, 0.0], [1.0, 1.0]], [[9.0, 1.0], [10.0, 2.0]]], color: Color::BLACK, width: 1.0, diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index df655908..7cdd9e09 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -324,6 +324,7 @@ impl PlotiumApp { .primary_color() .unwrap_or(OVERLAY_PALETTE[i % OVERLAY_PALETTE.len()]); for mut contour in part.contours { + contour.origin = Some(plotium_figure::SeriesOrigin(sb.id.get())); // A concrete ContourSpec owns independent positive and negative // styling. The legacy chart fallback retains its palette override // until the broad stack migration lands in phase 7. diff --git a/crates/core/src/state/table.rs b/crates/core/src/state/table.rs index 1eeb5cce..2ce03297 100644 --- a/crates/core/src/state/table.rs +++ b/crates/core/src/state/table.rs @@ -190,6 +190,7 @@ pub struct TableDataset { pub resource_id: crate::state::DatasetId, /// Persisted identity for the table's default series field. pub field_catalog: crate::state::FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, /// Executable extraction recipe used to refresh this immutable table. pub provenance: Option, @@ -247,6 +248,7 @@ impl TableDataset { Self { resource_id: new_resource_id(), field_catalog, + organization: Default::default(), acquisition_identity: plotium_io::AcquisitionIdentity { subject: None, acquisition: None, diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 05915006..1fb48b8c 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -275,6 +275,8 @@ pub struct UiState { /// Transient Data-browser state. Dataset and Derived-data branches default /// open, so only explicit collapses are recorded; Analysis defaults closed. pub data_browser_filter: String, + pub data_browser_tag: Option, + pub data_browser_batch: Option, pub data_browser_collapsed_datasets: HashSet, pub data_browser_collapsed_derived: HashSet, pub data_browser_expanded_analysis: HashSet, @@ -509,6 +511,8 @@ impl Default for UiState { frame_selection: Vec::new(), data_selection: Vec::new(), data_browser_filter: String::new(), + data_browser_tag: None, + data_browser_batch: None, data_browser_collapsed_datasets: HashSet::new(), data_browser_collapsed_derived: HashSet::new(), data_browser_expanded_analysis: HashSet::new(), diff --git a/crates/core/src/state/xas.rs b/crates/core/src/state/xas.rs index 706c12a8..6d6f9d30 100644 --- a/crates/core/src/state/xas.rs +++ b/crates/core/src/state/xas.rs @@ -148,6 +148,7 @@ impl XasCalibrationError { pub struct XasDataset { pub resource_id: DatasetId, pub field_catalog: FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub scan: XasScan, pub signal: Option, @@ -172,6 +173,7 @@ impl XasDataset { Self { resource_id: DatasetId::new(), field_catalog, + organization: Default::default(), acquisition_identity, scan, signal, diff --git a/crates/core/src/state/xps.rs b/crates/core/src/state/xps.rs index 4f93f71b..e2e811e9 100644 --- a/crates/core/src/state/xps.rs +++ b/crates/core/src/state/xps.rs @@ -45,6 +45,7 @@ pub struct StoredXpsFit { pub struct XpsDataset { pub resource_id: DatasetId, pub field_catalog: FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub experiment: Arc, pub name: Option, @@ -96,6 +97,7 @@ impl XpsDataset { Self { resource_id: DatasetId::new(), field_catalog, + organization: Default::default(), acquisition_identity, experiment: Arc::new(experiment), name: None, diff --git a/crates/core/src/state/xrd.rs b/crates/core/src/state/xrd.rs index df8341ad..087bf311 100644 --- a/crates/core/src/state/xrd.rs +++ b/crates/core/src/state/xrd.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; pub struct XrdDataset { pub resource_id: DatasetId, pub field_catalog: FieldCatalog, + pub organization: super::DatasetOrganization, pub acquisition_identity: plotium_io::AcquisitionIdentity, pub data: XrdData, pub params: XrdProcessing, @@ -32,6 +33,7 @@ impl XrdDataset { Self { resource_id: DatasetId::new(), field_catalog, + organization: Default::default(), acquisition_identity, data, params, diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index cff680ea..311e3150 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -509,6 +509,8 @@ pub struct RangeAnnotation { /// figure is built, so both renderers only project and stroke. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Contour { + #[serde(skip)] + pub origin: Option, pub segments: Vec<[[f64; 2]; 2]>, pub color: Color, pub width: f32, diff --git a/crates/render/src/screen_tests.rs b/crates/render/src/screen_tests.rs index e022ec23..e188a287 100644 --- a/crates/render/src/screen_tests.rs +++ b/crates/render/src/screen_tests.rs @@ -245,6 +245,7 @@ fn render_stats_separate_full_and_interactive_work() { (0..10_000).map(|i| [i as f64, (i % 3) as f64]).collect(), )); fig.contours.push(Contour { + origin: None, segments: (0..1_000) .map(|i| [[i as f64, 0.0], [i as f64, 1.0]]) .collect(), diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index 43c71554..ac023b43 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -33,6 +33,12 @@ continue working. When you import multiple datasets, they appear on the board in their original discovery order without changing your current page or selection. The status bar shows the current file and success/failure counts; review failures in the diagnostic history. +Use **View this import** in the import banner or **Import Results** to show +only datasets from that batch in **Data**. The batch filter combines with your +search and tag filter. Remove it with ×. Import results are kept for the +current project session, with up to 32 completed or cancelled batches retained; +the filter clears if its batch is dropped. + Additional imports wait for the current batch. Opening, closing, or creating a project cancels unfinished imports for the previous project. diff --git a/docs/src/content/docs/guides/organizing-data.md b/docs/src/content/docs/guides/organizing-data.md index 50972911..05ec6e1c 100644 --- a/docs/src/content/docs/guides/organizing-data.md +++ b/docs/src/content/docs/guides/organizing-data.md @@ -21,6 +21,32 @@ from its context menu. Search is case-insensitive and keeps the complete ancestor path visible; clearing it restores your previous expanded and collapsed branches. +Search terms can match across the dataset name, experiment, tags, data type, +dimensionality, source, and scientific summary (for example, `600.0 MHz`). +The search `hnco 3d batch-12` can match an +experiment label, the acquisition dimensionality, and a source folder. Hover a +row for source details. Click a tag to filter by it; remove the filter with +its × button. + +In **Canvas**, click the pencil beside the card's dataset summary. If the +canvas contains several datasets, choose which one to edit. In **Data**, +right-click a dataset and choose **Edit experiment and tags…**, or select +several datasets and use the button above the list to edit them together. +Enter a display name or choose an NMR suggestion. For a single dataset, the +editor shows the imported name separately. **Use imported names** clears your +overrides. Experiment names and tags are labels; they do not change +processing or assignment. Add a new tag or choose one from **Project tags**. +On a mixed selection, a count beside a tag shows how many datasets have it. +Removing a tag leaves the other tags in place. These edits support undo and +redo and are saved with the project. + +Use **Locate plot** to go straight to a dataset's series, including a series +in an overlay. If the dataset appears more than once, choose a destination in +the **Plot destinations** window, even if the Data List filters hide the row. +Hidden destinations are marked; use **Reveal** to show one. Locating a plot +keeps your filters and briefly highlights the visible series or outlines the +plot, for example when locating a heatmap. + ## Selecting and opening Click a dataset or reference to select it. `Shift`-click selects the continuous @@ -45,6 +71,10 @@ can remove those first. Deletion can be undone. Projects saved with much older versions of Plotium may show some derived results as top-level datasets; they remain fully usable. +In **Canvas**, the line under each title summarizes the datasets directly +plotted there, including hidden series. A dataset used more than once is +counted once. Hover the title to see the full composition. + ## Board views In **Canvas** mode, the lower part of the side bar holds named **Board diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 9dd13afe..10403588 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -31,6 +31,11 @@ Rigaku XRD 时才按这些格式打开,否则进入[表格导入](#导入表 NMR 导入包含默认处理,NUS 重建可能比读取文件耗时长得多。项目文件、表格预览、 ZIP 文件和手动补录采样表的导入使用各自的导入流程。 +在导入提示条或 **Import Results** 中单击 **View this import**,即可在 **Data** +中筛选该批次的数据集。批次筛选可与搜索词和标签筛选一起使用;单击 × 可移除。 +导入结果仅在当前项目会话中保留,最多保留 32 个已完成或已取消的批次。批次 +超出保留范围后,对应筛选会清除。 + ## 导入表格 对 CSV、TSV、带分隔符的 `.txt` 或 XLSX 文件,选择 **Import Table…**。表格也可以 diff --git a/docs/src/content/docs/zh-cn/guides/organizing-data.md b/docs/src/content/docs/zh-cn/guides/organizing-data.md index 9a253976..932635bc 100644 --- a/docs/src/content/docs/zh-cn/guides/organizing-data.md +++ b/docs/src/content/docs/zh-cn/guides/organizing-data.md @@ -17,6 +17,26 @@ description: 浏览数据集与派生结果,并把工作保存为项目。 搜索不区分大小写,并保留结果的完整祖先路径;清空搜索后会恢复此前的展开 状态。 +多个搜索词可分别匹配数据集名称、实验名称、标签、数据类型、维度、来源和科学摘要 +(例如 `600.0 MHz`)。 +例如,`hnco 3d batch-12` 可分别匹配实验名称、采集维度和来源文件夹。将鼠标 +悬停在数据行上可查看来源详情。单击标签即可筛选,再单击 × 可移除筛选条件。 + +在 **Canvas** 中,单击画布卡片上数据摘要旁的铅笔图标。如果画布包含多个 +数据集,请先选择要编辑的一个。在 **Data** 中,可右击数据集并选择 +**Edit experiment and tags…**,也可选中多个数据集,通过列表上方的按钮批量编辑。 +可以自由输入显示名称,也可选用常见的 NMR 实验名称。编辑单个数据集时,窗口会 +单独显示导入原名。**Use imported names** 会清除自定义名称,恢复导入值。 +实验名称和标签只用于标识和整理,不会改变处理或归属。 +可添加新标签,也可从 **Project tags** 选择已有标签。多选时,标签旁的计数 +显示有多少个所选数据集带有该标签。移除标签时会保留其他标签。这些编辑可撤销 +和重做,并随项目保存。 + +使用 **Locate plot** 可直接定位数据集所对应的绘图系列,包括叠加图中的系列。 +若有多个位置,请在 **Plot destinations** 窗口中选择目的地,即使筛选条件隐藏了 +数据行也可以选择。隐藏的系列会有标记;单击 **Reveal** 可显示该系列。定位时会 +保留筛选条件,并短暂高亮可见的目标系列;定位热图时则会描出图框。 + ## 选择与打开 单击数据集或引用可将其选中。`Shift` + 单击选择从上一个主项目到当前项目的 @@ -38,6 +58,9 @@ description: 浏览数据集与派生结果,并把工作保存为项目。 用很旧版本的 Plotium 保存的项目可能把部分派生结果显示为顶层数据集;它们 仍完全可用。 +在 **Canvas** 中,画布标题下方概括了直接绘制在其中的数据集,也包括隐藏的 +系列。同一数据集使用多次仍只计一次。将鼠标悬停在标题上可查看完整组成。 + ## 画板视野 在 **Canvas** 模式下,侧栏下方是已命名的 **Board views**——保存下来的