diff --git a/crates/app/src/ui/canvas/furniture.rs b/crates/app/src/ui/canvas/furniture.rs new file mode 100644 index 00000000..bc8fe9dc --- /dev/null +++ b/crates/app/src/ui/canvas/furniture.rs @@ -0,0 +1,358 @@ +use super::*; + +enum FurnitureHit { + Legend { + object: ObjectId, + rect: PlotRect, + }, + RegionLabel { + object: ObjectId, + dataset: plotx_core::state::DatasetId, + region: RegionId, + rect: PlotRect, + }, +} + +impl FurnitureHit { + fn object(&self) -> ObjectId { + match self { + Self::Legend { object, .. } | Self::RegionLabel { object, .. } => *object, + } + } +} + +pub(crate) fn handle_furniture_interactions( + app: &mut PlotxApp, + ci: usize, + canvas_rect: egui::Rect, + ui: &Ui, +) -> bool { + if !matches!(app.session.tool, Tool::Select | Tool::Regions) { + return false; + } + let (pointer, down, pressed, released, double_clicked, escape) = ui.input(|input| { + ( + input.pointer.hover_pos(), + input.pointer.primary_down(), + input.pointer.primary_pressed(), + input.pointer.primary_released(), + input + .pointer + .button_double_clicked(egui::PointerButton::Primary), + input.key_pressed(egui::Key::Escape), + ) + }); + + if matches!(app.interaction(), Interaction::Furniture(_)) { + if escape { + app.cancel_interaction(); + app.session.status = "Restored the previous label position.".to_owned(); + return true; + } + if down && let Some(pointer) = pointer { + update_furniture_drag(app, ci, canvas_rect, pointer); + } + if released || !down { + finish_furniture_drag(app); + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + return true; + } + + let Some(pointer) = pointer else { + return false; + }; + let Some(hit) = furniture_hit(app, ci, canvas_rect, pointer) else { + return false; + }; + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + if double_clicked { + reset_furniture_position(app, ci, hit); + return true; + } + if !pressed { + return true; + } + + let object = hit.object(); + app.select_object(ci, object); + freeze_board_for_gesture(app); + let target = match hit { + FurnitureHit::Legend { rect, .. } => { + let before = app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| plot.axis_overrides.clone()) + .unwrap_or_default(); + FurnitureTarget::Legend { + before, + grab_offset: [pointer.x - rect.left, pointer.y - rect.top], + } + } + FurnitureHit::RegionLabel { + dataset, + region, + rect, + .. + } => { + let before = app + .doc + .dataset_index(dataset) + .and_then(|index| app.doc.datasets[index].region_analysis()) + .map(|state| state.regions.clone()) + .unwrap_or_default(); + FurnitureTarget::RegionLabel { + dataset, + region, + before, + grab_offset: [ + pointer.x - (rect.left + rect.width * 0.5), + pointer.y - (rect.top + rect.height * 0.5), + ], + } + } + }; + app.begin_interaction(Interaction::Furniture(FurnitureDrag { + canvas: ci, + object, + target, + })); + true +} + +fn furniture_hit( + app: &PlotxApp, + ci: usize, + canvas_rect: egui::Rect, + pointer: Pos2, +) -> Option { + let canvas = app.doc.canvases.get(ci)?; + for object in canvas + .objects + .iter() + .rev() + .filter(|object| object.visible && !object.locked) + { + let Some(plot_object) = object.plot() else { + continue; + }; + let Some((plot, scale)) = plot_geometry(app, ci, object.id, canvas_rect) else { + continue; + }; + let figure = plot_object.figure(); + if let Some(rect) = plotx_render::legend_rect(figure, plot, scale) + && rect_contains(rect, pointer, 2.0) + { + return Some(FurnitureHit::Legend { + object: object.id, + rect, + }); + } + let Some(dataset) = object.dataset() else { + continue; + }; + for annotation in figure.range_annotations.iter().rev() { + let x0 = x_to_screen( + annotation.x0, + plot, + figure.x.min, + figure.x.span(), + figure.x.reversed, + ); + let x1 = x_to_screen( + annotation.x1, + plot, + figure.x.min, + figure.x.span(), + figure.x.reversed, + ); + let Some(layout) = plotx_render::range_label_layout( + plot, + x0.min(x1), + x0.max(x1), + figure.typography.tick_pt * scale, + &annotation.label, + annotation.label_position, + ) else { + continue; + }; + let rect = layout.rect(figure.typography.tick_pt * scale); + if rect_contains(rect, pointer, 3.0) { + return Some(FurnitureHit::RegionLabel { + object: object.id, + dataset, + region: RegionId::new(annotation.source_id), + rect, + }); + } + } + } + None +} + +pub(crate) fn furniture_hovered( + app: &PlotxApp, + ci: usize, + canvas_rect: egui::Rect, + pointer: Pos2, +) -> bool { + matches!(app.session.tool, Tool::Select | Tool::Regions) + && furniture_hit(app, ci, canvas_rect, pointer).is_some() +} + +fn plot_geometry( + app: &PlotxApp, + ci: usize, + object: ObjectId, + canvas_rect: egui::Rect, +) -> Option<(PlotRect, f32)> { + let outer = object_screen_rect( + app.session.board, + app.doc.canvases.get(ci)?, + object, + canvas_rect, + )?; + let figure = app.doc.canvases[ci].object(object)?.plot()?.figure(); + let scale = app.session.board.zoom; + let layout = plotx_render::axis_layout(figure, outer.width / scale, outer.height / scale); + let projector = plotx_render::Projector::new(figure, outer, &layout.margins.scaled(scale)); + Some((projector.plot, scale)) +} + +fn update_furniture_drag(app: &mut PlotxApp, ci: usize, canvas_rect: egui::Rect, pointer: Pos2) { + let Interaction::Furniture(drag) = app.interaction() else { + return; + }; + if drag.canvas != ci { + return; + } + let object = drag.object; + let target = drag.target.clone(); + let Some((plot, scale)) = plot_geometry(app, ci, object, canvas_rect) else { + return; + }; + match target { + FurnitureTarget::Legend { grab_offset, .. } => { + let Some(plot_object) = app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + else { + return; + }; + let Some(position) = plotx_render::legend_position_for_origin( + plot_object.figure(), + plot, + scale, + [pointer.x - grab_offset[0], pointer.y - grab_offset[1]], + ) else { + return; + }; + let mut overrides = plot_object.axis_overrides.clone(); + overrides.legend_position = Some(position); + app.set_axis_overrides_value(ci, object, &overrides); + } + FurnitureTarget::RegionLabel { + dataset, + region, + grab_offset, + .. + } => { + let Some(index) = app.doc.dataset_index(dataset) else { + return; + }; + let center = [pointer.x - grab_offset[0], pointer.y - grab_offset[1]]; + let position = [ + ((center[0] - plot.left) / plot.width).clamp(0.0, 1.0), + ((center[1] - plot.top) / plot.height).clamp(0.0, 1.0), + ]; + if let Some(item) = app.doc.datasets[index] + .region_analysis_mut() + .and_then(|state| state.regions.iter_mut().find(|item| item.id == region)) + { + item.label_position = Some(position); + app.rebuild_canvases_for(index); + } + } + } +} + +fn finish_furniture_drag(app: &mut PlotxApp) { + let Interaction::Furniture(drag) = app.take_interaction() else { + return; + }; + match drag.target { + FurnitureTarget::Legend { before, .. } => { + let Some(after) = app.doc.canvases[drag.canvas] + .object(drag.object) + .and_then(|object| object.plot()) + .map(|plot| plot.axis_overrides.clone()) + else { + return; + }; + app.execute_action(Action::set_axis_overrides( + drag.canvas, + drag.object, + before, + after, + )); + app.session.status = + "Moved legend. Double-click it to restore automatic placement.".into(); + } + FurnitureTarget::RegionLabel { + dataset, before, .. + } => { + let Some(index) = app.doc.dataset_index(dataset) else { + return; + }; + let after = app.doc.datasets[index] + .region_analysis() + .map(|state| state.regions.clone()) + .unwrap_or_default(); + app.execute_action(Action::set_regions(dataset, before, after)); + app.session.status = + "Moved region label. Double-click it to restore automatic placement.".into(); + } + } +} + +fn reset_furniture_position(app: &mut PlotxApp, ci: usize, hit: FurnitureHit) { + match hit { + FurnitureHit::Legend { object, .. } => { + let Some(before) = app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| plot.axis_overrides.clone()) + else { + return; + }; + let mut after = before.clone(); + after.legend_position = None; + app.execute_action(Action::set_axis_overrides(ci, object, before, after)); + app.session.status = "Restored automatic legend placement.".into(); + } + FurnitureHit::RegionLabel { + dataset, region, .. + } => { + let Some(index) = app.doc.dataset_index(dataset) else { + return; + }; + let before = app.doc.datasets[index] + .region_analysis() + .map(|state| state.regions.clone()) + .unwrap_or_default(); + let mut after = before.clone(); + if let Some(item) = after.iter_mut().find(|item| item.id == region) { + item.label_position = None; + } + app.execute_action(Action::set_regions(dataset, before, after)); + app.session.status = "Restored automatic region-label placement.".into(); + } + } +} + +fn rect_contains(rect: PlotRect, pointer: Pos2, padding: f32) -> bool { + pointer.x >= rect.left - padding + && pointer.x <= rect.right() + padding + && pointer.y >= rect.top - padding + && pointer.y <= rect.bottom() + padding +} diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index ba273577..fad8fb68 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -4,13 +4,14 @@ use plotx_core::layout::{self, MovableEdges, SnapGuide, SnapTargets}; use plotx_core::state::region_color; use plotx_core::state::{ AnalysisSelection, AuthorDrag, AxisRange, BOARD_GUTTER_PT, BoardFitTarget, BoardViewport, - CanvasDocument, CanvasObject, CanvasObjectKind, Dataset, FrameDrag, FrameRef, Integral2DDrag, - Integral2DDragKind, IntegralDrag, Interaction, MarqueeDrag, ObjectDrag, ObjectDragKind, - ObjectFrame, ObjectId, PanDrag, PanelLabelDrag, PanelNoteEditState, PhaseDrag, PhaseDragKind, - PhaseOrient, PlotxApp, Region, RegionDrag, RegionDragKind, ResizeHandle, SHEET_COL_W_PT, - SHEET_HEADER_H_PT, SHEET_MAX_ROWS, SHEET_ROW_H_PT, Selection, SelectionDrag, TableDataset, - TextEditState, TileDropCacheKey, TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frames, - frame_board_pos, frame_board_rect, set_frame_board_pos, toggle_frame_selection_synced, + CanvasDocument, CanvasObject, CanvasObjectKind, Dataset, FrameDrag, FrameRef, FurnitureDrag, + FurnitureTarget, Integral2DDrag, Integral2DDragKind, IntegralDrag, Interaction, MarqueeDrag, + ObjectDrag, ObjectDragKind, ObjectFrame, ObjectId, PanDrag, PanelLabelDrag, PanelNoteEditState, + PhaseDrag, PhaseDragKind, PhaseOrient, PlotxApp, Region, RegionDrag, RegionDragKind, RegionId, + RegionSelection, ResizeHandle, SHEET_COL_W_PT, SHEET_HEADER_H_PT, SHEET_MAX_ROWS, + SHEET_ROW_H_PT, Selection, SelectionDrag, TableDataset, TextEditState, TileDropCacheKey, + TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frames, frame_board_pos, frame_board_rect, + set_frame_board_pos, toggle_frame_selection_synced, }; use plotx_core::{Integral2D, IntegralResult}; use plotx_render::Rect as PlotRect; @@ -31,6 +32,7 @@ mod board; mod board_notes; mod chrome; mod cursors; +mod furniture; mod geometry; mod integrals; mod integrals2d; @@ -52,6 +54,7 @@ pub(crate) use board::*; pub(crate) use board_notes::*; pub(crate) use chrome::*; pub(crate) use cursors::*; +pub(crate) use furniture::*; pub(crate) use geometry::*; pub(crate) use integrals::*; pub(crate) use integrals2d::*; @@ -124,9 +127,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { ensure_board_view(app, rect); drive_board_fit(app, ui, rect); - // Gesture handlers below read the raw pointer, so nothing else stops them from - // acting on board content that lies (clipped) under a side bar, a popup or a - // window. A live drag keeps the pointer wherever it wanders. + // Raw-pointer gestures must not start through UI layered over the canvas. let pointer_hits_canvas_layer = ui .input(|input| input.pointer.hover_pos()) .is_none_or(|pos| { @@ -139,9 +140,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { let view_consumed = pointer_owned && handle_navigation(app, ci, rect, ui); - // Suppressed only while a non-frame gesture is mid-drag, so a live data/object - // drag isn't interrupted by a frame switch — a fresh click still activates - // another figure. + // A live non-frame gesture cannot be interrupted by a frame switch. let frame_consumed = if pointer_owned && !view_consumed && matches!( @@ -176,12 +175,24 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { } else { handle_frame_caption_interactions(app, rect, ui) }; + let furniture_consumed = if !pointer_owned + || view_consumed + || frame_consumed + || author_active + || label_consumed + || caption_consumed + { + false + } else { + handle_furniture_interactions(app, ci, rect, ui) + }; if pointer_owned && !view_consumed && !frame_consumed && !author_active && !label_consumed && !caption_consumed + && !furniture_consumed { if app.session.tool.is_layout_tool() { handle_object_interactions(app, ci, rect, ui, &resp); @@ -277,7 +288,8 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { proj.plot }; - if data_edit_target(app, ci) == Some(object_id) + if !furniture_consumed + && data_edit_target(app, ci) == Some(object_id) && !matches!(app.session.ui.interaction, Interaction::Pan(_)) { match app.session.tool { @@ -541,6 +553,10 @@ fn canvas_cursor(app: &PlotxApp, ci: usize, rect: egui::Rect, ui: &Ui) { }; // Ambient pan reads on top of the tool cursor: an active data-pan grabs, and // holding Space arms the hand anywhere on the board. + if matches!(app.session.ui.interaction, Interaction::Furniture(_)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + return; + } if matches!(app.session.ui.interaction, Interaction::Pan(_)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); return; @@ -549,6 +565,10 @@ fn canvas_cursor(app: &PlotxApp, ci: usize, rect: egui::Rect, ui: &Ui) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); return; } + if furniture_hovered(app, ci, rect, p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + return; + } let icon = if app.session.tool.is_layout_tool() { match screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, p) .and_then(|page| hit_object(&app.doc.canvases[ci], page, app.session.board.zoom)) diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 30c0c895..67d89d6d 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -9,17 +9,9 @@ pub(crate) fn paint_zoom_drag( painter: &egui::Painter, chrome: ChromeStyle, ) { - let drag = match &app.session.ui.interaction { - Interaction::Zoom(d) if d.axis == ZoomAxis::Box => *d, - _ => return, - }; - if drag.canvas != ci || drag.object != object_id { + let Some(r) = active_box_zoom_rect(app, ci, object_id, plot) else { return; - } - let r = EguiRect::from_two_pos(pos(drag.start), pos(drag.current)).intersect(plot_rect(plot)); - if r.width() < 1.0 || r.height() < 1.0 { - return; - } + }; painter.rect_filled(r, 0.0, chrome.selection_fill); painter.rect_stroke( r, @@ -29,6 +21,26 @@ pub(crate) fn paint_zoom_drag( ); } +fn active_box_zoom_rect( + app: &PlotxApp, + ci: usize, + object_id: ObjectId, + plot: PlotRect, +) -> Option { + let drag = match &app.session.ui.interaction { + Interaction::Zoom(d) if d.axis == ZoomAxis::Box => *d, + _ => return None, + }; + if drag.canvas != ci || drag.object != object_id { + return None; + } + let r = EguiRect::from_two_pos(pos(drag.start), pos(drag.current)).intersect(plot_rect(plot)); + if r.width() < 1.0 || r.height() < 1.0 { + return None; + } + Some(r) +} + /// Recomputes the plot rect from the drag's own object so it paints under any /// tool, regardless of which figure is selected. pub(crate) fn paint_axis_zoom( @@ -125,8 +137,8 @@ pub(crate) fn paint_analysis_selection( ); } -/// Bands show whenever the plotted dataset has regions, so they stay visible -/// outside the Regions tool too. +/// The figure owns persistent region bands; this overlay only adds editing +/// handles and the new-band preview while the Regions tool is active. pub(crate) fn paint_regions( app: &PlotxApp, ci: usize, @@ -136,6 +148,9 @@ pub(crate) fn paint_regions( painter: &egui::Painter, chrome: ChromeStyle, ) { + if app.session.tool != Tool::Regions { + return; + } let Some(fig) = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.plot()) @@ -143,11 +158,23 @@ pub(crate) fn paint_regions( else { return; }; - let Some(d2) = app.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { + let Some(state) = app + .doc + .datasets + .get(dataset) + .and_then(Dataset::region_analysis) + else { return; }; - let selected = app.session.ui.selected_region; - for region in &d2.regions { + let selected = app + .session + .ui + .selected_region + .and_then(|selection| selection.in_dataset(app.doc.datasets[dataset].resource_id())); + for region in &state.regions { + if selected != Some(region.id) { + continue; + } let x0 = x_to_screen(region.lo, plot, fig.x.min, fig.x.span(), fig.x.reversed); let x1 = x_to_screen(region.hi, plot, fig.x.min, fig.x.span(), fig.x.reversed); let r = EguiRect::from_min_max( @@ -160,21 +187,13 @@ pub(crate) fn paint_regions( } let [cr, cg, cb] = region.color; let stroke_col = Color32::from_rgb(cr, cg, cb); - painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(cr, cg, cb, 30)); - let is_sel = selected == Some(region.id); + let is_sel = true; painter.rect_stroke( r, 0.0, Stroke::new(if is_sel { 2.0_f32 } else { 1.0_f32 }, stroke_col), StrokeKind::Inside, ); - painter.text( - Pos2::new(r.left() + 3.0, r.top() + 2.0), - egui::Align2::LEFT_TOP, - region.column_name(), - egui::FontId::proportional(11.0), - stroke_col, - ); if is_sel { for ex in [r.left(), r.right()] { painter.line_segment( @@ -186,7 +205,7 @@ pub(crate) fn paint_regions( } if let Interaction::Region(drag) = &app.session.ui.interaction - && drag.dataset == dataset + && drag.dataset == app.doc.datasets[dataset].resource_id() && drag.canvas == ci && drag.kind == RegionDragKind::NewBand { @@ -720,3 +739,36 @@ pub(crate) fn paint_marquee( StrokeKind::Inside, ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browse_zoom_box_interaction_has_a_preview_rect() { + let mut app = PlotxApp::new(); + app.session.tool = Tool::BrowseZoom; + app.session.ui.interaction = Interaction::Zoom(ZoomDrag { + canvas: 2, + object: ObjectId::new(7), + start: [10.0, 20.0], + current: [40.0, 60.0], + axis: ZoomAxis::Box, + }); + + let rect = active_box_zoom_rect( + &app, + 2, + ObjectId::new(7), + PlotRect::new(0.0, 0.0, 100.0, 100.0), + ); + + assert_eq!( + rect, + Some(EguiRect::from_min_max( + Pos2::new(10.0, 20.0), + Pos2::new(40.0, 60.0) + )) + ); + } +} diff --git a/crates/app/src/ui/canvas/regions.rs b/crates/app/src/ui/canvas/regions.rs index a49ee994..9312ff04 100644 --- a/crates/app/src/ui/canvas/regions.rs +++ b/crates/app/src/ui/canvas/regions.rs @@ -3,8 +3,8 @@ use super::*; const REGION_EDGE_PX: f32 = 5.0; enum RegionHit { - Edge { id: u64, lo_edge: bool }, - Inside { id: u64 }, + Edge { id: RegionId, lo_edge: bool }, + Inside { id: RegionId }, } /// Which region band (if any) the screen x `px` lands on, edges taking priority. @@ -56,9 +56,7 @@ pub(crate) fn handle_region_drag( .doc .datasets .get(dataset) - .and_then(Dataset::as_nmr2d) - .map(|n| n.is_pseudo()) - .unwrap_or(false); + .is_some_and(Dataset::supports_region_analysis); if !is_series { return; } @@ -113,7 +111,10 @@ pub(crate) fn handle_region_drag( } let hit = { - let regions = &app.doc.datasets[dataset].as_nmr2d().unwrap().regions; + let regions = &app.doc.datasets[dataset] + .region_analysis() + .expect("the capability gate guarantees region state") + .regions; region_hit(regions, plot, xmin, xspan, xrev, p.x) }; match hit { @@ -127,14 +128,14 @@ pub(crate) fn handle_region_drag( if primary_pressed { let ppm = screen_to_x(p.x, plot, xmin, xspan, xrev); let before = app.doc.datasets[dataset] - .as_nmr2d() - .unwrap() + .region_analysis() + .expect("the capability gate guarantees region state") .regions .clone(); let mut drag = RegionDrag { canvas: ci, object: object_id, - dataset, + dataset: app.doc.datasets[dataset].resource_id(), kind: RegionDragKind::NewBand, region_id: None, before, @@ -151,7 +152,7 @@ pub(crate) fn handle_region_drag( RegionDragKind::EdgeHi }; drag.region_id = Some(id); - app.session.ui.selected_region = Some(id); + app.session.ui.selected_region = Some(RegionSelection::new(drag.dataset, id)); } Some(RegionHit::Inside { id }) => { if let Some(r) = drag.before.iter().find(|r| r.id == id) { @@ -160,7 +161,7 @@ pub(crate) fn handle_region_drag( } drag.kind = RegionDragKind::Move; drag.region_id = Some(id); - app.session.ui.selected_region = Some(id); + app.session.ui.selected_region = Some(RegionSelection::new(drag.dataset, id)); } None => { app.session.ui.selected_region = None; @@ -187,15 +188,15 @@ fn apply_region_drag_live(app: &mut PlotxApp, dataset: usize, ppm: f64) { let Some(id) = id else { return; }; - let Some(d2) = app + let Some(state) = app .doc .datasets .get_mut(dataset) - .and_then(Dataset::as_nmr2d_mut) + .and_then(Dataset::region_analysis_mut) else { return; }; - let Some(r) = d2.regions.iter_mut().find(|r| r.id == id) else { + let Some(r) = state.regions.iter_mut().find(|r| r.id == id) else { return; }; match kind { @@ -224,30 +225,33 @@ fn finish_region_drag(app: &mut PlotxApp, dataset: usize, xspan: f64) { if (hi - lo) <= min_w { return; } - let Some(d2) = app + let Some(state) = app .doc .datasets .get_mut(dataset) - .and_then(Dataset::as_nmr2d_mut) + .and_then(Dataset::region_analysis_mut) else { return; }; - let id = d2.next_region_id; - d2.next_region_id += 1; - let idx = d2.regions.len(); - d2.regions.push(Region { + let Some(id) = state.allocate_region_id() else { + app.session.status = "No more region identifiers are available.".to_owned(); + return; + }; + let idx = state.regions.len(); + state.regions.push(Region { id, lo, hi, name: String::new(), + label_position: None, color: region_color(idx), metric: None, }); - app.session.ui.selected_region = Some(id); + app.session.ui.selected_region = Some(RegionSelection::new(drag.dataset, id)); } let after = app.doc.datasets[dataset] - .as_nmr2d() - .unwrap() + .region_analysis() + .expect("the capability gate guarantees region state") .regions .clone(); app.execute_action(Action::set_regions( diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 0ecf46b1..bcaf7a8d 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -482,8 +482,8 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { .and_then(|()| { requires( dataset() - .and_then(Dataset::as_nmr2d) - .is_some_and(|series| !series.regions.is_empty()), + .and_then(Dataset::region_analysis) + .is_some_and(|state| !state.regions.is_empty()), "Add at least one region before building a series table.", ) }), diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index b9a0761a..4a426f8a 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -78,6 +78,36 @@ fn app_with_nmr() -> PlotxApp { app } +fn app_with_electrophysiology() -> PlotxApp { + let mut app = app(); + let recording = plotx_io::ElectrophysiologyData { + abf_version: "2.9.0.0".to_owned(), + sample_rate_hz: 10_000.0, + channels: vec![plotx_io::RecordedChannel { + name: "Current".to_owned(), + unit: plotx_io::ElectricalUnit::from_symbol("pA"), + }], + sweeps: vec![plotx_io::Sweep { + start_time_s: 0.0, + channels: vec![vec![0.0, -1.0, -2.0, 0.0]], + commands: Vec::new(), + }], + protocol: None, + source: "synthetic.abf".to_owned(), + import_warnings: Vec::new(), + }; + let action = Action::insert_dataset_with_default_canvas( + &app, + Dataset::Electrophysiology(Box::new(plotx_core::state::ElectrophysiologyDataset::load( + recording, + ))), + "Canvas — patch clamp".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + app +} + #[test] fn time_domain_nmr_hides_frequency_analysis_and_disables_spectral_commands() { let mut app = app_with_nmr(); @@ -605,6 +635,22 @@ fn ribbon_separates_peak_and_curve_fit_tasks() { ); } +#[test] +fn electrophysiology_exposes_enabled_region_commands_in_the_ribbon() { + let app = app_with_electrophysiology(); + let regions = describe(&app, CommandId::Regions); + assert!(regions.enabled); + assert_eq!(regions.ribbon.unwrap().group, "Regions"); + + let table = describe(&app, CommandId::SeriesTable); + assert!(!table.enabled); + assert_eq!(table.ribbon.unwrap().group, "Regions"); + assert_eq!( + table.disabled_reason, + Some("Add at least one region before building a series table.") + ); +} + #[test] fn symmetry_review_is_contextual_to_homonuclear_true_2d_data() { let empty = app(); diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index caf9a46c..97cec9b4 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -3,7 +3,8 @@ use egui::Ui; use egui_phosphor::regular as icon; use plotx_core::actions::{Action, ZOrder}; use plotx_core::state::{ - CanvasObjectKind, FrameRef, ObjectId, PlotxApp, PrimaryView, RenameState, RenameTarget, + CanvasObjectKind, FrameRef, ObjectId, PlotxApp, PrimaryView, RegionSelection, RenameState, + RenameTarget, }; mod board_views; @@ -688,7 +689,8 @@ fn select_analysis(app: &mut PlotxApp, ui: &Ui, di: usize, item: &AnalysisItem, } } AnalysisKind::Region(id) => { - app.session.ui.selected_region = Some(id); + app.session.ui.selected_region = + Some(RegionSelection::new(app.doc.datasets[di].resource_id(), id)); if open { app.set_tool(plotx_core::state::Tool::Regions); crate::ui::tools::open_region_task(app, di); diff --git a/crates/app/src/ui/primary_sidebar/data_browser.rs b/crates/app/src/ui/primary_sidebar/data_browser.rs index 68fc3cfb..11088df1 100644 --- a/crates/app/src/ui/primary_sidebar/data_browser.rs +++ b/crates/app/src/ui/primary_sidebar/data_browser.rs @@ -26,7 +26,7 @@ pub(super) struct AnalysisItem { pub(super) enum AnalysisKind { Peak(u64), Integral(u64), - Region(u64), + Region(plotx_core::state::RegionId), LineFit(u64), Multiplet(u64), CurveFitResponse(ColumnId), @@ -211,9 +211,12 @@ fn analysis_items(dataset: &Dataset) -> Vec { integral.name.clone() }, })); - result.extend(nmr2d.regions.iter().map(|region| AnalysisItem { + } + if let Some(state) = dataset.region_analysis() { + let unit = dataset.region_axis_unit().unwrap_or(""); + result.extend(state.regions.iter().map(|region| AnalysisItem { kind: AnalysisKind::Region(region.id), - label: region.column_name(), + label: region.column_name(unit), })); } if !time_nmr { diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index 0881a2eb..df33cb7b 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -120,6 +120,14 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ canvas_step: false, uses_canvas_length_unit: false, }, + PropertyPresentation { + id: axis::SHOW_LEGEND, + localized_label: LocalizedText("Show legend"), + localized_aliases: &[LocalizedText("legend visibility")], + home_route: AXIS_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, PropertyPresentation { id: contour::BASE_MAGNITUDE, localized_label: LocalizedText("Lowest level"), @@ -245,6 +253,22 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ canvas_step: false, uses_canvas_length_unit: false, }, + PropertyPresentation { + id: typography::LEGEND_PT, + localized_label: LocalizedText("Legend size"), + localized_aliases: &[LocalizedText("legend font size")], + home_route: TYPOGRAPHY_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: typography::LEGEND_COLOR, + localized_label: LocalizedText("Legend text color"), + localized_aliases: &[LocalizedText("legend text colour")], + home_route: TYPOGRAPHY_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, PropertyPresentation { id: canvas::MARGIN_TOP_MM, localized_label: LocalizedText("Top margin"), diff --git a/crates/app/src/ui/properties/tests.rs b/crates/app/src/ui/properties/tests.rs index 8ad90417..11c11ec5 100644 --- a/crates/app/src/ui/properties/tests.rs +++ b/crates/app/src/ui/properties/tests.rs @@ -297,7 +297,7 @@ fn migrated_canvas_and_typography_controls_keep_their_visibility_and_section_den ), ( panel::TYPOGRAPHY_SECTION, - 3, + 4, "a document with all typography controls", ), ] { @@ -365,6 +365,7 @@ fn equal_scale_is_directly_visible_and_title_visibility_is_advanced() { axis::Y_LABEL, axis::X_SHOW_TICK_LABELS, axis::Y_SHOW_TICK_LABELS, + axis::SHOW_LEGEND, ] ); assert_eq!( @@ -373,6 +374,12 @@ fn equal_scale_is_directly_visible_and_title_visibility_is_advanced() { .tier(), Some(Tier::Advanced) ); + assert_eq!( + presentation(typography::LEGEND_COLOR) + .expect("legend color presentation") + .tier(), + Some(Tier::Advanced) + ); assert_eq!( presentation(axis::Y_SHOW_LABEL) .expect("y-title visibility presentation") diff --git a/crates/app/src/ui/tools/electrophysiology.rs b/crates/app/src/ui/tools/electrophysiology.rs index 1e84cf03..372530b6 100644 --- a/crates/app/src/ui/tools/electrophysiology.rs +++ b/crates/app/src/ui/tools/electrophysiology.rs @@ -6,6 +6,12 @@ use plotx_core::state::{ }; pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) -> bool { + let selected_region = app.doc.datasets.get(di).and_then(|dataset| { + app.session + .ui + .selected_region + .and_then(|selection| selection.in_dataset(dataset.resource_id())) + }); let Some(recording) = app .doc .datasets @@ -131,17 +137,25 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui ); ui.separator(); - ui.strong("Time-window statistics"); - ui.horizontal(|ui| { - ui.label("Start (s)"); - dirty |= ui - .add(DragValue::new(&mut recording.analysis_window.start_s).speed(0.01)) - .changed(); - ui.label("End (s)"); - dirty |= ui - .add(DragValue::new(&mut recording.analysis_window.end_s).speed(0.01)) - .changed(); - }); + ui.strong("Region statistics"); + let region = selected_region + .and_then(|id| { + recording + .region_analysis + .regions + .iter() + .find(|region| region.id == id) + }) + .or_else(|| recording.region_analysis.regions.first()); + if let Some(region) = region { + ui.label(format!( + "Window: {:.4}–{:.4} s", + region.lo_min(), + region.hi_max() + )); + } else { + ui.weak("Draw and select a region on the trace to choose the analysis window."); + } ComboBox::from_label("Peak mode") .selected_text(format!("{:?}", recording.peak_mode)) .show_ui(ui, |ui| { @@ -171,14 +185,25 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui } let snapshot = recording.clone(); + let analysis_window = region.map(|region| plotx_analysis::electrophysiology::TimeWindow { + start_s: region.lo_min(), + end_s: region.hi_max(), + }); let mut create = None; ui.horizontal(|ui| { - if ui.button("Create statistics table").clicked() { + if ui + .add_enabled( + analysis_window.is_some(), + egui::Button::new("Create statistics table"), + ) + .on_disabled_hover_text("Draw a region before creating a statistics table.") + .clicked() + { create = Some( build_window_statistics_table( &snapshot, snapshot.selected_channel, - snapshot.analysis_window, + analysis_window.expect("the button is enabled only with a region"), snapshot.peak_mode, ) .map(|table| { @@ -190,12 +215,19 @@ pub(super) fn electrophysiology_group(app: &mut PlotxApp, di: usize, ui: &mut Ui }), ); } - if ui.button("Create IV table").clicked() { + if ui + .add_enabled( + analysis_window.is_some(), + egui::Button::new("Create IV table"), + ) + .on_disabled_hover_text("Draw a region before creating an IV table.") + .clicked() + { create = Some( build_iv_table( &snapshot, snapshot.selected_channel, - snapshot.analysis_window, + analysis_window.expect("the button is enabled only with a region"), snapshot.peak_mode, ) .map(|table| (table, "IV analysis", DerivationKind::IvTable)), diff --git a/crates/app/src/ui/tools/region_analysis.rs b/crates/app/src/ui/tools/region_analysis.rs index d19987ee..94e4b75c 100644 --- a/crates/app/src/ui/tools/region_analysis.rs +++ b/crates/app/src/ui/tools/region_analysis.rs @@ -1,7 +1,9 @@ use egui::{Area, Button, Order, Ui}; use egui_phosphor::regular as icon; use plotx_core::actions::Action; -use plotx_core::state::{Dataset, PlotxApp, RegionMetric, TaskDockTab, Tool}; +use plotx_core::state::{ + Dataset, PlotxApp, RegionId, RegionMetric, RegionSelection, TaskDockTab, Tool, +}; use super::task_card::{self, TaskCardGeometry}; @@ -11,8 +13,8 @@ pub(super) fn region_analysis_group(app: &mut PlotxApp, di: usize, ui: &mut Ui) .doc .datasets .get(di) - .and_then(|dataset| dataset.as_nmr2d()) - .map_or(0, |series| series.regions.len()); + .and_then(Dataset::region_analysis) + .map_or(0, |state| state.regions.len()); ui.small(format!("{count} regions · tools open over the canvas")); if ui.button("Show region tools").clicked() { open_task(app, di); @@ -30,15 +32,19 @@ pub(crate) fn open_task(app: &mut PlotxApp, di: usize) { { return; } - app.session.ui.region_task_dataset = Some(di); + app.session.ui.region_task_dataset = app.doc.datasets.get(di).map(Dataset::resource_id); app.session.ui.open_task_tab(TaskDockTab::Regions); } pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { + finish_detached_label_edit(app); if !task_card::is_active(app, TaskDockTab::Regions) { return; } - let Some(di) = app.session.ui.region_task_dataset else { + let Some(dataset_id) = app.session.ui.region_task_dataset else { + return; + }; + let Some(di) = app.doc.dataset_index(dataset_id) else { return; }; if app.active_dataset() != Some(di) @@ -73,7 +79,9 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { if task_card::tab_bar(app, TaskDockTab::Regions, ui) { ui.separator(); } - let count = app.doc.datasets[di].as_nmr2d().unwrap().regions.len(); + let count = app.doc.datasets[di] + .region_analysis() + .map_or(0, |state| state.regions.len()); ui.horizontal(|ui| { ui.strong("Regions"); let state = if app.session.tool == Tool::Regions { @@ -154,6 +162,7 @@ pub(crate) fn render_task(app: &mut PlotxApp, host: &mut Ui) { } fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { + let dataset_id = app.doc.datasets[di].resource_id(); let drawing = app.session.tool == Tool::Regions; if drawing { ui.label("Drag across a signal to add a region."); @@ -164,7 +173,9 @@ fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { ui.horizontal(|ui| { ui.label("Measure"); - let mut metric = app.doc.datasets[di].as_nmr2d().unwrap().region_metric; + let mut metric = app.doc.datasets[di] + .region_analysis() + .map_or(RegionMetric::Height, |state| state.default_metric); let mut changed = false; egui::ComboBox::from_id_salt((di, "region_metric")) .selected_text(metric.label()) @@ -174,17 +185,31 @@ fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { } }); if changed { - if let Some(d2) = app.doc.datasets[di].as_nmr2d_mut() { - d2.region_metric = metric; - } - app.sync_region_table(di); + app.set_region_default_metric(di, metric); } }); + let mut show_annotations = app.doc.datasets[di] + .region_analysis() + .is_some_and(|state| state.show_annotations); + if ui + .checkbox(&mut show_annotations, "Show regions on figure and export") + .changed() + { + if let Some(state) = app.doc.datasets[di].region_analysis_mut() { + state.show_annotations = show_annotations; + } + app.rebuild_canvases_for(di); + app.mark_document_dirty(); + } - let selected = app.session.ui.selected_region; - let mut delete_id: Option = None; - let mut metric_change: Option<(u64, Option)> = None; - let mut select_id: Option = None; + let selected = app + .session + .ui + .selected_region + .and_then(|selection| selection.in_dataset(dataset_id)); + let mut delete_id: Option = None; + let mut metric_change: Option<(RegionId, Option)> = None; + let mut select_id: Option = None; let mut name_gained = false; let mut name_lost = false; let table_exists = app.region_table_index(di).is_some(); @@ -201,92 +226,96 @@ fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { 46.0 + mirror.len() as f32 * 16.0 }; let list_height = (ui.available_height() - footer_height).max(72.0); + let axis_unit = app.doc.datasets[di].region_axis_unit().unwrap_or(""); egui::ScrollArea::vertical() .max_height(list_height) .min_scrolled_height(list_height) .auto_shrink([false, false]) .show(ui, |ui| { - let d2 = app.doc.datasets[di].as_nmr2d_mut().unwrap(); - if d2.regions.is_empty() { + let Some(state) = app.doc.datasets[di].region_analysis_mut() else { + return; + }; + if state.regions.is_empty() { ui.weak("No regions yet — turn on Draw regions and drag across a signal."); } - for region in d2.regions.iter_mut() { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let [cr, cg, cb] = region.color; - let (rect, _) = - ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); - ui.painter() - .rect_filled(rect, 2.0, egui::Color32::from_rgb(cr, cg, cb)); - - let is_sel = selected == Some(region.id); - if ui - .add_sized( - [104.0, ui.spacing().interact_size.y], - Button::selectable( - is_sel, - format!("{:.2}–{:.2}", region.lo_min(), region.hi_max()), - ), - ) - .clicked() - { - select_id = Some(region.id); - } - - let resp = ui.add( - egui::TextEdit::singleline(&mut region.name) - .hint_text("name") - .desired_width(60.0), - ); - if resp.gained_focus() { - name_gained = true; - } - if resp.lost_focus() { - name_lost = true; - } - - let mut m = region.metric; - egui::ComboBox::from_id_salt((region.id, "rm")) - .selected_text(m.map(RegionMetric::label).unwrap_or("default")) - .width(68.0) - .show_ui(ui, |ui| { - ui.selectable_value(&mut m, None, "default"); - for &opt in RegionMetric::all() { - ui.selectable_value(&mut m, Some(opt), opt.label()); + for region in state.regions.iter_mut() { + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let [cr, cg, cb] = region.color; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, 2.0, egui::Color32::from_rgb(cr, cg, cb)); + let interval = if axis_unit.is_empty() { + format!("{:.3}–{:.3}", region.lo_min(), region.hi_max()) + } else { + format!("{:.3}–{:.3} {axis_unit}", region.lo_min(), region.hi_max()) + }; + if ui + .add(Button::selectable(selected == Some(region.id), interval)) + .clicked() + { + select_id = Some(region.id); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_sized( + [18.0, ui.spacing().interact_size.y], + Button::new(icon::X).small(), + ) + .on_hover_text("Delete region") + .clicked() + { + delete_id = Some(region.id); } }); - if m != region.metric { - metric_change = Some((region.id, m)); - } - - if ui - .add_sized( - [18.0, ui.spacing().interact_size.y], - Button::new(icon::X).small(), - ) - .clicked() - { - delete_id = Some(region.id); - } + }); + ui.horizontal(|ui| { + ui.label("Label"); + let response = ui.add( + egui::TextEdit::singleline(&mut region.name) + .hint_text("Use axis midpoint") + .desired_width(ui.available_width()), + ); + if response.gained_focus() { + name_gained = true; + } + if response.lost_focus() { + name_lost = true; + } + }); + ui.horizontal(|ui| { + ui.label("Measure"); + let mut metric = region.metric; + egui::ComboBox::from_id_salt((region.id, "rm")) + .selected_text(metric.map(RegionMetric::label).unwrap_or("Default")) + .width(92.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut metric, None, "Default"); + for &option in RegionMetric::all() { + ui.selectable_value(&mut metric, Some(option), option.label()); + } + }); + if metric != region.metric { + metric_change = Some((region.id, metric)); + } + }); }); } }); if let Some(id) = select_id { - app.session.ui.selected_region = Some(id); + app.session.ui.selected_region = Some(RegionSelection::new(dataset_id, id)); } - if name_gained && app.session.ui.region_edit_before.is_none() { - app.session.ui.region_edit_before = - Some(app.doc.datasets[di].as_nmr2d().unwrap().regions.clone()); + if name_lost { + finish_label_edit(app, dataset_id); } - if name_lost && let Some(before) = app.session.ui.region_edit_before.take() { - let after = app.doc.datasets[di].as_nmr2d().unwrap().regions.clone(); - app.execute_action(Action::set_regions( - app.doc.datasets[di].resource_id(), - before, - after, - )); + if name_gained && app.session.ui.region_edit_before.is_none() { + app.session.ui.region_edit_before = app.doc.datasets[di] + .region_analysis() + .map(|state| (dataset_id, state.regions.clone())); } if let Some((id, m)) = metric_change { app.edit_regions(di, |regions, _| { @@ -297,13 +326,20 @@ fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { } if let Some(id) = delete_id { app.edit_regions(di, |regions, _| regions.retain(|r| r.id != id)); - if app.session.ui.selected_region == Some(id) { + if app + .session + .ui + .selected_region + .is_some_and(|selection| selection.dataset == dataset_id && selection.region == id) + { app.session.ui.selected_region = None; } } ui.separator(); - let count = app.doc.datasets[di].as_nmr2d().unwrap().regions.len(); + let count = app.doc.datasets[di] + .region_analysis() + .map_or(0, |state| state.regions.len()); let table = app.region_table_index(di); if table.is_some() { ui.small(format!("{} Live series table · Synced", icon::CHECK)); @@ -347,6 +383,38 @@ fn region_task_body(app: &mut PlotxApp, di: usize, ui: &mut Ui) { ui.add_space(12.0); } +fn finish_detached_label_edit(app: &mut PlotxApp) { + let Some((dataset, _)) = app.session.ui.region_edit_before.as_ref() else { + return; + }; + let remains_attached = task_card::is_active(app, TaskDockTab::Regions) + && app.session.ui.region_task_dataset == Some(*dataset) + && app + .doc + .dataset_index(*dataset) + .is_some_and(|index| app.active_dataset() == Some(index)); + if !remains_attached { + finish_label_edit(app, *dataset); + } +} + +fn finish_label_edit(app: &mut PlotxApp, dataset: plotx_core::state::DatasetId) { + let Some((snapshot_dataset, before)) = app.session.ui.region_edit_before.take() else { + return; + }; + if snapshot_dataset != dataset { + app.session.ui.region_edit_before = Some((snapshot_dataset, before)); + return; + } + let Some(index) = app.doc.dataset_index(dataset) else { + return; + }; + let after = app.doc.datasets[index] + .region_analysis() + .map_or_else(Vec::new, |state| state.regions.clone()); + app.execute_action(Action::set_regions(dataset, before, after)); +} + pub(crate) fn open_region_table(app: &mut PlotxApp, di: usize) { if app.region_table_index(di).is_none() { app.create_region_table(di); diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index b3db1262..c3b0f6fb 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -63,7 +63,10 @@ pub(super) fn tab_bar(app: &mut PlotxApp, current: TaskDockTab, ui: &mut Ui) -> TaskDockTab::Regions, icon::SELECTION, "Regions", - app.session.ui.region_task_dataset, + app.session + .ui + .region_task_dataset + .and_then(|id| app.doc.dataset_index(id)), ), ( TaskDockTab::CurveFit, diff --git a/crates/core/src/actions/app_impl/axis_overrides.rs b/crates/core/src/actions/app_impl/axis_overrides.rs index 3eb7ffb6..0db3afbc 100644 --- a/crates/core/src/actions/app_impl/axis_overrides.rs +++ b/crates/core/src/actions/app_impl/axis_overrides.rs @@ -105,7 +105,9 @@ impl PlotxApp { || cleared(&before.x_show_tick_labels, &after.x_show_tick_labels) || cleared(&before.x_show_label, &after.x_show_label) || cleared(&before.y_show_tick_labels, &after.y_show_tick_labels) - || cleared(&before.y_show_label, &after.y_show_label); + || cleared(&before.y_show_label, &after.y_show_label) + || cleared(&before.show_legend, &after.show_legend) + || cleared(&before.legend_position, &after.legend_position); let rebuilt = needs_automatic_rebuild.then(|| { let size = [ diff --git a/crates/core/src/actions/tests/authoring.rs b/crates/core/src/actions/tests/authoring.rs index af940f6e..db0bc132 100644 --- a/crates/core/src/actions/tests/authoring.rs +++ b/crates/core/src/actions/tests/authoring.rs @@ -115,6 +115,8 @@ fn set_figure_typography_restamps_plots_and_is_undoable() { tick_pt: 9.0, label_pt: 10.5, title_pt: 11.0, + legend_pt: 6.0, + legend_color: plotx_figure::Color::rgb(10, 20, 30), }; app.execute_action(Action::set_figure_typography(before, after)); @@ -143,6 +145,7 @@ fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { y_label: Some("Response".to_owned()), x_range: Some(AxisRange::new(1.0, 8.0)), y_range: Some(AxisRange::new(-2.0, 12.0)), + legend_position: Some([0.2, 0.8]), ..AxisOverrides::default() }; @@ -155,6 +158,7 @@ fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { assert_eq!(first_plot(&app).axis_overrides, after); assert_eq!(first_plot(&app).figure().x.label, "Chemical shift"); assert_eq!(first_plot(&app).figure().y.label, "Response"); + assert_eq!(first_plot(&app).figure().legend_position, Some([0.2, 0.8])); assert_eq!(first_plot(&app).viewport.full_x, AxisRange::new(1.0, 8.0)); assert_eq!(first_plot(&app).viewport.full_y, AxisRange::new(-2.0, 12.0)); assert!(!first_plot(&app).viewport.auto_y); diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index 3e325f76..41cc38eb 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -111,7 +111,7 @@ fn gesture_active_covers_only_the_board_freezing_drags() { Interaction::Region(RegionDrag { canvas: 0, object, - dataset: 0, + dataset: app.doc.datasets[0].resource_id(), kind: RegionDragKind::NewBand, region_id: None, before: Vec::new(), diff --git a/crates/core/src/automation/resources.rs b/crates/core/src/automation/resources.rs index 8e855073..0abc315b 100644 --- a/crates/core/src/automation/resources.rs +++ b/crates/core/src/automation/resources.rs @@ -49,6 +49,7 @@ pub const CAP_FIELD_NMR_STACK: &str = "field.nmr.stack"; pub const CAP_FIELD_SWEEP_COLLECTION: &str = "field.sweep_collection"; pub const CAP_FIELD_FORCE_CURVE: &str = "field.force_curve"; pub const CAP_FIELD_AFM_MAP: &str = "field.afm.map"; +pub const CAP_FIELD_REGION_SERIES: &str = "field.region_series"; /// Capability-oriented resource access. New resource types can participate by /// implementing this trait; query and tool orchestration do not dispatch on a diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index 74f907de..ab882934 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -59,8 +59,8 @@ pub struct PageMetrics { } /// Scan a page for the smallest authored (user-controlled) font and line width. -/// Since figure typography became a document style, tick and axis-title sizes -/// count too; only truly fixed renderer chrome (the axis frame line) stays out. +/// Since figure typography became a document style, tick, axis-title, and +/// visible legend sizes count too; only fixed renderer chrome stays out. pub fn page_metrics(canvas: &CanvasDocument) -> PageMetrics { let mut fonts: Vec = Vec::new(); let mut lines: Vec = Vec::new(); @@ -83,9 +83,15 @@ pub fn page_metrics(canvas: &CanvasDocument) -> PageMetrics { if plot.figure().axis_frame != AxisFrame::Hidden { fonts.extend([typography.tick_pt, typography.label_pt]); } + if !plot.figure().range_annotations.is_empty() { + fonts.push(typography.tick_pt); + } if !plot.figure().title.trim().is_empty() { fonts.push(typography.title_pt); } + if plotx_render::renders_legend(plot.figure()) { + fonts.push(typography.legend_pt); + } for annotation in &plot.figure().annotations { fonts.push(annotation.size); } @@ -201,7 +207,7 @@ mod tests { AxisOverrides, AxisProjections, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, DataBinding, ObjectFrame, ObjectId, PanelMeta, PlotObject, StackSpec, }; - use plotx_figure::{Axis, Figure}; + use plotx_figure::{Axis, Color, Figure, RangeAnnotation, Series}; fn thresholds() -> ComplianceThresholds { ComplianceThresholds { @@ -293,4 +299,83 @@ mod tests { .set_axis_frame(AxisFrame::Open); assert_eq!(page_metrics(&canvas).min_font_pt, Some(3.0)); } + + #[test] + fn visible_legend_contributes_its_authored_font_size() { + let mut figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + figure.axis_frame = AxisFrame::Hidden; + figure.show_legend = true; + figure.typography.legend_pt = 5.5; + figure.series = vec![ + Series::line("A", vec![[0.0, 0.0]]), + Series::line("B", vec![[1.0, 1.0]]).colored(Color::rgb(200, 0, 0)), + ]; + let viewport = CanvasViewport::from_figure(&figure); + let mut panel = PanelMeta::new(String::new(), 100.0); + panel.visible = false; + let mut canvas = CanvasDocument::new("Legend".to_owned(), [200.0, 100.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "Plot".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 100.0, 100.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + crate::state::SeriesId::new(1), + DataBinding { series: Vec::new() }, + ChartSpec::default(), + StackSpec::default(), + AxisProjections::default(), + AxisOverrides::default(), + figure, + viewport, + panel, + ))), + }); + + assert_eq!(page_metrics(&canvas).min_font_pt, Some(5.5)); + } + + #[test] + fn range_label_counts_even_when_the_axis_frame_is_hidden() { + let mut figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + figure.axis_frame = AxisFrame::Hidden; + figure.typography.tick_pt = 4.5; + figure.range_annotations.push(RangeAnnotation { + source_id: 1, + x0: 0.2, + x1: 0.4, + label: "window".to_owned(), + label_position: None, + color: Color::AXIS, + fill_opacity: 0.1, + width: 1.0, + }); + let viewport = CanvasViewport::from_figure(&figure); + let mut panel = PanelMeta::new(String::new(), 100.0); + panel.visible = false; + let mut canvas = CanvasDocument::new("Ranges".to_owned(), [200.0, 100.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "Plot".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 100.0, 100.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + crate::state::SeriesId::new(1), + DataBinding { series: Vec::new() }, + ChartSpec::default(), + StackSpec::default(), + AxisProjections::default(), + AxisOverrides::default(), + figure, + viewport, + panel, + ))), + }); + + assert_eq!(page_metrics(&canvas).min_font_pt, Some(4.5)); + } } diff --git a/crates/core/src/project/axis_overrides.rs b/crates/core/src/project/axis_overrides.rs index 34dc6481..5d8eda72 100644 --- a/crates/core/src/project/axis_overrides.rs +++ b/crates/core/src/project/axis_overrides.rs @@ -22,6 +22,10 @@ pub struct AxisOverridesDto { y_show_tick_labels: Option, #[serde(default, skip_serializing_if = "Option::is_none")] y_show_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + show_legend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + legend_position: Option<[f32; 2]>, } impl AxisOverridesDto { @@ -36,6 +40,8 @@ impl AxisOverridesDto { x_show_label: overrides.x_show_label, y_show_tick_labels: overrides.y_show_tick_labels, y_show_label: overrides.y_show_label, + show_legend: overrides.show_legend, + legend_position: overrides.legend_position, }) } @@ -50,6 +56,8 @@ impl AxisOverridesDto { x_show_label: self.x_show_label, y_show_tick_labels: self.y_show_tick_labels, y_show_label: self.y_show_label, + show_legend: self.show_legend, + legend_position: self.legend_position, } .normalized() } diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 3091a700..9da38e2c 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -1,4 +1,4 @@ -use super::convert_recipes::{nmr2d_recipe_extensions, read_regions}; +use super::convert_recipes::{nmr2d_recipe_extensions, read_region_analysis}; use super::electrophysiology_convert::{ electrophysiology_from_object, electrophysiology_to_objects, }; @@ -372,7 +372,7 @@ pub fn object_to_dataset( }); dataset.field_catalog = read_field_catalog(data)?; apply_2d_recipe(&mut dataset, recipe)?; - read_regions(&mut dataset, recipe); + read_region_analysis(&mut dataset, recipe)?; read_integrals_2d(&mut dataset, recipe)?; read_peaks_2d(&mut dataset, recipe)?; dataset.name = data.label.clone(); diff --git a/crates/core/src/project/convert_recipes.rs b/crates/core/src/project/convert_recipes.rs index a53eb1b9..354e40b9 100644 --- a/crates/core/src/project/convert_recipes.rs +++ b/crates/core/src/project/convert_recipes.rs @@ -85,28 +85,23 @@ fn legacy_peaks(analysis: &serde_json::Value) -> PeakSet { peaks } -pub(super) fn read_regions(dataset: &mut Nmr2DDataset, recipe: &RecipeObject) { - let Some(ext) = recipe.extensions.get("plotx.regions") else { - return; - }; - if let Some(regions) = ext - .get("regions") - .cloned() - .and_then(|v| serde_json::from_value::>(v).ok()) - { - dataset.regions = regions; - } - if let Some(metric) = ext - .get("metric") - .cloned() - .and_then(|v| serde_json::from_value::(v).ok()) - { - dataset.region_metric = metric; - } - dataset.next_region_id = ext - .get("next_id") - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| dataset.regions.iter().map(|r| r.id + 1).max().unwrap_or(0)); +pub(super) fn read_region_analysis( + dataset: &mut Nmr2DDataset, + recipe: &RecipeObject, +) -> Result<()> { + let extension = recipe + .extensions + .get("plotx.region_analysis") + .ok_or_else(|| { + ProjectError::Invalid("2D NMR recipe is missing region analysis state".to_owned()) + })?; + dataset.region_analysis = serde_json::from_value(extension.clone()).map_err(|error| { + ProjectError::Invalid(format!("invalid region analysis state: {error}")) + })?; + dataset.region_analysis.validate().map_err(|error| { + ProjectError::Invalid(format!("invalid region analysis state: {error}")) + })?; + Ok(()) } pub(super) fn nmr2d_recipe_extensions( @@ -118,16 +113,10 @@ pub(super) fn nmr2d_recipe_extensions( "plotx.step_allocator".to_owned(), serde_json::json!({ "next_id": dataset.next_step_id }), ); - if !dataset.regions.is_empty() { - extensions.insert( - "plotx.regions".to_owned(), - serde_json::json!({ - "regions": &dataset.regions, - "metric": &dataset.region_metric, - "next_id": dataset.next_region_id, - }), - ); - } + extensions.insert( + "plotx.region_analysis".to_owned(), + serde_json::json!(&dataset.region_analysis), + ); let mut analysis = serde_json::Map::new(); if !dataset.integrals.is_empty() { analysis.insert( diff --git a/crates/core/src/project/electrophysiology_convert.rs b/crates/core/src/project/electrophysiology_convert.rs index ceab032e..9e7082c3 100644 --- a/crates/core/src/project/electrophysiology_convert.rs +++ b/crates/core/src/project/electrophysiology_convert.rs @@ -3,11 +3,8 @@ use crate::state::ElectrophysiologyDataset; /// Binary payload tag: bulk samples live in `payload.blob` as length-prefixed /// little-endian `f64`, while the light structure and metadata are JSON in the -/// object's extensions. Superseded the earlier all-JSON `-json-v1` layout, whose -/// numeric arrays made large recordings slow to parse and many times larger on -/// disk. Loading of the legacy tag is retained below. +/// object's extensions. const STORAGE_ELECTROPHYSIOLOGY_BIN: &str = "electrophysiology-bin-v1"; -const STORAGE_ELECTROPHYSIOLOGY_JSON: &str = "electrophysiology-json-v1"; pub(super) const VALUES_PER_CHUNK: usize = 4096; pub(super) fn electrophysiology_to_objects( @@ -130,16 +127,15 @@ pub(super) fn electrophysiology_from_object( } recording } - // Legacy layout: the blob is the whole dataset serialized as JSON. - STORAGE_ELECTROPHYSIOLOGY_JSON => serde_json::from_slice(&blob).map_err(|error| { - ProjectError::Invalid(format!("invalid electrophysiology payload: {error}")) - })?, other => { return Err(ProjectError::Unsupported(format!( "electrophysiology payload storage {other}" ))); } }; + recording.region_analysis.validate().map_err(|error| { + ProjectError::Invalid(format!("invalid region analysis state: {error}")) + })?; let dataset = Dataset::Electrophysiology(Box::new(recording)); dataset .validate_field_catalog() diff --git a/crates/core/src/project/electrophysiology_tests.rs b/crates/core/src/project/electrophysiology_tests.rs index c284e16a..44ec790d 100644 --- a/crates/core/src/project/electrophysiology_tests.rs +++ b/crates/core/src/project/electrophysiology_tests.rs @@ -81,14 +81,19 @@ fn project_roundtrip_preserves_raw_data_and_settings() { let mut recording = crate::state::ElectrophysiologyDataset::load(data); recording.metadata.cell_id = "cell-42".to_owned(); recording.processing.cutoff_hz = 750.0; - let mut legacy_metadata = serde_json::to_value(&recording).unwrap(); - legacy_metadata - .as_object_mut() - .unwrap() - .remove("resource_id"); - let legacy_recording: crate::state::ElectrophysiologyDataset = - serde_json::from_value(legacy_metadata).unwrap(); - assert!(!legacy_recording.resource_id.to_string().is_empty()); + recording + .region_analysis + .regions + .push(crate::state::Region { + id: crate::state::RegionId::new(0), + lo: 0.0001, + hi: 0.0003, + name: "transient".to_owned(), + label_position: Some([0.2, 0.8]), + color: crate::state::region_color(0), + metric: Some(crate::state::RegionMetric::Area), + }); + recording.region_analysis.next_region_id = crate::state::RegionId::new(1); let mut app = PlotxApp::new(); app.doc .datasets @@ -104,5 +109,15 @@ fn project_roundtrip_preserves_raw_data_and_settings() { assert_eq!(recording.data.sweeps[0].commands[0].samples[1], -90.0); assert_eq!(recording.metadata.cell_id, "cell-42"); assert_eq!(recording.processing.cutoff_hz, 750.0); + assert_eq!(recording.region_analysis.regions.len(), 1); + assert_eq!(recording.region_analysis.regions[0].name, "transient"); + assert_eq!( + recording.region_analysis.regions[0].label_position, + Some([0.2, 0.8]) + ); + assert_eq!( + recording.region_analysis.default_metric, + crate::state::RegionMetric::Height + ); std::fs::remove_file(path).unwrap(); } diff --git a/crates/core/src/project/lineage_tests.rs b/crates/core/src/project/lineage_tests.rs index 3e1349ae..8a5b8cb5 100644 --- a/crates/core/src/project/lineage_tests.rs +++ b/crates/core/src/project/lineage_tests.rs @@ -1,7 +1,8 @@ use super::tests::{synthetic_1d, temp_project}; use super::*; use crate::state::{ - FloatSeries, TableImportSource, TableMetric, TableProvenance, materialized_float_series_table, + FloatSeries, RegionColumnProvenance, RegionId, RegionMetric, TableImportSource, + TableProvenance, materialized_float_series_table, }; #[test] @@ -44,26 +45,42 @@ fn project_roundtrip_maps_multi_source_lineage_by_data_id() { } #[test] -fn provenance_without_explicit_v1_lineage_stays_unlinked() { +fn region_provenance_without_lineage_stays_unlinked() { let mut app = PlotxApp::new(); app.doc .datasets .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); let source_resource = app.doc.datasets[0].resource_id().to_string(); + let source_field = app.doc.datasets[0].default_field_id().unwrap(); let mut table = materialized_float_series_table( ("x".into(), "".into(), vec![Some(0.0)]), - Vec::new(), - "plotx.test.provenance-table.v1", + vec![FloatSeries { + name: "Region 1".into(), + unit: "a.u.".into(), + values: vec![Some(1.0)], + uncertainty: None, + fit: None, + }], + "plotx.test.region-provenance-table", ) .unwrap(); + let column = table.series_bindings[0].value_column; table.provenance = Some(TableProvenance { source_resource, - regions: vec![(1.0, 2.0)], - metric: TableMetric::PeakHeight, + source_field, + regions: vec![RegionColumnProvenance { + region: RegionId::new(1), + column, + bounds: [1.0, 2.0], + metric: RegionMetric::Height, + label: "Region 1".into(), + unit: "ppm".into(), + color: [220, 80, 80], + }], }); app.doc.datasets.push(Dataset::Table(Box::new(table))); - let path = temp_project("legacy_region_lineage"); + let path = temp_project("region_provenance_lineage"); let _ = std::fs::remove_file(&path); save_project(&app, &path, false).unwrap(); let loaded = load_project(&path).unwrap(); diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index afacd79e..b8869108 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -2,8 +2,8 @@ use crate::layout::PageLayout; use crate::state::{ AnalysisSelection, AxisRange, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, DataBinding, Dataset, DatasetLineage, DerivationKind, Nmr2DDataset, NmrDataset, ObjectFrame, - ObjectId, PanelMeta, PlotObject, PlotxApp, PrimaryView, Region, RegionMetric, SeriesBinding, - ShapeKind, ShapeObject, StackMode, StackSpec, TextAlign, TextBox, Tool, + ObjectId, PanelMeta, PlotObject, PlotxApp, PrimaryView, SeriesBinding, ShapeKind, ShapeObject, + StackMode, StackSpec, TextAlign, TextBox, Tool, }; use num_complex::Complex64; use plotx_figure::Color; diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index 307497ec..92647639 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -262,6 +262,8 @@ fn project_roundtrip_preserves_data_recipe_and_view() { y_label: Some("Response".to_owned()), x_range: Some(AxisRange::new(1.0, 8.0)), y_range: Some(AxisRange::new(-2.0, 12.0)), + show_legend: Some(false), + legend_position: Some([0.25, 0.75]), ..AxisOverrides::default() }; let plot_id = app.doc.canvases[0].objects[0].id; @@ -270,6 +272,8 @@ fn project_roundtrip_preserves_data_recipe_and_view() { tick_pt: 9.5, label_pt: 10.0, title_pt: 11.0, + legend_pt: 6.25, + legend_color: plotx_figure::Color::rgb(12, 34, 56), }; app.set_figure_typography_value(custom_typography); let path = temp_project("roundtrip"); diff --git a/crates/core/src/properties/axis.rs b/crates/core/src/properties/axis.rs index a103931a..295fb4ab 100644 --- a/crates/core/src/properties/axis.rs +++ b/crates/core/src/properties/axis.rs @@ -18,6 +18,7 @@ pub const X_SHOW_TICK_LABELS: PropertyId = PropertyId("object.axes.x_show_tick_l pub const X_SHOW_LABEL: PropertyId = PropertyId("object.axes.x_show_label"); pub const Y_SHOW_TICK_LABELS: PropertyId = PropertyId("object.axes.y_show_tick_labels"); pub const Y_SHOW_LABEL: PropertyId = PropertyId("object.axes.y_show_label"); +pub const SHOW_LEGEND: PropertyId = PropertyId("object.figure.show_legend"); const OBJECT: Applicability = Applicability::component(ComponentKind::None); @@ -97,6 +98,13 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ &["y title visibility"], Tier::Advanced, ), + axis_definition( + SHOW_LEGEND, + ValueSchema::Bool, + "Show legend", + &["legend visibility", "hide legend", "show key"], + Tier::Essential, + ), ]; pub(crate) struct AxisProvider; @@ -196,6 +204,9 @@ impl PropertyProvider for AxisProvider { (Y_SHOW_LABEL, EditOp::Set(PropertyValue::Bool(value))) => { overrides.y_show_label = Some(*value) } + (SHOW_LEGEND, EditOp::Set(PropertyValue::Bool(value))) => { + overrides.show_legend = Some(*value) + } (EQUAL_F1_F2_SCALE, EditOp::Reset) => overrides.lock_aspect = None, (X_LABEL, EditOp::Reset) => overrides.x_label = None, (Y_LABEL, EditOp::Reset) => overrides.y_label = None, @@ -203,6 +214,7 @@ impl PropertyProvider for AxisProvider { (X_SHOW_LABEL, EditOp::Reset) => overrides.x_show_label = None, (Y_SHOW_TICK_LABELS, EditOp::Reset) => overrides.y_show_tick_labels = None, (Y_SHOW_LABEL, EditOp::Reset) => overrides.y_show_label = None, + (SHOW_LEGEND, EditOp::Reset) => overrides.show_legend = None, (_, EditOp::Step(_)) => { return Err(PropertyError::InvalidValue { property: definition.id, @@ -242,6 +254,7 @@ fn value_of(id: PropertyId, plot: &PlotObject) -> Result Ok(PropertyValue::Bool(plot.figure().x.show_label)), Y_SHOW_TICK_LABELS => Ok(PropertyValue::Bool(plot.figure().y.show_tick_labels)), Y_SHOW_LABEL => Ok(PropertyValue::Bool(plot.figure().y.show_label)), + SHOW_LEGEND => Ok(PropertyValue::Bool(plot.figure().show_legend)), _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), } } @@ -275,6 +288,7 @@ fn default_value(id: PropertyId, plot: &PlotObject) -> Result Ok(PropertyValue::Bool(plot.derived_axes().x_show_label)), Y_SHOW_TICK_LABELS => Ok(PropertyValue::Bool(plot.derived_axes().y_show_tick_labels)), Y_SHOW_LABEL => Ok(PropertyValue::Bool(plot.derived_axes().y_show_label)), + SHOW_LEGEND => Ok(PropertyValue::Bool(plot.derived_axes().show_legend)), _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), } } @@ -287,6 +301,7 @@ fn has_override(id: PropertyId, plot: &PlotObject) -> Result Ok(plot.axis_overrides.x_show_label.is_some()), Y_SHOW_TICK_LABELS => Ok(plot.axis_overrides.y_show_tick_labels.is_some()), Y_SHOW_LABEL => Ok(plot.axis_overrides.y_show_label.is_some()), + SHOW_LEGEND => Ok(plot.axis_overrides.show_legend.is_some()), _ => Err(PropertyError::UnknownProperty(id.as_str().to_owned())), } } diff --git a/crates/core/src/properties/axis_tests.rs b/crates/core/src/properties/axis_tests.rs index 0fd5697f..883a9d77 100644 --- a/crates/core/src/properties/axis_tests.rs +++ b/crates/core/src/properties/axis_tests.rs @@ -39,6 +39,7 @@ fn every_axis_property_reset_clears_its_stored_override() { x_show_label: Some(false), y_show_tick_labels: Some(false), y_show_label: Some(false), + show_legend: Some(false), ..AxisOverrides::default() }, ); @@ -65,6 +66,9 @@ fn every_axis_property_reset_clears_its_stored_override() { (axis::Y_SHOW_LABEL, |value: &AxisOverrides| { value.y_show_label.is_none() }), + (axis::SHOW_LEGEND, |value: &AxisOverrides| { + value.show_legend.is_none() + }), ]; for &(property, cleared) in cases { let commit = app @@ -75,6 +79,45 @@ fn every_axis_property_reset_clears_its_stored_override() { } } +#[test] +fn legend_visibility_is_a_persistent_undoable_plot_override() { + let (mut app, target, object) = axis_app(); + let before = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .unwrap() + .figure() + .show_legend; + let commit = app + .plan_property_write( + axis::SHOW_LEGEND, + std::slice::from_ref(&target), + &PropertyValue::Bool(!before), + ) + .expect("legend visibility write plans"); + app.commit_property(commit); + assert_eq!(overrides(&app, object).show_legend, Some(!before)); + assert_eq!( + app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .unwrap() + .figure() + .show_legend, + !before + ); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .unwrap() + .figure() + .show_legend, + before + ); +} + #[test] fn equal_scale_write_changes_the_2d_plot_and_undo_restores_it() { let (mut app, target, object) = axis_app(); diff --git a/crates/core/src/properties/provider_tests.rs b/crates/core/src/properties/provider_tests.rs index 8bc8a6e8..86c58b82 100644 --- a/crates/core/src/properties/provider_tests.rs +++ b/crates/core/src/properties/provider_tests.rs @@ -49,6 +49,7 @@ fn every_document_typography_property_resets_to_its_declared_default() { (typography::TICK_PT, 12.0), (typography::LABEL_PT, 13.0), (typography::TITLE_PT, 14.0), + (typography::LEGEND_PT, 6.0), ] { let commit = app .plan_property_write( @@ -71,11 +72,12 @@ fn every_document_typography_property_resets_to_its_declared_default() { } #[test] -fn all_three_typography_sizes_share_the_declared_point_schema() { +fn all_typography_sizes_share_the_declared_point_schema() { for property in [ typography::TICK_PT, typography::LABEL_PT, typography::TITLE_PT, + typography::LEGEND_PT, ] { let definition = definition(property).expect("typography is registered"); assert_eq!( @@ -90,6 +92,27 @@ fn all_three_typography_sizes_share_the_declared_point_schema() { } } +#[test] +fn legend_text_color_uses_the_document_typography_action() { + let mut app = PlotxApp::new(); + let target = app.document_target(); + let color = plotx_figure::Color::rgb(12, 34, 56); + let commit = app + .plan_property_write( + typography::LEGEND_COLOR, + std::slice::from_ref(&target), + &PropertyValue::Color(color), + ) + .expect("legend color plans"); + app.commit_property(commit); + assert_eq!(app.doc.style_library.figure_typography.legend_color, color); + app.undo(); + assert_eq!( + app.doc.style_library.figure_typography.legend_color, + plotx_figure::Color::AXIS + ); +} + #[test] fn data_line_widths_share_the_fine_point_schema() { for property in [line::STROKE_WIDTH, contour::LINE_WIDTH] { diff --git a/crates/core/src/properties/typography.rs b/crates/core/src/properties/typography.rs index b39abdad..dd8f12b7 100644 --- a/crates/core/src/properties/typography.rs +++ b/crates/core/src/properties/typography.rs @@ -9,10 +9,13 @@ use super::{ ValueCopies, ValueSchema, definition, }; use crate::state::PlotxApp; +use plotx_figure::Color; pub const TICK_PT: PropertyId = PropertyId("document.figure.typography.tick_pt"); pub const LABEL_PT: PropertyId = PropertyId("document.figure.typography.label_pt"); pub const TITLE_PT: PropertyId = PropertyId("document.figure.typography.title_pt"); +pub const LEGEND_PT: PropertyId = PropertyId("document.figure.typography.legend_pt"); +pub const LEGEND_COLOR: PropertyId = PropertyId("document.figure.typography.legend_color"); const POINT_BOUNDS: FloatBounds = FloatBounds::inclusive(1.0, 72.0); /// A quarter point per drag notch: point sizes are chosen to a half point, and @@ -63,6 +66,24 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ "Figure title size", &["title size", "figure heading", "figure typography"], ), + typography_definition( + LEGEND_PT, + 7.0, + "Figure legend size", + &["legend font size", "key size", "figure typography"], + ), + PropertyDefinition { + id: LEGEND_COLOR, + scope_kind: ScopeKind::Document, + value_schema: ValueSchema::Color, + access: PropertyAccess::ReadWrite, + applicability: Applicability::component(ComponentKind::None), + default_policy: DefaultPolicy::Fixed(PropertyValue::Color(Color::AXIS)), + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Figure legend text color", + canonical_aliases: &["legend colour", "key text color", "figure typography"], + }, ]; pub(crate) struct TypographyProvider; @@ -84,15 +105,45 @@ impl PropertyProvider for TypographyProvider { })?; require_document_target(&address.target, definition)?; let typography = app.doc.style_library.figure_typography; + let (value, schema) = match definition.id { + TICK_PT => ( + PropertyValue::Float(f64::from(typography.tick_pt)), + ResolvedSchema::Float { + bounds: POINT_BOUNDS, + display: FloatDisplay::Linear("pt"), + }, + ), + LABEL_PT => ( + PropertyValue::Float(f64::from(typography.label_pt)), + ResolvedSchema::Float { + bounds: POINT_BOUNDS, + display: FloatDisplay::Linear("pt"), + }, + ), + TITLE_PT => ( + PropertyValue::Float(f64::from(typography.title_pt)), + ResolvedSchema::Float { + bounds: POINT_BOUNDS, + display: FloatDisplay::Linear("pt"), + }, + ), + LEGEND_PT => ( + PropertyValue::Float(f64::from(typography.legend_pt)), + ResolvedSchema::Float { + bounds: POINT_BOUNDS, + display: FloatDisplay::Linear("pt"), + }, + ), + LEGEND_COLOR => ( + PropertyValue::Color(typography.legend_color), + ResolvedSchema::Color, + ), + _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), + }; Ok(ResolvedProperty { address: address.clone(), modified: None, - value: AggregateValue::Uniform(PropertyValue::Float(f64::from(match definition.id { - TICK_PT => typography.tick_pt, - LABEL_PT => typography.label_pt, - TITLE_PT => typography.title_pt, - _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), - }))), + value: AggregateValue::Uniform(value), default_value: match &definition.default_policy { DefaultPolicy::Fixed(value) => Some(value.clone()), DefaultPolicy::EncodingFactory @@ -101,10 +152,7 @@ impl PropertyProvider for TypographyProvider { | DefaultPolicy::None => None, }, availability: Availability::Editable, - schema: ResolvedSchema::Float { - bounds: POINT_BOUNDS, - display: FloatDisplay::Linear("pt"), - }, + schema, }) } @@ -119,6 +167,26 @@ impl PropertyProvider for TypographyProvider { PropertyError::UnknownProperty(address.definition.as_str().to_owned()) })?; require_document_target(&address.target, definition)?; + if definition.id == LEGEND_COLOR { + let value = match operation { + EditOp::Set(PropertyValue::Color(value)) => *value, + EditOp::Reset => Color::AXIS, + EditOp::Set(value) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: format!("expected a color, got {}", value.kind()), + }); + } + EditOp::Step(_) => { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "this setting has no step gesture".to_owned(), + }); + } + }; + transaction.figure_typography(app).legend_color = value; + return Ok(()); + } let value = match operation { EditOp::Set(PropertyValue::Float(value)) => { POINT_BOUNDS.check(definition.id, definition.canonical_label, *value)? @@ -154,6 +222,7 @@ impl PropertyProvider for TypographyProvider { TICK_PT => typography.tick_pt = value as f32, LABEL_PT => typography.label_pt = value as f32, TITLE_PT => typography.title_pt = value as f32, + LEGEND_PT => typography.legend_pt = value as f32, _ => return Err(PropertyError::UnknownProperty(definition.id.to_string())), } Ok(()) diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 0ce9a41a..0ec648eb 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -268,92 +268,6 @@ impl PlotxApp { } } - pub fn interaction(&self) -> &Interaction { - &self.session.ui.interaction - } - - pub fn set_interaction(&mut self, interaction: Interaction) { - self.session.ui.interaction = interaction; - } - - /// Take the current gesture, leaving `Idle`. Unlike [`Self::reset_interaction`] - /// this preserves the derived `tile_drop`/`snap_guides` previews, so a handler - /// can consume the drag and still read the preview it produced. - pub fn take_interaction(&mut self) -> Interaction { - std::mem::replace(&mut self.session.ui.interaction, Interaction::Idle) - } - - /// The single "drop any in-flight gesture" transition: clears the interaction - /// and its derived object-drag previews. - pub fn reset_interaction(&mut self) { - self.session.ui.interaction = Interaction::Idle; - self.session.ui.tile_drop = None; - self.session.ui.snap_guides.clear(); - } - - /// Start a gesture, dropping any prior one first. The debug assert is a cheap - /// sanity check that the gesture matches the active tool and canvas. - pub fn begin_interaction(&mut self, interaction: Interaction) { - debug_assert!( - interaction.belongs_to(self.session.tool, self.session.active_canvas), - "gesture started under a tool/canvas it does not belong to" - ); - self.reset_interaction(); - self.session.ui.interaction = interaction; - } - - /// Cancel the in-flight gesture (Esc), restoring the pre-gesture state for the - /// gestures that mutate the document live: a phase drag restores the dataset's - /// processing state and a region drag its bands. All others just drop. - pub fn cancel_interaction(&mut self) { - match self.take_interaction() { - Interaction::Phase(drag) => { - self.set_dataset_processing_state(drag.dataset, &drag.gesture_before); - } - Interaction::Region(drag) => { - if let Some(d2) = self - .doc - .datasets - .get_mut(drag.dataset) - .and_then(Dataset::as_nmr2d_mut) - { - d2.regions = drag.before; - } - } - Interaction::Integral(drag) => { - let dataset = drag.dataset; - if let Some(n) = self - .doc - .datasets - .get_mut(dataset) - .and_then(Dataset::as_nmr_mut) - { - n.integrals = drag.before; - } - self.sync_integral_curves_for(dataset); - } - Interaction::Integral2D(drag) => { - if let Some(n) = self - .doc - .datasets - .get_mut(drag.dataset) - .and_then(Dataset::as_nmr2d_mut) - { - n.integrals = drag.before; - } - } - Interaction::Object(drag) => { - self.set_object_frame(drag.canvas, drag.object, drag.before); - for (id, frame) in drag.others { - self.set_object_frame(drag.canvas, id, frame); - } - } - _ => {} - } - self.session.ui.tile_drop = None; - self.session.ui.snap_guides.clear(); - } - pub fn set_tool(&mut self, tool: Tool) { if self.session.tool == tool { return; @@ -672,6 +586,7 @@ impl PlotxApp { return; } self.rebuild_canvases_for(dataset); + self.sync_region_table(dataset); self.mark_document_dirty(); } @@ -687,6 +602,7 @@ impl PlotxApp { return; } self.rebuild_canvases_for(dataset); + self.sync_region_table(dataset); self.mark_document_dirty(); } diff --git a/crates/core/src/state/app_impl_analysis.rs b/crates/core/src/state/app_impl_analysis.rs index a75ac679..1ce508f6 100644 --- a/crates/core/src/state/app_impl_analysis.rs +++ b/crates/core/src/state/app_impl_analysis.rs @@ -170,34 +170,65 @@ impl PlotxApp { /// Worker behind `SetRegions`: install the regions and re-derive the linked /// table so apply and undo both land in a consistent state. pub fn set_regions(&mut self, dataset: usize, regions: &[Region]) { - if let Some(d2) = self + if let Some(state) = self .doc .datasets .get_mut(dataset) - .and_then(Dataset::as_nmr2d_mut) + .and_then(Dataset::region_analysis_mut) { - d2.regions = regions.to_vec(); + state.regions = regions.to_vec(); } self.sync_region_table(dataset); + self.rebuild_canvases_for(dataset); + } + + /// Change the persisted fallback metric and keep any linked table in sync. + pub fn set_region_default_metric(&mut self, dataset: usize, metric: RegionMetric) { + let changed = self + .doc + .datasets + .get_mut(dataset) + .and_then(Dataset::region_analysis_mut) + .is_some_and(|state| { + if state.default_metric == metric { + false + } else { + state.default_metric = metric; + true + } + }); + if changed { + self.sync_region_table(dataset); + self.mark_document_dirty(); + } } /// Snapshot the regions, let `edit` mutate a working copy (and hand out fresh /// ids), then commit the change as one undoable step. - pub fn edit_regions(&mut self, dataset: usize, edit: impl FnOnce(&mut Vec, &mut u64)) { - let Some(d2) = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d) else { + pub fn edit_regions( + &mut self, + dataset: usize, + edit: impl FnOnce(&mut Vec, &mut RegionId), + ) { + let Some(state) = self + .doc + .datasets + .get(dataset) + .and_then(Dataset::region_analysis) + else { return; }; - let before = d2.regions.clone(); + let before = state.regions.clone(); let mut after = before.clone(); - let mut next_id = d2.next_region_id; + let mut next_id = state.next_region_id; edit(&mut after, &mut next_id); - if let Some(d2) = self + if let Some(state) = self .doc .datasets .get_mut(dataset) - .and_then(Dataset::as_nmr2d_mut) + .and_then(Dataset::region_analysis_mut) { - d2.next_region_id = next_id; + state.next_region_id = next_id; } self.execute_action(Action::set_regions( self.doc.datasets[dataset].resource_id(), diff --git a/crates/core/src/state/app_impl_analysis_tables.rs b/crates/core/src/state/app_impl_analysis_tables.rs index 577e5f4c..5045c390 100644 --- a/crates/core/src/state/app_impl_analysis_tables.rs +++ b/crates/core/src/state/app_impl_analysis_tables.rs @@ -4,6 +4,60 @@ use super::app_impl_analysis::{ }; use super::*; +fn reduce_electrophysiology_region( + recording: &ElectrophysiologyDataset, + sweep: usize, + channel: usize, + region: &Region, + metric: RegionMetric, +) -> Result { + let values = recording + .processed_trace(sweep, channel) + .map_err(|error| error.to_string())?; + let rate = recording.data.sample_rate_hz; + if !rate.is_finite() || rate <= 0.0 { + return Err("The recording has an invalid sample rate.".to_owned()); + } + let lo = region.lo_min().max(0.0); + let hi = region.hi_max().max(lo); + let start = (lo * rate).floor().max(0.0) as usize; + let end = ((hi * rate).ceil() as usize).min(values.len()); + let slice = values + .get(start..end) + .filter(|slice| !slice.is_empty()) + .ok_or_else(|| "A region does not overlap the selected sweep.".to_owned())?; + if slice.iter().any(|value| !value.is_finite()) { + return Err("A region contains non-finite samples.".to_owned()); + } + let value = match metric { + RegionMetric::Height => { + plotx_analysis::electrophysiology::window_statistics( + &values, + rate, + 0.0, + plotx_analysis::electrophysiology::TimeWindow { + start_s: lo, + end_s: hi, + }, + recording.peak_mode, + ) + .map_err(|error| error.to_string())? + .peak + } + RegionMetric::Area => slice + .windows(2) + .map(|pair| 0.5 * (pair[0] + pair[1]) / rate) + .sum(), + RegionMetric::Mean => slice.iter().sum::() / slice.len() as f64, + RegionMetric::Max => slice.iter().copied().fold(f64::NEG_INFINITY, f64::max), + RegionMetric::Min => slice.iter().copied().fold(f64::INFINITY, f64::min), + }; + value + .is_finite() + .then_some(value) + .ok_or_else(|| "A region contains no finite samples.".to_owned()) +} + impl PlotxApp { /// Create an empty editable data table from scratch: a small starter grid, /// placed as a board sheet frame (right of the page grid), selected, with its @@ -83,43 +137,129 @@ impl PlotxApp { dataset_index } - /// Build a fresh series table from a pseudo-2D dataset's regions: one column - /// per region (x = the raw indirect ruler, y = the region reduced by its - /// metric). `None` when the dataset is not a series or has no regions. - fn build_region_table(&self, dataset: usize) -> Option { - let source_resource = self.doc.datasets.get(dataset)?.resource_id().to_string(); - let d2 = self.doc.datasets.get(dataset).and_then(Dataset::as_nmr2d)?; - let (Processed2D::Stack(stack), Some(axis)) = (&d2.processed, &d2.data.pseudo_axis) else { - return None; - }; - if d2.regions.is_empty() { - return None; + /// Build a fresh series table from any field that exposes ordered 1D + /// members. Rows follow the member ruler; every region becomes one column. + fn build_region_table(&self, dataset: usize) -> Result { + let source = self + .doc + .datasets + .get(dataset) + .ok_or_else(|| "The region source is no longer available.".to_owned())?; + let source_resource = source.resource_id().to_string(); + let source_field = source + .region_source_field() + .ok_or_else(|| "Plot a region-analyzable field before building a table.".to_owned())?; + let state = source + .region_analysis() + .ok_or_else(|| "The selected field does not support region analysis.".to_owned())?; + if state.regions.is_empty() { + return Err("Add at least one region before creating a table.".to_owned()); } - let x_label = match axis.kind { - plotx_io::PseudoKind::Gradient => "Gradient".to_owned(), - plotx_io::PseudoKind::Delay => "Delay".to_owned(), - plotx_io::PseudoKind::Generic if !axis.name.is_empty() => axis.name.clone(), - plotx_io::PseudoKind::Generic => "Ruler".to_owned(), + + let (x_label, x_unit, x, values, value_units) = match source { + Dataset::Nmr2D(d2) => { + let (Processed2D::Stack(stack), Some(axis)) = (&d2.processed, &d2.data.pseudo_axis) + else { + return Err("The selected NMR field is not an ordered series.".to_owned()); + }; + let x_label = match axis.kind { + plotx_io::PseudoKind::Gradient => "Gradient".to_owned(), + plotx_io::PseudoKind::Delay => "Delay".to_owned(), + plotx_io::PseudoKind::Generic if !axis.name.is_empty() => axis.name.clone(), + plotx_io::PseudoKind::Generic => "Ruler".to_owned(), + }; + let values = state + .regions + .iter() + .map(|region| { + let op = region.metric.unwrap_or(state.default_metric).into(); + extract_region_series(stack, axis, (region.lo, region.hi), op).y + }) + .collect::>(); + ( + x_label, + axis.unit.clone(), + axis.values.clone(), + values, + vec!["".to_owned(); state.regions.len()], + ) + } + Dataset::Electrophysiology(recording) => { + let selected = recording + .selected_sweeps + .iter() + .enumerate() + .filter_map(|(index, selected)| (*selected).then_some(index)) + .collect::>(); + let signal_unit = recording + .data + .channels + .get(recording.selected_channel) + .map(|channel| channel.unit.symbol.clone()) + .unwrap_or_default(); + let mut values = Vec::with_capacity(state.regions.len()); + let mut units = Vec::with_capacity(state.regions.len()); + for region in &state.regions { + let metric = region.metric.unwrap_or(state.default_metric); + let mut column = Vec::with_capacity(selected.len()); + for &sweep in &selected { + column.push(reduce_electrophysiology_region( + recording, + sweep, + recording.selected_channel, + region, + metric, + )?); + } + units.push(if metric == RegionMetric::Area && !signal_unit.is_empty() { + format!("{signal_unit}·s") + } else { + signal_unit.clone() + }); + values.push(column); + } + ( + "Sweep".to_owned(), + "".to_owned(), + selected.iter().map(|index| (*index + 1) as f64).collect(), + values, + units, + ) + } + Dataset::Nmr(_) | Dataset::Table(_) | Dataset::Afm(_) => { + return Err("The selected field does not contain an ordered series.".to_owned()); + } }; + let (mut x_schema, x_values) = - materialized_float_column(x_label, &axis.unit, axis.values.iter().copied().map(Some)); + materialized_float_column(x_label, &x_unit, x.into_iter().map(Some)); x_schema.role = plotx_data::SemanticRole::Custom("space.nmrtist.plotx.axis.x".into()); let x_binding = x_schema.id; let mut columns = vec![(x_schema, x_values)]; - let mut series_bindings = Vec::with_capacity(d2.regions.len()); - let mut windows = Vec::with_capacity(d2.regions.len()); - for region in &d2.regions { - let op = region.metric.unwrap_or(d2.region_metric).into(); - let series = extract_region_series(stack, axis, (region.lo, region.hi), op); + let mut series_bindings = Vec::with_capacity(state.regions.len()); + let mut region_provenance = Vec::with_capacity(state.regions.len()); + let axis_unit = source.region_axis_unit().unwrap_or(""); + for ((region, series), unit) in state.regions.iter().zip(values).zip(value_units) { + let metric = region.metric.unwrap_or(state.default_metric); + let label = region.column_name(axis_unit); let (schema, values) = - materialized_float_column(region.column_name(), "", series.y.into_iter().map(Some)); + materialized_float_column(&label, &unit, series.into_iter().map(Some)); + let column = schema.id; series_bindings.push(TableSeriesBinding { - value_column: schema.id, + value_column: column, uncertainty_column: None, fit: None, }); + region_provenance.push(RegionColumnProvenance { + region: region.id, + column, + bounds: [region.lo_min(), region.hi_max()], + metric, + label, + unit, + color: region.color, + }); columns.push((schema, values)); - windows.push((region.lo_min(), region.hi_max())); } let mut table = TableDataset::from_materialized( columns, @@ -128,21 +268,20 @@ impl PlotxApp { series_bindings, "plotx.analysis.region-table.v1", ) - .ok()?; - table.meta.diffusion = d2 - .data - .diffusion - .as_ref() - .map(DiffusionConstants::from_meta); + .map_err(|error| error.to_string())?; + if let Dataset::Nmr2D(d2) = source { + table.meta.diffusion = d2 + .data + .diffusion + .as_ref() + .map(DiffusionConstants::from_meta); + } table.provenance = Some(TableProvenance { source_resource, - regions: windows, - metric: match d2.region_metric { - RegionMetric::Area => TableMetric::Integral, - _ => TableMetric::PeakHeight, - }, + source_field, + regions: region_provenance, }); - Some(table) + Ok(table) } /// The `Dataset::Table` linked to `source` (its provenance points back), if any. @@ -162,8 +301,12 @@ impl PlotxApp { let Some(tj) = self.region_table_index(source) else { return; }; - let Some(table) = self.build_region_table(source) else { - return; + let table = match self.build_region_table(source) { + Ok(table) => table, + Err(error) => { + self.session.status = error; + return; + } }; if let Some(t) = self.doc.datasets[tj].as_table_mut() { t.typed_state = table.typed_state; @@ -182,9 +325,20 @@ impl PlotxApp { self.session.status = "This dataset already has a linked series table.".into(); return; } - let Some(table) = self.build_region_table(dataset) else { - self.session.status = "Add at least one region before creating a table.".into(); + if self.doc.datasets[dataset] + .as_electrophysiology() + .is_some_and(|recording| !recording.selected_sweeps.iter().any(|selected| *selected)) + { + self.session.status = + "Select at least one sweep before building a region table.".to_owned(); return; + } + let table = match self.build_region_table(dataset) { + Ok(table) => table, + Err(error) => { + self.session.status = error; + return; + } }; let count = table.series_bindings.len(); let mut tds = table; @@ -212,9 +366,12 @@ impl PlotxApp { /// Place an independent, unlinked snapshot of the current region values as a /// new table (no provenance), so later region edits leave it untouched. pub fn freeze_region_table(&mut self, dataset: usize) { - let Some(mut tds) = self.build_region_table(dataset) else { - self.session.status = "Add at least one region before freezing a copy.".into(); - return; + let mut tds = match self.build_region_table(dataset) { + Ok(table) => table, + Err(error) => { + self.session.status = error; + return; + } }; tds.provenance = None; tds.lineage = Some(DatasetLineage::new( diff --git a/crates/core/src/state/app_impl_analysis_tests.rs b/crates/core/src/state/app_impl_analysis_tests.rs index af110d6b..c1697a39 100644 --- a/crates/core/src/state/app_impl_analysis_tests.rs +++ b/crates/core/src/state/app_impl_analysis_tests.rs @@ -1,6 +1,9 @@ use super::*; use num_complex::Complex64; -use plotx_io::{AxisSource, Dim, NmrData2D, PseudoAxis, PseudoKind, QuadMode}; +use plotx_io::{ + AxisSource, Dim, ElectricalUnit, ElectrophysiologyData, NmrData2D, PseudoAxis, PseudoKind, + QuadMode, RecordedChannel, Sweep, +}; #[test] fn live_and_frozen_region_tables_record_lineage() { @@ -33,19 +36,47 @@ fn live_and_frozen_region_tables_record_lineage() { source: "series".to_owned(), }; let mut source = Nmr2DDataset::load(data); - source.regions.push(Region { - id: 0, + source.region_analysis.regions.push(Region { + id: RegionId::new(0), lo: 4.0, hi: 6.0, name: "signal".to_owned(), + label_position: Some([0.25, 0.75]), color: region_color(0), metric: None, }); + source.region_analysis.regions.push(Region { + id: RegionId::new(1), + lo: 4.5, + hi: 5.5, + name: "reference".to_owned(), + label_position: None, + color: region_color(1), + metric: Some(RegionMetric::Area), + }); + source.region_analysis.next_region_id = RegionId::new(2); let mut app = PlotxApp::new(); app.doc.datasets.push(Dataset::Nmr2D(Box::new(source))); + let source_figure = app.build_full_canvas_figure( + 0, + &ChartSpec::default_for(app.doc.datasets[0].domain()), + [120.0, 80.0], + ); + assert_eq!(source_figure.range_annotations.len(), 2); + assert_eq!(source_figure.range_annotations[0].label, "signal"); + assert_eq!( + source_figure.range_annotations[0].label_position, + Some([0.25, 0.75]) + ); + assert_eq!( + source_figure.range_annotations[0].color, + plotx_figure::Color::rgb(region_color(0)[0], region_color(0)[1], region_color(0)[2]) + ); + app.create_region_table(0); app.freeze_region_table(0); + assert_eq!(app.doc.datasets.len(), 3, "{}", app.session.status); assert_eq!( app.doc.datasets[1].lineage(), @@ -63,6 +94,190 @@ fn live_and_frozen_region_tables_record_lineage() { ); assert!(app.doc.datasets[1].as_table().unwrap().provenance.is_some()); assert!(app.doc.datasets[2].as_table().unwrap().provenance.is_none()); + + let table_figure = app.doc.datasets[1].as_table().unwrap().figure(); + assert!(table_figure.series_colors_are_semantic); + assert_eq!(table_figure.series.len(), 2); + assert_eq!(table_figure.series[0].name, "signal"); + assert_eq!(table_figure.series[1].name, "reference"); + assert_ne!(table_figure.series[0].color, table_figure.series[1].color); + for (series, expected) in table_figure + .series + .iter() + .zip([region_color(0), region_color(1)]) + { + assert_eq!( + series.color, + plotx_figure::Color::rgb(expected[0], expected[1], expected[2]) + ); + } +} + +#[test] +fn electrophysiology_edits_keep_the_live_region_table_synchronized() { + let data = ElectrophysiologyData { + abf_version: "test".to_owned(), + sample_rate_hz: 10.0, + channels: vec![ + RecordedChannel { + name: "A".to_owned(), + unit: ElectricalUnit::from_symbol("pA"), + }, + RecordedChannel { + name: "B".to_owned(), + unit: ElectricalUnit::from_symbol("pA"), + }, + ], + sweeps: vec![ + Sweep { + start_time_s: 0.0, + channels: vec![vec![0.0, -10.0, 0.0, 0.0], vec![0.0, -100.0, 0.0, 0.0]], + commands: Vec::new(), + }, + Sweep { + start_time_s: 1.0, + channels: vec![vec![0.0, -20.0, 0.0, 0.0], vec![0.0, -200.0, 0.0, 0.0]], + commands: Vec::new(), + }, + ], + protocol: None, + source: "synthetic.abf".to_owned(), + import_warnings: Vec::new(), + }; + let mut recording = ElectrophysiologyDataset::load(data); + recording.processing.gaussian_lowpass_enabled = false; + recording.region_analysis.regions.push(Region { + id: RegionId::new(0), + lo: 0.0, + hi: 0.4, + name: "response".to_owned(), + label_position: None, + color: region_color(0), + metric: Some(RegionMetric::Height), + }); + recording.region_analysis.next_region_id = RegionId::new(1); + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Electrophysiology(Box::new(recording))); + app.create_region_table(0); + + let values = |app: &PlotxApp| { + app.doc.datasets[1].as_table().unwrap().figure().series[0] + .points + .iter() + .map(|point| point[1]) + .collect::>() + }; + let channel_a = values(&app); + app.doc.datasets[0] + .as_electrophysiology_mut() + .unwrap() + .selected_channel = 1; + app.apply_dataset_edit(0); + let channel_b = values(&app); + assert_ne!(channel_a, channel_b); + + app.doc.datasets[0] + .as_electrophysiology_mut() + .unwrap() + .selected_sweeps[0] = false; + app.apply_dataset_edit(0); + assert_eq!(values(&app).len(), 1); + + let raw = values(&app); + let recording = app.doc.datasets[0].as_electrophysiology_mut().unwrap(); + recording.processing.gaussian_lowpass_enabled = true; + recording.processing.cutoff_hz = 1.0; + app.apply_dataset_edit(0); + assert_ne!(values(&app), raw); + + app.doc.datasets[0] + .as_electrophysiology_mut() + .unwrap() + .selected_sweeps + .fill(false); + app.apply_dataset_edit(0); + assert!(values(&app).is_empty()); +} + +fn electrophysiology_region_app(samples: Vec, metric: RegionMetric) -> PlotxApp { + let sample_count = samples.len(); + let data = ElectrophysiologyData { + abf_version: "test".to_owned(), + sample_rate_hz: 10.0, + channels: vec![RecordedChannel { + name: "A".to_owned(), + unit: ElectricalUnit::from_symbol("pA"), + }], + sweeps: vec![Sweep { + start_time_s: 0.0, + channels: vec![samples], + commands: Vec::new(), + }], + protocol: None, + source: "synthetic.abf".to_owned(), + import_warnings: Vec::new(), + }; + let mut recording = ElectrophysiologyDataset::load(data); + recording.processing.gaussian_lowpass_enabled = false; + recording.region_analysis.default_metric = metric; + recording.region_analysis.regions.push(Region { + id: RegionId::new(0), + lo: 0.0, + hi: sample_count as f64 / 10.0, + name: "window".to_owned(), + label_position: None, + color: region_color(0), + metric: None, + }); + recording.region_analysis.next_region_id = RegionId::new(1); + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Electrophysiology(Box::new(recording))); + app +} + +fn first_region_table_value(app: &PlotxApp) -> f64 { + app.doc.datasets[1].as_table().unwrap().figure().series[0].points[0][1] +} + +#[test] +fn electrophysiology_region_metrics_reject_non_finite_windows() { + for samples in [ + vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY], + vec![1.0, f64::NAN, 3.0], + ] { + let mut app = electrophysiology_region_app(samples, RegionMetric::Area); + + app.create_region_table(0); + + assert_eq!(app.doc.datasets.len(), 1); + assert!(app.session.status.contains("non-finite samples")); + } +} + +#[test] +fn changing_default_region_metric_dirties_and_resynchronizes_the_document() { + let mut app = electrophysiology_region_app(vec![-1.0, -2.0, -3.0, -4.0], RegionMetric::Height); + app.create_region_table(0); + let height = first_region_table_value(&app); + app.doc.dirty = false; + let generation = app.doc.edit_generation; + + app.set_region_default_metric(0, RegionMetric::Area); + + assert!(app.doc.dirty); + assert_eq!(app.doc.edit_generation, generation + 1); + assert_eq!( + app.doc.datasets[0] + .region_analysis() + .unwrap() + .default_metric, + RegionMetric::Area + ); + assert_ne!(first_region_table_value(&app), height); } fn fit_table(meta: Option) -> TableDataset { diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 67d31c0b..696a5860 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -1,5 +1,5 @@ use super::*; -use plotx_figure::Figure; +use plotx_figure::{Color, Figure, RangeAnnotation}; use std::sync::Arc; impl PlotxApp { @@ -18,6 +18,27 @@ impl PlotxApp { if let Some(nmr) = self.doc.datasets[dataset].as_nmr() { figure.integral_curves = nmr.integral_curves(); } + if let Some(state) = self.doc.datasets[dataset] + .region_analysis() + .filter(|state| state.show_annotations) + { + let unit = self.doc.datasets[dataset].region_axis_unit().unwrap_or(""); + figure + .range_annotations + .extend(state.regions.iter().map(|region| { + let [r, g, b] = region.color; + RangeAnnotation { + source_id: region.id.get(), + x0: region.lo, + x1: region.hi, + label: region.column_name(unit), + label_position: region.label_position, + color: Color::rgb(r, g, b), + fill_opacity: 0.12, + width: 1.0, + } + })); + } // Every figure build stamps the document's typography, so a doc-level // edit reaches each plot on its next rebuild without per-plot state. figure.typography = self.doc.style_library.figure_typography; @@ -76,15 +97,20 @@ impl PlotxApp { }) { let color = line.color.resolve(); + let semantic_colors = fig.series_colors_are_semantic; for series in &mut fig.series { - series.color = color; + if !semantic_colors { + series.color = color; + } series.width = line.width.get(); for point in &mut series.points { point[1] *= line.scale; } } for error_bar in &mut fig.error_bars { - error_bar.color = color; + if !semantic_colors { + error_bar.color = color; + } error_bar.center[1] *= line.scale; error_bar.negative *= line.scale.abs(); error_bar.positive *= line.scale.abs(); @@ -93,7 +119,10 @@ impl PlotxApp { // Value-mapped figures (heatmap cells, colormap surfaces, pie // wedges) keep their own colours — one override would erase the // encoding they carry. - if fig.heatmap.is_none() && fig.axis_frame != plotx_figure::AxisFrame::Hidden { + if !semantic_colors + && fig.heatmap.is_none() + && fig.axis_frame != plotx_figure::AxisFrame::Hidden + { let background = fig.background; for polygon in &mut fig.polygons { polygon.fill = color; diff --git a/crates/core/src/state/app_impl_interaction.rs b/crates/core/src/state/app_impl_interaction.rs new file mode 100644 index 00000000..7c938f0e --- /dev/null +++ b/crates/core/src/state/app_impl_interaction.rs @@ -0,0 +1,99 @@ +use super::*; + +impl PlotxApp { + pub fn interaction(&self) -> &Interaction { + &self.session.ui.interaction + } + + pub fn set_interaction(&mut self, interaction: Interaction) { + self.session.ui.interaction = interaction; + } + + /// Take the current gesture while preserving its derived previews. + pub fn take_interaction(&mut self) -> Interaction { + std::mem::replace(&mut self.session.ui.interaction, Interaction::Idle) + } + + pub fn reset_interaction(&mut self) { + self.session.ui.interaction = Interaction::Idle; + self.session.ui.tile_drop = None; + self.session.ui.snap_guides.clear(); + } + + pub fn begin_interaction(&mut self, interaction: Interaction) { + debug_assert!( + interaction.belongs_to(self.session.tool, self.session.active_canvas), + "gesture started under a tool/canvas it does not belong to" + ); + self.reset_interaction(); + self.session.ui.interaction = interaction; + } + + /// Restore the pre-gesture state for interactions that mutate live. + pub fn cancel_interaction(&mut self) { + match self.take_interaction() { + Interaction::Phase(drag) => { + self.set_dataset_processing_state(drag.dataset, &drag.gesture_before); + } + Interaction::Region(drag) => { + if let Some(state) = self + .doc + .dataset_index(drag.dataset) + .and_then(|dataset| self.doc.datasets.get_mut(dataset)) + .and_then(Dataset::region_analysis_mut) + { + state.regions = drag.before; + } + if let Some(dataset) = self.doc.dataset_index(drag.dataset) { + self.rebuild_canvases_for(dataset); + } + } + Interaction::Furniture(drag) => match drag.target { + FurnitureTarget::Legend { before, .. } => { + self.set_axis_overrides_value(drag.canvas, drag.object, &before); + } + FurnitureTarget::RegionLabel { + dataset, before, .. + } => { + if let Some(index) = self.doc.dataset_index(dataset) { + if let Some(state) = self.doc.datasets[index].region_analysis_mut() { + state.regions = before; + } + self.rebuild_canvases_for(index); + } + } + }, + Interaction::Integral(drag) => { + let dataset = drag.dataset; + if let Some(n) = self + .doc + .datasets + .get_mut(dataset) + .and_then(Dataset::as_nmr_mut) + { + n.integrals = drag.before; + } + self.sync_integral_curves_for(dataset); + } + Interaction::Integral2D(drag) => { + if let Some(n) = self + .doc + .datasets + .get_mut(drag.dataset) + .and_then(Dataset::as_nmr2d_mut) + { + n.integrals = drag.before; + } + } + Interaction::Object(drag) => { + self.set_object_frame(drag.canvas, drag.object, drag.before); + for (id, frame) in drag.others { + self.set_object_frame(drag.canvas, id, frame); + } + } + _ => {} + } + self.session.ui.tile_drop = None; + self.session.ui.snap_guides.clear(); + } +} diff --git a/crates/core/src/state/axis_overrides.rs b/crates/core/src/state/axis_overrides.rs index 7e8a3025..4aecb544 100644 --- a/crates/core/src/state/axis_overrides.rs +++ b/crates/core/src/state/axis_overrides.rs @@ -164,6 +164,8 @@ pub struct AxisOverrides { pub x_show_label: Option, pub y_show_tick_labels: Option, pub y_show_label: Option, + pub show_legend: Option, + pub legend_position: Option<[f32; 2]>, } impl AxisOverrides { @@ -201,6 +203,10 @@ impl AxisOverrides { if let Some(show) = self.y_show_label { figure.y.show_label = show; } + if let Some(show) = self.show_legend { + figure.show_legend = show; + } + figure.legend_position = self.legend_position; } pub fn normalized(mut self) -> Self { @@ -208,6 +214,9 @@ impl AxisOverrides { self.y_label = normalize_label(self.y_label); self.x_range = self.x_range.filter(|range| range.is_valid()); self.y_range = self.y_range.filter(|range| range.is_valid()); + self.legend_position = self.legend_position.and_then(|[x, y]| { + (x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]) + }); self } } @@ -273,4 +282,21 @@ mod tests { assert_eq!(AxisRange::from_axis(&figure.x), AxisRange::new(-0.5, 2.5)); } + + #[test] + fn legend_position_normalizes_to_the_plot_area() { + let clamped = AxisOverrides { + legend_position: Some([-0.5, 1.5]), + ..AxisOverrides::default() + } + .normalized(); + assert_eq!(clamped.legend_position, Some([0.0, 1.0])); + + let invalid = AxisOverrides { + legend_position: Some([f32::NAN, 0.5]), + ..AxisOverrides::default() + } + .normalized(); + assert_eq!(invalid.legend_position, None); + } } diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 10ee6e54..94107b24 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -210,10 +210,8 @@ pub struct Nmr2DDataset { pub dosy_figure: Option>, /// Cached contour geometry for `ilt_map`. pub ilt_figure: Option>, - /// Persistent series-analysis windows, their default reducer, and an id source. - pub regions: Vec, - pub region_metric: RegionMetric, - pub next_region_id: u64, + /// Persistent analysis windows for the pseudo-series field. + pub region_analysis: RegionAnalysisState, /// Rectangular volumes on true-2D contour spectra. Independent of pseudo-2D /// Regions windows, so both collections survive layout/project round-trips. pub integrals: Vec, @@ -281,9 +279,7 @@ impl Nmr2DDataset { ilt_provenance: None, dosy_figure: None, ilt_figure: None, - regions: Vec::new(), - region_metric: RegionMetric::Height, - next_region_id: 0, + region_analysis: RegionAnalysisState::default(), integrals: Vec::new(), peaks: Peak2DSet::default(), next_integral_id: 0, diff --git a/crates/core/src/state/datasets/pseudo_tests.rs b/crates/core/src/state/datasets/pseudo_tests.rs index 70dd4434..95179c05 100644 --- a/crates/core/src/state/datasets/pseudo_tests.rs +++ b/crates/core/src/state/datasets/pseudo_tests.rs @@ -105,15 +105,16 @@ fn ordered_series_supports_region_analysis() { fn region_support_matches_what_the_table_builder_accepts() { let mut app = crate::state::PlotxApp::new_with_settings(crate::settings::Settings::default()); let mut series = Nmr2DDataset::load(synthetic_dosy(1.2e-9)); - series.regions = vec![Region { - id: 0, + series.region_analysis.regions = vec![Region { + id: RegionId::new(0), lo: 0.9, hi: 1.1, name: "peak".to_owned(), + label_position: None, color: [200, 80, 80], metric: None, }]; - series.next_region_id = 1; + series.region_analysis.next_region_id = RegionId::new(1); app.doc.datasets.push(Dataset::Nmr2D(Box::new(series))); assert!(app.doc.datasets[0].supports_region_analysis()); app.create_region_table(0); @@ -128,11 +129,12 @@ fn region_support_matches_what_the_table_builder_accepts() { let mut ruler_less = synthetic_dosy(1.2e-9); ruler_less.pseudo_axis = None; let mut stale = Nmr2DDataset::load(ruler_less); - stale.regions = vec![Region { - id: 0, + stale.region_analysis.regions = vec![Region { + id: RegionId::new(0), lo: 0.9, hi: 1.1, name: "peak".to_owned(), + label_position: None, color: [200, 80, 80], metric: None, }]; diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 0f14cf0f..1fa0603c 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -216,16 +216,64 @@ impl Dataset { } pub fn supports_region_analysis(&self) -> bool { - matches!( - self, - Dataset::Nmr2D(dataset) - if dataset.is_pseudo() + self.field_descriptors().iter().any(|field| { + field + .capabilities + .contains(crate::automation::CAP_FIELD_REGION_SERIES) + }) && match self { + Dataset::Nmr2D(dataset) => { + dataset.is_pseudo() && matches!( &dataset.processed, Processed2D::Stack(stack) if stack.direct_domain == plotx_io::Domain::Frequency ) - ) + } + Dataset::Electrophysiology(dataset) => { + !dataset.data.channels.is_empty() && !dataset.data.sweeps.is_empty() + } + Dataset::Nmr(_) | Dataset::Table(_) | Dataset::Afm(_) => false, + } + } + + pub fn region_analysis(&self) -> Option<&RegionAnalysisState> { + match self { + Dataset::Nmr2D(dataset) if self.supports_region_analysis() => { + Some(&dataset.region_analysis) + } + Dataset::Electrophysiology(dataset) if self.supports_region_analysis() => { + Some(&dataset.region_analysis) + } + _ => None, + } + } + + pub fn region_analysis_mut(&mut self) -> Option<&mut RegionAnalysisState> { + let supported = self.supports_region_analysis(); + match self { + Dataset::Nmr2D(dataset) if supported => Some(&mut dataset.region_analysis), + Dataset::Electrophysiology(dataset) if supported => Some(&mut dataset.region_analysis), + _ => None, + } + } + + pub fn region_axis_unit(&self) -> Option<&'static str> { + match self { + Dataset::Nmr2D(_) if self.supports_region_analysis() => Some("ppm"), + Dataset::Electrophysiology(_) if self.supports_region_analysis() => Some("s"), + _ => None, + } + } + + pub fn region_source_field(&self) -> Option { + match self { + Dataset::Nmr2D(_) if self.supports_region_analysis() => self.default_field_id(), + Dataset::Electrophysiology(recording) if self.supports_region_analysis() => self + .field_descriptors() + .get(recording.selected_channel) + .map(|field| field.id), + _ => None, + } } pub fn tool_groups(&self) -> &'static [ToolGroup] { @@ -249,6 +297,9 @@ impl Dataset { ToolGroup::LineFit, ToolGroup::Statistics, ], + Dataset::Electrophysiology(_) if self.supports_region_analysis() => { + &[ToolGroup::Electrophysiology, ToolGroup::RegionAnalysis] + } Dataset::Electrophysiology(_) => &[ToolGroup::Electrophysiology], Dataset::Afm(_) => &[], } diff --git a/crates/core/src/state/derived_axes.rs b/crates/core/src/state/derived_axes.rs index 07338421..2cf798c1 100644 --- a/crates/core/src/state/derived_axes.rs +++ b/crates/core/src/state/derived_axes.rs @@ -10,6 +10,7 @@ pub struct DerivedAxes { pub x_show_label: bool, pub y_show_tick_labels: bool, pub y_show_label: bool, + pub show_legend: bool, } impl DerivedAxes { @@ -21,6 +22,7 @@ impl DerivedAxes { x_show_label: figure.x.show_label, y_show_tick_labels: figure.y.show_tick_labels, y_show_label: figure.y.show_label, + show_legend: figure.show_legend, } } } diff --git a/crates/core/src/state/electrophysiology.rs b/crates/core/src/state/electrophysiology.rs index e5de8ca9..3221b50c 100644 --- a/crates/core/src/state/electrophysiology.rs +++ b/crates/core/src/state/electrophysiology.rs @@ -74,7 +74,6 @@ impl Default for ElectrophysiologyProcessing { #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ElectrophysiologyDataset { - #[serde(default = "new_resource_id")] pub resource_id: DatasetId, /// Persisted mapping from stable channel keys to dataset-local field ids. pub field_catalog: FieldCatalog, @@ -89,7 +88,7 @@ pub struct ElectrophysiologyDataset { pub selected_channel: usize, pub stimulus: Option, pub lineage: Option, - pub analysis_window: TimeWindow, + pub region_analysis: RegionAnalysisState, pub peak_mode: PeakMode, } @@ -114,12 +113,6 @@ impl ElectrophysiologyDataset { cell_id, ..RecordingMetadata::default() }; - let end_s = data - .sweeps - .iter() - .filter_map(|s| s.channels.first()) - .map(|v| v.len() as f64 / data.sample_rate_hz) - .fold(0.0, f64::max); let stimulus = stimulus.or_else(|| data.protocol.as_deref().and_then(suggested_stimulus)); let field_keys = crate::state::electrophysiology_channel_keys(&data); let mut field_catalog = crate::state::electrophysiology_field_catalog_for_keys(&field_keys); @@ -136,10 +129,7 @@ impl ElectrophysiologyDataset { selected_channel: 0, stimulus, lineage: None, - analysis_window: TimeWindow { - start_s: 0.0, - end_s, - }, + region_analysis: RegionAnalysisState::default(), peak_mode: PeakMode::Negative, } } @@ -239,6 +229,8 @@ impl ElectrophysiologyDataset { .colored(colors[index % colors.len()]), ); } + figure.show_legend = figure.series.len() >= 2; + figure.series_colors_are_semantic = figure.series.len() >= 2; figure } diff --git a/crates/core/src/state/field.rs b/crates/core/src/state/field.rs index 663c4b2b..50320c76 100644 --- a/crates/core/src/state/field.rs +++ b/crates/core/src/state/field.rs @@ -3,8 +3,9 @@ use super::{FieldCatalog, FieldId, electrophysiology_channel_key}; use crate::automation::{ CAP_FIELD_AFM_MAP, CAP_FIELD_BOUNDED, CAP_FIELD_COLORED_RASTER_2D, CAP_FIELD_CURVE_1D, CAP_FIELD_FORCE_CURVE, CAP_FIELD_LOCATION_SCALE, CAP_FIELD_NMR_CONTOUR, CAP_FIELD_NMR_SIGNAL, - CAP_FIELD_NMR_STACK, CAP_FIELD_NOISE_SCALE, CAP_FIELD_SCALAR_GRID_2D_REGULAR, CAP_FIELD_SIGNED, - CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_TABLE, CapabilityId, + CAP_FIELD_NMR_STACK, CAP_FIELD_NOISE_SCALE, CAP_FIELD_REGION_SERIES, + CAP_FIELD_SCALAR_GRID_2D_REGULAR, CAP_FIELD_SIGNED, CAP_FIELD_SWEEP_COLLECTION, + CAP_FIELD_TABLE, CapabilityId, }; use plotx_figure::{ ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, @@ -128,7 +129,7 @@ impl super::Dataset { id, "nmr.stack", "Stack", - capabilities(id, &[CAP_FIELD_NMR_STACK]), + capabilities(id, &[CAP_FIELD_NMR_STACK, CAP_FIELD_REGION_SERIES]), vec![nmr.data.cols], vec![match &nmr.processed { plotx_processing::Processed2D::Stack(spectrum) => { @@ -177,7 +178,7 @@ impl super::Dataset { id, &key, &channel.name, - capabilities(id, &[CAP_FIELD_SWEEP_COLLECTION]), + capabilities(id, &[CAP_FIELD_SWEEP_COLLECTION, CAP_FIELD_REGION_SERIES]), vec![recording.data.sweeps.len()], vec![channel.unit.symbol.clone()], "line", diff --git a/crates/core/src/state/field_tests.rs b/crates/core/src/state/field_tests.rs index 7992a002..dfd573fd 100644 --- a/crates/core/src/state/field_tests.rs +++ b/crates/core/src/state/field_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::state::{AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset}; +use crate::state::{AfmDataset, Dataset, ElectrophysiologyDataset, Nmr2DDataset, ToolGroup}; use std::sync::Arc; /// Every field of every dataset variant must derive the same capabilities from @@ -537,11 +537,17 @@ fn electrophysiology_keys_include_the_channel_quantity() { source: "quantity test".to_owned(), import_warnings: Vec::new(), }; - let dataset = Dataset::Electrophysiology(Box::new(ElectrophysiologyDataset::load(data))); + let mut recording = ElectrophysiologyDataset::load(data); + recording.selected_channel = 1; + let dataset = Dataset::Electrophysiology(Box::new(recording)); let fields = dataset.field_descriptors(); assert_eq!(fields.len(), 2); assert_ne!(fields[0].local_id, fields[1].local_id); dataset.validate_field_catalog().unwrap(); + assert!(dataset.supports_region_analysis()); + assert!(dataset.tool_groups().contains(&ToolGroup::RegionAnalysis)); + assert_eq!(dataset.region_axis_unit(), Some("s")); + assert_eq!(dataset.region_source_field(), Some(fields[1].id)); } fn afm_channel( diff --git a/crates/core/src/state/interaction.rs b/crates/core/src/state/interaction.rs index 6c329d1f..dfdd3900 100644 --- a/crates/core/src/state/interaction.rs +++ b/crates/core/src/state/interaction.rs @@ -35,6 +35,7 @@ pub enum Interaction { Selection(SelectionDrag), Pan(PanDrag), Phase(PhaseDrag), + Furniture(FurnitureDrag), Region(RegionDrag), Integral(IntegralDrag), Integral2D(Integral2DDrag), @@ -69,6 +70,7 @@ impl Interaction { | Interaction::Selection(_) | Interaction::Pan(_) | Interaction::Phase(_) + | Interaction::Furniture(_) | Interaction::Region(_) | Interaction::Integral(_) | Interaction::Integral2D(_) @@ -88,6 +90,7 @@ impl Interaction { Interaction::Zoom(d) => Some(d.canvas), Interaction::Selection(d) => Some(d.canvas), Interaction::Pan(d) => Some(d.canvas), + Interaction::Furniture(d) => Some(d.canvas), Interaction::Region(d) => Some(d.canvas), Interaction::Integral(d) => Some(d.canvas), Interaction::Integral2D(d) => Some(d.canvas), @@ -107,6 +110,10 @@ impl Interaction { { return active_canvas.is_none_or(|c| self.canvas() == Some(c)); } + if matches!(self, Interaction::Furniture(_)) { + return matches!(tool, Tool::Select | Tool::Regions) + && active_canvas.is_none_or(|c| self.canvas() == Some(c)); + } let family_ok = match self.family() { GestureFamily::Idle => true, // A frame drag rides the board under any tool; other layout gestures diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 9a9f0e81..53324b13 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -32,6 +32,7 @@ mod app_impl_compute; #[cfg(test)] mod app_impl_compute_tests; mod app_impl_figures; +mod app_impl_interaction; mod app_impl_io; mod app_impl_linefit; mod app_impl_multiplet; diff --git a/crates/core/src/state/region.rs b/crates/core/src/state/region.rs index c84db7c1..b89af37f 100644 --- a/crates/core/src/state/region.rs +++ b/crates/core/src/state/region.rs @@ -1,24 +1,65 @@ use plotx_analysis::series::ReduceOp; use serde::{Deserialize, Serialize}; +use super::DatasetId; + +/// Stable identity of one analysis region within its source field. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(transparent)] +pub struct RegionId(u64); + +impl RegionId { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn get(self) -> u64 { + self.0 + } + + pub fn checked_next(self) -> Option { + self.0.checked_add(1).map(Self) + } +} + +impl std::fmt::Display for RegionId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +/// Owner-scoped identity of a selected analysis region. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RegionSelection { + pub dataset: DatasetId, + pub region: RegionId, +} + +impl RegionSelection { + pub const fn new(dataset: DatasetId, region: RegionId) -> Self { + Self { dataset, region } + } + + pub fn in_dataset(self, dataset: DatasetId) -> Option { + (self.dataset == dataset).then_some(self.region) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Region { - pub id: u64, + pub id: RegionId, pub lo: f64, pub hi: f64, - #[serde(default)] pub name: String, - #[serde(default = "default_region_color")] + /// Manually placed label center as fractions of the plot rectangle. + pub label_position: Option<[f32; 2]>, pub color: [u8; 3], /// `None` follows the dataset's default metric. - #[serde(default)] pub metric: Option, } -fn default_region_color() -> [u8; 3] { - REGION_PALETTE[0] -} - impl Region { pub fn lo_min(&self) -> f64 { self.lo.min(self.hi) @@ -32,15 +73,80 @@ impl Region { 0.5 * (self.lo + self.hi) } - pub fn column_name(&self) -> String { + pub fn column_name(&self, unit: &str) -> String { if self.name.trim().is_empty() { - format!("{:.3} ppm", self.center()) + if unit.is_empty() { + format!("{:.3}", self.center()) + } else { + format!("{:.3} {unit}", self.center()) + } } else { self.name.clone() } } } +/// Persistent state shared by every field that exposes ordered 1D members. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RegionAnalysisState { + pub regions: Vec, + pub default_metric: RegionMetric, + pub next_region_id: RegionId, + pub show_annotations: bool, +} + +impl Default for RegionAnalysisState { + fn default() -> Self { + Self { + regions: Vec::new(), + default_metric: RegionMetric::Height, + next_region_id: RegionId::default(), + show_annotations: true, + } + } +} + +impl RegionAnalysisState { + pub fn allocate_region_id(&mut self) -> Option { + let id = self.next_region_id; + self.next_region_id = id.checked_next()?; + Some(id) + } + + pub fn validate(&self) -> Result<(), String> { + let mut ids = std::collections::HashSet::with_capacity(self.regions.len()); + for region in &self.regions { + if !ids.insert(region.id) { + return Err(format!("duplicate region id {}", region.id)); + } + if !region.lo.is_finite() || !region.hi.is_finite() { + return Err(format!("region {} has non-finite bounds", region.id)); + } + if region.label_position.is_some_and(|[x, y]| { + !x.is_finite() + || !y.is_finite() + || !(0.0..=1.0).contains(&x) + || !(0.0..=1.0).contains(&y) + }) { + return Err(format!( + "region {} has an invalid label position", + region.id + )); + } + if region.id >= self.next_region_id { + return Err(format!( + "next region id {} does not follow region {}", + self.next_region_id, region.id + )); + } + } + if self.next_region_id.checked_next().is_none() { + return Err("region id space is exhausted".to_owned()); + } + Ok(()) + } +} + /// Serializable UI mirror of the analysis [`ReduceOp`]; keep in sync with it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RegionMetric { @@ -80,15 +186,124 @@ impl From for ReduceOp { } } -pub const REGION_PALETTE: [[u8; 3]; 6] = [ +pub const REGION_PALETTE: [[u8; 3]; 12] = [ [0x1a, 0x7f, 0x37], [0x2b, 0x6c, 0xb0], [0xc0, 0x4a, 0x2b], [0x7a, 0x4f, 0xa3], [0xb8, 0x8a, 0x1e], [0x2f, 0x8f, 0x8f], + [0xd9, 0x5f, 0x02], + [0x56, 0xb4, 0xe9], + [0xcc, 0x79, 0xa7], + [0x6f, 0x4e, 0x37], + [0x00, 0x70, 0x73], + [0xe3, 0x77, 0xc2], ]; pub fn region_color(i: usize) -> [u8; 3] { - REGION_PALETTE[i % REGION_PALETTE.len()] + if let Some(color) = REGION_PALETTE.get(i) { + return *color; + } + // Golden-angle hue spacing avoids a visible cycle when a dataset has more + // regions than the curated palette. Alternating saturation/value bands keep + // later hues distinguishable from earlier hues with a similar angle. + let hue = ((i as f64) * 0.618_033_988_749_894_9).fract(); + let band = (i / REGION_PALETTE.len()) % 3; + let saturation = 0.58 + band as f64 * 0.08; + let value = 0.72 + ((i / (REGION_PALETTE.len() * 3)) % 2) as f64 * 0.14; + hsv_to_rgb(hue, saturation, value) +} + +fn hsv_to_rgb(hue: f64, saturation: f64, value: f64) -> [u8; 3] { + let sector = hue * 6.0; + let index = sector.floor() as u8; + let fraction = sector - f64::from(index); + let p = value * (1.0 - saturation); + let q = value * (1.0 - saturation * fraction); + let t = value * (1.0 - saturation * (1.0 - fraction)); + let (red, green, blue) = match index { + 0 => (value, t, p), + 1 => (q, value, p), + 2 => (p, value, t), + 3 => (p, q, value), + 4 => (t, p, value), + _ => (value, p, q), + }; + [ + (red * 255.0).round() as u8, + (green * 255.0).round() as u8, + (blue * 255.0).round() as u8, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_rejects_duplicate_non_finite_and_stale_identity_state() { + let region = Region { + id: RegionId::new(2), + lo: 0.1, + hi: 0.2, + name: String::new(), + label_position: None, + color: region_color(0), + metric: None, + }; + let mut state = RegionAnalysisState { + regions: vec![region.clone(), region], + default_metric: RegionMetric::Height, + next_region_id: RegionId::new(3), + show_annotations: true, + }; + assert!(state.validate().unwrap_err().contains("duplicate")); + + state.regions.truncate(1); + state.regions[0].lo = f64::NAN; + assert!(state.validate().unwrap_err().contains("non-finite")); + + state.regions[0].lo = 0.1; + state.regions[0].label_position = Some([1.2, 0.5]); + assert!( + state + .validate() + .unwrap_err() + .contains("invalid label position") + ); + + state.regions[0].label_position = None; + state.next_region_id = RegionId::new(2); + assert!(state.validate().unwrap_err().contains("does not follow")); + } + + #[test] + fn allocator_never_wraps_region_identity() { + let mut state = RegionAnalysisState { + next_region_id: RegionId::new(u64::MAX), + ..RegionAnalysisState::default() + }; + assert_eq!(state.allocate_region_id(), None); + assert_eq!(state.next_region_id, RegionId::new(u64::MAX)); + } + + #[test] + fn selection_is_only_visible_in_its_owner_dataset() { + let owner = DatasetId::new(); + let other = DatasetId::new(); + let id = RegionId::new(0); + let selection = RegionSelection::new(owner, id); + + assert_eq!(selection.in_dataset(owner), Some(id)); + assert_eq!(selection.in_dataset(other), None); + } + + #[test] + fn practical_region_counts_do_not_repeat_colors() { + let colors = (0..256) + .map(region_color) + .collect::>(); + assert_eq!(colors.len(), 256); + } } diff --git a/crates/core/src/state/table.rs b/crates/core/src/state/table.rs index 0ab17368..427dac8e 100644 --- a/crates/core/src/state/table.rs +++ b/crates/core/src/state/table.rs @@ -1,5 +1,4 @@ -use super::{TableImportSource, TypedTableState}; -use plotx_analysis::series::IntensityMode; +use super::{FieldId, RegionId, RegionMetric, TableImportSource, TypedTableState}; use plotx_data::{ColumnId, RevisionId, RowId}; use plotx_figure::{Axis, Color, ErrorBar, Figure, Series}; use plotx_io::DiffusionMeta; @@ -101,34 +100,19 @@ pub struct ModelInstanceBinding { #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct TableProvenance { pub source_resource: String, - pub regions: Vec<(f64, f64)>, - pub metric: TableMetric, + pub source_field: FieldId, + pub regions: Vec, } -/// Serialisable mirror of the extraction `IntensityMode`, so the table owns its -/// provenance without depending on the processing layer's non-serde enum. -#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum TableMetric { - PeakHeight, - Integral, -} - -impl From for TableMetric { - fn from(mode: IntensityMode) -> Self { - match mode { - IntensityMode::PeakHeight => TableMetric::PeakHeight, - IntensityMode::Integral => TableMetric::Integral, - } - } -} - -impl From for IntensityMode { - fn from(metric: TableMetric) -> Self { - match metric { - TableMetric::PeakHeight => IntensityMode::PeakHeight, - TableMetric::Integral => IntensityMode::Integral, - } - } +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct RegionColumnProvenance { + pub region: RegionId, + pub column: ColumnId, + pub bounds: [f64; 2], + pub metric: RegionMetric, + pub label: String, + pub unit: String, + pub color: [u8; 3], } /// Model constants a fit preset may need, copied from the source acquisition. @@ -395,7 +379,17 @@ impl TableDataset { let title = self.name.clone().unwrap_or_else(|| "Data table".to_owned()); let mut fig = Figure::new(title, x, y); for (i, series) in plot.series.iter().enumerate() { - let color = series_color(i); + let color = self + .provenance + .as_ref() + .and_then(|provenance| { + provenance + .regions + .iter() + .find(|region| region.column == series.binding.value_column) + .map(|region| Color::rgb(region.color[0], region.color[1], region.color[2])) + }) + .unwrap_or_else(|| series_color(i)); let mut points = Vec::with_capacity(plot.x.len().min(series.y.len())); for (row, (&px, &py)) in plot.x.iter().zip(&series.y).enumerate() { points.push([px, py]); @@ -416,9 +410,12 @@ impl TableDataset { .push(Series::points(series.name.clone(), points).colored(color)); } for (i, curve) in fit_curves { - let name = format!("{} fit", plot.series[i].name); - fig = fig.with_series(Series::line(name, curve).colored(series_color(i))); + let name = plot.series[i].name.clone(); + let color = fig.series[i].color; + fig = fig.with_series(Series::line(name, curve).colored(color)); } + fig.show_legend = plot.series.len() >= 2; + fig.series_colors_are_semantic = plot.series.len() >= 2; fig } } diff --git a/crates/core/src/state/ui_drag.rs b/crates/core/src/state/ui_drag.rs index e5e90222..30772da9 100644 --- a/crates/core/src/state/ui_drag.rs +++ b/crates/core/src/state/ui_drag.rs @@ -4,7 +4,7 @@ //! absolutely from the grab state every frame so nothing accumulates drift, and //! `before` snapshots the dataset so the gesture commits as one undoable step. -use super::{ObjectId, Region}; +use super::{AxisOverrides, DatasetId, ObjectId, Region, RegionId}; use crate::{Integral2D, IntegralResult}; /// An in-progress region-band edit on a series plot. `region_id` names the band @@ -13,9 +13,9 @@ use crate::{Integral2D, IntegralResult}; pub struct RegionDrag { pub canvas: usize, pub object: ObjectId, - pub dataset: usize, + pub dataset: DatasetId, pub kind: RegionDragKind, - pub region_id: Option, + pub region_id: Option, pub before: Vec, /// Pointer ppm at grab time (for `Move`) or the fixed anchor (for `NewBand`). pub anchor_ppm: f64, @@ -34,6 +34,27 @@ pub enum RegionDragKind { Move, } +#[derive(Clone, Debug)] +pub struct FurnitureDrag { + pub canvas: usize, + pub object: ObjectId, + pub target: FurnitureTarget, +} + +#[derive(Clone, Debug)] +pub enum FurnitureTarget { + Legend { + before: AxisOverrides, + grab_offset: [f32; 2], + }, + RegionLabel { + dataset: DatasetId, + region: RegionId, + before: Vec, + grab_offset: [f32; 2], + }, +} + /// An in-progress integral-band edit on a 1D spectrum — the direct analogue of /// [`RegionDrag`], reusing [`RegionDragKind`]. #[derive(Clone, Debug)] diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index cd5be2be..33d24f91 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -372,7 +372,7 @@ pub struct UiState { /// state, so it is deliberately absent from the property catalog itself. pub property_focus: Option, /// Source dataset whose Regions workflow is shown in the canvas task card. - pub region_task_dataset: Option, + pub region_task_dataset: Option, /// Whether the Regions task card is reduced to its one-line summary. pub region_task_collapsed: bool, /// Data table whose Curve Fit workflow is shown in the canvas task card. @@ -395,8 +395,8 @@ pub struct UiState { /// Snap guide previews painted during an `Interaction::Object` drag; cleared /// alongside it. pub snap_guides: Vec, - /// The selected region band's id (shows handles + drives the panel row). - pub selected_region: Option, + /// The selected region band and its owning dataset. + pub selected_region: Option, /// The selected 1D integral band's id (shows handles + the context menu target). pub selected_integral: Option, /// The selected hand-placed peak's mark id (drives Delete and the label editor). @@ -419,9 +419,9 @@ pub struct UiState { /// Whether cursor positions snap without holding Shift. pub symmetry_snap: bool, pub symmetry_filter: SymmetryAuditFilter, - /// Pre-edit region snapshot for an in-progress panel rename, so a typing run - /// commits as one undo step on focus loss. - pub region_edit_before: Option>, + /// Dataset-bound pre-edit snapshot for an in-progress region rename, so a + /// typing run commits as one undo step without crossing dataset switches. + pub region_edit_before: Option<(DatasetId, Vec)>, /// Curve-fit tool state for a `Dataset::Table`: chosen preset id (empty = /// pick a default from the table's meta), whether to fit all columns or one, /// and the selected column index. Scoped to `fit_dataset`. diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs index 9b415161..fe34e845 100644 --- a/crates/core/src/theme.rs +++ b/crates/core/src/theme.rs @@ -105,6 +105,9 @@ impl Theme { tick_pt: 10.0, label_pt: 12.0, title_pt: 12.0, + legend_pt: 9.0, + // All renderers put legends on a translucent white backing. + legend_color: Color::AXIS, }, trace_palette: vec![ Color::rgb(0x4d, 0xa6, 0xff), @@ -127,6 +130,8 @@ impl Theme { tick_pt: 8.0, label_pt: 9.0, title_pt: 9.0, + legend_pt: 7.0, + legend_color: Color::rgb(0x1a, 0x1a, 0x1a), }, trace_palette: vec![ Color::rgb(0xe6, 0x00, 0x49), diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index eb887c07..a9335223 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -47,6 +47,10 @@ pub struct FigureTypography { pub label_pt: f32, /// Figure title above the plot. pub title_pt: f32, + /// Legend labels. + pub legend_pt: f32, + /// Legend-label text color. + pub legend_color: Color, } impl Default for FigureTypography { @@ -55,6 +59,8 @@ impl Default for FigureTypography { tick_pt: 7.0, label_pt: 8.0, title_pt: 8.0, + legend_pt: 7.0, + legend_color: Color::AXIS, } } } @@ -238,6 +244,22 @@ pub struct Annotation { pub size: f32, } +/// A semantic x-range that spans the data area. Unlike editor selection chrome, +/// range annotations are figure content and are exported by every renderer. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RangeAnnotation { + /// Stable source-region identity for direct manipulation. + pub source_id: u64, + pub x0: f64, + pub x1: f64, + pub label: String, + /// Manually placed label center as fractions of the plot rectangle. + pub label_position: Option<[f32; 2]>, + pub color: Color, + pub fill_opacity: f32, + pub width: f32, +} + /// A 2D contour overlay as pre-computed data-space line segments (each /// `[[x0,y0],[x1,y1]]`). The heavy marching-squares pass runs once when the /// figure is built, so both renderers only project and stroke. @@ -391,6 +413,7 @@ pub struct Figure { #[serde(default)] pub error_bars: Vec, pub annotations: Vec, + pub range_annotations: Vec, /// Zero, one, or many contour overlays painted on the shared axes (e.g. one /// per dataset in a 2D color-overlay stack), each with its own colour/width. #[serde(default)] @@ -412,13 +435,19 @@ pub struct Figure { /// multi-series overlays; defaults off so single-trace figures are unchanged. #[serde(default)] pub show_legend: bool, + /// Manually placed legend origin within its available plot area. + pub legend_position: Option<[f32; 2]>, + /// The figure's logical child series own their colours. A parent field + /// binding may still apply scale and width, but must not collapse a + /// categorical or repeated-series palette to one colour. + pub series_colors_are_semantic: bool, /// Render the data area with equal data-units-per-pixel on both axes, /// letterboxed within the frame. Set for homonuclear 2D (square COSY/NOESY). #[serde(default)] pub lock_aspect: bool, #[serde(default)] pub axis_frame: AxisFrame, - /// Text sizes (pt) of the axis furniture; see [`FigureTypography`]. + /// Text sizes and legend text color; see [`FigureTypography`]. #[serde(default)] pub typography: FigureTypography, } @@ -435,6 +464,7 @@ impl Figure { heatmap: None, error_bars: Vec::new(), annotations: Vec::new(), + range_annotations: Vec::new(), contours: Vec::new(), top_projection: None, left_projection: None, @@ -443,6 +473,8 @@ impl Figure { background: Color::rgb(255, 255, 255), show_grid: false, show_legend: false, + legend_position: None, + series_colors_are_semantic: false, lock_aspect: false, axis_frame: AxisFrame::Open, typography: FigureTypography::default(), diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index 0739327b..ab0dc9c1 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -5,8 +5,9 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShapeKind, Projector, Rect, TICK_LABEL_PAD, - TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, - legend_entries, polygon_outline, projection_points, + TICK_LENGTH, TextAnchor, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, + legend_entries, legend_layout, legend_rect, polygon_outline, projection_points, + range_label_layout, }; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; use std::collections::HashMap; @@ -343,6 +344,40 @@ fn write_figure(dc: &mut Dc, fig: &Figure, outer: Rect) { let fill = blend(poly.fill, poly.opacity.clamp(0.0, 1.0), fig.background); dc.polygon(&outline, Some(fill), poly.stroke); } + for annotation in &fig.range_annotations { + let (x0, _) = proj.project([annotation.x0, fig.y.min]); + let (x1, _) = proj.project([annotation.x1, fig.y.min]); + let rect = Rect::new(x0.min(x1), plot.top, (x1 - x0).abs(), plot.height); + let fill = blend( + annotation.color, + annotation.fill_opacity.clamp(0.0, 1.0), + fig.background, + ); + dc.rect(rect, Some(fill), Some((annotation.color, annotation.width))); + let Some(label) = range_label_layout( + plot, + rect.left, + rect.right(), + fig.typography.tick_pt, + &annotation.label, + annotation.label_position, + ) else { + continue; + }; + dc.text( + &label.text, + (label.x, label.top + fig.typography.tick_pt), + TextStyle::new( + fig.typography.tick_pt, + annotation.color, + match label.anchor { + TextAnchor::Left => TA_LEFT, + TextAnchor::Center => TA_CENTER, + TextAnchor::Right => TA_RIGHT, + }, + ), + ); + } for contour in &fig.contours { let segs: Vec<[(f32, f32); 2]> = contour .segments @@ -444,16 +479,18 @@ fn write_legend(dc: &mut Dc, fig: &Figure, plot: Rect) { if !fig.show_legend || entries.len() < 2 { return; } - let (row, sw, pad, font) = (15.0f32, 16.0f32, 6.0f32, 11.0f32); - let chars = entries - .iter() - .map(|(n, _, _)| n.chars().count()) - .max() - .unwrap_or(0); - let box_w = sw + 5.0 + chars as f32 * font * 0.6 + pad * 2.0; - let box_h = entries.len() as f32 * row + pad * 2.0; - let bx = (plot.right() - box_w - 8.0).max(plot.left + 2.0); - let by = plot.top + 8.0; + let font = fig.typography.legend_pt; + let layout = legend_layout(&entries, font); + let (row, sw, pad) = (layout.row, layout.swatch, layout.padding); + let Some(box_geometry) = legend_rect(fig, plot, 1.0) else { + return; + }; + let (bx, by, box_w, box_h) = ( + box_geometry.left, + box_geometry.top, + box_geometry.width, + box_geometry.height, + ); let box_fill = blend(Color::rgb(255, 255, 255), 0.85, fig.background); dc.round_rect( Rect::new(bx, by, box_w, box_h), @@ -471,12 +508,20 @@ fn write_legend(dc: &mut Dc, fig: &Figure, plot: Rect) { Some(*color), None, ), + LegendMark::LinePoints => { + dc.line((lx, ly), (lx + sw, ly), *color, 2.0); + dc.ellipse( + Rect::new(lx + sw * 0.5 - 3.0, ly - 3.0, 6.0, 6.0), + Some(*color), + None, + ); + } LegendMark::Rect => dc.rect(Rect::new(lx, ly - 4.0, sw, 8.0), Some(*color), None), } dc.text( name, (lx + sw + 5.0, ly), - TextStyle::new(font, Color::AXIS, TA_LEFT).middle(), + TextStyle::new(font, fig.typography.legend_color, TA_LEFT).middle(), ); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b60e500b..5bb4162a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,8 @@ pub use ticks::{AxisLayout, AxisTicks, axis_layout, axis_ticks, axis_ticks_for, #[cfg(feature = "screen")] pub mod screen; +#[cfg(feature = "screen")] +mod screen_annotations; #[cfg(all(windows, feature = "emf"))] pub mod emf; @@ -351,24 +353,46 @@ pub(crate) fn heatmap_cells(proj: &Projector<'_>, grid: &HeatmapGrid) -> Vec<(Re pub(crate) enum LegendMark { Line, Points, + LinePoints, Rect, } -/// Legend entries across series and named polygons (deduplicated by name, in -/// first-appearance order), shared by every backend so legends stay identical. +impl LegendMark { + fn merged(self, other: Self) -> Self { + match (self, other) { + (Self::Line, Self::Points) + | (Self::Points, Self::Line) + | (Self::LinePoints, Self::Line) + | (Self::LinePoints, Self::Points) + | (Self::Line, Self::LinePoints) + | (Self::Points, Self::LinePoints) => Self::LinePoints, + _ => self, + } + } +} + +/// Legend entries across series and named polygons. Series with the same name +/// and color merge into one semantic entry, including a combined point+line +/// swatch for measured samples with a fitted curve. pub(crate) fn legend_entries(fig: &Figure) -> Vec<(&str, Color, LegendMark)> { - let mut entries: Vec<(&str, Color, LegendMark)> = fig - .series - .iter() - .filter(|s| !s.points.is_empty() && !s.name.is_empty()) - .map(|s| { - let mark = match s.kind { - plotx_figure::SeriesKind::Line => LegendMark::Line, - plotx_figure::SeriesKind::Points => LegendMark::Points, - }; - (s.name.as_str(), s.color, mark) - }) - .collect(); + let mut entries: Vec<(&str, Color, LegendMark)> = Vec::new(); + for series in &fig.series { + if series.points.is_empty() || series.name.is_empty() { + continue; + } + let mark = match series.kind { + plotx_figure::SeriesKind::Line => LegendMark::Line, + plotx_figure::SeriesKind::Points => LegendMark::Points, + }; + if let Some((_, _, existing)) = entries + .iter_mut() + .find(|(name, color, _)| *name == series.name && *color == series.color) + { + *existing = existing.merged(mark); + } else { + entries.push((series.name.as_str(), series.color, mark)); + } + } for poly in &fig.polygons { if poly.name.is_empty() || entries.iter().any(|(n, _, _)| *n == poly.name) { continue; @@ -378,6 +402,181 @@ pub(crate) fn legend_entries(fig: &Figure) -> Vec<(&str, Color, LegendMark)> { entries } +/// Whether the renderer will emit a legend for this figure. +pub fn renders_legend(fig: &Figure) -> bool { + fig.show_legend && legend_entries(fig).len() >= 2 +} + +#[derive(Clone, Copy)] +pub(crate) struct LegendLayout { + pub row: f32, + pub swatch: f32, + pub padding: f32, + pub width: f32, + pub height: f32, +} + +/// Keep legend geometry identical across screen, SVG, and EMF output. +pub(crate) fn legend_layout(entries: &[(&str, Color, LegendMark)], font: f32) -> LegendLayout { + let row = (font * 1.4).max(9.0); + let swatch = (font * 1.8).max(10.0); + let padding = (font * 0.6).max(3.0); + let chars = entries + .iter() + .map(|(name, _, _)| name.chars().count()) + .max() + .unwrap_or(0); + LegendLayout { + row, + swatch, + padding, + width: swatch + 5.0 + chars as f32 * font * 0.6 + padding * 2.0, + height: entries.len() as f32 * row + padding * 2.0, + } +} + +/// Legend box in output coordinates. Manual coordinates are fractions of the +/// space in which the complete box can move, so resizing keeps it inside. +pub fn legend_rect(fig: &Figure, plot: Rect, scale: f32) -> Option { + let entries = legend_entries(fig); + if !fig.show_legend || entries.len() < 2 { + return None; + } + let layout = legend_layout(&entries, fig.typography.legend_pt); + let width = layout.width * scale; + let height = layout.height * scale; + let available_x = (plot.width - width).max(0.0); + let available_y = (plot.height - height).max(0.0); + let (left, top) = fig.legend_position.map_or_else( + || { + ( + (plot.right() - width - 8.0 * scale).max(plot.left + 2.0 * scale), + plot.top + 8.0 * scale, + ) + }, + |[x, y]| { + ( + plot.left + x.clamp(0.0, 1.0) * available_x, + plot.top + y.clamp(0.0, 1.0) * available_y, + ) + }, + ); + Some(Rect::new(left, top, width, height)) +} + +pub fn legend_position_for_origin( + fig: &Figure, + plot: Rect, + scale: f32, + origin: [f32; 2], +) -> Option<[f32; 2]> { + let rect = legend_rect(fig, plot, scale)?; + let available_x = plot.width - rect.width; + let available_y = plot.height - rect.height; + Some([ + if available_x > 0.0 { + ((origin[0] - plot.left) / available_x).clamp(0.0, 1.0) + } else { + 0.0 + }, + if available_y > 0.0 { + ((origin[1] - plot.top) / available_y).clamp(0.0, 1.0) + } else { + 0.0 + }, + ]) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextAnchor { + Left, + Center, + Right, +} + +pub struct RangeLabelLayout { + pub text: String, + pub x: f32, + pub top: f32, + pub anchor: TextAnchor, + width: f32, +} + +impl RangeLabelLayout { + pub fn rect(&self, font: f32) -> Rect { + let left = match self.anchor { + TextAnchor::Left => self.x, + TextAnchor::Center => self.x - self.width * 0.5, + TextAnchor::Right => self.x - self.width, + }; + Rect::new(left, self.top, self.width, font) + } +} + +/// Fit a visible region label inside the plot, or omit an off-screen region. +pub fn range_label_layout( + plot: Rect, + region_left: f32, + region_right: f32, + font: f32, + text: &str, + manual_position: Option<[f32; 2]>, +) -> Option { + if region_right <= plot.left || region_left >= plot.right() { + return None; + } + let inset = font.max(f32::MIN_POSITIVE) * (3.0 / 7.0); + // One em per scalar is conservative for the proportional fonts used by + // each backend and prevents wide glyphs from leaking through the clip. + let char_width = font.max(f32::MIN_POSITIVE); + let max_chars = ((plot.width - inset * 2.0).max(char_width) / char_width).floor() as usize; + let count = text.chars().count(); + let displayed = if count > max_chars { + let keep = max_chars.saturating_sub(1); + format!("{}…", text.chars().take(keep).collect::()) + } else { + text.to_owned() + }; + let width = displayed.chars().count() as f32 * char_width; + if let Some([x, y]) = manual_position { + let half_width = (width * 0.5).min(plot.width * 0.5); + let center_x = (plot.left + x.clamp(0.0, 1.0) * plot.width) + .clamp(plot.left + half_width, plot.right() - half_width); + let center_y = plot.top + y.clamp(0.0, 1.0) * plot.height; + return Some(RangeLabelLayout { + text: displayed, + x: center_x, + top: (center_y - font * 0.5).clamp(plot.top, (plot.bottom() - font).max(plot.top)), + anchor: TextAnchor::Center, + width, + }); + } + let left = (region_left + inset).clamp(plot.left + inset, plot.right() - inset); + if left + width <= plot.right() - inset { + Some(RangeLabelLayout { + text: displayed, + x: left, + top: plot.top + 2.0, + anchor: TextAnchor::Left, + width, + }) + } else { + let region_anchor = (region_right - inset).clamp(plot.left + inset, plot.right() - inset); + let x = if region_anchor - width >= plot.left + inset { + region_anchor + } else { + plot.right() - inset + }; + Some(RangeLabelLayout { + text: displayed, + x, + top: plot.top + 2.0, + anchor: TextAnchor::Right, + width, + }) + } +} + /// Lay a marginal projection `trace` into its `band` as output-space points, /// sharing `plot`'s along-axis mapping. `along_x` selects the top band (shares /// the x/F2 mapping, autoscaled vertically); otherwise the left band (shares the diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 74475039..91d3e71f 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -4,7 +4,8 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, DocumentViewport, LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShape, OverlayShapeKind, OverlayText, Projector, Rect, TICK_LABEL_PAD, TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, - heatmap_cells, integral, legend_entries, polygon_outline, projection_points, + heatmap_cells, integral, legend_entries, legend_layout, legend_rect, polygon_outline, + projection_points, }; use egui::{Align2, Color32, FontId, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2}; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; @@ -28,10 +29,7 @@ pub fn show(ui: &mut Ui, fig: &Figure) { paint(&painter, outer, fig, 1.0); } -/// Paint a figure into an explicit rectangle of an existing painter. `scale` is -/// the single page→screen factor: `outer` is already scaled, and every intrinsic -/// size (margins, fonts, strokes, offsets) is a page-unit constant multiplied by -/// it here, so the whole figure stays proportional at any zoom. +/// Paint a figure into an existing painter at the supplied page-to-screen scale. pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { paint_with_stats(painter, outer, fig, scale, None); } @@ -152,8 +150,7 @@ pub fn paint_with_stats( ); } } - // A left projection band sits between the contour and its ppm scale, so nudge - // the F1 tick numbers out past the band to keep them clear of the trace. + // Keep F1 tick numbers outside a left projection band. let y_tick_x = y_axis_x - (TICK_LENGTH + TICK_LABEL_PAD) * scale; for (&yt, label) in y_ticks.values.iter().zip(&y_ticks.labels) { let (_, py) = proj.project([fig.x.min, yt]); @@ -274,6 +271,8 @@ pub fn paint_with_stats( clipped.add(Shape::convex_polygon(pts, fill, stroke)); } + crate::screen_annotations::paint(&clipped, fig, &proj, plot, scale); + for contour in &fig.contours { let stroke = Stroke::new(contour.width * scale, col(contour.color)); for seg in &contour.segments { @@ -413,10 +412,7 @@ fn line_columns(plot_width: f32, pixels_per_point: f32) -> usize { .clamp(MIN_LINE_COLUMNS, MAX_LINE_COLUMNS) } -/// Clip a line to the viewport, then pool it to `columns` min/max buckets when -/// the visible samples exceed the screen-space output budget. The envelope -/// preserves each bucket's extrema in source order; it is visually equivalent -/// at this sub-pixel density while avoiding tessellating invisible detail. +/// Clip to the viewport, then pool dense lines into min/max envelope buckets. fn screen_line_points( points: &[[f64; 2]], x_min: f64, @@ -539,17 +535,21 @@ fn paint_legend(painter: &egui::Painter, plot: Rect, fig: &Figure, scale: f32) { if !fig.show_legend || entries.len() < 2 { return; } - let (row, sw, pad, font) = (15.0 * scale, 16.0 * scale, 6.0 * scale, 11.0 * scale); - let chars = entries - .iter() - .map(|(n, _, _)| n.chars().count()) - .max() - .unwrap_or(0); - let box_w = sw + 5.0 * scale + chars as f32 * font * 0.6 + pad * 2.0; - let box_h = entries.len() as f32 * row + pad * 2.0; - let bx = (plot.right() - box_w - 8.0 * scale).max(plot.left + 2.0 * scale); - let by = plot.top + 8.0 * scale; - let box_rect = egui::Rect::from_min_size(Pos2::new(bx, by), Vec2::new(box_w, box_h)); + let layout = legend_layout(&entries, fig.typography.legend_pt); + let font = fig.typography.legend_pt * scale; + let (row, sw, pad) = ( + layout.row * scale, + layout.swatch * scale, + layout.padding * scale, + ); + let Some(box_geometry) = legend_rect(fig, plot, scale) else { + return; + }; + let (bx, by) = (box_geometry.left, box_geometry.top); + let box_rect = egui::Rect::from_min_size( + Pos2::new(bx, by), + Vec2::new(box_geometry.width, box_geometry.height), + ); painter.rect_filled(box_rect, 3.0 * scale, Color32::from_white_alpha(217)); painter.rect_stroke( box_rect, @@ -571,6 +571,13 @@ fn paint_legend(painter: &egui::Painter, plot: Rect, fig: &Figure, scale: f32) { LegendMark::Points => { painter.circle_filled(Pos2::new(lx + sw * 0.5, ly), 3.0 * scale, col(*color)); } + LegendMark::LinePoints => { + painter.line_segment( + [Pos2::new(lx, ly), Pos2::new(lx + sw, ly)], + Stroke::new(2.0 * scale, col(*color)), + ); + painter.circle_filled(Pos2::new(lx + sw * 0.5, ly), 3.0 * scale, col(*color)); + } LegendMark::Rect => { painter.rect_filled( egui::Rect::from_min_size( @@ -587,13 +594,12 @@ fn paint_legend(painter: &egui::Painter, plot: Rect, fig: &Figure, scale: f32) { Align2::LEFT_CENTER, name, font_id.clone(), - col(Color::AXIS), + col(fig.typography.legend_color), ); } } -/// Paint a fixed-size page document through a screen viewport. Page geometry is -/// left untouched; zoom/pan only affect the screen projection. +/// Paint a fixed-size page document through a zoomable screen viewport. pub fn paint_document( painter: &egui::Painter, screen: Rect, @@ -623,9 +629,7 @@ pub fn paint_document_with_stats( Pos2::new(page.left, page.top), Vec2::new(page.width, page.height), ); - // Screen documents are page-clipped. Besides matching physical-page - // semantics, this makes the page body the complete culling bound used by - // the board; SVG and EMF paths are intentionally unaffected. + // Page clipping also supplies the board's complete culling bound. let painter = painter.with_clip_rect(page_rect); painter.rect_filled(page_rect, 0.0, col(document.background)); diff --git a/crates/render/src/screen_annotations.rs b/crates/render/src/screen_annotations.rs new file mode 100644 index 00000000..0ce3d61c --- /dev/null +++ b/crates/render/src/screen_annotations.rs @@ -0,0 +1,55 @@ +use crate::{Projector, Rect, TextAnchor, range_label_layout}; +use egui::{Align2, Color32, FontId, Pos2, Stroke}; +use plotx_figure::Figure; + +pub(crate) fn paint( + painter: &egui::Painter, + figure: &Figure, + projector: &Projector, + plot: Rect, + scale: f32, +) { + for annotation in &figure.range_annotations { + let (x0, _) = projector.project([annotation.x0, figure.y.min]); + let (x1, _) = projector.project([annotation.x1, figure.y.min]); + let rect = egui::Rect::from_min_max( + Pos2::new(x0.min(x1), plot.top), + Pos2::new(x0.max(x1), plot.bottom()), + ); + let color = Color32::from_rgb(annotation.color.r, annotation.color.g, annotation.color.b); + let fill = Color32::from_rgba_unmultiplied( + annotation.color.r, + annotation.color.g, + annotation.color.b, + (annotation.fill_opacity.clamp(0.0, 1.0) * 255.0).round() as u8, + ); + painter.rect_filled(rect, 0.0, fill); + painter.rect_stroke( + rect, + 0.0, + Stroke::new(annotation.width * scale, color), + egui::StrokeKind::Inside, + ); + let Some(label) = range_label_layout( + plot, + rect.left(), + rect.right(), + figure.typography.tick_pt * scale, + &annotation.label, + annotation.label_position, + ) else { + continue; + }; + painter.text( + Pos2::new(label.x, label.top), + match label.anchor { + TextAnchor::Left => Align2::LEFT_TOP, + TextAnchor::Center => Align2::CENTER_TOP, + TextAnchor::Right => Align2::RIGHT_TOP, + }, + label.text, + FontId::proportional(figure.typography.tick_pt * scale), + color, + ); + } +} diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 0b9fe1eb..6ab88b32 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -1,8 +1,9 @@ use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShapeKind, Projector, Rect, TICK_LABEL_PAD, - TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, - legend_entries, polygon_outline, projection_points, + TICK_LENGTH, TextAnchor, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, + legend_entries, legend_layout, legend_rect, polygon_outline, projection_points, + range_label_layout, }; use plotx_figure::{AxisFrame, AxisTrace, Figure, SeriesKind}; use std::fmt::Write as _; @@ -10,7 +11,6 @@ use std::fmt::Write as _; mod document; pub use document::{export_document, export_document_for_bounds, export_document_page}; -/// Render a [`Figure`] to a standalone SVG document string. pub fn export(fig: &Figure) -> String { let w = fig.width; let h = fig.height; @@ -344,7 +344,6 @@ fn write_figure( ); let _ = write!(s, r#""#); if let Some(grid) = &fig.heatmap { - // crispEdges keeps abutting cells seam-free in SVG viewers. let _ = write!(s, r#""#); for (cell, color) in heatmap_cells(&proj, grid) { let _ = write!( @@ -382,6 +381,46 @@ fn write_figure( col = poly.fill.to_hex(), ); } + for annotation in &fig.range_annotations { + let (x0, _) = proj.project([annotation.x0, fig.y.min]); + let (x1, _) = proj.project([annotation.x1, fig.y.min]); + let left = x0.min(x1); + let right = x0.max(x1); + let width = (x1 - x0).abs(); + let _ = write!( + s, + r#""#, + top = plot.top, + height = plot.height, + color = annotation.color.to_hex(), + opacity = annotation.fill_opacity.clamp(0.0, 1.0), + stroke = annotation.width, + ); + let Some(label) = range_label_layout( + plot, + left, + right, + fig.typography.tick_pt, + &annotation.label, + annotation.label_position, + ) else { + continue; + }; + let _ = write!( + s, + r#"{label}"#, + x = label.x, + y = label.top + fig.typography.tick_pt, + anchor = match label.anchor { + TextAnchor::Left => "start", + TextAnchor::Center => "middle", + TextAnchor::Right => "end", + }, + font = fig.typography.tick_pt, + color = annotation.color.to_hex(), + label = escape(&label.text), + ); + } for contour in &fig.contours { let mut path = String::new(); for seg in &contour.segments { @@ -547,16 +586,18 @@ fn write_legend(s: &mut String, fig: &Figure, plot: Rect) { if !fig.show_legend || entries.len() < 2 { return; } - let (row, sw, pad, font) = (15.0f32, 16.0f32, 6.0f32, 11.0f32); - let chars = entries - .iter() - .map(|(n, _, _)| n.chars().count()) - .max() - .unwrap_or(0); - let box_w = sw + 5.0 + chars as f32 * font * 0.6 + pad * 2.0; - let box_h = entries.len() as f32 * row + pad * 2.0; - let bx = (plot.right() - box_w - 8.0).max(plot.left + 2.0); - let by = plot.top + 8.0; + let font = fig.typography.legend_pt; + let layout = legend_layout(&entries, font); + let (row, sw, pad) = (layout.row, layout.swatch, layout.padding); + let Some(box_geometry) = legend_rect(fig, plot, 1.0) else { + return; + }; + let (bx, by, box_w, box_h) = ( + box_geometry.left, + box_geometry.top, + box_geometry.width, + box_geometry.height, + ); let _ = write!( s, r#""#, @@ -582,6 +623,15 @@ fn write_legend(s: &mut String, fig: &Figure, plot: Rect) { col = color.to_hex(), ); } + LegendMark::LinePoints => { + let _ = write!( + s, + r#""#, + x2 = lx + sw, + cx = lx + sw * 0.5, + col = color.to_hex(), + ); + } LegendMark::Rect => { let _ = write!( s, @@ -595,7 +645,7 @@ fn write_legend(s: &mut String, fig: &Figure, plot: Rect) { s, r#"{txt}"#, tx = lx + sw + 5.0, - axis = plotx_figure::Color::AXIS.to_hex(), + axis = fig.typography.legend_color.to_hex(), txt = escape(name), ); } @@ -638,24 +688,6 @@ mod tests { use super::*; use plotx_figure::{Axis, AxisFrame, Color, ErrorBar, Figure, IntegralCurve, Series}; - #[test] - fn exports_wellformed_ish_svg_with_polyline() { - let fig = Figure::new( - "Demo", - Axis::new("ppm", 0.0, 10.0).reversed(true), - Axis::new("intensity", 0.0, 1.0), - ) - .with_series(Series::line( - "trace", - vec![[0.0, 0.0], [5.0, 1.0], [10.0, 0.0]], - )); - let out = export(&fig); - assert!(out.starts_with("")); - assert!(out.contains("", - Axis::categorical("x", vec!["A & B".into(), "".into()]), - Axis::categorical("y", vec!["north & south".into(), "".into()]), - ); - let out = export(&fig); - assert!(out.contains("A & B <test>")); - assert!(out.contains("A & B")); - assert!(out.contains("<ctrl>")); - assert!(out.contains("north & south")); - assert!(out.contains("<root>")); - assert!(!out.contains(">A & B<")); - assert!(!out.contains("><")); - } - - #[test] - fn exports_error_bar_stem_and_caps_inside_the_plot_clip() { - let fig = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 2.0)) - .with_error_bar(ErrorBar::symmetric([0.5, 1.0], 0.25)); - let out = export(&fig); - assert_eq!(out.matches("class=\"error-bar\"").count(), 1); - assert!(out.contains("clip-path=\"url(#plot)\"")); - } - #[test] fn foreground_error_bar_is_written_after_its_data_series() { let fig = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 2.0)) @@ -775,3 +781,7 @@ mod tests { assert_eq!(svg.matches("stroke=\"#272727\"").count(), 1); } } + +#[cfg(test)] +#[path = "svg_annotation_tests.rs"] +mod annotation_tests; diff --git a/crates/render/src/svg_annotation_tests.rs b/crates/render/src/svg_annotation_tests.rs new file mode 100644 index 00000000..133bbecb --- /dev/null +++ b/crates/render/src/svg_annotation_tests.rs @@ -0,0 +1,135 @@ +use super::export; +use plotx_figure::{Axis, Color, ErrorBar, Figure, RangeAnnotation, Series}; + +#[test] +fn exports_wellformed_ish_svg_with_polyline() { + let figure = Figure::new( + "Demo", + Axis::new("ppm", 0.0, 10.0).reversed(true), + Axis::new("intensity", 0.0, 1.0), + ) + .with_series(Series::line( + "trace", + vec![[0.0, 0.0], [5.0, 1.0], [10.0, 0.0]], + )); + let output = export(&figure); + assert!(output.starts_with("")); + assert!(output.contains("", + Axis::categorical("x", vec!["A & B".into(), "".into()]), + Axis::categorical("y", vec!["north & south".into(), "".into()]), + ); + let output = export(&figure); + assert!(output.contains("A & B <test>")); + assert!(output.contains("<ctrl>")); + assert!(output.contains("north & south")); + assert!(output.contains("<root>")); + assert!(!output.contains(">A & B<")); + assert!(!output.contains("><")); +} + +#[test] +fn exports_range_annotation_band_and_escaped_label() { + let mut figure = Figure::new( + "", + Axis::new("Time (s)", 0.0, 1.0), + Axis::new("Current (pA)", -2.0, 2.0), + ); + figure.range_annotations.push(RangeAnnotation { + source_id: 1, + x0: 0.97, + x1: 0.99, + label: "Peak < 1 & 2".to_owned(), + label_position: None, + color: Color::rgb(0x2b, 0x6c, 0xb0), + fill_opacity: 0.12, + width: 1.0, + }); + + let output = export(&figure); + assert_eq!(output.matches("class=\"range-annotation\"").count(), 1); + assert_eq!( + output.matches("class=\"range-annotation-label\"").count(), + 1 + ); + assert!(output.contains("#2b6cb0")); + assert!(output.contains("Peak < 1 & 2")); + let label = output + .split("class=\"range-annotation-label\"") + .nth(1) + .expect("range label is present") + .split("") + .next() + .unwrap(); + assert!(!label.contains("dominant-baseline")); + assert!(label.contains("text-anchor=\"end\"")); +} + +#[test] +fn omits_a_range_label_wholly_outside_the_visible_x_axis() { + let mut figure = Figure::new( + "", + Axis::new("Time (s)", 0.0, 1.0), + Axis::new("Current (pA)", -2.0, 2.0), + ); + figure.range_annotations.push(RangeAnnotation { + source_id: 1, + x0: 2.0, + x1: 3.0, + label: "outside".to_owned(), + label_position: None, + color: Color::AXIS, + fill_opacity: 0.12, + width: 1.0, + }); + + let output = export(&figure); + assert!(!output.contains("class=\"range-annotation-label\"")); + assert!(!output.contains(">outside")); +} + +#[test] +fn exports_a_manually_positioned_range_label_at_its_normalized_center() { + let mut figure = Figure::new( + "", + Axis::new("Time (s)", 0.0, 1.0), + Axis::new("Current (pA)", -2.0, 2.0), + ); + figure.range_annotations.push(RangeAnnotation { + source_id: 7, + x0: 0.2, + x1: 0.3, + label: "moved".to_owned(), + label_position: Some([0.5, 0.75]), + color: Color::AXIS, + fill_opacity: 0.12, + width: 1.0, + }); + + let output = export(&figure); + let label = output + .split("class=\"range-annotation-label\"") + .nth(1) + .unwrap() + .split("") + .next() + .unwrap(); + assert!(label.contains("text-anchor=\"middle\"")); + assert!(label.contains(">moved")); +} + +#[test] +fn exports_error_bar_stem_and_caps_inside_the_plot_clip() { + let figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 2.0)) + .with_error_bar(ErrorBar::symmetric([0.5, 1.0], 0.25)); + let output = export(&figure); + assert_eq!(output.matches("class=\"error-bar\"").count(), 1); + assert!(output.contains("clip-path=\"url(#plot)\"")); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccab7677..e05afc85 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::ticks::estimated_text_width; -use plotx_figure::{Axis, Figure}; +use plotx_figure::{Axis, Figure, Series}; #[test] fn ticks_are_nice_and_bounded() { @@ -437,6 +437,37 @@ fn legend_merges_series_and_named_polygons_once() { assert!(matches!(entries[1].2, LegendMark::Rect)); } +#[test] +fn legend_merges_same_name_points_and_line_into_one_semantic_entry() { + let color = Color::rgb(20, 80, 160); + let figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)) + .with_series(Series::points("Region A", vec![[0.0, 0.0]]).colored(color)) + .with_series(Series::line("Region A", vec![[0.0, 0.0], [1.0, 1.0]]).colored(color)) + .with_series(Series::points("Region B", vec![[0.0, 1.0]]).colored(Color::rgb(160, 80, 20))); + + let entries = legend_entries(&figure); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0, "Region A"); + assert!(matches!(entries[0].2, LegendMark::LinePoints)); +} + +#[test] +fn manual_legend_position_maps_to_the_available_plot_area() { + let mut figure = Figure::new("", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)) + .with_series(Series::line("A", vec![[0.0, 0.0], [1.0, 1.0]])) + .with_series(Series::line("B", vec![[0.0, 1.0], [1.0, 0.0]])); + figure.show_legend = true; + figure.legend_position = Some([0.0, 1.0]); + let plot = Rect::new(10.0, 20.0, 300.0, 200.0); + let legend = legend_rect(&figure, plot, 1.0).unwrap(); + assert_eq!(legend.left, plot.left); + assert_eq!(legend.bottom(), plot.bottom()); + + let position = + legend_position_for_origin(&figure, plot, 1.0, [plot.right(), plot.top]).unwrap(); + assert_eq!(position, [1.0, 0.0]); +} + #[test] fn heatmap_cells_project_every_finite_cell() { let fig = Figure::new("", Axis::new("x", 0.0, 2.0), Axis::new("y", 0.0, 2.0)); diff --git a/docs/src/content/docs/guides/choosing-a-tool.md b/docs/src/content/docs/guides/choosing-a-tool.md index 996b907b..ea986311 100644 --- a/docs/src/content/docs/guides/choosing-a-tool.md +++ b/docs/src/content/docs/guides/choosing-a-tool.md @@ -10,7 +10,7 @@ what you want to measure: | --- | --- | --- | --- | | Locate and list the peaks in a spectrum | **Peaks** (`P`) | 1D spectra | Peak list | | Read one position or compare two positions | **Inspect / Delta cursors** (`C`) | Frequency-domain 1D and true-2D NMR spectra | Coordinates, intensity, and two-point differences | -| Follow a signal's intensity through an ordered series | **Regions** | Pseudo-2D and stacked series | Live series table, one column per region | +| Follow a signal through an ordered series or sweep collection | **Regions** | Pseudo-2D/stacked series and electrophysiology recordings | Color-linked live series table, one column per region | | Measure cross-peak volumes in a 2D spectrum | **Integrate** (`I`) | True 2D spectra (COSY, HSQC, …) | Integral table with normalized volumes | | Compare a cross peak with its reflected partner | **Symmetry review** (third `C` cursor) | Homonuclear true-2D spectra | Paired cross-peak marks and review states | | Separate overlapping spectral peaks into components | **Peak Fit** (`D`) | Any 1D trace | Per-peak position, height, width, area | @@ -27,7 +27,8 @@ Rules of thumb when two tools seem to apply: - **Regions vs Integrate** — Regions measures the *same 1D interval* across every member of a series; Integrate measures a *rectangle* in a single true 2D spectrum. A DOSY or relaxation dataset is a series → Regions; an HSQC is - a true 2D spectrum → Integrate. + a true 2D spectrum → Integrate. To compare the same patch-clamp response + window across sweeps, use Regions. - **Peak Fit vs Fit Curves** — Peak Fit works on a spectrum's line shapes; Fit Curves works on tabulated x-y values (a decay, a titration, an IV curve). A pseudo-2D analysis uses both stages: Regions extracts the decay diff --git a/docs/src/content/docs/guides/electrophysiology.md b/docs/src/content/docs/guides/electrophysiology.md index ed534fdf..e88624ef 100644 --- a/docs/src/content/docs/guides/electrophysiology.md +++ b/docs/src/content/docs/guides/electrophysiology.md @@ -16,13 +16,30 @@ choose the recorded channel. The optional zero-phase Gaussian low-pass is enabled at 1 kHz by default. It affects charts and analysis consistently; raw samples remain unchanged and the setting is saved in the project. -## Window statistics +Sweep names share the plot legend. To recover plot area, select the plot and +turn off **Show legend** under **Axes** in the Object inspector. **Legend size** +and **Legend text color** under **Figure typography** style legends throughout +the document. With **Select** active, drag the legend to a clear part of the +plot; double-click it to restore automatic placement. -Enter the start and end time in seconds and choose Positive, Negative, or -Absolute peak mode. **Create statistics table** creates a normal PlotX data -table containing signed peak, average, and peak time for every selected sweep. -An empty window or non-finite sample produces an error instead of a fabricated -zero. Use the normal Data Sheet and **Export Data…** to inspect or export results. +## Regions and window statistics + +Select the recording, then choose **Analyze** → **Draw Regions**. Drag across +the trace to mark one or more time windows. Each window is measured in every +selected sweep. Choose Height, Area, Max, Min, or Mean, then select +**Continue to Series Table** to create a live table and a color-matched point +series for each region. + +For peak, average, and peak-time values, open **Patch clamp**. PlotX uses the +selected region, or the first region in the list when none is selected. Choose +Positive, Negative, or Absolute under **Peak mode**, then select **Create +statistics table**. The button is disabled until you draw a region. If the +window does not overlap a sweep or contains a non-finite sample, PlotX reports +an error instead of inserting zero. Inspect the result in Data Sheet or export +it with **Export Data…**. + +**Show regions on figure and export** is enabled by default. With it enabled, +figure exports include each region's colored band, boundary, and label. For the recording itself, **Export Data…** writes every selected sweep from the current channel after the active filter. Time is the first column and each @@ -35,13 +52,14 @@ file does not contain a waveform, PlotX may suggest a Voltage Step, Current Step, or Ramp from the protocol name. Suggested values are placeholders: edit them and explicitly confirm the template before IV analysis is enabled. -**Create IV table** combines the stimulus value with peak and average response. -Voltage stimuli require a current response; current stimuli require a voltage -response. A unit mismatch is reported and calculation is stopped. Ramp protocols -do not support IV analysis: the stimulus varies continuously within a sweep, so -there is no single stimulus value to plot against. In the data browser the -table stays listed under the recording it came from, and its stimulus source -remains part of the saved dataset. +**Create IV table** uses the same selected region and combines the stimulus +value with the peak and average response. Voltage stimuli require a current +response; current stimuli require a voltage response. A unit mismatch is +reported and calculation is stopped. Ramp protocols do not support IV +analysis: the stimulus varies continuously within a sweep, so there is no +single stimulus value to plot against. In the data browser the table stays +listed under the recording it came from, and its stimulus source remains part +of the saved dataset. ## Recording metadata diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 72c78feb..5b9d03f9 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -131,7 +131,8 @@ What you control directly: - Select one plot and use **Axes** in the Object inspector to override its X and Y titles or numeric ranges, or to hide either axis's tick labels and - title. For a true 2D NMR spectrum, **F1/F2 equal scale (1:1)** uses the same + title. **Show legend** controls that plot's legend independently. For a true + 2D NMR spectrum, **F1/F2 equal scale (1:1)** uses the same data units per screen unit on both axes. PlotX sets its initial value when the spectrum is imported; you can change it afterward without changing the import preference. Leave a title blank, or keep a range on **Auto**, to use @@ -139,16 +140,20 @@ What you control directly: range: zooming and panning stay inside it, and a double-click on the plot returns to it. Charts without visible axes offer no axis settings, and categorical axes have no range controls. -- **Figure Typography…** on the Figure Ribbon tab sets the axis text sizes - (tick labels, axis titles, and the figure title) for every plot at once, in - absolute points — a document-level style, so resizing a panel never changes - its type size. Tick labels accept 1 to 72 pt; axis titles and the figure title - accept 4 to 24 pt. +- With **Select** active, drag a visible legend to a clearer part of its plot. + The position belongs to that plot, survives resizing, and is used by every + export format. Double-click the legend to restore automatic top-right + placement. +- **Figure Typography…** on the Figure Ribbon tab sets the text sizes (tick + labels, axis titles, the figure title, and legends) for every plot at once, + in absolute points — a document-level style, so resizing a panel never + changes its type size. These sizes accept 1 to 72 pt. Legends default to + 7 pt. - **Figure typography** in the Object inspector holds the same tick-label size - as **Tick-label size**, over the same 1 to 72 pt range, and changing it in - either place changes the one document-wide value. Because it belongs to the - document rather than to a plot, the section is shown whatever is selected — - including when nothing is. + as **Tick-label size**, plus **Legend size** and **Legend text color**. + Changing a value in either surface changes the one document-wide value. + Because it belongs to the document rather than to a plot, the section is + shown whatever is selected — including when nothing is. - **Line** in the Object inspector sets the **Stroke width** of the selected plot's line series, in points. New line series are 0.5 pt; drag or type any value from 0.05 to 10 pt, or take one from **Presets** — *Fine* 0.50 pt, diff --git a/docs/src/content/docs/guides/peaks-and-regions.md b/docs/src/content/docs/guides/peaks-and-regions.md index 0b82403d..a7fefdcb 100644 --- a/docs/src/content/docs/guides/peaks-and-regions.md +++ b/docs/src/content/docs/guides/peaks-and-regions.md @@ -43,13 +43,30 @@ spectrum recalculates both. Regions measure the same x-axis interval across every member of a series. This is useful whenever you want to follow a signal through an ordered series, -including DOSY, relaxation, and other series data. +including DOSY, relaxation, and patch-clamp sweeps. The **Regions** group is +available for pseudo-2D series and electrophysiology recordings. Open the **Analyze** tab and choose **Draw Regions** in the **Regions** group, then drag across each signal of interest. A Regions task card opens at the upper-right of the canvas with the drawing instructions, measurement choice, and region list. Drag the handle at the lower-right corner of the card to adjust its height. The regions remain on the plot and can be moved or resized. +Choose Height, Area, Max, Min, or Mean as the default measurement; +an individual region can override that choice. Enter a descriptive **Label** +in a region card to replace its automatic axis-midpoint label everywhere, +including the source figure, series table, fitted plot, and export. +With **Select** or **Regions** active, drag a region label to place it where it +does not cover the trace. Its position is saved and used by figure exports. +Double-click the label to return it to automatic placement. + +Each region has a stable color. The same color identifies its band on the +source figure, its scatter series in the generated table, and any fit drawn for +that series. A fitted point series and its curve share one legend entry with a +combined point-and-line symbol. **Show regions on figure and export** is +enabled by default. Turn it off to export the source figure without region +annotations. SVG, PDF, bitmap, EMF, and vector clipboard output preserve the +colored bands, boundaries, and labels; editing handles and drag previews are +not exported. When the regions are ready, choose **Continue to Series Table** in the task card. Each region becomes a column in a live table that stays synchronized with @@ -61,7 +78,9 @@ To export either the linked table or a frozen Series in full, select that table and use **Export Data…** → **Complete typed table / series**. For pseudo-2D analysis, each region becomes one decay curve — see -[Pseudo-2D analysis](/guides/pseudo-2d/). +[Pseudo-2D analysis](/guides/pseudo-2d/). For patch-clamp recordings, each +region measures the same time window in every selected sweep — see +[Electrophysiology](/guides/electrophysiology/). ## Peak fitting diff --git a/docs/src/content/docs/zh-cn/guides/choosing-a-tool.md b/docs/src/content/docs/zh-cn/guides/choosing-a-tool.md index f9aca94f..f8935829 100644 --- a/docs/src/content/docs/zh-cn/guides/choosing-a-tool.md +++ b/docs/src/content/docs/zh-cn/guides/choosing-a-tool.md @@ -9,7 +9,7 @@ PlotX 有几个名字相近的分析工具。从你想测量什么出发: | --- | --- | --- | --- | | 找出并列出谱中的峰 | **峰**(`P`) | 1D 谱 | 峰列表 | | 读取一个位置或比较两个位置 | **Inspect / Delta 光标**(`C`) | 频率域 1D 与真 2D NMR 谱 | 坐标、强度与两点差值 | -| 跟踪某信号在有序系列中的强度变化 | **区域** | 伪 2D 与堆叠系列 | 联动系列表,每区域一列 | +| 跟踪某信号在有序系列或 sweep 集合中的变化 | **区域** | 伪 2D/堆叠系列与电生理记录 | 颜色对应的联动系列表,每区域一列 | | 测量 2D 谱中交叉峰的体积 | **积分**(`I`) | 真 2D 谱(COSY、HSQC 等) | 含归一化体积的积分表 | | 比较交叉峰与其关于对角线的对应峰 | **Symmetry review**(第三个 `C` 光标) | 同核真 2D 谱 | 成对交叉峰标记与审核状态 | | 把重叠的谱峰分解为组分 | **谱峰拟合**(`D`) | 任意 1D 谱线 | 每峰的位置、高度、宽度、面积 | @@ -24,7 +24,8 @@ PlotX 有几个名字相近的分析工具。从你想测量什么出发: 需要定量面积时再拟合。 - **区域 vs 积分**——区域在系列的*每个成员*上测量*同一个 1D 区间*; 积分测量*单个真 2D 谱*中的*矩形*。DOSY 或弛豫数据是系列 → 用区域; - HSQC 是真 2D 谱 → 用积分。 + HSQC 是真 2D 谱 → 用积分。若要在多个 sweep 中比较同一个膜片钳响应 + 时间窗,请使用区域。 - **谱峰拟合 vs 曲线拟合**——谱峰拟合作用于谱线形;曲线拟合作用于表 格化的 x-y 数值(衰减、滴定、IV 曲线)。伪 2D 分析两者都用:区域把 衰减曲线提取到表中,曲线拟合再去拟合它们。 diff --git a/docs/src/content/docs/zh-cn/guides/electrophysiology.md b/docs/src/content/docs/zh-cn/guides/electrophysiology.md index 89ae9aac..d7bf49c5 100644 --- a/docs/src/content/docs/zh-cn/guides/electrophysiology.md +++ b/docs/src/content/docs/zh-cn/guides/electrophysiology.md @@ -14,12 +14,28 @@ float32、单/多记录通道、定长或变长 sweep、ADC 缩放、通道名 零相位 Gaussian 低通默认启用,截止频率为 1 kHz。绘图和分析使用同一 处理结果;原始样本不改变,设置会随项目保存。 -## 时间窗统计 +各 sweep 的名称共用图内图例。若要释放数据区空间,请选中该图,并在对象 +检查器的 **Axes** 中关闭 **Show legend**。**Figure typography** 中的 +**Legend size** 和 **Legend text color** 统一设定文档内所有图例的样式。 +启用 **Select** 工具后可把图例拖到不遮挡曲线的位置;双击图例即可恢复 +自动放置。 -输入起止时间(秒),并选择 Positive、Negative 或 Absolute 峰值模式。 -**Create statistics table** 会为每个所选 sweep 生成包含带符号峰值、平均值 -和峰值时间的标准 PlotX 数据表。空窗口或非有限值会明确报错,不会伪造 -`0`。结果可用现有 Data Sheet 与**导出数据…**查看和导出。 +## 区域与时间窗统计 + +选中 recording,然后选择**分析** → **绘制区域**。在曲线上拖动,标出一个或 +多个时间窗;每个时间窗都会在所有已选 sweep 中测量。选择 Height、Area、Max、 +Min 或 Mean,再选择**继续到系列表**,即可为每个区域创建联动表格和颜色对应的 +点系列。 + +若要得到峰值、平均值和峰值时间,请打开 **Patch clamp**。PlotX 使用当前选中的 +区域;若未选中区域,则使用列表中的第一个区域。在 **Peak mode** 下选择 +Positive、Negative 或 Absolute,然后选择 **Create statistics table**。画出 +区域前,该按钮保持禁用。如果时间窗与某个 sweep 不重叠,或其中含有非有限值, +PlotX 会报告错误,而不会填入 `0`。可在 Data Sheet 中查看结果,或通过 +**导出数据…**导出。 + +**Show regions on figure and export**(在图形与导出中显示区域)默认开启。 +开启时,图形导出会包含每个区域的彩色带、边界和标签。 对于 recording 本身,**导出数据…**会写出当前通道中全部已选 sweep,并应用 当前滤波设置。第一列为时间,后续每列对应一个 sweep;较短 sweep 的尾部留空。 @@ -30,11 +46,11 @@ float32、单/多记录通道、定长或变长 sweep、ADC 缩放、通道名 协议名建议 Voltage Step、Current Step 或 Ramp;建议值只是占位,必须编辑 并明确确认模板后才能进行 IV 分析。 -**Create IV table** 把刺激值与 peak/average 响应组合起来。电压刺激要求 -电流响应,电流刺激要求电压响应;物理量不匹配时会停止计算并说明原因。 -Ramp 协议不支持 IV 分析:刺激在每个 sweep 内连续变化,没有可以对应的 -单一刺激值。在数据浏览器中,派生表始终列在其来源记录之下,刺激来源也随 -数据集保存。 +**Create IV table** 使用同一个已选区域,把刺激值与峰值和平均响应组合起来。 +电压刺激要求电流响应,电流刺激要求电压响应;物理量不匹配时会停止计算并 +说明原因。Ramp 协议不支持 IV 分析:刺激在每个 sweep 内连续变化,没有 +可以对应的单一刺激值。在数据浏览器中,派生表始终列在其来源记录之下, +刺激来源也随数据集保存。 ## Recording 元数据 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 8dc171f2..7dc9954a 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 @@ -109,20 +109,24 @@ NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个 你可以直接控制的部分: - 选中单个图形后,可在对象检查器的 **Axes** 区域覆盖 X/Y 轴标题或数值 - 范围,也可隐藏某条轴的刻度标签和标题。对于真正的 2D NMR 谱, + 范围,也可隐藏某条轴的刻度标签和标题;**Show legend** 可单独控制该图 + 是否显示图例。对于真正的 2D NMR 谱, **F1/F2 equal scale (1:1)** 让两个坐标轴使用相同的单位/屏幕距离; PlotX 在导入谱时设定其初始值,此后可单独修改,且不会改变导入偏好设置。 标题留空或范围保持 **Auto**,即可继续使用由数据自动推导的值。 手动范围会成为该轴的完整范围:缩放和平移仍限制在其中,双击图内即回到 这个手动范围。不显示坐标轴的图表没有轴设置;分类轴不提供范围控制。 +- 启用 **Select** 工具后,可把可见图例拖到图内更合适的位置。该位置属于 + 当前图形,调整图框尺寸后仍会保留,并用于所有导出格式;双击图例即可恢复 + 自动放置在右上角。 - Figure Ribbon 选项卡的 **Figure Typography…** 一次设定文档内所有图的 - 坐标轴文字尺寸(刻度标签、轴标题与图标题),单位为绝对磅值——这是 - 文档级样式,缩放分图不会改变字号。刻度标签取值 1 到 72 pt,轴标题与 - 图标题取值 4 到 24 pt。 + 文字尺寸(刻度标签、轴标题、图标题与图例),单位为绝对磅值——这是 + 文档级样式,缩放分图不会改变字号。各字号取值 1 到 72 pt;图例默认为 + 7 pt。 - 对象检查器的 **Figure typography** 区域中的 **Tick-label size** 就是同 - 一个刻度标签字号,范围同样是 1 到 72 pt,在两处中任何一处修改,改的都 - 是文档里的同一个值。它属于文档而不属于某个图,因此无论当前选中什么 - (包括什么都没选中)都会显示。 + 一个刻度标签字号,并提供 **Legend size** 与 **Legend text color**。 + 在两处中任何一处修改,改的都是文档里的同一个值。它属于文档而不属于 + 某个图,因此无论当前选中什么(包括什么都没选中)都会显示。 - 对象检查器的 **Line** 区域中的 **Stroke width** 设定所选图中线条序列的 线宽,单位为 pt;新线条序列默认为 0.5 pt。可以拖动或输入 0.05 到 10 pt 之间的任意值,也可以从 **Presets**(预设)中取一个——*Fine* 0.50 pt、 diff --git a/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md b/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md index 9030d4de..1bddab43 100644 --- a/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md +++ b/docs/src/content/docs/zh-cn/guides/peaks-and-regions.md @@ -34,11 +34,23 @@ description: 峰拾取与交互式区域分析。 ## 区域 区域分析会在系列中的每一组数据上测量相同的横轴范围。凡是需要观察某个信号 -如何随系列变化时,都可以使用这一工具,例如 DOSY、弛豫以及其他系列数据。 +如何随系列变化时,都可以使用这一工具,例如 DOSY、弛豫和膜片钳 sweep。 +**区域**组适用于伪 2D 系列和电生理记录。 打开**分析**选项卡,在**区域**组中选择**绘制区域**,然后在每个感兴趣的信号上 拖动。画布右上角会出现区域任务卡片,其中包含绘制提示、测量方式和区域列表。 拖动卡片右下角的手柄可以调整高度。画出的区域会保留在图中,并可继续移动或调整宽度。 +默认测量可选择 Height、Area、Max、Min 或 Mean;每个区域还可以单独覆盖该选择。 +在区域卡片的 **Label** 中输入说明性名称,即可在来源图、系列表、拟合图和导出中 +统一替换自动生成的坐标轴中点标签。 +启用 **Select** 或 **Regions** 工具后,可直接拖动区域标签,避免它遮住曲线。 +该位置会随数据保存并用于图形导出;双击标签即可恢复自动放置。 + +每个区域都有稳定的颜色:来源图中的区域带、生成表格中的散点系列,以及该系列 +的拟合曲线都使用同一颜色。拟合后的点系列与曲线共用一个带点线组合符号的图例项。 +**Show regions on figure and export**(在图形与导出中显示区域)默认开启; +若要导出不带区域标记的来源图,请将其关闭。SVG、PDF、位图、EMF 与剪贴板 +矢量输出会保留彩色区域带、边界和标签;编辑手柄和拖动预览不会导出。 区域准备好后,在任务卡片中选择**继续到系列表**。每个区域会成为实时表格中的一列, 之后调整区域时,表格也会同步更新。Ribbon 中的**系列表**按钮会打开同一张表。 @@ -48,7 +60,8 @@ description: 峰拾取与交互式区域分析。 **导出数据…** → **完整类型化表格 / series**。 在伪 2D 分析中,每个区域对应一条衰减曲线——参见 -[伪 2D 分析](/zh-cn/guides/pseudo-2d/)。 +[伪 2D 分析](/zh-cn/guides/pseudo-2d/)。对于膜片钳记录,每个区域会在所有 +已选 sweep 中测量同一时间窗——参见[电生理](/zh-cn/guides/electrophysiology/)。 ## 谱峰拟合