From d961c0ae6159c86d78bf48ceb5308bb85e7d315b Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 19 Aug 2026 20:18:32 +0800 Subject: [PATCH] feat(app): standardize scientific summaries across canvas pages Capture and persist normalized scientific identity during data import. Derive summaries consistently from field descriptors and plot bindings. Separate generated summaries from user-authored page and panel notes. --- AGENTS.md | 2 + crates/app/src/ui/canvas/board.rs | 24 +- crates/app/src/ui/canvas/board_caption.rs | 44 + crates/app/src/ui/canvas/board_notes.rs | 44 +- crates/app/src/ui/canvas/mod.rs | 2 + crates/app/src/ui/canvas/panel_notes.rs | 7 +- crates/app/src/ui/properties/mod.rs | 6 +- crates/app/src/ui/windows/canvas_settings.rs | 10 +- crates/core/src/actions/tests/mod.rs | 6 +- crates/core/src/actions/tests/stack.rs | 2 +- crates/core/src/figures.rs | 6 +- crates/core/src/project/convert.rs | 41 +- .../src/project/electrophysiology_convert.rs | 3 +- crates/core/src/project/mod.rs | 2 + .../src/project/scientific_identity_tests.rs | 20 + crates/core/src/project/tests.rs | 9 + crates/core/src/project/typed_table.rs | 6 +- crates/core/src/project/xps_convert.rs | 1 + .../core/src/properties/object_definitions.rs | 8 +- crates/core/src/state/afm.rs | 4 + crates/core/src/state/app_impl_io.rs | 21 +- crates/core/src/state/app_impl_slice.rs | 5 + crates/core/src/state/datasets.rs | 8 + crates/core/src/state/datasets_dispatch.rs | 26 + crates/core/src/state/document.rs | 9 +- crates/core/src/state/electrophysiology.rs | 4 + crates/core/src/state/field.rs | 5 + crates/core/src/state/mass_spec.rs | 4 + crates/core/src/state/mod.rs | 2 + crates/core/src/state/panel.rs | 4 +- crates/core/src/state/panel_label.rs | 20 +- .../core/src/state/scientific_summary/mod.rs | 131 +++ .../src/state/scientific_summary/resolver.rs | 770 ++++++++++++++++++ crates/core/src/state/stack.rs | 4 +- crates/core/src/state/table.rs | 6 + crates/core/src/state/xps.rs | 5 + crates/core/src/state/xrd.rs | 8 + crates/core/src/workflow.rs | 5 +- .../core/src/workflow/scientific_identity.rs | 14 + crates/core/src/workflow_tests.rs | 5 +- crates/io/src/abf2.rs | 5 + crates/io/src/bruker.rs | 51 +- crates/io/src/bruker/processed.rs | 20 +- crates/io/src/jcamp_dx.rs | 1 + crates/io/src/jeol.rs | 60 +- crates/io/src/jeol/tests.rs | 12 + crates/io/src/lib.rs | 44 +- crates/io/src/mzml.rs | 1 + crates/io/src/nanoscope.rs | 1 + crates/io/src/varian.rs | 35 +- crates/io/src/waters.rs | 1 + crates/io/src/xps.rs | 8 + crates/io/src/xrd.rs | 1 + crates/io/tests/bruker_processed.rs | 18 +- .../content/docs/guides/layout-and-export.md | 10 + .../docs/zh-cn/guides/layout-and-export.md | 9 + 56 files changed, 1441 insertions(+), 139 deletions(-) create mode 100644 crates/app/src/ui/canvas/board_caption.rs create mode 100644 crates/core/src/project/scientific_identity_tests.rs create mode 100644 crates/core/src/state/scientific_summary/mod.rs create mode 100644 crates/core/src/state/scientific_summary/resolver.rs create mode 100644 crates/core/src/workflow/scientific_identity.rs diff --git a/AGENTS.md b/AGENTS.md index 4ec1c34..a667907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,8 @@ These instructions apply to the entire repository. - Encoding applicability is attached to field rendering capabilities, never to a data-domain enum. New domains gain an encoding by exposing its capability; do not add domain branches or domain names to encoding/property registries. +- Scientific summaries take identity from importers, observation from field + descriptors, and context from active bindings; panel notes are user-authored. - A persisted default, an invocation input, and result provenance are lifecycle copies of one value, not parallel sources of state. Resolve them in priority order: explicit input, target provenance, then default. diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index 24b1d79..a53e00f 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -339,26 +339,26 @@ pub(crate) fn paint_frame_captions( ) { let bt = BoardTransform::from_board(app.session.board, screen); let color = ui.visuals().text_color(); - for canvas in &app.doc.canvases { + for (ci, canvas) in app.doc.canvases.iter().enumerate() { if !canvas.caption_visible { continue; } - let mut lines: Vec = Vec::new(); - if !canvas.caption.trim().is_empty() { - lines.push(canvas.caption.clone()); - } - lines.extend( - canvas - .panel_notes() - .into_iter() - .map(|(letter, note)| format!("{letter} — {note}")), - ); + let lines = frame_caption_lines(app, ci); if lines.is_empty() { continue; } let page = bt.page_screen_rect(canvas); let font = egui::FontId::proportional((11.0 * bt.zoom).clamp(7.0, 28.0)); - let galley = painter.layout(lines.join("\n"), font, color, page.width().max(1.0)); + let galley = painter.layout( + lines + .into_iter() + .map(|line| line.text) + .collect::>() + .join("\n"), + font, + color, + page.width().max(1.0), + ); let top_left = Pos2::new(page.left(), page.bottom() + CAPTION_GAP_PX); if !screen.intersects(egui::Rect::from_min_size(top_left, galley.size())) { continue; diff --git a/crates/app/src/ui/canvas/board_caption.rs b/crates/app/src/ui/canvas/board_caption.rs new file mode 100644 index 0000000..d930887 --- /dev/null +++ b/crates/app/src/ui/canvas/board_caption.rs @@ -0,0 +1,44 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct FrameCaptionLine { + pub text: String, + pub panel_note: Option, +} + +/// One canonical board representation: derived scientific summary first, +/// followed by explicitly authored page and panel notes. +pub(crate) fn frame_caption_lines(app: &PlotxApp, ci: usize) -> Vec { + let Some(canvas) = app.doc.canvases.get(ci) else { + return Vec::new(); + }; + let mut lines = app + .canvas_scientific_summary(ci) + .formatted_lines() + .into_iter() + .map(|text| FrameCaptionLine { + text, + panel_note: None, + }) + .collect::>(); + if !canvas.caption.trim().is_empty() { + lines.push(FrameCaptionLine { + text: format!("Note: {}", canvas.caption.trim()), + panel_note: None, + }); + } + lines.extend( + canvas + .panel_note_entries() + .into_iter() + .map(|(id, letter, note)| FrameCaptionLine { + text: if letter.is_empty() { + format!("Note: {note}") + } else { + format!("{letter} note — {note}") + }, + panel_note: Some(id), + }), + ); + lines +} diff --git a/crates/app/src/ui/canvas/board_notes.rs b/crates/app/src/ui/canvas/board_notes.rs index c94ff36..3608a87 100644 --- a/crates/app/src/ui/canvas/board_notes.rs +++ b/crates/app/src/ui/canvas/board_notes.rs @@ -16,24 +16,16 @@ pub(crate) fn handle_frame_caption_interactions( let page = bt.page_screen_rect(canvas); let font = egui::FontId::proportional((11.0 * bt.zoom).clamp(7.0, 28.0)); let mut y = page.bottom() + CAPTION_GAP_PX; - if !canvas.caption.trim().is_empty() { - let galley = ui.painter().layout( - canvas.caption.clone(), - font.clone(), - color, - page.width().max(1.0), - ); - y += galley.size().y; - } - - let entries = canvas.panel_note_entries(); - for (object_id, letter, note) in entries { - let text = format!("{letter} - {note}"); + for line in frame_caption_lines(app, ci) { + let text = line.text; let galley = ui .painter() .layout(text, font.clone(), color, page.width().max(1.0)); let row = egui::Rect::from_min_size(Pos2::new(page.left(), y), galley.size()); y += galley.size().y; + let Some(object_id) = line.panel_note else { + continue; + }; if !screen.intersects(row) { continue; } @@ -43,7 +35,7 @@ pub(crate) fn handle_frame_caption_interactions( ui.id().with(("panel_note_row", ci, object_id)), Sense::click(), ) - .on_hover_text("Click to edit this panel description."); + .on_hover_text("Click to edit this panel note."); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); consumed = true; @@ -57,11 +49,11 @@ pub(crate) fn handle_frame_caption_interactions( resp.context_menu(|ui| { app.session.active_canvas = Some(ci); app.select_object(ci, object_id); - if ui.button("Edit description in place").clicked() { + if ui.button("Edit note in place").clicked() { open_inline_panel_note_editor(app, ci, object_id); ui.close(); } - if ui.button("Edit description in dialog").clicked() { + if ui.button("Edit note in dialog").clicked() { app.session.ui.panel_note_inline_edit = None; open_panel_note_editor(app, ci, object_id); ui.close(); @@ -159,23 +151,13 @@ fn panel_note_row_rect( let color = ui.visuals().text_color(); let font = egui::FontId::proportional((11.0 * bt.zoom).clamp(7.0, 28.0)); let mut y = page.bottom() + CAPTION_GAP_PX; - if !canvas.caption.trim().is_empty() { - let galley = ui.painter().layout( - canvas.caption.clone(), - font.clone(), - color, - page.width().max(1.0), - ); - y += galley.size().y; - } - - for (id, letter, note) in canvas.panel_note_entries() { - let text = format!("{letter} - {note}"); + for line in frame_caption_lines(app, ci) { + let text = line.text; let galley = ui .painter() .layout(text, font.clone(), color, page.width().max(1.0)); let row = egui::Rect::from_min_size(Pos2::new(page.left(), y), galley.size()); - if id == object_id { + if line.panel_note == Some(object_id) { return Some(row); } y += galley.size().y; @@ -197,7 +179,7 @@ fn commit_inline_panel_note_edit(app: &mut PlotxApp) { return; }; app.execute_action(Action::set_panel_meta(ci, id, before, after)); - app.session.status = "Panel description updated.".to_owned(); + app.session.status = "Panel note updated.".to_owned(); } fn cancel_inline_panel_note_edit(app: &mut PlotxApp) { @@ -208,5 +190,5 @@ fn cancel_inline_panel_note_edit(app: &mut PlotxApp) { if let Some(canvas) = app.doc.canvases.get_mut(ci) { canvas.set_panel_meta_for_content(id, before); } - app.session.status = "Panel description edit cancelled.".to_owned(); + app.session.status = "Panel note edit cancelled.".to_owned(); } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 5753869..2f2a912 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -30,6 +30,7 @@ const SNAP_PX: f32 = 6.0; mod authoring; mod board; +mod board_caption; mod board_marquee; mod board_notes; mod breadcrumb; @@ -60,6 +61,7 @@ mod tiling; pub(crate) use authoring::*; pub(crate) use board::*; +pub(crate) use board_caption::*; pub(crate) use board_notes::*; pub(crate) use breadcrumb::*; pub(crate) use chrome::*; diff --git a/crates/app/src/ui/canvas/panel_notes.rs b/crates/app/src/ui/canvas/panel_notes.rs index 54e1634..b845b10 100644 --- a/crates/app/src/ui/canvas/panel_notes.rs +++ b/crates/app/src/ui/canvas/panel_notes.rs @@ -32,7 +32,7 @@ pub(crate) fn handle_panel_label_interactions( let id = ui.id().with(("panel_label", ci, object_id)); let resp = ui .interact(label_rect, id, Sense::click_and_drag()) - .on_hover_text("Double-click to edit this panel description"); + .on_hover_text("Double-click to edit this panel note"); let mut consumed = label_hovered || resp.hovered() || matches!(app.interaction(), Interaction::PanelLabel(_)); @@ -41,8 +41,7 @@ pub(crate) fn handle_panel_label_interactions( if matches!(app.interaction(), Interaction::Object(_)) { app.reset_interaction(); } - app.session.status = - "Panel letter selected. Double-click to edit its description.".to_owned(); + app.session.status = "Panel letter selected. Double-click to edit its note.".to_owned(); if let (Some(pointer), Some(panel)) = ( hover, app.doc.canvases[ci].panel_meta_for_content(object_id), @@ -65,7 +64,7 @@ pub(crate) fn handle_panel_label_interactions( resp.context_menu(|ui| { app.select_panel_label(ci, object_id); - if ui.button("Edit panel description").clicked() { + if ui.button("Edit panel note").clicked() { open_panel_note_editor(app, ci, object_id); ui.close(); } diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index 9ea15e3..acb028f 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -48,7 +48,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ object_entry(object::CHART_COLORMAP, "Colormap", CHART_HOME), object_entry(object::CHART_VIEW_AZIMUTH, "Azimuth", CHART_HOME), object_entry(object::CHART_VIEW_ELEVATION, "Elevation", CHART_HOME), - object_entry(object::PANEL_USER_NOTE, "Description", PANEL_HOME), + object_entry(object::PANEL_USER_NOTE, "Note", PANEL_HOME), object_entry(object::PANEL_VISIBLE, "Show letter", PANEL_HOME), object_entry(object::TEXT, "Text", TEXT_HOME), object_entry(object::TEXT_FONT_SIZE, "Size", TEXT_HOME), @@ -397,8 +397,8 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ }, PropertyPresentation { id: canvas::CAPTION_VISIBLE, - localized_label: LocalizedText("Show caption below page"), - localized_aliases: &[LocalizedText("caption visibility")], + localized_label: LocalizedText("Show summary below page"), + localized_aliases: &[LocalizedText("summary visibility")], home_route: CANVAS_CAPTION_HOME, canvas_step: false, uses_canvas_length_unit: false, diff --git a/crates/app/src/ui/windows/canvas_settings.rs b/crates/app/src/ui/windows/canvas_settings.rs index c7cdd38..8445049 100644 --- a/crates/app/src/ui/windows/canvas_settings.rs +++ b/crates/app/src/ui/windows/canvas_settings.rs @@ -64,13 +64,15 @@ pub(in crate::ui) fn canvas_settings_window(app: &mut PlotxApp, ctx: &egui::Cont }); crate::ui::properties::panel::canvas_caption_section(app, &target, ui); - ui.weak("Shown below the page on the board only — not exported or presented."); + ui.weak( + "The scientific summary is generated automatically. This optional page note is shown after it on the board only.", + ); let resp = ui.add( egui::TextEdit::multiline(&mut app.doc.canvases[ci].caption) .desired_width(340.0) .desired_rows(3) - .hint_text("e.g. Figure 1. Concentration vs. time…"), + .hint_text("Optional page note…"), ); if resp.gained_focus() { app.session.ui.caption_edit_before = Some(( @@ -97,12 +99,12 @@ pub(in crate::ui) fn canvas_settings_window(app: &mut PlotxApp, ctx: &egui::Cont } } -/// Notes are auto-listed below the page on the board. +/// User notes are listed after the automatic scientific summary. fn panels_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { ui.label(crate::typography::headline("Panels")); ui.add_space(6.0); - ui.weak("Letters are top-left in each plot; notes list below the page (board only)."); + ui.weak("Letters identify multi-panel plots; optional notes follow the scientific summary."); ui.add_space(4.0); let order = app.doc.canvases[ci].plot_reading_order(); diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index 6440116..fb3f3e8 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -141,7 +141,6 @@ fn insert_dataset_existing_canvas_does_not_select_inserted_object() { let inserted_id = app.doc.canvases[0].next_object_id; let dataset_index = app.doc.datasets.len(); let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); - let expected_note = crate::workflow::dataset_title(&dataset); app.execute_action(Action::InsertDatasetWithCanvas { dataset_index, @@ -159,10 +158,7 @@ fn insert_dataset_existing_canvas_does_not_select_inserted_object() { assert_eq!(app.doc.canvases[0].objects.len(), 2); assert_eq!(app.doc.canvases[0].selected_object, None); let panel = app.doc.canvases[0].parent_panel(inserted_id).unwrap(); - assert_eq!( - app.doc.canvases[0].panel(panel).unwrap().note, - expected_note - ); + assert!(app.doc.canvases[0].panel(panel).unwrap().note.is_empty()); app.doc.canvases[0].selected_object = Some(inserted_id); app.undo(); diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index 1d6cf29..e15f873 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -166,7 +166,7 @@ fn plain_then_ctrl_click_selects_two_datasets_for_stacking() { canvas.panel_letter(canvas.objects[0].id).as_deref(), Some("a") ); - assert_eq!(canvas.panel_notes().len(), 1); + assert!(canvas.panel_notes().is_empty()); let plot = canvas.objects[0].plot().unwrap(); assert_ne!( plot.binding.series[0].primary_color(), diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 2348de7..715bc41 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -216,6 +216,10 @@ pub fn build_stack_figure(stack: &StackSpectrum) -> Figure { } pub(crate) fn axis_label(nucleus: &str) -> String { + format!("{} chemical shift (ppm)", format_nucleus(nucleus)) +} + +pub(crate) fn format_nucleus(nucleus: &str) -> String { let mut formatted = String::new(); let mut chars = nucleus.chars().peekable(); while chars.peek().is_some_and(char::is_ascii_digit) { @@ -235,7 +239,7 @@ pub(crate) fn axis_label(nucleus: &str) -> String { }); } formatted.extend(chars); - format!("{formatted} chemical shift (ppm)") + formatted } /// Build a DOSY contour figure from a per-column diffusion map: x = chemical diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 80f11b8..7124460 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -40,7 +40,7 @@ pub fn dataset_to_objects<'a>( data_id: &str, recipe_id: &str, ) -> Result> { - Ok(match dataset { + let mut objects = match dataset { Dataset::Nmr(n) => { let data = DataObject { id: data_id.to_owned(), @@ -315,7 +315,39 @@ pub fn dataset_to_objects<'a>( DatasetObjects::primary(data, DatasetBlob::Xrd(&xrd.data), recipe) } Dataset::Xps(xps) => super::xps_convert::to_objects(xps, data_id, recipe_id), - }) + }; + write_scientific_identity(&mut objects.data, dataset.scientific_identity())?; + Ok(objects) +} + +pub(super) fn write_scientific_identity( + data: &mut DataObject, + identity: &plotx_io::ImportedScientificIdentity, +) -> Result<()> { + let extensions = data.extensions.as_object_mut().ok_or_else(|| { + ProjectError::Invalid(format!("dataset {} extensions are not an object", data.id)) + })?; + extensions.insert( + "plotx.scientific_identity".to_owned(), + serde_json::to_value(identity)?, + ); + Ok(()) +} + +pub(super) fn read_scientific_identity( + data: &DataObject, +) -> Result { + let value = data + .extensions + .get("plotx.scientific_identity") + .cloned() + .ok_or_else(|| { + ProjectError::Invalid(format!( + "dataset {} is missing plotx.scientific_identity", + data.id + )) + })?; + serde_json::from_value(value).map_err(ProjectError::from) } pub fn object_to_dataset( zip: &mut zip::ZipArchive, @@ -353,6 +385,7 @@ pub fn object_to_dataset( .validate() .map_err(|error| ProjectError::Invalid(error.to_owned()))?; let mut dataset = crate::state::XrdDataset::load(decoded); + dataset.scientific_identity = read_scientific_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; dataset.name = data.label.clone(); if let Some(value) = recipe @@ -391,6 +424,7 @@ pub fn object_to_dataset( ProjectLoadLimits::default().max_entry_bytes, |reader| super::mass_spec_convert::decode(reader), )?; + dataset.scientific_identity = read_scientific_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; dataset.name = data.label.clone(); dataset.repair_selection().map_err(ProjectError::Invalid)?; @@ -410,6 +444,7 @@ pub fn object_to_dataset( |reader| super::afm_convert::decode_afm(reader), )?; let mut dataset = crate::state::AfmDataset::load(decoded); + dataset.scientific_identity = read_scientific_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; dataset.name = data.label.clone(); if let Some(state) = data.extensions.get("plotx.afm") @@ -518,6 +553,7 @@ pub fn object_to_dataset( source: nmr_source(data), group_delay: dim.group_delay.unwrap_or(0.0), }); + dataset.scientific_identity = read_scientific_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; apply_1d_recipe(&mut dataset, recipe)?; dataset.name = data.label.clone(); @@ -581,6 +617,7 @@ pub fn object_to_dataset( nus: None, source: nmr_source(data), }); + dataset.scientific_identity = read_scientific_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; apply_2d_recipe(&mut dataset, recipe)?; read_region_analysis(&mut dataset, recipe)?; diff --git a/crates/core/src/project/electrophysiology_convert.rs b/crates/core/src/project/electrophysiology_convert.rs index acc4058..0f4f21d 100644 --- a/crates/core/src/project/electrophysiology_convert.rs +++ b/crates/core/src/project/electrophysiology_convert.rs @@ -100,7 +100,7 @@ pub(super) fn electrophysiology_from_object( zip: &mut zip::ZipArchive, data: &DataObject, ) -> Result { - let recording = match data.payload.storage.as_str() { + let mut recording = match data.payload.storage.as_str() { STORAGE_ELECTROPHYSIOLOGY_BIN => { let value = data .extensions @@ -138,6 +138,7 @@ pub(super) fn electrophysiology_from_object( recording.region_analysis.validate().map_err(|error| { ProjectError::Invalid(format!("invalid region analysis state: {error}")) })?; + recording.scientific_identity = super::convert::read_scientific_identity(data)?; let dataset = Dataset::Electrophysiology(Box::new(recording)); dataset .validate_field_catalog() diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index a2292fb..18facf3 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -783,6 +783,8 @@ mod reference_tests; #[cfg(test)] mod schema_tests; #[cfg(test)] +mod scientific_identity_tests; +#[cfg(test)] mod step_identity_tests; #[cfg(test)] mod symmetry_tests; diff --git a/crates/core/src/project/scientific_identity_tests.rs b/crates/core/src/project/scientific_identity_tests.rs new file mode 100644 index 0000000..4a893b1 --- /dev/null +++ b/crates/core/src/project/scientific_identity_tests.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn v1_rejects_dataset_objects_without_scientific_identity() { + let app = tests::sample_app(); + let mut objects = dataset_to_objects(&app.doc.datasets[0], "data-1", "recipe-1").unwrap(); + objects + .data + .extensions + .as_object_mut() + .expect("data extensions") + .remove("plotx.scientific_identity"); + + let error = read_scientific_identity(&objects.data).unwrap_err(); + assert!( + error + .to_string() + .contains("missing plotx.scientific_identity") + ); +} diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index e3fbc06..cfdb5b4 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -228,6 +228,11 @@ fn view_layout_without_board_pos_lands_on_grid_slot() { #[test] fn project_roundtrip_preserves_data_recipe_and_view() { let mut app = sample_app(); + app.doc.datasets[0].set_scientific_identity(plotx_io::ImportedScientificIdentity { + subject: Some("Sample A".to_owned()), + acquisition: Some("zg30".to_owned()), + source_label: "synthetic".to_owned(), + }); app.doc.canvases[0].layout = PageLayout { margin_mm: [11.0, 4.0, 9.0, 6.0], gutter_mm: 7.0, @@ -288,6 +293,10 @@ fn project_roundtrip_preserves_data_recipe_and_view() { let _ = std::fs::remove_file(&path); assert_eq!(loaded.doc.datasets.len(), 1); + assert_eq!( + loaded.doc.datasets[0].scientific_identity(), + app.doc.datasets[0].scientific_identity() + ); assert_eq!(loaded.doc.canvases.len(), 1); assert_eq!(loaded.active_dataset(), Some(0)); assert_eq!(loaded.session.active_canvas, Some(0)); diff --git a/crates/core/src/project/typed_table.rs b/crates/core/src/project/typed_table.rs index bede77b..c4f6add 100644 --- a/crates/core/src/project/typed_table.rs +++ b/crates/core/src/project/typed_table.rs @@ -186,7 +186,10 @@ pub(crate) fn table_dataset_to_v1( shape: typed_shape, domain: "table".into(), }, - extensions: serde_json::json!({ "plotx.table.v1": sidecar }), + extensions: serde_json::json!({ + "plotx.table.v1": sidecar, + "plotx.scientific_identity": &table.scientific_identity, + }), }; let recipe = RecipeObject { id: recipe_id.to_owned(), @@ -231,6 +234,7 @@ pub(crate) fn table_dataset_from_v1( ProjectError::Invalid(format!("table has invalid stable id {}", data.id)) })?, field_catalog: sidecar.field_catalog, + scientific_identity: super::convert::read_scientific_identity(data)?, provenance: sidecar.provenance, meta: sidecar.meta, curve_fit_analyses: sidecar.curve_fit_analyses, diff --git a/crates/core/src/project/xps_convert.rs b/crates/core/src/project/xps_convert.rs index c70c77e..379408f 100644 --- a/crates/core/src/project/xps_convert.rs +++ b/crates/core/src/project/xps_convert.rs @@ -84,6 +84,7 @@ pub(super) fn from_objects( )?; experiment.validate().map_err(ProjectError::Invalid)?; let mut dataset = crate::state::XpsDataset::load(experiment); + dataset.scientific_identity = super::convert::read_scientific_identity(data)?; dataset.field_catalog = super::field_catalog::read(data)?; dataset.name = data.label.clone(); let state = recipe diff --git a/crates/core/src/properties/object_definitions.rs b/crates/core/src/properties/object_definitions.rs index 1cea80f..7b4c09a 100644 --- a/crates/core/src/properties/object_definitions.rs +++ b/crates/core/src/properties/object_definitions.rs @@ -143,7 +143,13 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ definition(CHART_COLORMAP, ValueSchema::Enum { variants: COLORMAPS }, DefaultPolicy::Fixed(PropertyValue::Enum("viridis")), "Chart colormap", &["colour map"]), definition(CHART_VIEW_AZIMUTH, ValueSchema::Float { bounds: FloatBounds::inclusive(-180.0_f64.to_radians(), 180.0_f64.to_radians()), display: FloatDisplay::Degrees, drag_step: Some(1.0) }, DefaultPolicy::Fixed(PropertyValue::Float(-50.0_f64.to_radians())), "Surface azimuth", &["view azimuth"]), definition(CHART_VIEW_ELEVATION, ValueSchema::Float { bounds: FloatBounds::inclusive(5.0_f64.to_radians(), 90.0_f64.to_radians()), display: FloatDisplay::Degrees, drag_step: Some(1.0) }, DefaultPolicy::Fixed(PropertyValue::Float(30.0_f64.to_radians())), "Surface elevation", &["view elevation"]), - definition(PANEL_USER_NOTE, ValueSchema::Text, DefaultPolicy::Fixed(PropertyValue::Text(String::new())), "Panel description", &["panel note", "figure note", "caption entry"]), + definition( + PANEL_USER_NOTE, + ValueSchema::Text, + DefaultPolicy::Fixed(PropertyValue::Text(String::new())), + "Panel note", + &[], + ), definition(PANEL_VISIBLE, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(true)), "Show panel letter", &["panel label visible"]), PropertyDefinition { id: SERIES_VISIBLE, scope_kind: ScopeKind::Object, value_schema: ValueSchema::Bool, access: PropertyAccess::ReadWrite, applicability: SERIES, default_policy: DefaultPolicy::Fixed(PropertyValue::Bool(true)), tier: Tier::Essential, copies: ValueCopies::PerTarget, canonical_label: "Series visibility", canonical_aliases: &["show series"] }, definition(TEXT, ValueSchema::Text, DefaultPolicy::Derived, "Text", &["label text"]), diff --git a/crates/core/src/state/afm.rs b/crates/core/src/state/afm.rs index 0a7a67e..e1eef2d 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 scientific_identity: plotx_io::ImportedScientificIdentity, pub data: Arc, /// Immutable keys calculated with the loaded raster data, never serialized. pub(crate) image_field_keys: Arc<[String]>, @@ -17,6 +18,8 @@ pub struct AfmDataset { impl AfmDataset { pub fn load(data: AfmData) -> Self { + let scientific_identity = + plotx_io::ImportedScientificIdentity::from_path(std::path::Path::new(&data.source)); let selected_pixel = data.forces.as_ref().map_or([0, 0], |forces| { [forces.grid_width / 2, forces.grid_height / 2] }); @@ -26,6 +29,7 @@ impl AfmDataset { Self { resource_id: DatasetId::new(), field_catalog, + scientific_identity, data: Arc::new(data), image_field_keys, name: None, diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 965748d..2f4ce6d 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -223,7 +223,8 @@ impl PlotxApp { Ok(result) => { let format = result.format.as_str(); let warnings = result.warnings; - let source = self.insert_acquisition(result.acquisition); + let source = + self.insert_acquisition(result.acquisition, result.scientific_identity); let mut report = if warnings.is_empty() { OperationReport::success( operation_id, @@ -300,7 +301,7 @@ impl PlotxApp { let mut warnings = result.warnings; for item in result.items { warnings.extend(item.warnings); - self.insert_acquisition(item.acquisition); + self.insert_acquisition(item.acquisition, item.scientific_identity); } let summary = if warnings.is_empty() { format!("Loaded {count} spectra from {archive}") @@ -362,12 +363,16 @@ impl PlotxApp { // Turn a loaded acquisition into a dataset on its own default canvas, as one // undoable step, and return its source label. - fn insert_acquisition(&mut self, acq: plotx_io::Acquisition) -> String { - let (dataset, source) = - crate::workflow::dataset_from_acquisition_with_equal_scale_preference( - acq, - self.settings.general.equal_scale_homonuclear_2d_imports, - ); + fn insert_acquisition( + &mut self, + acq: plotx_io::Acquisition, + scientific_identity: plotx_io::ImportedScientificIdentity, + ) -> String { + let (dataset, source) = crate::workflow::dataset_from_loaded_acquisition( + acq, + scientific_identity, + self.settings.general.equal_scale_homonuclear_2d_imports, + ); let name = Self::short_name(&source); self.execute_action(Action::insert_dataset_with_default_canvas( self, diff --git a/crates/core/src/state/app_impl_slice.rs b/crates/core/src/state/app_impl_slice.rs index 1e1058e..c4d7aa5 100644 --- a/crates/core/src/state/app_impl_slice.rs +++ b/crates/core/src/state/app_impl_slice.rs @@ -52,6 +52,11 @@ impl NmrDataset { Self { resource_id: DatasetId::new(), field_catalog, + scientific_identity: plotx_io::ImportedScientificIdentity { + subject: None, + acquisition: None, + source_label: source.clone(), + }, data, base: processed.clone(), pipeline, diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 112df39..5b6b6dc 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -35,6 +35,7 @@ pub struct NmrDataset { /// Persisted child-field identity allocator and key mapping. pub field_catalog: FieldCatalog, pub data: NmrData, + pub scientific_identity: plotx_io::ImportedScientificIdentity, pub base: Processed1D, pub pipeline: AxisPipeline, /// Persistent owner-local allocator; excluded from processing undo snapshots. @@ -62,6 +63,8 @@ pub struct NmrDataset { impl NmrDataset { pub fn load(data: NmrData) -> Self { + let scientific_identity = + plotx_io::ImportedScientificIdentity::from_path(std::path::Path::new(&data.source)); let pipeline = match data.domain { Domain::Time => AxisPipeline::default_1d(), Domain::Frequency => AxisPipeline::frequency_1d(), @@ -77,6 +80,7 @@ impl NmrDataset { resource_id: DatasetId::new(), field_catalog, data, + scientific_identity, base, pipeline, next_step_id: 0, @@ -168,6 +172,7 @@ pub struct Nmr2DDataset { /// Persisted child-field identity allocator and key mapping. pub field_catalog: FieldCatalog, pub data: Arc, + pub scientific_identity: plotx_io::ImportedScientificIdentity, pub params: Params2D, /// Persistent owner-local allocator shared by both axes. pub next_step_id: u64, @@ -233,6 +238,8 @@ impl Nmr2DDataset { data: NmrData2D, equal_scale_homonuclear_2d_imports: bool, ) -> Self { + let scientific_identity = + plotx_io::ImportedScientificIdentity::from_path(std::path::Path::new(&data.source)); let preset = recommend_preset(&data); let params = match data.domain { Domain::Time => Params2D::default_for(preset), @@ -260,6 +267,7 @@ impl Nmr2DDataset { resource_id: DatasetId::new(), field_catalog, data: Arc::new(data), + scientific_identity, base_params: params.clone(), base_stale: false, params, diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 8224405..bbca62d 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -1,5 +1,31 @@ use super::*; impl Dataset { + pub fn scientific_identity(&self) -> &plotx_io::ImportedScientificIdentity { + match self { + Dataset::Nmr(data) => &data.scientific_identity, + Dataset::Nmr2D(data) => &data.scientific_identity, + Dataset::Table(data) => &data.scientific_identity, + Dataset::Electrophysiology(data) => &data.scientific_identity, + Dataset::Afm(data) => &data.scientific_identity, + Dataset::MassSpec(data) => &data.scientific_identity, + Dataset::Xrd(data) => &data.scientific_identity, + Dataset::Xps(data) => &data.scientific_identity, + } + } + + pub fn set_scientific_identity(&mut self, identity: plotx_io::ImportedScientificIdentity) { + match self { + Dataset::Nmr(data) => data.scientific_identity = identity, + Dataset::Nmr2D(data) => data.scientific_identity = identity, + Dataset::Table(data) => data.scientific_identity = identity, + Dataset::Electrophysiology(data) => data.scientific_identity = identity, + Dataset::Afm(data) => data.scientific_identity = identity, + Dataset::MassSpec(data) => data.scientific_identity = identity, + Dataset::Xrd(data) => data.scientific_identity = identity, + Dataset::Xps(data) => data.scientific_identity = identity, + } + } + pub fn as_xps(&self) -> Option<&XpsDataset> { match self { Dataset::Xps(data) => Some(data), diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index a5acc01..3bd8571 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -54,8 +54,7 @@ pub struct PanDrag { pub before: CanvasViewport, } -/// A plot panel's identity: its long descriptive `note` (board-only, auto-listed -/// in the page notes region) plus the placement/visibility of its auto-assigned +/// A plot panel's user note plus the placement/visibility of its auto-assigned /// panel letter (a/b/c…), which is drawn bold in the frame's top-left corner. The /// letter glyph itself is computed from reading order + the page's label style, so /// it is not stored here. @@ -448,9 +447,9 @@ pub struct CanvasDocument { pub selected_object: Option, /// Top-left of this page on the board, in world (pt) space. pub board_pos: [f32; 2], - /// Board-only figure caption shown below the page frame (never exported or - /// presented). Acts as the page-level caption; per-panel descriptions live in - /// each plot's user note. Empty renders nothing; `caption_visible` toggles it + /// Board-only user note shown below the page frame (never exported or + /// presented). Per-panel notes live on panels. `caption_visible` toggles the + /// scientific summary and all user notes /// per page. pub caption: String, pub caption_visible: bool, diff --git a/crates/core/src/state/electrophysiology.rs b/crates/core/src/state/electrophysiology.rs index 5e34e67..678bba3 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 scientific_identity: plotx_io::ImportedScientificIdentity, pub data: ElectrophysiologyData, /// Calculated from loaded samples and omitted from project metadata. #[serde(skip, default)] @@ -170,6 +171,8 @@ pub(crate) fn resolve_abf_stimulus(data: &ElectrophysiologyData) -> Option Self { + let scientific_identity = + plotx_io::ImportedScientificIdentity::from_path(std::path::Path::new(&data.source)); let stimulus = data .sweeps .iter() @@ -200,6 +203,7 @@ impl ElectrophysiologyDataset { Self { resource_id: new_resource_id(), field_catalog, + scientific_identity, data, field_keys: OnceLock::from(field_keys), name: None, diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index a11bfb0..a16d7b4 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -1,4 +1,5 @@ use super::field_runtime::*; +use super::scientific_summary::SummaryPart; use super::{ FieldCatalog, FieldId, channel_key, electrophysiology_channel_key, extracted_stream_spectrum_key, extraction_title, mass_spec_dataset_field_keys, stream_bpi_key, @@ -45,6 +46,7 @@ impl super::Dataset { id, local_id: local_id.to_owned(), name: name.to_owned(), + scientific_observation: SummaryPart::new(format!("field:{local_id}"), name), capabilities, dimensions, units, @@ -639,6 +641,9 @@ pub struct FieldDescriptor { pub id: FieldId, pub local_id: String, pub name: String, + /// The scientific concept represented by this field. This is required so + /// every new field participates in the v1 summary contract by construction. + pub scientific_observation: SummaryPart, pub capabilities: FieldCapabilities, pub dimensions: Vec, pub units: Vec, diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index 30bae96..70452c0 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -67,6 +67,7 @@ pub struct ExtractedMassSpectrum { pub struct MassSpecDataset { pub resource_id: DatasetId, pub field_catalog: FieldCatalog, + pub scientific_identity: plotx_io::ImportedScientificIdentity, pub run: MassSpecRun, pub name: Option, pub lineage: Option, @@ -118,6 +119,8 @@ impl MassSpecDataset { .then_some(self.active_stream) } pub fn load(run: MassSpecRun) -> Self { + let scientific_identity = + plotx_io::ImportedScientificIdentity::from_path(std::path::Path::new(&run.source)); let active_stream = first_ms_stream(&run).expect("a validated LC–MS run has a readable primary stream"); let mut field_catalog = mass_spec_field_catalog(&run); @@ -125,6 +128,7 @@ impl MassSpecDataset { Self { resource_id: DatasetId::new(), field_catalog, + scientific_identity, run, name: None, lineage: None, diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index b4e5a88..71fad21 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -92,6 +92,7 @@ mod plot_interaction; mod plot_object; mod pseudo_map_field; mod region; +mod scientific_summary; mod selection; mod series_binding; mod size_presets; @@ -180,6 +181,7 @@ pub use plot_interaction::*; pub use plot_object::*; pub(crate) use pseudo_map_field::{DOSY_GRID_COLS, DOSY_GRID_ROWS, dosy_scalar_grid}; pub use region::*; +pub use scientific_summary::*; pub use selection::*; pub use series_binding::*; pub use size_presets::*; diff --git a/crates/core/src/state/panel.rs b/crates/core/src/state/panel.rs index 3f9376d..589f68d 100644 --- a/crates/core/src/state/panel.rs +++ b/crates/core/src/state/panel.rs @@ -328,8 +328,7 @@ impl CanvasDocument { } } - /// Give loose panel-capable content its default one-item semantic panel while - /// preserving its page position and existing display name as the panel note. + /// Give loose panel-capable content its default one-item semantic panel. pub fn create_panel_for_content(&mut self, content: ContentId) -> Option { if let Some(panel) = self.parent_panel(content) { return Some(panel); @@ -351,7 +350,6 @@ impl CanvasDocument { } let panel = self.panel_mut(panel_id)?; panel.item_order.push(content); - panel.note = name; Some(panel_id) } diff --git a/crates/core/src/state/panel_label.rs b/crates/core/src/state/panel_label.rs index 7ec924d..26a221b 100644 --- a/crates/core/src/state/panel_label.rs +++ b/crates/core/src/state/panel_label.rs @@ -147,21 +147,25 @@ impl CanvasDocument { } } - /// Skips empty notes. + /// User-authored notes only. Skips empty notes and returns an empty label + /// when the panel letter is not displayed. pub fn panel_note_entries(&self) -> Vec<(ObjectId, String, String)> { self.panels .iter() .filter_map(|panel| { let id = *panel.item_order.first()?; let note = panel.note.trim(); - if !panel.label.visible { - return None; - } - let letter = match &panel.label.mode { - PanelLabelMode::Auto { slot } => self.panel_label_style.format(*slot as usize), - PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => { - value.clone() + let letter = if self.panel_label_is_displayed(panel.id) { + match &panel.label.mode { + PanelLabelMode::Auto { slot } => { + self.panel_label_style.format(*slot as usize) + } + PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => { + value.clone() + } } + } else { + String::new() }; (!note.is_empty()).then(|| (id, letter, note.to_owned())) }) diff --git a/crates/core/src/state/scientific_summary/mod.rs b/crates/core/src/state/scientific_summary/mod.rs new file mode 100644 index 0000000..b561b33 --- /dev/null +++ b/crates/core/src/state/scientific_summary/mod.rs @@ -0,0 +1,131 @@ +mod resolver; + +pub use resolver::*; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SummaryPart { + pub semantic_key: String, + pub text: String, +} + +impl SummaryPart { + pub fn new(semantic_key: impl Into, text: impl Into) -> Self { + Self { + semantic_key: semantic_key.into(), + text: text.into(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScientificSummary { + pub subject: SummaryPart, + pub observation: SummaryPart, + pub context: Option, +} + +impl ScientificSummary { + pub fn parts(&self) -> Vec { + std::iter::once(self.subject.clone()) + .chain(std::iter::once(self.observation.clone())) + .chain(self.context.clone()) + .collect() + } + + pub fn format(&self) -> String { + format_parts(&self.parts()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SummaryLine { + pub panel_label: Option, + pub parts: Vec, +} + +impl SummaryLine { + pub fn format(&self) -> String { + let body = format_parts(&self.parts); + match (&self.panel_label, body.is_empty()) { + (Some(label), false) => format!("{label} — {body}"), + (_, false) => body, + _ => String::new(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CanvasScientificSummary { + pub lines: Vec, +} + +impl CanvasScientificSummary { + pub fn formatted_lines(&self) -> Vec { + self.lines + .iter() + .map(SummaryLine::format) + .filter(|line| !line.is_empty()) + .collect() + } +} + +fn format_parts(parts: &[SummaryPart]) -> String { + parts + .iter() + .map(|part| part.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" · ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{Dataset, NmrDataset, PlotxApp}; + use num_complex::Complex64; + use plotx_io::{Domain, ImportedScientificIdentity, NmrData}; + + fn nmr_dataset() -> Dataset { + let mut dataset = NmrDataset::load(NmrData { + points: vec![Complex64::new(1.0, 0.0); 8], + domain: Domain::Frequency, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + source: "raw/exp1/fid".to_owned(), + group_delay: 0.0, + }); + dataset.scientific_identity = ImportedScientificIdentity { + subject: Some("Sample A".to_owned()), + acquisition: Some("zg30".to_owned()), + source_label: "fid".to_owned(), + }; + Dataset::Nmr(Box::new(dataset)) + } + + #[test] + fn nmr_summary_contains_only_the_v1_scientific_contract() { + let dataset = nmr_dataset(); + let mut app = PlotxApp::default(); + app.doc + .canvases + .push(crate::workflow::build_default_canvas(&dataset, "fid")); + app.doc.datasets.push(dataset); + + assert_eq!( + app.canvas_scientific_summary(0).formatted_lines(), + vec!["Sample A · 1H · zg30"] + ); + } + + #[test] + fn equal_text_with_distinct_semantics_is_not_silently_dropped() { + let summary = ScientificSummary { + subject: SummaryPart::new("subject:a", "A"), + observation: SummaryPart::new("observation:a", "A"), + context: None, + }; + assert_eq!(summary.format(), "A · A"); + } +} diff --git a/crates/core/src/state/scientific_summary/resolver.rs b/crates/core/src/state/scientific_summary/resolver.rs new file mode 100644 index 0000000..904967b --- /dev/null +++ b/crates/core/src/state/scientific_summary/resolver.rs @@ -0,0 +1,770 @@ +use super::{CanvasScientificSummary, ScientificSummary, SummaryLine, SummaryPart}; +use crate::state::{ + CanvasDocument, Dataset, DatasetId, FieldDescriptor, PlotObject, PlotxApp, SeriesBinding, +}; + +pub trait ScientificSummaryProvider { + fn scientific_summary( + &self, + app: &PlotxApp, + field: &FieldDescriptor, + series: &SeriesBinding, + plot: &PlotObject, + ) -> ScientificSummary; +} + +impl PlotxApp { + pub fn canvas_scientific_summary(&self, canvas_index: usize) -> CanvasScientificSummary { + let Some(canvas) = self.doc.canvases.get(canvas_index) else { + return CanvasScientificSummary::default(); + }; + canvas_summary(self, canvas) + } +} + +fn canvas_summary(app: &PlotxApp, canvas: &CanvasDocument) -> CanvasScientificSummary { + let panels = canvas + .panel_reading_order() + .into_iter() + .filter_map(|panel_id| { + let panel = canvas.panel(panel_id)?; + if !panel.visible { + return None; + } + let summaries = panel + .item_order + .iter() + .filter_map(|id| canvas.object(*id)) + .filter(|object| object.visible) + .filter_map(|object| object.plot()) + .filter_map(|plot| plot_summary(app, plot)) + .collect::>(); + combine_summaries(summaries).map(|summary| (panel_id, summary)) + }) + .collect::>(); + + if panels.is_empty() { + return CanvasScientificSummary::default(); + } + if panels.len() == 1 { + return CanvasScientificSummary { + lines: vec![SummaryLine { + panel_label: None, + parts: panels[0].1.parts(), + }], + }; + } + + let all_parts = panels + .iter() + .map(|(_, summary)| summary.parts()) + .collect::>(); + let common_len = longest_common_prefix(&all_parts); + let mut lines = Vec::new(); + if common_len > 0 { + lines.push(SummaryLine { + panel_label: None, + parts: all_parts[0][..common_len].to_vec(), + }); + } + for (panel_index, ((panel_id, _), parts)) in panels.iter().zip(all_parts).enumerate() { + let remainder = parts[common_len..].to_vec(); + if remainder.is_empty() { + continue; + } + lines.push(SummaryLine { + panel_label: Some(if canvas.panel_label_is_displayed(*panel_id) { + canvas + .panel(*panel_id) + .and_then(|panel| panel.item_order.first()) + .and_then(|id| canvas.panel_letter(*id)) + .unwrap_or_else(|| format!("Panel {}", panel_index + 1)) + } else { + format!("Panel {}", panel_index + 1) + }), + parts: remainder, + }); + } + CanvasScientificSummary { lines } +} + +fn plot_summary(app: &PlotxApp, plot: &PlotObject) -> Option { + let summaries = plot + .binding + .series + .iter() + .filter(|series| series.visible) + .filter_map(|series| { + let dataset = app + .doc + .dataset_index(series.source.resource) + .and_then(|index| app.doc.datasets.get(index))?; + let field = dataset.field_descriptor(series.source.field)?; + Some(dataset.scientific_summary(app, &field, series, plot)) + }) + .collect::>(); + combine_summaries(summaries) +} + +fn combine_summaries(summaries: Vec) -> Option { + let first = summaries.first()?.clone(); + if summaries.len() == 1 { + return Some(first); + } + Some(ScientificSummary { + subject: combine_part(&summaries, |summary| &summary.subject, "subjects"), + observation: combine_part(&summaries, |summary| &summary.observation, "observations"), + context: combine_optional_part(&summaries, |summary| summary.context.as_ref(), "contexts"), + }) +} + +fn combine_part( + summaries: &[ScientificSummary], + part: impl Fn(&ScientificSummary) -> &SummaryPart, + plural: &str, +) -> SummaryPart { + combine_unique_parts( + summaries.iter().map(|summary| part(summary).clone()), + plural, + ) + .expect("a combined required summary part has at least one value") +} + +fn combine_unique_parts( + parts: impl IntoIterator, + plural: &str, +) -> Option { + let mut unique = Vec::::new(); + for candidate in parts { + if !unique + .iter() + .any(|value| value.semantic_key == candidate.semantic_key) + { + unique.push(candidate); + } + } + Some(match unique.as_slice() { + [] => return None, + [only] => only.clone(), + [left, right] => SummaryPart::new( + format!("{}+{}", left.semantic_key, right.semantic_key), + format!("{} + {}", left.text, right.text), + ), + many => SummaryPart::new( + format!("{plural}:{}", many.len()), + format!("{} {plural}", many.len()), + ), + }) +} + +fn combine_optional_part<'a>( + summaries: &'a [ScientificSummary], + part: impl Fn(&'a ScientificSummary) -> Option<&'a SummaryPart>, + plural: &str, +) -> Option { + combine_unique_parts(summaries.iter().filter_map(part).cloned(), plural) +} + +fn longest_common_prefix(parts: &[Vec]) -> usize { + let Some(first) = parts.first() else { return 0 }; + (0..first.len()) + .take_while(|&index| { + parts.iter().all(|candidate| { + candidate + .get(index) + .is_some_and(|part| part.semantic_key == first[index].semantic_key) + }) + }) + .count() +} + +impl ScientificSummaryProvider for Dataset { + fn scientific_summary( + &self, + app: &PlotxApp, + field: &FieldDescriptor, + series: &SeriesBinding, + plot: &PlotObject, + ) -> ScientificSummary { + provider_summary(self, app, field, series, plot) + } +} + +fn provider_summary( + dataset: &Dataset, + app: &PlotxApp, + field: &FieldDescriptor, + series: &SeriesBinding, + plot: &PlotObject, +) -> ScientificSummary { + let subject = subject_part(dataset, app); + let mut summary = match dataset { + Dataset::Nmr(data) => ScientificSummary { + subject, + observation: nmr_1d_observation(data), + context: acquisition_part(dataset, None), + }, + Dataset::Nmr2D(data) => ScientificSummary { + subject, + observation: nmr_2d_observation(data), + context: acquisition_part(dataset, data.data.experiment.as_deref()), + }, + Dataset::Table(data) => ScientificSummary { + subject, + observation: table_observation(data, plot), + context: table_chart_context(dataset, plot), + }, + Dataset::Electrophysiology(data) => ScientificSummary { + subject, + observation: field.scientific_observation.clone(), + context: acquisition_part(dataset, data.data.protocol.as_deref()), + }, + Dataset::Afm(data) => ScientificSummary { + subject, + observation: field.scientific_observation.clone(), + context: afm_context(data, field), + }, + Dataset::MassSpec(data) => ScientificSummary { + subject, + observation: field.scientific_observation.clone(), + context: mass_spec_context(data, field.id), + }, + Dataset::Xrd(data) => ScientificSummary { + subject, + observation: SummaryPart::new("xrd:powder", "Powder XRD"), + context: data + .data + .target + .as_ref() + .map(|target| SummaryPart::new(format!("xrd:target:{target}"), target.clone())) + .or_else(|| { + data.data.wavelength_angstrom.map(|value| { + SummaryPart::new( + format!("xrd:wavelength:{value}"), + format!("λ {value:.4} Å"), + ) + }) + }), + }, + Dataset::Xps(data) => xps_summary(data, field, subject), + }; + if let Some(item_context) = trace_item_context(dataset, field, series) { + summary.context = merge_context(summary.context, item_context); + } + summary +} + +fn subject_part(dataset: &Dataset, app: &PlotxApp) -> SummaryPart { + let identity = dataset.scientific_identity(); + let inherited = dataset + .lineage() + .and_then(|lineage| inherited_subject(app, &lineage.sources)); + let text = identity + .subject + .clone() + .or(inherited) + .or_else(|| dataset.name()) + .unwrap_or_else(|| identity.source_label.clone()); + SummaryPart::new(format!("subject:{}", normalize_key(&text)), text) +} + +fn nmr_2d_observation(data: &crate::state::Nmr2DDataset) -> SummaryPart { + let direct = data.data.direct.nucleus.trim(); + let indirect = data.data.indirect.nucleus.trim(); + SummaryPart::new( + format!("nmr:{direct}:{indirect}"), + format!( + "{}–{}", + crate::figures::format_nucleus(direct), + crate::figures::format_nucleus(indirect) + ), + ) +} + +fn nmr_1d_observation(data: &crate::state::NmrDataset) -> SummaryPart { + let domain = data.output_domain(); + SummaryPart::new( + format!("nmr:{domain:?}:{}", data.data.nucleus), + if domain == plotx_io::Domain::Time { + format!("{} FID", data.data.nucleus) + } else { + data.data.nucleus.clone() + }, + ) +} + +fn inherited_subject(app: &PlotxApp, sources: &[DatasetId]) -> Option { + let mut values = sources.iter().filter_map(|id| { + app.doc + .dataset_index(*id) + .and_then(|index| app.doc.datasets.get(index)) + .map(|dataset| { + dataset + .scientific_identity() + .subject + .clone() + .unwrap_or_else(|| dataset.scientific_identity().source_label.clone()) + }) + }); + let first = values.next()?; + values + .all(|value| normalize_key(&value) == normalize_key(&first)) + .then_some(first) +} + +fn acquisition_part(dataset: &Dataset, preferred: Option<&str>) -> Option { + preferred + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .or_else(|| dataset.scientific_identity().acquisition.clone()) + .map(|text| SummaryPart::new(format!("acquisition:{}", normalize_key(&text)), text)) +} + +fn table_observation(data: &crate::state::TableDataset, plot: &PlotObject) -> SummaryPart { + let columns = &data.typed_state.envelope.revision.snapshot.schema.columns; + let name = |id| { + columns + .iter() + .find(|column| column.id == id) + .map(|column| column.name.clone()) + }; + let x = data.x_binding.and_then(name); + let all_y = data + .series_bindings + .iter() + .filter_map(|binding| name(binding.value_column)) + .collect::>(); + let selected = plot.chart.column.and_then(name).map(|column| vec![column]); + let multi_column = matches!( + plot.chart.type_id.as_str(), + "" | "table_line" + | "table_bar_grouped" + | "table_box" + | "table_violin" + | "table_heatmap" + | "table_surface" + ); + let y = if multi_column { + all_y + } else { + selected.unwrap_or_else(|| all_y.into_iter().take(1).collect()) + }; + let y_text = y.join(" + "); + if y_text.is_empty() { + return SummaryPart::new("table:data", "Data table"); + } + let y_key = y + .iter() + .map(|value| normalize_key(value)) + .collect::>() + .join("+"); + if matches!( + plot.chart.type_id.as_str(), + "table_box" | "table_violin" | "table_histogram" + ) { + return SummaryPart::new(format!("table:{y_key}"), y_text); + } + match x { + Some(x) => SummaryPart::new( + format!("table:{y_key}:vs:{}", normalize_key(&x)), + format!("{y_text} vs {x}"), + ), + None => SummaryPart::new(format!("table:{y_key}"), y_text), + } +} + +fn table_chart_context(dataset: &Dataset, plot: &PlotObject) -> Option { + let name = crate::state::resolved_chart_type(dataset.domain(), &plot.chart.type_id).name; + let display = name.strip_suffix(" chart").unwrap_or(name); + (!display.eq_ignore_ascii_case("line")) + .then(|| SummaryPart::new(format!("chart:{}", plot.chart.type_id), display)) +} + +fn trace_item_context( + dataset: &Dataset, + field: &FieldDescriptor, + series: &SeriesBinding, +) -> Option { + let item_id = series.source.item?; + let item = dataset.trace_collection(field.id)?.item(item_id)?; + let label = item.automatic_label()?; + Some(SummaryPart::new( + format!("trace-item:{}:{item_id}", dataset.resource_id()), + label, + )) +} + +fn merge_context(existing: Option, item: SummaryPart) -> Option { + match existing { + None => Some(item), + Some(existing) => Some(SummaryPart::new( + format!("{}+{}", existing.semantic_key, item.semantic_key), + format!("{} + {}", existing.text, item.text), + )), + } +} + +fn afm_context(data: &crate::state::AfmDataset, field: &FieldDescriptor) -> Option { + if field.local_id == "afm.force_curve" { + let [x, y] = data.selected_pixel; + return Some(SummaryPart::new( + format!("afm:pixel:{x}:{y}"), + format!("Pixel ({x}, {y})"), + )); + } + data.data + .images + .iter() + .zip(data.image_field_keys.iter()) + .find(|(_, key)| key.as_str() == field.local_id) + .and_then(|(channel, _)| match channel.frame_direction { + plotx_io::AfmFrameDirection::Trace => Some(("afm:trace", "Trace")), + plotx_io::AfmFrameDirection::Retrace => Some(("afm:retrace", "Retrace")), + plotx_io::AfmFrameDirection::Unknown => None, + }) + .map(|(key, text)| SummaryPart::new(key, text)) +} + +fn mass_spec_context( + data: &crate::state::MassSpecDataset, + field: crate::state::FieldId, +) -> Option { + let stream_id = data + .chromatogram_stream_for_field(field) + .or_else(|| data.spectrum_stream_for_field(field))?; + let stream = data.run.stream(stream_id)?; + let polarity = match stream.polarity() { + plotx_io::Polarity::Positive => "positive", + plotx_io::Polarity::Negative => "negative", + plotx_io::Polarity::Unknown => "", + }; + let level = stream.spectra.first().map(|spectrum| spectrum.ms_level); + let text = match (polarity.is_empty(), level) { + (false, Some(level)) => format!("{polarity} MS{level}"), + (false, None) => polarity.to_owned(), + (true, Some(level)) => format!("MS{level}"), + (true, None) => return None, + }; + Some(SummaryPart::new( + format!("mass:{}:{:?}", polarity, level), + text, + )) +} + +fn xps_summary( + data: &crate::state::XpsDataset, + field: &FieldDescriptor, + fallback_subject: SummaryPart, +) -> ScientificSummary { + let region = data + .experiment + .regions + .iter() + .find(|region| data.field_for_region(region.id) == Some(field.id)); + let subject = region + .and_then(|region| { + data.experiment + .measurements + .iter() + .find(|measurement| measurement.id == region.measurement) + }) + .map(|measurement| { + SummaryPart::new( + xps_local_key(data.resource_id, "measurement", measurement.id.0), + measurement.label.clone(), + ) + }) + .unwrap_or(fallback_subject); + let observation = region + .map(|region| { + SummaryPart::new( + xps_local_key(data.resource_id, "region", region.id.0), + region.name.clone(), + ) + }) + .unwrap_or_else(|| field.scientific_observation.clone()); + let context = region.and_then(|region| { + let shift = data + .measurement_shifts + .get(®ion.measurement) + .copied() + .unwrap_or(0.0); + if shift.abs() > f64::EPSILON { + return Some(SummaryPart::new( + format!("xps:shift:{shift}"), + format!("Energy shift {shift:+.2} eV"), + )); + } + region + .metadata + .get("anode") + .or_else(|| data.experiment.metadata.get("anode")) + .map(|anode| { + SummaryPart::new(format!("xps:anode:{}", normalize_key(anode)), anode.clone()) + }) + }); + ScientificSummary { + subject, + observation, + context, + } +} + +fn xps_local_key(dataset: DatasetId, kind: &str, local_id: u64) -> String { + format!("xps:{dataset}:{kind}:{local_id}") +} + +fn normalize_key(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{ + DatasetLineage, DerivationKind, FloatSeries, Nmr2DDataset, NmrDataset, + materialized_float_series_table, + }; + use num_complex::Complex64; + use plotx_data::{ + TraceCollectionCatalog, TraceCollectionId, TraceItemDescriptor, TraceItemId, + TraceItemParameter, TraceParameterValue, + }; + use plotx_io::{Dim, Domain, ImportedScientificIdentity, NmrData, NmrData2D, QuadMode}; + use plotx_processing::Slice1D; + + fn nmr(domain: Domain, subject: &str, acquisition: &str) -> Dataset { + let mut data = NmrDataset::load(NmrData { + points: vec![Complex64::new(1.0, 0.0); 8], + domain, + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 4.7, + nucleus: "1H".to_owned(), + source: "fid".to_owned(), + group_delay: 0.0, + }); + data.scientific_identity = ImportedScientificIdentity { + subject: Some(subject.to_owned()), + acquisition: Some(acquisition.to_owned()), + source_label: "fid".to_owned(), + }; + Dataset::Nmr(Box::new(data)) + } + + fn nmr_2d(direct: &str, indirect: &str) -> Nmr2DDataset { + let dimension = |nucleus: &str| Dim { + spectral_width_hz: 4_000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 0.0, + nucleus: nucleus.to_owned(), + group_delay: 0.0, + }; + Nmr2DDataset::load(NmrData2D { + data: vec![Complex64::new(1.0, 0.0); 4], + rows: 2, + cols: 2, + domain: Domain::Frequency, + direct: dimension(direct), + indirect: dimension(indirect), + quad: QuadMode::Complex, + indirect_conjugate: false, + experiment: None, + pseudo_axis: None, + diffusion: None, + nus: None, + source: "2d".to_owned(), + }) + } + + #[test] + fn two_dimensional_nmr_observation_uses_literature_nucleus_notation() { + assert_eq!(nmr_2d_observation(&nmr_2d("1H", "13C")).text, "¹H–¹³C"); + assert_eq!(nmr_2d_observation(&nmr_2d("1H", "1H")).text, "¹H–¹H"); + } + + #[test] + fn derived_slice_inherits_the_source_subject() { + let source = nmr(Domain::Frequency, "Specimen A", "HSQC"); + let source_id = source.resource_id(); + let mut derived = Dataset::Nmr(Box::new(NmrDataset::from_slice( + Slice1D { + coordinates: vec![2.0, 1.0], + domain: Domain::Frequency, + values: vec![Complex64::new(1.0, 0.0); 2], + nucleus: "1H".to_owned(), + observe_freq_mhz: 400.0, + position: Some(3.0), + position_domain: Domain::Frequency, + }, + "F2 slice at 3 ppm".to_owned(), + ))); + derived.set_lineage(Some(DatasetLineage::new( + DerivationKind::Slice, + [source_id], + ))); + let mut app = PlotxApp::default(); + app.doc + .canvases + .push(crate::workflow::build_default_canvas_for_dataset( + &derived, + 1, + "Slice".to_owned(), + crate::state::DEFAULT_CANVAS_SIZE_MM, + )); + app.doc.datasets.extend([source, derived]); + + assert_eq!( + app.canvas_scientific_summary(0).formatted_lines(), + vec!["Specimen A · 1H"] + ); + } + + #[test] + fn overlay_keeps_distinct_nonempty_contexts() { + let summaries = vec![ + ScientificSummary { + subject: SummaryPart::new("subject:a", "A"), + observation: SummaryPart::new("nmr:1h", "1H"), + context: Some(SummaryPart::new("experiment:cosy", "COSY")), + }, + ScientificSummary { + subject: SummaryPart::new("subject:a", "A"), + observation: SummaryPart::new("nmr:1h", "1H"), + context: Some(SummaryPart::new("experiment:tocsy", "TOCSY")), + }, + ]; + assert_eq!( + combine_summaries(summaries).unwrap().format(), + "A · 1H · COSY + TOCSY" + ); + } + + #[test] + fn fid_and_spectrum_have_distinct_semantic_observations() { + let time = NmrDataset::from_slice( + Slice1D { + coordinates: vec![0.0, 0.001], + domain: Domain::Time, + values: vec![Complex64::new(1.0, 0.0); 2], + nucleus: "1H".to_owned(), + observe_freq_mhz: 400.0, + position: None, + position_domain: Domain::Time, + }, + "FID".to_owned(), + ); + let frequency = nmr(Domain::Frequency, "A", "zg30"); + let time = nmr_1d_observation(&time); + let frequency = nmr_1d_observation(frequency.as_nmr().unwrap()); + assert_ne!(time.semantic_key, frequency.semantic_key); + assert_eq!(time.text, "1H FID"); + assert_eq!(frequency.text, "1H"); + } + + #[test] + fn active_trace_item_is_included_in_context() { + let mut dataset = nmr(Domain::Frequency, "A", "zg30"); + let nmr = dataset.as_nmr_mut().unwrap(); + let field = nmr.field_catalog.id_for_key("nmr.real").unwrap(); + let collection = TraceCollectionId::new(); + let item = TraceItemId::derived(collection, b"10 ms"); + nmr.field_catalog.set_trace_collection( + field, + TraceCollectionCatalog { + id: collection, + axis_quantity: "Delay".to_owned(), + axis_unit: "ms".to_owned(), + items: vec![TraceItemDescriptor { + id: item, + parameters: vec![TraceItemParameter { + key: "delay".to_owned(), + name: "Delay".to_owned(), + value: TraceParameterValue::Number { + value: 10.0, + unit: "ms".to_owned(), + }, + }], + primary_label_parameter: "delay".to_owned(), + label_override: None, + }], + }, + ); + let mut app = PlotxApp::default(); + let mut canvas = crate::workflow::build_default_canvas(&dataset, "fid"); + canvas.objects[0].plot_mut().unwrap().binding.series[0] + .source + .item = Some(item); + app.doc.canvases.push(canvas); + app.doc.datasets.push(dataset); + + assert_eq!( + app.canvas_scientific_summary(0).formatted_lines(), + vec!["A · 1H · zg30 + 10 ms"] + ); + } + + #[test] + fn table_summary_tracks_all_or_selected_plotted_columns() { + let mut table = materialized_float_series_table( + ( + "Time".to_owned(), + "s".to_owned(), + vec![Some(0.0), Some(1.0)], + ), + vec![ + FloatSeries { + name: "Signal".to_owned(), + unit: String::new(), + values: vec![Some(1.0), Some(2.0)], + uncertainty: None, + fit: None, + }, + FloatSeries { + name: "Baseline".to_owned(), + unit: String::new(), + values: vec![Some(0.5), Some(0.7)], + uncertainty: None, + fit: None, + }, + ], + "summary-test", + ) + .unwrap(); + table.scientific_identity.subject = Some("Sample".to_owned()); + let baseline = table.series_bindings[1].value_column; + let dataset = Dataset::Table(Box::new(table)); + let mut app = PlotxApp::default(); + let mut canvas = crate::workflow::build_default_canvas(&dataset, "table"); + assert_eq!( + table_observation( + dataset.as_table().unwrap(), + canvas.objects[0].plot().unwrap() + ) + .text, + "Signal + Baseline vs Time" + ); + let plot = canvas.objects[0].plot_mut().unwrap(); + plot.chart.type_id = "table_histogram".to_owned(); + plot.chart.column = Some(baseline); + app.doc.canvases.push(canvas); + app.doc.datasets.push(dataset); + assert_eq!( + app.canvas_scientific_summary(0).formatted_lines(), + vec!["Sample · Baseline · Histogram"] + ); + } + + #[test] + fn xps_local_keys_are_scoped_to_the_dataset() { + assert_ne!( + xps_local_key(DatasetId::new(), "region", 1), + xps_local_key(DatasetId::new(), "region", 1) + ); + } +} diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index dce619f..451f306 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -601,11 +601,9 @@ impl PlotxApp { visible: true, kind: CanvasObjectKind::Plot(Box::new(plot)), }); - let panel_id = canvas + canvas .create_panel_for_plot(id) .expect("a newly materialized stack object is a plot"); - canvas.panel_mut(panel_id).expect("new panel exists").note = - self.default_plot_title(primary); let index = self.doc.canvases.len(); let canvas_count = self.doc.canvases.len(); self.execute_action(Action::insert_canvas( diff --git a/crates/core/src/state/table.rs b/crates/core/src/state/table.rs index ada0f3f..b205f3b 100644 --- a/crates/core/src/state/table.rs +++ b/crates/core/src/state/table.rs @@ -171,6 +171,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 scientific_identity: plotx_io::ImportedScientificIdentity, /// Executable extraction recipe used to refresh this immutable table. pub provenance: Option, /// Domain constants consumed by analysis bindings. @@ -225,6 +226,11 @@ impl TableDataset { Self { resource_id: new_resource_id(), field_catalog, + scientific_identity: plotx_io::ImportedScientificIdentity { + subject: None, + acquisition: None, + source_label: "Data table".to_owned(), + }, provenance: None, meta: TableMeta::default(), curve_fit_analyses: Vec::new(), diff --git a/crates/core/src/state/xps.rs b/crates/core/src/state/xps.rs index 9036748..dd060f0 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 scientific_identity: plotx_io::ImportedScientificIdentity, pub experiment: Arc, pub name: Option, pub lineage: Option, @@ -58,6 +59,9 @@ pub struct XpsDataset { impl XpsDataset { pub fn load(experiment: XpsExperiment) -> Self { + let scientific_identity = plotx_io::ImportedScientificIdentity::from_path( + std::path::Path::new(&experiment.source), + ); let active_region = experiment .regions .iter() @@ -93,6 +97,7 @@ impl XpsDataset { Self { resource_id: DatasetId::new(), field_catalog, + scientific_identity, experiment: Arc::new(experiment), name: None, lineage: None, diff --git a/crates/core/src/state/xrd.rs b/crates/core/src/state/xrd.rs index 4246bff..205f736 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 scientific_identity: plotx_io::ImportedScientificIdentity, pub data: XrdData, pub params: XrdProcessing, pub processed: ProcessedXrd, @@ -21,6 +22,8 @@ pub struct XrdDataset { impl XrdDataset { pub fn load(data: XrdData) -> Self { + let scientific_identity = + plotx_io::ImportedScientificIdentity::from_path(std::path::Path::new(&data.source)); let params = XrdProcessing::default(); let processed = process(&data.two_theta_deg, &data.intensity, params) .expect("validated XRD acquisition has matching axes"); @@ -29,6 +32,7 @@ impl XrdDataset { Self { resource_id: DatasetId::new(), field_catalog, + scientific_identity, data, params, processed, @@ -80,6 +84,10 @@ impl XrdDataset { id, local_id: "xrd.intensity".to_owned(), name: "Intensity".to_owned(), + scientific_observation: crate::state::scientific_summary::SummaryPart::new( + "xrd:powder", + "Powder XRD", + ), capabilities: FieldCapabilities::new([ CapabilityId::new(CAP_FIELD_CURVE_1D), CapabilityId::new(CAP_FIELD_XRD_PATTERN), diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 0533a1a..508d0da 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -13,6 +13,8 @@ use plotx_io::{Acquisition, DataFormat, Domain, LoadWarning, LoadWarningCode, Pr use serde::Serialize; use std::path::{Path, PathBuf}; use std::time::Duration; +mod scientific_identity; +pub use scientific_identity::dataset_from_loaded_acquisition; #[path = "workflow/mass_spec_layout.rs"] mod mass_spec_layout; #[path = "workflow/trace_collection.rs"] @@ -169,7 +171,8 @@ pub fn load_dataset(path: &Path) -> Result { &loaded.warnings, &loaded.acquisition, ); - let (dataset, source) = dataset_from_acquisition(loaded.acquisition); + let (dataset, source) = + dataset_from_loaded_acquisition(loaded.acquisition, loaded.scientific_identity, true); Ok(LoadedDataset { dataset, inspection, diff --git a/crates/core/src/workflow/scientific_identity.rs b/crates/core/src/workflow/scientific_identity.rs new file mode 100644 index 0000000..577bf29 --- /dev/null +++ b/crates/core/src/workflow/scientific_identity.rs @@ -0,0 +1,14 @@ +use super::*; + +pub fn dataset_from_loaded_acquisition( + acquisition: Acquisition, + scientific_identity: plotx_io::ImportedScientificIdentity, + equal_scale_homonuclear_2d_imports: bool, +) -> (Dataset, String) { + let (mut dataset, source) = dataset_from_acquisition_with_equal_scale_preference( + acquisition, + equal_scale_homonuclear_2d_imports, + ); + dataset.set_scientific_identity(scientific_identity); + (dataset, source) +} diff --git a/crates/core/src/workflow_tests.rs b/crates/core/src/workflow_tests.rs index d8686c9..4aa770a 100644 --- a/crates/core/src/workflow_tests.rs +++ b/crates/core/src/workflow_tests.rs @@ -52,9 +52,8 @@ fn canonical_conversion_and_default_canvas_share_dataset_identity() { canvas.panel_letter(canvas.objects[0].id).as_deref(), Some("a") ); - assert_eq!(canvas.panels[0].note, dataset_title(&dataset)); - assert_ne!(canvas.panels[0].note, "Plot 1"); - assert_eq!(canvas.panel_notes().len(), 1); + assert!(canvas.panels[0].note.is_empty()); + assert!(canvas.panel_notes().is_empty()); assert!(crate::state::document_items(&canvas).iter().any(|item| { matches!( item, diff --git a/crates/io/src/abf2.rs b/crates/io/src/abf2.rs index 79c07d2..1bf0327 100644 --- a/crates/io/src/abf2.rs +++ b/crates/io/src/abf2.rs @@ -66,6 +66,11 @@ pub fn load(path: &Path) -> Result { let bytes = std::fs::read(path)?; let (data, warnings) = parse(&bytes, path.to_string_lossy().into_owned())?; Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity { + subject: None, + acquisition: data.protocol.clone(), + source_label: crate::ImportedScientificIdentity::from_path(path).source_label, + }, acquisition: Acquisition::Electrophysiology(Box::new(data)), format: DataFormat::Abf2, provenance: Provenance { diff --git a/crates/io/src/bruker.rs b/crates/io/src/bruker.rs index b0c4da6..fb0d4dd 100644 --- a/crates/io/src/bruker.rs +++ b/crates/io/src/bruker.rs @@ -76,6 +76,38 @@ fn source_prefix(dir: &Path) -> String { } } +fn scientific_identity( + dir: &Path, + params: Option<&JcampParams>, +) -> crate::ImportedScientificIdentity { + let source_label = dir + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Untitled NMR") + .to_owned(); + let subject = dir + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let acquisition = params + .and_then(|params| params.string("EXP").or_else(|| params.string("PULPROG"))) + .map(|value| { + value + .trim_matches(|c| c == '<' || c == '>') + .trim() + .to_owned() + }) + .filter(|value| !value.is_empty()); + crate::ImportedScientificIdentity { + subject, + acquisition, + source_label, + } +} + // The first non-empty line of a processed-data `title` file, preferring proc no. // 1 and otherwise the lowest-numbered proc dir carrying a non-empty title. fn pdata_title(dir: &Path) -> Option { @@ -118,13 +150,16 @@ pub fn read_bruker(path: &Path) -> Result { pub fn load_raw(path: &Path) -> Result { let (dir, data_path) = resolve_bruker(path); + let params = JcampParams::parse(&std::fs::read_to_string(dir.join("acqus"))?); let mut parameter_paths = vec![dir.join("acqus")]; if data_path.file_name().and_then(|s| s.to_str()) == Some("ser") && dir.join("acqu2s").is_file() { parameter_paths.push(dir.join("acqu2s")); } + let acquisition = read_bruker(path)?; Ok(LoadResult { - acquisition: read_bruker(path)?, + scientific_identity: scientific_identity(&dir, Some(¶ms)), + acquisition, format: DataFormat::BrukerRaw, provenance: Provenance { selected_path: path.to_path_buf(), @@ -295,13 +330,7 @@ fn read_bruker_2d( let direct = dim_from(f2, group_delay(f2)); let indirect = dim_from(&f1, 0.0); - let experiment = f2 - .string("PULPROG") - .map(|s| { - s.trim_matches(|c| c == '<' || c == '>') - .to_ascii_lowercase() - }) - .filter(|s| !s.is_empty()); + let experiment = scientific_identity(dir, Some(f2)).acquisition; let source = format!( "{} (Bruker TopSpin 2D, {sample:?}, {cols}×{rows})", @@ -720,6 +749,12 @@ mod tests { std::fs::remove_file(expno.join("pdata").join("2").join("title")).unwrap(); assert_eq!(source_prefix(&expno), "Sucrose (expno 3)"); + let params = JcampParams::parse("##$EXP= \n##$PULPROG= \n"); + let identity = scientific_identity(&expno, Some(¶ms)); + assert_eq!(identity.subject.as_deref(), Some("Sucrose")); + assert_eq!(identity.acquisition.as_deref(), Some("COSY")); + assert_eq!(identity.source_label, "3"); + std::fs::remove_dir_all(&base).unwrap(); } diff --git a/crates/io/src/bruker/processed.rs b/crates/io/src/bruker/processed.rs index cc923a9..c5d3f0e 100644 --- a/crates/io/src/bruker/processed.rs +++ b/crates/io/src/bruker/processed.rs @@ -98,7 +98,7 @@ pub fn load_processed(path: &Path) -> Result { let procs_path = resolved.proc_dir.join("procs"); let procs = JcampParams::parse(&std::fs::read_to_string(&procs_path)?); let mut warnings = Vec::new(); - let (acquisition, format, mut parameter_paths) = if resolved.two_d { + let (mut acquisition, format, mut parameter_paths) = if resolved.two_d { let proc2s_path = resolved.proc_dir.join("proc2s"); let proc2s = JcampParams::parse(&std::fs::read_to_string(&proc2s_path)?); ( @@ -132,6 +132,18 @@ pub fn load_processed(path: &Path) -> Result { vec![procs_path], ) }; + let experiment_dir = resolved.proc_dir.parent().and_then(Path::parent); + let scientific_identity = experiment_dir + .map(|dir| { + let params = std::fs::read_to_string(dir.join("acqus")) + .ok() + .map(|text| JcampParams::parse(&text)); + scientific_identity(dir, params.as_ref()) + }) + .unwrap_or_else(|| crate::ImportedScientificIdentity::from_path(path)); + if let Acquisition::D2(data) = &mut acquisition { + data.experiment.clone_from(&scientific_identity.acquisition); + } if let Some(acqus) = acquisition_params_for(&resolved.proc_dir, "acqus") { parameter_paths.push(acqus); } @@ -141,6 +153,7 @@ pub fn load_processed(path: &Path) -> Result { parameter_paths.push(acqu2s); } Ok(LoadResult { + scientific_identity, acquisition, format, provenance: Provenance { @@ -312,10 +325,7 @@ fn read_processed_2d( } fn processed_source_prefix(proc_dir: &Path) -> String { - let experiment = proc_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent); + let experiment = proc_dir.parent().and_then(Path::parent); experiment .map(source_prefix) .unwrap_or_else(|| proc_dir.display().to_string()) diff --git a/crates/io/src/jcamp_dx.rs b/crates/io/src/jcamp_dx.rs index 93e228c..368ae1f 100644 --- a/crates/io/src/jcamp_dx.rs +++ b/crates/io/src/jcamp_dx.rs @@ -99,6 +99,7 @@ pub fn load(path: &Path) -> Result { let bytes = std::fs::read(path)?; let acquisition = parse_bytes(&bytes, path.to_string_lossy().as_ref())?; Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity::from_path(path), acquisition, format: DataFormat::JcampDx1D, provenance: Provenance { diff --git a/crates/io/src/jeol.rs b/crates/io/src/jeol.rs index dd17b0e..03b860a 100644 --- a/crates/io/src/jeol.rs +++ b/crates/io/src/jeol.rs @@ -1,8 +1,9 @@ //! JEOL Delta `.jdf` reader. use crate::{ - Acquisition, DiffusionMeta, Dim, Domain, IoError, NmrData, NmrData2D, PseudoAxis, PseudoKind, - QuadMode, gradient_shape_factor, gyromagnetic_ratio, + Acquisition, DataFormat, DiffusionMeta, Dim, Domain, ImportedScientificIdentity, IoError, + LoadResult, NmrData, NmrData2D, Provenance, PseudoAxis, PseudoKind, QuadMode, + gradient_shape_factor, gyromagnetic_ratio, }; use num_complex::Complex64; use std::collections::HashMap; @@ -67,6 +68,36 @@ pub fn read_jdf_path(path: &Path) -> Result { read_jdf_bytes(&bytes, source) } +pub fn load_jdf_path(path: &Path) -> Result { + let bytes = std::fs::read(path)?; + let source = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_owned(); + let acquisition = read_jdf_bytes(&bytes, source)?; + let endian = if bytes[off::ENDIAN] == 0 { + Endian::Big + } else { + Endian::Little + }; + let params = Params::parse(&bytes, off::PARAM_LIST, endian); + let mut scientific_identity = ImportedScientificIdentity::from_path(path); + scientific_identity.acquisition = experiment_name(¶ms); + Ok(LoadResult { + acquisition, + scientific_identity, + format: DataFormat::JeolDelta, + provenance: Provenance { + selected_path: path.to_path_buf(), + data_path: path.to_path_buf(), + parameter_paths: Vec::new(), + companion_paths: Vec::new(), + }, + warnings: Vec::new(), + }) +} + pub fn read_jdf_bytes(bytes: &[u8], source: String) -> Result { if bytes.len() < HEADER_LEN { return Err(IoError::Truncated { @@ -395,11 +426,7 @@ fn read_jdf_2d(bytes: &[u8], source: String, body_endian: Endian) -> Result Result Option { + params + .string_ci("experiment") + .or_else(|| params.string_ci("content")) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn experiment_name(params: &Params) -> Option { + let value = experiment_value(params)?; + let file_name = value.rsplit(['/', '\\']).next().unwrap_or(&value).trim(); + let name = file_name + .rsplit_once('.') + .filter(|(_, extension)| extension.eq_ignore_ascii_case("jxp")) + .map_or(file_name, |(stem, _)| stem) + .trim(); + (!name.is_empty()).then(|| name.to_owned()) +} + /// Recover the pseudo-2D indirect ruler and (for DOSY) the diffusion-encoding /// parameters. The ruler comes from the embedded experiment text; diffusion /// scalars come from the SI-normalized parameter list. diff --git a/crates/io/src/jeol/tests.rs b/crates/io/src/jeol/tests.rs index 87495ef..d235012 100644 --- a/crates/io/src/jeol/tests.rs +++ b/crates/io/src/jeol/tests.rs @@ -46,6 +46,18 @@ fn params_with(strings: &[(&str, &str)]) -> Params { p } +#[test] +fn experiment_name_is_cleaned_for_scientific_identity() { + let params = params_with(&[( + "experiment", + r"C:\Program Files\JEOL\experiments\13c_eb_sn.jxp", + )]); + assert_eq!(experiment_name(¶ms).as_deref(), Some("13c_eb_sn")); + + let params = params_with(&[("CONTENT", "cosy.JXP")]); + assert_eq!(experiment_name(¶ms).as_deref(), Some("cosy")); +} + #[test] fn group_delay_from_fir_cascade() { // orders = " ", factors = per-stage decimation. diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 9bfc6f8..5db9d25 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -101,11 +101,43 @@ pub struct LoadWarning { #[derive(Debug, Clone)] pub struct LoadResult { pub acquisition: Acquisition, + /// Normalized, user-facing identity recovered by the importer. This is + /// deliberately separate from provenance paths and parser diagnostics: the + /// application must never reverse-parse `source` strings to name a sample. + pub scientific_identity: ImportedScientificIdentity, pub format: DataFormat, pub provenance: Provenance, pub warnings: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ImportedScientificIdentity { + /// The specimen, recording, run, or other scientific subject. + pub subject: Option, + /// The acquisition experiment, protocol, or method when the format names it. + pub acquisition: Option, + /// A clean logical source name used only when no subject was recovered. + pub source_label: String, +} + +impl ImportedScientificIdentity { + pub fn from_path(path: &Path) -> Self { + let source_label = path + .file_stem() + .or_else(|| path.file_name()) + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Untitled data") + .to_owned(); + Self { + subject: None, + acquisition: None, + source_label, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Domain { Time, @@ -683,17 +715,7 @@ pub fn load_path(path: impl AsRef) -> Result { let path = path.as_ref(); match detect_format(path)? { DataFormat::Abf2 => abf2::load(path), - DataFormat::JeolDelta => Ok(LoadResult { - acquisition: jeol::read_jdf_path(path)?, - format: DataFormat::JeolDelta, - provenance: Provenance { - selected_path: path.to_path_buf(), - data_path: path.to_path_buf(), - parameter_paths: Vec::new(), - companion_paths: Vec::new(), - }, - warnings: Vec::new(), - }), + DataFormat::JeolDelta => jeol::load_jdf_path(path), DataFormat::BrukerRaw => bruker::load_raw(path), DataFormat::VarianAgilentRaw => varian::load_raw(path), DataFormat::BrukerProcessed1D | DataFormat::BrukerProcessed2D => { diff --git a/crates/io/src/mzml.rs b/crates/io/src/mzml.rs index 4eeafeb..7066644 100644 --- a/crates/io/src/mzml.rs +++ b/crates/io/src/mzml.rs @@ -133,6 +133,7 @@ pub fn load(path: &Path) -> Result { }) .collect(); Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity::from_path(path), acquisition: Acquisition::MassSpec(Box::new(run)), format: DataFormat::MzMl, provenance: Provenance { diff --git a/crates/io/src/nanoscope.rs b/crates/io/src/nanoscope.rs index 0ed0cb9..b16bda4 100644 --- a/crates/io/src/nanoscope.rs +++ b/crates/io/src/nanoscope.rs @@ -138,6 +138,7 @@ pub fn load(path: &Path) -> Result { } Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity::from_path(path), acquisition: Acquisition::Afm(Box::new(data)), format, provenance: Provenance { diff --git a/crates/io/src/varian.rs b/crates/io/src/varian.rs index f2bd660..617b765 100644 --- a/crates/io/src/varian.rs +++ b/crates/io/src/varian.rs @@ -38,6 +38,15 @@ pub fn load_raw(path: &Path) -> Result { reject_unsupported(¶ms)?; let acquisition = assemble(&dir, ¶ms, raw)?; Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity { + subject: sample_name(&dir, ¶ms), + acquisition: experiment_name(¶ms), + source_label: dir + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Untitled NMR") + .to_owned(), + }, acquisition, format: DataFormat::VarianAgilentRaw, provenance: Provenance { @@ -235,7 +244,16 @@ fn description(dir: &Path, p: &Procpar, direct: &Dim, indirect: Option<&Dim>) -> Some(indirect) => format!("{}/{}", direct.nucleus, indirect.nucleus), None => direct.nucleus.clone(), }; - let data_name = ["samplename", "sample", "name", "filename"] + sample_name(dir, p) + .into_iter() + .chain(std::iter::once(nuclei)) + .chain(experiment_name(p)) + .collect::>() + .join(" — ") +} + +fn sample_name(dir: &Path, p: &Procpar) -> Option { + ["samplename", "sample", "name", "filename"] .into_iter() .find_map(|name| p.string(name).and_then(nonempty)) .map(str::to_owned) @@ -244,17 +262,14 @@ fn description(dir: &Path, p: &Procpar, direct: &Dim, indirect: Option<&Dim>) -> .and_then(|name| name.to_str()) .and_then(nonempty) .map(str::to_owned) - }); - let experiment = ["pslabel", "seqfil"] + }) +} + +fn experiment_name(p: &Procpar) -> Option { + ["pslabel", "seqfil"] .into_iter() .find_map(|name| p.string(name).and_then(nonempty)) - .map(str::to_owned); - data_name - .into_iter() - .chain(std::iter::once(nuclei)) - .chain(experiment) - .collect::>() - .join(" — ") + .map(str::to_owned) } fn nonempty(value: &str) -> Option<&str> { diff --git a/crates/io/src/waters.rs b/crates/io/src/waters.rs index 987de3b..7c16498 100644 --- a/crates/io/src/waters.rs +++ b/crates/io/src/waters.rs @@ -247,6 +247,7 @@ pub fn load(path: &Path) -> Result { }; run.validate().map_err(invalid)?; Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity::from_path(path), acquisition: Acquisition::MassSpec(Box::new(run)), format: DataFormat::WatersMassLynxRaw, provenance: provenance(&bundle), diff --git a/crates/io/src/xps.rs b/crates/io/src/xps.rs index 2590f3e..498543b 100644 --- a/crates/io/src/xps.rs +++ b/crates/io/src/xps.rs @@ -248,6 +248,14 @@ fn load_result( }) .collect(); Ok(LoadResult { + scientific_identity: crate::ImportedScientificIdentity { + subject: experiment + .measurements + .first() + .map(|item| item.label.clone()), + acquisition: None, + source_label: crate::ImportedScientificIdentity::from_path(path).source_label, + }, acquisition: Acquisition::Xps(Box::new(experiment)), format, provenance: Provenance { diff --git a/crates/io/src/xrd.rs b/crates/io/src/xrd.rs index f708928..b79e43d 100644 --- a/crates/io/src/xrd.rs +++ b/crates/io/src/xrd.rs @@ -260,6 +260,7 @@ fn result(path: &Path, format: DataFormat, data: XrdData) -> Result\n##$PULPROG= \n", + ) + .unwrap(); std::fs::write( proc_dir.join("procs"), "##$SI= 4\n##$DTYPP= 0\n##$BYTORDP= 1\n##$NC_proc= 1\n\ @@ -33,6 +37,14 @@ fn loads_big_endian_scaled_1r_from_experiment_directory() { ); let loaded = plotx_io::load_path(&experiment).unwrap(); assert_eq!(loaded.format, DataFormat::BrukerProcessed1D); + assert_eq!( + loaded.scientific_identity.subject.as_deref(), + Some("sample") + ); + assert_eq!( + loaded.scientific_identity.acquisition.as_deref(), + Some("PROTON") + ); assert!( loaded .provenance @@ -88,6 +100,10 @@ fn loads_2rr_and_reverses_both_frequency_axes() { let loaded = plotx_io::load_path(&proc_dir).unwrap(); assert_eq!(loaded.format, DataFormat::BrukerProcessed2D); + assert_eq!( + loaded.scientific_identity.subject.as_deref(), + Some("sample") + ); let data = match loaded.acquisition { Acquisition::D2(data) => *data, Acquisition::D1(_) => panic!("expected 2D"), diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 882dbb9..ae1b6e1 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -18,6 +18,16 @@ undoes as one step. In the Canvas list, `Shift`-click selects a continuous range and `Ctrl`-click adds or removes one canvas. `Ctrl` + `A` selects every frame or canvas according to the area you last used. +## Scientific summary and notes + +PlotX shows an automatic summary of the plotted data below each page on the +board. On a multi-panel page, shared information appears once and the remaining +details follow the panel labels. + +Open **Canvas settings** to add a page note, or select a Panel and edit its +**Note**. Notes appear after the summary and are not included in exports. Use +**Show summary below page** to show or hide this whole board-only block. + ## Add external images To place each image on its own fitted page, choose **Add Images…** from the File diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 63d52da..d71135c 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -16,6 +16,15 @@ description: 在无限画板上排布图形,并按期刊规范设定页面尺 `Ctrl` + 单击添加或移除单个画布。`Ctrl` + `A` 会根据最近使用的区域全选图框 或画布。 +## 科学摘要与备注 + +PlotX 会在画板中每个页面的下方自动概述当前绘制的数据。对于多 Panel 页面, +共同信息只显示一次,其余内容跟在相应的 Panel 标签后。 + +打开 **Canvas settings** 可以添加页面备注;也可以选中 Panel 后编辑其 +**Note**。备注显示在摘要之后,不会包含在导出结果中。使用 +**Show summary below page** 可以显示或隐藏整个画板专用信息区块。 + ## 添加外部图片 若要让每张图片各占一个适配大小的页面,请从 File 菜单、Ribbon 或命令面板选择