From a1aa492667b67779f3cd2e1ed72f41433926f999 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sat, 24 Oct 2026 17:32:41 +0800 Subject: [PATCH 1/2] fix(plot): scale peak heights without moving baselines --- crates/app/src/ui/canvas/cursors.rs | 6 +- crates/app/src/ui/canvas/navigation.rs | 63 +---- crates/app/src/ui/canvas/navigation_gain.rs | 110 +++++++++ .../src/ui/canvas/navigation_gain_tests.rs | 217 ++++++++++++++++++ crates/app/src/ui/canvas/navigation_tests.rs | 4 + crates/app/src/ui/canvas/painting.rs | 2 +- crates/app/src/ui/canvas/peaks.rs | 6 +- crates/app/src/ui/canvas/readout.rs | 31 ++- crates/app/src/ui/canvas/view_fit.rs | 44 ++++ crates/app/src/ui/command_exec.rs | 3 + crates/app/src/ui/commands.rs | 2 + crates/app/src/ui/commands/helpers.rs | 7 + crates/app/src/ui/commands/identity.rs | 4 + crates/app/src/ui/commands/roster.rs | 1 + crates/app/src/ui/menus.rs | 1 + .../src/automation/diagnostics/hints_tests.rs | 1 + .../core/src/automation/diagnostics/tests.rs | 1 + crates/core/src/figures.rs | 5 +- crates/core/src/project/convert_views.rs | 13 ++ crates/core/src/project/display_gain_tests.rs | 86 +++++++ crates/core/src/project/dto.rs | 8 + crates/core/src/project/mod.rs | 3 + crates/core/src/project/render_provenance.rs | 35 +++ .../core/src/properties/axis_scale_tests.rs | 24 ++ crates/core/src/state/app_impl_figures.rs | 1 + crates/core/src/state/axis_overrides.rs | 40 +++- crates/core/src/state/category_chart_tests.rs | 61 +++++ crates/core/src/state/curve_extract.rs | 2 + crates/core/src/state/curve_layout.rs | 11 +- crates/core/src/state/plot_annotations.rs | 1 + crates/core/src/state/plot_object.rs | 9 + crates/core/src/state/right_axis_tests.rs | 41 ++++ crates/core/src/state/series_binding_style.rs | 2 + crates/core/src/state/stack.rs | 2 + crates/core/src/state/table_charts/pie.rs | 1 + crates/core/src/workflow/trace_collection.rs | 1 + crates/figure/src/display_gain.rs | 154 +++++++++++++ crates/figure/src/display_gain_tests.rs | 120 ++++++++++ crates/figure/src/error_bar.rs | 3 + crates/figure/src/lib.rs | 22 ++ crates/render/src/label_placement_tests.rs | 1 + crates/render/src/legend_placement_tests.rs | 2 + crates/render/src/projected.rs | 1 + .../docs/getting-started/quick-tour.md | 6 +- docs/src/content/docs/guides/pseudo-2d.md | 8 + docs/src/content/docs/reference/shortcuts.md | 14 +- .../docs/zh-cn/getting-started/quick-tour.md | 5 +- .../content/docs/zh-cn/guides/pseudo-2d.md | 5 + .../content/docs/zh-cn/reference/shortcuts.md | 10 +- 49 files changed, 1120 insertions(+), 80 deletions(-) create mode 100644 crates/app/src/ui/canvas/navigation_gain.rs create mode 100644 crates/app/src/ui/canvas/navigation_gain_tests.rs create mode 100644 crates/core/src/project/display_gain_tests.rs create mode 100644 crates/figure/src/display_gain.rs create mode 100644 crates/figure/src/display_gain_tests.rs diff --git a/crates/app/src/ui/canvas/cursors.rs b/crates/app/src/ui/canvas/cursors.rs index d19e3178..81690b1c 100644 --- a/crates/app/src/ui/canvas/cursors.rs +++ b/crates/app/src/ui/canvas/cursors.rs @@ -250,7 +250,11 @@ fn point_to_screen( .figure(); Some(Pos2::new( to_screen_x(point.x, plot, &figure.x), - to_screen_y(point.y.unwrap_or(point.intensity), plot, &figure.y), + to_screen_y( + point.y.unwrap_or(point.intensity * figure.display_gain), + plot, + &figure.y, + ), )) } diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index f088149d..f34dda8a 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -3,6 +3,10 @@ use super::*; #[path = "navigation_input.rs"] mod input; use input::*; +#[path = "navigation_gain.rs"] +mod gain; +use gain::scale_line_intensity; +pub(crate) use gain::{peak_height_label, supports_peak_height}; #[path = "navigation_pointer.rs"] mod pointer; pub(crate) use input::{last_wheel_was_trackpad, selected_plot_body, sync_macos_trackpad_gesture}; @@ -238,8 +242,12 @@ pub(crate) fn handle_navigation( NavigationInput::WheelZoom(amount) => { let consumed = match data_target { Some((id, outer, plot)) => { - if alt && hit_zone(p, outer, plot) == HitZone::Plot { - app.finish_pending_wheel_zoom(now, true); + if hit_zone(p, outer, plot) == HitZone::Plot + && (alt || supports_peak_height(app, ci, id)) + { + if !supports_peak_height(app, ci, id) { + app.finish_pending_wheel_zoom(now, true); + } adjust_plot_display(app, ci, id, plot, p, amount, now, ui) } else { app.finish_pending_wheel_property(now, true); @@ -668,57 +676,6 @@ fn step_wheel_property( .request_repaint_after(std::time::Duration::from_millis(200)); } -#[allow(clippy::too_many_arguments)] -fn scale_line_intensity( - app: &mut PlotiumApp, - canvas: usize, - object: ObjectId, - plot: PlotRect, - pointer: Pos2, - scroll_y: f32, - now: f64, - ui: &Ui, -) -> bool { - let Some(plot_object) = app.doc.canvases[canvas] - .object(object) - .and_then(|object| object.plot()) - else { - return false; - }; - if plot_object.figure().y.categories.is_some() { - app.session.status = - "This categorical plot has no continuous display-intensity scale.".to_owned(); - return true; - } - let before = plot_object.viewport.clone(); - let y_axis = plot_object.figure().y.clone(); - let view = before.view_y; - let anchor = if view.min <= 0.0 && view.max >= 0.0 { - 0.0 - } else if y_axis.scale == plotium_figure::AxisScale::Log10 { - (view.min * view.max).sqrt() - } else { - (view.min + view.max) * 0.5 - }; - let scale = f64::from((-scroll_y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0); - zoom_plot_viewport( - app, - canvas, - object, - plot_rect(plot), - plot, - Pos2::new(pointer.x, to_screen_y(anchor, plot, &y_axis)), - scale, - (false, true), - now, - ui, - ); - app.session.status = - "Adjusted plot intensity; automatic Y scaling is now off. Double-click Y to reset." - .to_owned(); - true -} - /// Zoom a plot's data viewport around the cursor on the requested axes. /// Coalesces one stream of wheel or pinch events into one undo step. #[allow(clippy::too_many_arguments)] diff --git a/crates/app/src/ui/canvas/navigation_gain.rs b/crates/app/src/ui/canvas/navigation_gain.rs new file mode 100644 index 00000000..53a84295 --- /dev/null +++ b/crates/app/src/ui/canvas/navigation_gain.rs @@ -0,0 +1,110 @@ +use super::*; + +pub(crate) fn peak_height_label(gain: f64) -> String { + if (0.001..1000.0).contains(&gain) { + format!("×{gain:.3}") + } else { + format!("×{gain:.3e}") + } +} + +pub(crate) fn supports_peak_height(app: &PlotiumApp, canvas: usize, object: ObjectId) -> bool { + app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .is_some_and(|plot| { + !plot + .binding + .coordinates + .as_ref() + .is_some_and(|coordinates| coordinates.transposed) + && plot.figure().supports_display_gain() + }) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn scale_line_intensity( + app: &mut PlotiumApp, + canvas: usize, + object: ObjectId, + plot: PlotRect, + pointer: Pos2, + scroll_y: f32, + now: f64, + ui: &Ui, +) -> bool { + let Some(plot_object) = app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + else { + return false; + }; + if plot_object.figure().y.categories.is_some() { + app.session.status = + "This categorical plot has no continuous display-intensity scale.".to_owned(); + return true; + } + if supports_peak_height(app, canvas, object) { + let gain = (plot_object.viewport.display_gain + * f64::from((scroll_y * WHEEL_ZOOM_SPEED).exp())) + .clamp( + plotium_figure::MIN_DISPLAY_GAIN, + plotium_figure::MAX_DISPLAY_GAIN, + ); + if let Err(error) = plot_object.figure().check_display_gain(gain) { + app.session.status = format!("Could not adjust peak height: {error}"); + return true; + } + if !update_pending_viewport_edit(app, canvas, object, now) { + return false; + } + let plot_object = app.doc.canvases[canvas] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("the wheel target was just validated"); + // Freeze the drawn window, including any space reserved for a legend. + // Amplification must not trigger an automatic refit or move a baseline. + plot_object.viewport.view_x = + AxisRange::new(plot_object.figure().x.min, plot_object.figure().x.max); + plot_object.viewport.view_y = + AxisRange::new(plot_object.figure().y.min, plot_object.figure().y.max); + plot_object.viewport.auto_y = false; + plot_object.viewport.display_gain = gain; + plot_object.apply_viewport(); + app.mark_document_dirty(); + app.session.status = format!( + "Peak height {} · baselines fixed · View > Reset Peak Height", + peak_height_label(gain) + ); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); + return true; + } + let before = plot_object.viewport.clone(); + let y_axis = plot_object.figure().y.clone(); + let view = before.view_y; + let anchor = if view.min <= 0.0 && view.max >= 0.0 { + 0.0 + } else if y_axis.scale == plotium_figure::AxisScale::Log10 { + (view.min * view.max).sqrt() + } else { + (view.min + view.max) * 0.5 + }; + let scale = f64::from((-scroll_y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0); + zoom_plot_viewport( + app, + canvas, + object, + plot_rect(plot), + plot, + Pos2::new(pointer.x, to_screen_y(anchor, plot, &y_axis)), + scale, + (false, true), + now, + ui, + ); + app.session.status = + "Adjusted plot intensity; automatic Y scaling is now off. Double-click Y to reset." + .to_owned(); + true +} diff --git a/crates/app/src/ui/canvas/navigation_gain_tests.rs b/crates/app/src/ui/canvas/navigation_gain_tests.rs new file mode 100644 index 00000000..ee83185c --- /dev/null +++ b/crates/app/src/ui/canvas/navigation_gain_tests.rs @@ -0,0 +1,217 @@ +use super::*; +use plotium_figure::Series; + +fn gain_fixture() -> (PlotiumApp, egui::Context, egui::Rect, Pos2) { + let (mut app, _, _) = zoomed_plot_fixture(); + let mut figure = Figure::new("stack", Axis::new("x", 0., 10.), Axis::new("y", -3., 16.)); + figure + .series + .push(Series::line("first", vec![[0., 0.], [5., 2.], [10., -1.]])); + let mut second = Series::line("second", vec![[0., 10.], [5., 11.], [10., 9.5]]); + second.display_baseline = 10.; + figure.series.push(second); + let mut viewport = CanvasViewport::from_figure(&figure); + viewport.auto_y = false; + let object = app.doc.canvases[0].object_mut(PLOT_ID).unwrap(); + object.kind = CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, + plotium_core::state::SeriesId::new(1), + plotium_core::state::DataBinding { + coordinates: None, + series: vec![], + }, + plotium_core::state::ChartSpec::default(), + plotium_core::state::StackSpec::default(), + plotium_core::state::AxisProjections::default(), + plotium_core::state::AxisOverrides::default(), + figure, + viewport, + ))); + app.session.active_canvas = Some(0); + let screen = egui::Rect::from_min_size(Pos2::ZERO, Vec2::new(400., 300.)); + let plot = plot_inner_rect(&app, 0, PLOT_ID, screen).unwrap(); + let pointer = Pos2::new(plot.left + plot.width * 0.5, plot.top + plot.height * 0.5); + let ctx = egui::Context::default(); + for _ in 0..2 { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |_| {}, + ); + } + (app, ctx, screen, pointer) +} + +fn wheel( + app: &mut PlotiumApp, + ctx: &egui::Context, + screen: egui::Rect, + pointer: Pos2, + time: f64, + modifiers: egui::Modifiers, +) { + let input = egui::RawInput { + screen_rect: Some(screen), + time: Some(time), + modifiers, + events: vec![ + egui::Event::PointerMoved(pointer), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::new(0., 80.), + modifiers, + phase: egui::TouchPhase::Move, + }, + ], + ..Default::default() + }; + let _ = ctx.run_ui(input, |ui| { + assert!(handle_navigation(app, 0, screen, ui)); + }); +} + +#[test] +fn wheel_amplifies_signed_stack_without_moving_baselines_and_coalesces_undo() { + let (mut app, ctx, screen, pointer) = gain_fixture(); + let before = app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .clone(); + let before_rect = plot_inner_rect(&app, 0, PLOT_ID, screen).unwrap(); + for time in [0.1, 0.12, 0.14] { + wheel(&mut app, &ctx, screen, pointer, time, egui::Modifiers::NONE); + } + let plot = app.doc.canvases[0].object(PLOT_ID).unwrap().plot().unwrap(); + let gain = plot.viewport.display_gain; + assert!(gain > 1.); + assert_eq!(plot.viewport.view_x, before.viewport.view_x); + assert_eq!(plot.viewport.view_y, before.viewport.view_y); + let after_rect = plot_inner_rect(&app, 0, PLOT_ID, screen).unwrap(); + for (series, old) in plot.figure().series.iter().zip(&before.figure().series) { + assert_eq!( + to_screen_y(series.display_baseline, after_rect, &plot.figure().y), + to_screen_y(old.display_baseline, before_rect, &before.figure().y) + ); + for (point, old_point) in series.points.iter().zip(&old.points) { + assert!( + (point[1] - (old.display_baseline + gain * (old_point[1] - old.display_baseline))) + .abs() + < 1e-10 + ); + } + } + assert!(app.session.undo_stack.is_empty()); + app.finish_pending_wheel_zoom(0.5, false); + assert_eq!(app.session.undo_stack.len(), 1); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .viewport, + before.viewport + ); + app.redo(); + assert_eq!( + app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .viewport + .display_gain, + gain + ); +} + +#[test] +fn axis_wheel_and_command_wheel_keep_coordinate_navigation() { + let (mut app, ctx, screen, pointer) = gain_fixture(); + let (_, outer, plot) = plot_under_cursor(&app, 0, screen, pointer).unwrap(); + let axis = Pos2::new(pointer.x, (plot.bottom() + outer.bottom()) * 0.5); + wheel(&mut app, &ctx, screen, axis, 0.1, egui::Modifiers::NONE); + let plot = app.doc.canvases[0].object(PLOT_ID).unwrap().plot().unwrap(); + assert_eq!(plot.viewport.display_gain, 1.); + assert!(plot.viewport.view_x.span() < plot.viewport.full_x.span()); + let old_zoom = app.session.board.zoom; + wheel( + &mut app, + &ctx, + screen, + pointer, + 0.2, + egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }, + ); + assert_ne!(app.session.board.zoom, old_zoom); + assert_eq!( + app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .viewport + .display_gain, + 1. + ); +} + +#[test] +fn locked_plot_refuses_peak_height_changes() { + let (mut app, ctx, screen, pointer) = gain_fixture(); + app.doc.canvases[0].object_mut(PLOT_ID).unwrap().locked = true; + wheel(&mut app, &ctx, screen, pointer, 0.1, egui::Modifiers::NONE); + assert_eq!( + app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .viewport + .display_gain, + 1. + ); + assert!(app.session.undo_stack.is_empty()); +} + +#[test] +fn reset_peak_height_is_independent_and_undoable() { + let (mut app, ctx, screen, pointer) = gain_fixture(); + wheel(&mut app, &ctx, screen, pointer, 0.1, egui::Modifiers::NONE); + app.finish_pending_wheel_zoom(0.5, true); + let before = app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .viewport + .clone(); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::ResetPeakHeight, + &mut app, + &ctx, + ); + let plot = app.doc.canvases[0].object(PLOT_ID).unwrap().plot().unwrap(); + assert_eq!(plot.viewport.display_gain, 1.); + assert_eq!(plot.viewport.view_x, before.view_x); + assert_eq!(plot.viewport.view_y, before.view_y); + app.undo(); + assert_eq!( + app.doc.canvases[0] + .object(PLOT_ID) + .unwrap() + .plot() + .unwrap() + .viewport, + before + ); +} diff --git a/crates/app/src/ui/canvas/navigation_tests.rs b/crates/app/src/ui/canvas/navigation_tests.rs index 764afd17..b333c9a9 100644 --- a/crates/app/src/ui/canvas/navigation_tests.rs +++ b/crates/app/src/ui/canvas/navigation_tests.rs @@ -9,6 +9,7 @@ fn zoomed_plot_fixture() -> (PlotiumApp, ObjectId, PlotRect) { let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 120.0]); let mut figure = Figure::new("plot", Axis::new("x", 0.0, 10.0), Axis::new("y", 0.0, 10.0)); let viewport = CanvasViewport { + display_gain: 1.0, full_x: AxisRange::new(0.0, 10.0), full_y: AxisRange::new(0.0, 10.0), view_x: AxisRange::new(2.0, 8.0), @@ -536,3 +537,6 @@ fn consecutive_plot_pans_coalesce_into_one_undo_action() { assert_eq!(app.session.undo_stack.len(), 1); assert!(app.session.ui.wheel_zoom.is_none()); } + +#[path = "navigation_gain_tests.rs"] +mod gain_tests; diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 58cf1dda..a1f52e29 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -490,7 +490,7 @@ pub(crate) fn paint_peaks( } _ => None, }; - let sy = |v: f64| to_screen_y(v, plot, &fig.y); + let sy = |v: f64| to_screen_y(v * fig.display_gain, plot, &fig.y); let sx = |v: f64| to_screen_x(v, plot, &fig.x); // Confine every marker, ring, line and preview to the plot box so nothing spills // into the axes or margins when the view is zoomed in. diff --git a/crates/app/src/ui/canvas/peaks.rs b/crates/app/src/ui/canvas/peaks.rs index 93af5d59..7a607454 100644 --- a/crates/app/src/ui/canvas/peaks.rs +++ b/crates/app/src/ui/canvas/peaks.rs @@ -55,6 +55,7 @@ fn threshold_y(peaks: &PeakSet, trace: &Trace1d) -> f64 { } struct Screen { + gain: f64, plot: PlotRect, x_axis: plotium_figure::Axis, y_axis: plotium_figure::Axis, @@ -65,13 +66,13 @@ impl Screen { to_screen_x(x, self.plot, &self.x_axis) } fn y(&self, y: f64) -> f32 { - to_screen_y(y, self.plot, &self.y_axis) + to_screen_y(y * self.gain, self.plot, &self.y_axis) } fn to_x(&self, px: f32) -> f64 { from_screen_x(px, self.plot, &self.x_axis) } fn to_y(&self, py: f32) -> f64 { - from_screen_y(py, self.plot, &self.y_axis) + from_screen_y(py, self.plot, &self.y_axis) / self.gain } } @@ -110,6 +111,7 @@ pub(crate) fn handle_peaks( .figure() .clone(); let sc = Screen { + gain: fig.display_gain, plot, x_axis: fig.x, y_axis: fig.y, diff --git a/crates/app/src/ui/canvas/readout.rs b/crates/app/src/ui/canvas/readout.rs index 0194a383..190f146e 100644 --- a/crates/app/src/ui/canvas/readout.rs +++ b/crates/app/src/ui/canvas/readout.rs @@ -10,8 +10,8 @@ //! anything. A plot whose estimate has not arrived says so. use super::{ - HitZone, content_screen_rect, hit_zone, last_wheel_was_trackpad, plot_rect, plot_under_cursor, - selected_plot_body, + HitZone, content_screen_rect, hit_zone, last_wheel_was_trackpad, peak_height_label, plot_rect, + plot_under_cursor, selected_plot_body, supports_peak_height, }; use crate::ui::properties; use egui::{Align2, Color32, FontId}; @@ -150,19 +150,30 @@ pub(crate) fn paint_wheel_target_hint( ) }) }); - let navigation = if two_dimensional { + let peak_height = supports_peak_height(app, ci, object); + let navigation = if peak_height { + "Scroll: peak height · Baselines fixed" + } else if two_dimensional { "Scroll/pinch: zoom X+Y" } else { "Scroll: zoom X · Pinch: zoom X+Y" }; - let display = match canvas_step_target(app, ci, object) { - CanvasStepTarget::Unique { label, targets, .. } => { - format!("Alt+scroll: {label} ({} series)", targets.len()) - } - CanvasStepTarget::Ambiguous { labels } => { - format!("Alt+scroll: choose layer ({})", labels.join(" / ")) + let display = if peak_height { + let gain = app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .map_or(1.0, |plot| plot.viewport.display_gain); + format!("{} · View > Reset Peak Height", peak_height_label(gain)) + } else { + match canvas_step_target(app, ci, object) { + CanvasStepTarget::Unique { label, targets, .. } => { + format!("Alt+scroll: {label} ({} series)", targets.len()) + } + CanvasStepTarget::Ambiguous { labels } => { + format!("Alt+scroll: choose layer ({})", labels.join(" / ")) + } + CanvasStepTarget::None => "Alt+scroll: Y intensity".to_owned(), } - CanvasStepTarget::None => "Alt+scroll: Y intensity".to_owned(), }; format!( "{navigation} · {display} · Alt+drag: box zoom{}", diff --git a/crates/app/src/ui/canvas/view_fit.rs b/crates/app/src/ui/canvas/view_fit.rs index b791cf81..bd204ca0 100644 --- a/crates/app/src/ui/canvas/view_fit.rs +++ b/crates/app/src/ui/canvas/view_fit.rs @@ -80,6 +80,9 @@ pub(crate) fn fit_plot_viewport( ctx: &egui::Context, axes: PlotFitAxes, ) -> bool { + let now = ctx.input(|input| input.time); + app.finish_pending_wheel_zoom(now, true); + app.finish_pending_wheel_property(now, true); let Some((ci, object_id)) = fit_target(app, ctx) else { return false; }; @@ -99,6 +102,46 @@ pub(crate) fn fit_plot_viewport( true } +pub(crate) fn can_reset_peak_height(app: &PlotiumApp) -> bool { + let Some(ci) = app.session.active_canvas else { + return false; + }; + let Some(id) = app + .doc + .canvases + .get(ci) + .and_then(|canvas| canvas.active_plot_object_id()) + else { + return false; + }; + app.doc.canvases[ci] + .object(id) + .is_some_and(|object| !object.locked) + && supports_peak_height(app, ci, id) +} + +pub(crate) fn reset_peak_height(app: &mut PlotiumApp, ctx: &egui::Context) { + let Some(ci) = app.session.active_canvas else { + return; + }; + let Some(id) = app.doc.canvases[ci].active_plot_object_id() else { + return; + }; + let now = ctx.input(|input| input.time); + app.finish_pending_wheel_zoom(now, true); + app.finish_pending_wheel_property(now, true); + let before = app.doc.canvases[ci] + .object(id) + .and_then(|object| object.plot()) + .expect("the command requires an active plot") + .viewport + .clone(); + let mut after = before.clone(); + after.display_gain = 1.0; + app.commit_object_viewport(ci, id, before, after); + app.session.status = "Reset peak height to ×1; axis ranges unchanged.".into(); +} + #[cfg(test)] mod tests { use super::*; @@ -130,6 +173,7 @@ mod tests { ], )); let viewport = CanvasViewport { + display_gain: 1.0, full_x: AxisRange::new(0.0, 10.0), full_y: AxisRange::new(-1.0, 100.0), view_x: AxisRange::new(2.0, 8.0), diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 0c618eb0..969bafb4 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -166,6 +166,9 @@ fn execute_inner( _ => format!("Fit {count} selected frames to view."), }; } + CommandId::ResetPeakHeight => { + super::canvas::reset_peak_height(app, ctx); + } CommandId::FitPlotY => { if super::canvas::fit_plot_viewport(app, ctx, super::canvas::PlotFitAxes::Y) { app.session.status = "Fit intensity to the visible window.".into(); diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 2a78cbfa..1eae0a98 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -82,6 +82,7 @@ pub enum CommandId { ZoomToSelection, FitPlotY, FitPlotXY, + ResetPeakHeight, UiScaleUp, UiScaleDown, UiScaleReset, @@ -412,6 +413,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { has_active_plot(app), "Plot a dataset on the canvas before fitting its data view.", ), + CommandId::ResetPeakHeight => helpers::peak_height_gate(app), CommandId::ZoomToSelection => { requires(has_canvas, "Open a canvas before showing the selection.") } diff --git a/crates/app/src/ui/commands/helpers.rs b/crates/app/src/ui/commands/helpers.rs index e90d338d..a03b883f 100644 --- a/crates/app/src/ui/commands/helpers.rs +++ b/crates/app/src/ui/commands/helpers.rs @@ -106,3 +106,10 @@ pub(super) fn tool_commands() -> [Tool; 17] { Tool::Arrow, ] } + +pub(super) fn peak_height_gate(app: &PlotiumApp) -> Result<(), &'static str> { + requires( + crate::ui::canvas::can_reset_peak_height(app), + "Select an unlocked numeric trace plot before resetting peak height.", + ) +} diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index a56acd77..066061ad 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -150,6 +150,9 @@ pub(super) fn command_identity( ), CommandId::ZoomToFit => ("Show All".into(), Some(icon::ARROWS_OUT), None), CommandId::ZoomToSelection => ("Show Selection".into(), Some(icon::CORNERS_OUT), None), + CommandId::ResetPeakHeight => { + plain("Reset Peak Height", Some(icon::ARROWS_COUNTER_CLOCKWISE)) + } CommandId::FitPlotY => ( "Fit Plot Vertically".into(), Some(icon::ARROWS_VERTICAL), @@ -489,6 +492,7 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::ZoomToSelection => "view.zoom_selection", CommandId::FitPlotY => "view.fit_plot_y", CommandId::FitPlotXY => "view.fit_plot_xy", + CommandId::ResetPeakHeight => "view.reset_peak_height", CommandId::UiScaleUp => "view.ui_scale_up", CommandId::UiScaleDown => "view.ui_scale_down", CommandId::UiScaleReset => "view.ui_scale_reset", diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index 7906d37e..b810dab2 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -65,6 +65,7 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::ZoomToSelection, CommandId::FitPlotY, CommandId::FitPlotXY, + CommandId::ResetPeakHeight, CommandId::UiScaleUp, CommandId::UiScaleDown, CommandId::UiScaleReset, diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 16b2f02d..e1301a0f 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -144,6 +144,7 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Command(CommandId::ZoomToSelection), Command(CommandId::FitPlotY), Command(CommandId::FitPlotXY), + Command(CommandId::ResetPeakHeight), Command(CommandId::Present), Separator, Command(CommandId::UiScaleUp), diff --git a/crates/core/src/automation/diagnostics/hints_tests.rs b/crates/core/src/automation/diagnostics/hints_tests.rs index 3bc26f3f..2efa886d 100644 --- a/crates/core/src/automation/diagnostics/hints_tests.rs +++ b/crates/core/src/automation/diagnostics/hints_tests.rs @@ -330,6 +330,7 @@ fn text_on_a_bar_is_text_over_data() { let canvas = &app.doc.canvases[0]; let mut figure = canvas.objects[0].plot().unwrap().figure().clone(); figure.annotations.push(plotium_figure::Annotation { + display_baseline: 0.0, text: "inside".into(), at: [0.0, 1.0], size: 7., diff --git a/crates/core/src/automation/diagnostics/tests.rs b/crates/core/src/automation/diagnostics/tests.rs index e8e168e2..af8d5449 100644 --- a/crates/core/src/automation/diagnostics/tests.rs +++ b/crates/core/src/automation/diagnostics/tests.rs @@ -129,6 +129,7 @@ fn missing_data_and_unsupported_native_geometry_cannot_claim_clean() { plotium_figure::Axis::new("", 0., 1.), ); f.annotations.push(plotium_figure::Annotation { + display_baseline: 0.0, text: "derived".into(), at: [0.5, 0.5], size: 8., diff --git a/crates/core/src/figures.rs b/crates/core/src/figures.rs index 3741e67d..af12df14 100644 --- a/crates/core/src/figures.rs +++ b/crates/core/src/figures.rs @@ -75,6 +75,7 @@ pub fn build_processed_1d_figure( pub fn apply_peak_labels(mut fig: Figure, peaks: &[ResolvedPeak]) -> Figure { for peak in peaks { fig = fig.with_annotation(Annotation { + display_baseline: 0.0, text: peak.label.clone(), at: [peak.x, peak.y], color: Color::rgb(0x8a, 0x1c, 0x1c), @@ -225,7 +226,9 @@ pub fn build_stack_figure(stack: &StackSpectrum) -> Figure { .zip(&stack.traces[i]) .map(|(&p, c)| [p, c.re + offset]) .collect(); - fig = fig.with_series(Series::line(format!("{i}"), pts).colored(Color::TRACE)); + let mut series = Series::line(format!("{i}"), pts).colored(Color::TRACE); + series.display_baseline = offset; + fig = fig.with_series(series); } fig } diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index e9202b49..f86f98ae 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -611,6 +611,19 @@ pub fn view_to_canvas( .as_ref() .map(ViewportDto::to_viewport) .unwrap_or_else(|| CanvasViewport::from_figure(&figure)); + viewport.reset_unsupported_gain(&figure); + figure + .check_display_gain(viewport.display_gain) + .map_err(|error| { + ProjectError::Invalid(format!("Invalid plot display gain: {error}")) + })?; + if snapshot_backed { + figure + .set_display_gain(viewport.display_gain) + .map_err(|error| { + ProjectError::Invalid(format!("Invalid plot display gain: {error}")) + })?; + } if !snapshot_backed { if axis_overrides.y_range.is_some() && figure.y.categories.is_none() { viewport.auto_y = false; diff --git a/crates/core/src/project/display_gain_tests.rs b/crates/core/src/project/display_gain_tests.rs new file mode 100644 index 00000000..ced71f1d --- /dev/null +++ b/crates/core/src/project/display_gain_tests.rs @@ -0,0 +1,86 @@ +use super::tests::{first_plot, first_plot_mut, synthetic_dosy_2d, temp_project}; +use super::*; + +#[test] +fn stack_display_gain_survives_rebuild_undo_and_both_project_save_modes() { + let mut app = PlotiumApp::new(); + app.doc.datasets.push(Dataset::Nmr2D(Box::new( + Nmr2DDataset::load(synthetic_dosy_2d()).unwrap(), + ))); + let mut canvas = CanvasDocument::new("stack".into(), [120., 80.]); + let id = canvas.allocate_object_id(); + canvas.objects.push(app.build_plot_object( + 0, + ObjectFrame::new(0., 0., 340., 226.), + id, + "Stack".into(), + )); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + let raw = stack_traces(&app); + let original = first_plot(&app).figure().clone(); + assert!(original.series.len() > 1); + assert!(original.series[1].display_baseline > 0.); + let before = first_plot(&app).viewport.clone(); + let mut after = before.clone(); + after.auto_y = false; + after.display_gain = 2.; + after.view_x = AxisRange::new(original.x.min, original.x.max); + after.view_y = AxisRange::new(original.y.min, original.y.max); + let frozen = after.clone(); + app.commit_object_viewport(0, id, before.clone(), after); + assert_amplified(first_plot(&app).figure(), &original); + app.undo(); + assert_eq!(first_plot(&app).viewport, before); + app.redo(); + app.rebuild_canvas(0); + assert_eq!(first_plot(&app).viewport.view_x, frozen.view_x); + assert_eq!(first_plot(&app).viewport.view_y, frozen.view_y); + assert_amplified(first_plot(&app).figure(), &original); + assert_eq!(stack_traces(&app), raw); + + for snapshots in [false, true] { + let path = temp_project(if snapshots { + "gain_snapshot" + } else { + "gain_recipe" + }); + save_project(&app, &path, snapshots).unwrap(); + let mut loaded = load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(first_plot(&loaded).viewport.display_gain, 2.); + assert_amplified(first_plot(&loaded).figure(), &original); + loaded.rebuild_canvas(0); + assert_amplified(first_plot(&loaded).figure(), &original); + let plot = first_plot_mut(&mut loaded); + plot.viewport.display_gain = 1.; + plot.apply_viewport(); + for (actual, expected) in plot.figure().series.iter().zip(&original.series) { + for (a, b) in actual.points.iter().zip(&expected.points) { + assert!((a[1] - b[1]).abs() < 1e-10); + } + } + } +} + +fn assert_amplified(actual: &plotium_figure::Figure, original: &plotium_figure::Figure) { + assert_eq!(actual.series.len(), original.series.len()); + for (actual, original) in actual.series.iter().zip(&original.series) { + assert_eq!(actual.display_baseline, original.display_baseline); + for (a, b) in actual.points.iter().zip(&original.points) { + assert_eq!(a[0], b[0]); + assert!( + (a[1] - (original.display_baseline + 2. * (b[1] - original.display_baseline))) + .abs() + < 1e-10 + ); + } + } +} + +fn stack_traces(app: &PlotiumApp) -> Vec> { + match &app.doc.datasets[0].as_nmr2d().unwrap().processed { + plotium_processing::Processed2D::Stack(stack) => stack.traces.clone(), + _ => panic!("expected a processed spectrum stack"), + } +} diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index e55fa25a..3fc62221 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -674,6 +674,8 @@ pub struct RangeDto { #[derive(Serialize, Deserialize)] pub struct ViewportDto { + #[serde(default = "unit_display_gain")] + pub display_gain: f64, pub full_x: RangeDto, pub full_y: RangeDto, pub view_x: RangeDto, @@ -695,9 +697,14 @@ pub struct SelectionDto { pub y_range: Option, } +fn unit_display_gain() -> f64 { + 1.0 +} + impl ViewportDto { pub fn from_viewport(v: &CanvasViewport) -> Self { Self { + display_gain: v.display_gain, full_x: RangeDto::from_range(v.full_x), full_y: RangeDto::from_range(v.full_y), view_x: RangeDto::from_range(v.view_x), @@ -710,6 +717,7 @@ impl ViewportDto { pub fn to_viewport(&self) -> CanvasViewport { CanvasViewport { + display_gain: self.display_gain, full_x: self.full_x.into_range(), full_y: self.full_y.into_range(), view_x: self.view_x.into_range(), diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index da3795e4..f3285f1a 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -754,3 +754,6 @@ mod xrd_tests; #[cfg(test)] mod save_boundary_tests; + +#[cfg(test)] +mod display_gain_tests; diff --git a/crates/core/src/project/render_provenance.rs b/crates/core/src/project/render_provenance.rs index 0c6d6e16..50ea885c 100644 --- a/crates/core/src/project/render_provenance.rs +++ b/crates/core/src/project/render_provenance.rs @@ -6,6 +6,20 @@ use plotium_figure::{Figure, Polygon, Series}; /// by names, matching individual points, or an unvalidated collection position. /// A diverged snapshot remains viewable but has explicitly unverified provenance. pub(super) fn restore(snapshot: &mut Figure, rebuilt: &Figure) { + let amplified; + let rebuilt = if snapshot.display_gain != rebuilt.display_gain { + amplified = { + let mut figure = rebuilt.clone(); + if let Err(error) = figure.set_display_gain(snapshot.display_gain) { + eprintln!("Could not restore amplified series provenance: {error}"); + return; + } + figure + }; + &lified + } else { + rebuilt + }; if snapshot.polygons.len() == rebuilt.polygons.len() && snapshot .polygons @@ -26,6 +40,7 @@ pub(super) fn restore(snapshot: &mut Figure, rebuilt: &Figure) { { return; } + snapshot.inherit_display_gain_sources(rebuilt); for (saved, current) in snapshot.series.iter_mut().zip(&rebuilt.series) { saved.origin = current.origin; } @@ -35,6 +50,7 @@ fn same_mark(a: &Polygon, b: &Polygon) -> bool { } fn same_run(a: &Series, b: &Series) -> bool { a.points == b.points + && a.display_baseline == b.display_baseline && a.name == b.name && a.color == b.color && a.width == b.width @@ -68,4 +84,23 @@ mod tests { restore(&mut saved, &native); assert!(saved.series.iter().all(|s| s.origin.is_none())); } + + #[test] + fn restores_origins_after_repeated_display_gain_steps() { + let mut native = Figure::new("", Axis::new("", 0., 1.), Axis::new("", 0., 20.)); + let mut series = Series::line("trace", vec![[0., 10.], [1., 10.123456789]]); + series.display_baseline = 10.; + series.origin = Some(SeriesOrigin(7)); + native.series.push(series); + let mut displayed = native.clone(); + for gain in [1.13, 2.63, 0.00001, 3.27] { + displayed.set_display_gain(gain).unwrap(); + } + let mut saved: Figure = + serde_json::from_value(serde_json::to_value(&displayed).unwrap()).unwrap(); + restore(&mut saved, &native); + assert_eq!(saved.series[0].origin, Some(SeriesOrigin(7))); + saved.set_display_gain(1.).unwrap(); + assert_eq!(saved.series[0].points, native.series[0].points); + } } diff --git a/crates/core/src/properties/axis_scale_tests.rs b/crates/core/src/properties/axis_scale_tests.rs index 29c9970f..45f02da5 100644 --- a/crates/core/src/properties/axis_scale_tests.rs +++ b/crates/core/src/properties/axis_scale_tests.rs @@ -137,6 +137,30 @@ fn a_non_positive_manual_range_is_rejected_on_a_log_axis() { assert_eq!((y.min, y.max, y.scale), (0.5, 50.0, AxisScale::Log10)); } +#[test] +fn switching_to_log_y_clears_display_gain() { + let (mut app, target, id) = line_app(); + let original = figure(&app, id).series[0].points.clone(); + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.viewport.auto_y = false; + plot.viewport.display_gain = 2.; + plot.apply_viewport(); + assert_ne!(figure(&app, id).series[0].points, original); + write( + &mut app, + &target, + axis::Y_SCALE, + PropertyValue::Enum(axis::LOG), + ); + let plot = app.doc.canvases[0].objects[0].plot().unwrap(); + assert_eq!(plot.viewport.display_gain, 1.); + assert_eq!(plot.figure().display_gain, 1.); + assert_eq!(plot.figure().series[0].points, original); + app.undo(); + assert_eq!(figure(&app, id).display_gain, 1.); + assert_eq!(figure(&app, id).series[0].points, original); +} + #[test] fn switching_to_log_over_a_non_positive_manual_range_is_rejected() { let (mut app, target, _) = line_app(); diff --git a/crates/core/src/state/app_impl_figures.rs b/crates/core/src/state/app_impl_figures.rs index 2c62a722..56538fd0 100644 --- a/crates/core/src/state/app_impl_figures.rs +++ b/crates/core/src/state/app_impl_figures.rs @@ -717,6 +717,7 @@ fn unsupported_series_figure(series: &SeriesBinding) -> Figure { plotium_figure::Axis::new("y", 0.0, 1.0), ); figure.annotations.push(plotium_figure::Annotation { + display_baseline: 0.0, text: format!( "The selected field cannot be rendered with {}.", match series.encoding { diff --git a/crates/core/src/state/axis_overrides.rs b/crates/core/src/state/axis_overrides.rs index b8e6d1dd..56ad8a01 100644 --- a/crates/core/src/state/axis_overrides.rs +++ b/crates/core/src/state/axis_overrides.rs @@ -68,6 +68,8 @@ impl AxisRange { #[derive(Clone, Debug, PartialEq)] pub struct CanvasViewport { + /// Display-only trace amplification; scientific observations stay unchanged. + pub display_gain: f64, pub full_x: AxisRange, pub full_y: AxisRange, pub view_x: AxisRange, @@ -88,6 +90,7 @@ impl CanvasViewport { let full_y = AxisRange::from_axis(&fig.y); let full_y2 = fig.y2.as_ref().map(AxisRange::from_axis); Self { + display_gain: fig.display_gain, full_x, full_y, view_x: full_x, @@ -100,18 +103,33 @@ impl CanvasViewport { } pub fn sync_full_from(&mut self, fig: &Figure) { + self.reset_unsupported_gain(fig); + let keep_x = self.display_gain != 1.0 && self.full_x == AxisRange::from_axis(&fig.x); + let keep_y = self.display_gain != 1.0 && self.full_y == AxisRange::from_axis(&fig.y); self.full_x = AxisRange::from_axis(&fig.x); self.full_y = AxisRange::from_axis(&fig.y); - self.view_x = self.view_x.clamp_in(self.full_x, fig.x.scale); + // A frozen gain window may include automatic padding or legend room. + // Rebuilding unchanged data must not trim that room and move baselines. + if !keep_x { + self.view_x = self.view_x.clamp_in(self.full_x, fig.x.scale); + } self.sync_full_y2(fig); if self.auto_y { self.refresh_auto_y(fig); - } else { + } else if !keep_y { self.view_y = self.view_y.clamp_in(self.full_y, fig.y.scale); } } pub fn apply_to(&self, fig: &mut Figure) { + let gain = if fig.supports_display_gain() { + self.display_gain + } else { + 1.0 + }; + if let Err(error) = fig.set_display_gain(gain) { + eprintln!("Could not apply plot display gain: {error}"); + } fig.x.min = self.view_x.min; fig.x.max = self.view_x.max; fig.y.min = self.view_y.min; @@ -122,7 +140,14 @@ impl CanvasViewport { } } + pub(crate) fn reset_unsupported_gain(&mut self, fig: &Figure) { + if !fig.supports_display_gain() { + self.display_gain = 1.0; + } + } + pub fn reset_all(&mut self) { + self.display_gain = 1.0; self.view_x = self.full_x; self.view_y = self.full_y; self.view_y2 = self.full_y2; @@ -137,8 +162,17 @@ impl CanvasViewport { } pub fn reset_y(&mut self, fig: &Figure) { + self.display_gain = 1.0; self.auto_y = true; - self.refresh_auto_y(fig); + if fig.display_gain != 1.0 { + let mut original = fig.clone(); + match original.set_display_gain(1.0) { + Ok(()) => self.refresh_auto_y(&original), + Err(error) => eprintln!("Could not reset plot display gain: {error}"), + } + } else { + self.refresh_auto_y(fig); + } } pub fn zoom_x(&mut self, fig: &Figure, anchor: f64, scale: f64) { diff --git a/crates/core/src/state/category_chart_tests.rs b/crates/core/src/state/category_chart_tests.rs index 2b1b44fd..9ad5531f 100644 --- a/crates/core/src/state/category_chart_tests.rs +++ b/crates/core/src/state/category_chart_tests.rs @@ -409,3 +409,64 @@ fn category_chart_settings_survive_save_and_load() { }; assert_eq!(encodings(after), encodings(before)); } + +#[test] +fn offset_numeric_value_labels_stay_on_their_traces_after_gain_and_project_restore() { + let mut app = fixture(); + let summary = create(&mut app, |app| { + vec![ + curve(CurveKind::Line, source(app, Y2, Y, Some(ERROR))), + curve(CurveKind::Scatter, source(app, Y2, Y, Some(ERROR))), + ] + }); + for series in summary.series_defaults { + set(&mut app, &series.target, "series.value_labels", json!(true)); + } + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.stack.mode = StackMode::Offset; + plot.stack.spacing_y = 1.; + app.rebuild_canvas(0); + let original = figure(&app).clone(); + assert!(original.supports_display_gain()); + assert!(original.series[1].display_baseline > 0.); + let check_labels = |figure: &plotium_figure::Figure| { + let mut labels = figure.annotations.iter(); + for series in &figure.series { + for point in &series.points { + let label = labels.next().unwrap(); + assert_eq!(label.display_baseline, series.display_baseline); + assert_eq!(label.at, *point); + } + } + assert!(labels.next().is_none()); + }; + check_labels(&original); + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.viewport.auto_y = false; + plot.viewport.display_gain = 2.; + plot.apply_viewport(); + check_labels(figure(&app)); + for snapshots in [false, true] { + let path = std::env::temp_dir().join(format!( + "plotium-stacked-labels-{}.plotium", + uuid::Uuid::new_v4() + )); + crate::project::save_project(&app, &path, snapshots).unwrap(); + let mut loaded = crate::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(figure(&loaded).display_gain, 2.); + check_labels(figure(&loaded)); + let plot = loaded.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.viewport.display_gain = 1.; + plot.apply_viewport(); + check_labels(figure(&loaded)); + for (actual, expected) in figure(&loaded) + .annotations + .iter() + .zip(&original.annotations) + { + assert_eq!(actual.at, expected.at); + assert_eq!(actual.text, expected.text); + } + } +} diff --git a/crates/core/src/state/curve_extract.rs b/crates/core/src/state/curve_extract.rs index 5d0e4c8e..024903fa 100644 --- a/crates/core/src/state/curve_extract.rs +++ b/crates/core/src/state/curve_extract.rs @@ -374,6 +374,7 @@ impl PlotiumApp { 0.0 }; for series in &mut part.series { + series.display_baseline += yoff; if stack.active == Some(index) { series.width = series.width.max(1.0) * 2.0; } @@ -391,6 +392,7 @@ impl PlotiumApp { } } for e in &mut part.error_bars { + e.display_baseline += yoff; e.center[0] += xoff; e.center[1] += yoff; if stack.active == Some(index) { diff --git a/crates/core/src/state/curve_layout.rs b/crates/core/src/state/curve_layout.rs index 5f4d037c..db48bc18 100644 --- a/crates/core/src/state/curve_layout.rs +++ b/crates/core/src/state/curve_layout.rs @@ -212,6 +212,7 @@ fn lower_bar( figure.annotations.push(value_label( y - bar.baseline, [x + dx, end], + bar.baseline, anchor, label, )); @@ -337,6 +338,7 @@ fn lower_overlay( figure.annotations.push(value_label( point[1] - overlay.baseline, *point, + series.display_baseline, TextAnchor::Above, label, )); @@ -355,10 +357,17 @@ fn lower_overlay( Ok(()) } -fn value_label(value: f64, at: [f64; 2], anchor: TextAnchor, style: LabelStyle) -> Annotation { +fn value_label( + value: f64, + at: [f64; 2], + display_baseline: f64, + anchor: TextAnchor, + style: LabelStyle, +) -> Annotation { Annotation { text: format_value(value), at, + display_baseline, color: style.color, size: style.size, anchor, diff --git a/crates/core/src/state/plot_annotations.rs b/crates/core/src/state/plot_annotations.rs index 8ca222ce..6974cb01 100644 --- a/crates/core/src/state/plot_annotations.rs +++ b/crates/core/src/state/plot_annotations.rs @@ -40,6 +40,7 @@ impl PlotAnnotation { fn lower(&self) -> Annotation { Annotation { + display_baseline: 0.0, text: self.text.clone(), at: self.at, color: self.color, diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index ae5f50f8..31bbe30a 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -338,7 +338,12 @@ impl PlotObject { } pub fn apply_viewport(&mut self) { + let old_gain = self.figure.display_gain; + self.viewport.reset_unsupported_gain(&self.figure); self.viewport.apply_to(&mut self.figure); + if old_gain != self.figure.display_gain { + self.figure_geometry_generation = next_figure_geometry_generation(); + } self.make_legend_room(); } @@ -348,6 +353,9 @@ impl PlotObject { /// lives only in the figure and is recomputed from the viewport each time. fn make_legend_room(&mut self) { let viewport = &self.viewport; + if viewport.display_gain != 1.0 { + return; + } let full_x = viewport.view_x == viewport.full_x; let grow_x = self.axis_overrides.x_range.is_none() && full_x; let grow_y = self.axis_overrides.y_range.is_none() @@ -444,6 +452,7 @@ impl PlotObject { } pub(crate) fn normalize_viewport(&self, viewport: &mut CanvasViewport) { + viewport.reset_unsupported_gain(&self.figure); if self.has_manual_y_range(&self.figure) && viewport.auto_y { viewport.view_y = viewport.full_y; viewport.auto_y = false; diff --git a/crates/core/src/state/right_axis_tests.rs b/crates/core/src/state/right_axis_tests.rs index 37f4af03..a117836d 100644 --- a/crates/core/src/state/right_axis_tests.rs +++ b/crates/core/src/state/right_axis_tests.rs @@ -342,6 +342,16 @@ fn moving_a_unitless_curve_right_fixes_the_right_axis_unit_once() { ]; create(&mut app, curves).unwrap(); let object_id = app.doc.canvases[0].objects[0].id; + let original = plot(&app).figure().clone(); + let amplified = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + amplified.viewport.auto_y = false; + amplified.viewport.display_gain = 2.; + amplified.apply_viewport(); + assert_eq!(plot(&app).figure().display_gain, 2.); + assert_ne!( + plot(&app).figure().series[0].points, + original.series[0].points + ); let second = app.series_targets(0, object_id)[1].clone(); write( &mut app, @@ -352,6 +362,12 @@ fn moving_a_unitless_curve_right_fixes_the_right_axis_unit_once() { let units = plot(&app).binding.coordinates.as_ref().unwrap(); assert_eq!(units.y2.as_ref().unwrap().display_unit, "%"); let figure = plot(&app).figure(); + assert_eq!(plot(&app).viewport.display_gain, 1.); + assert_eq!(figure.display_gain, 1.); + assert!(!figure.supports_display_gain()); + for (actual, expected) in figure.series.iter().zip(&original.series) { + assert_eq!(actual.points, expected.points); + } assert_eq!(figure.y2.as_ref().unwrap().label, "bars (%)"); assert!(figure.series[1].appearance.y_axis.is_right()); @@ -363,6 +379,31 @@ fn moving_a_unitless_curve_right_fixes_the_right_axis_unit_once() { PropertyValue::Enum(line_display::LEFT), ); assert!(plot(&app).figure().y2.is_none(), "the right axis goes away"); + assert_eq!(plot(&app).viewport.display_gain, 1.); + assert_eq!(plot(&app).figure().display_gain, 1.); + app.undo(); + assert!(plot(&app).figure().y2.is_some()); + assert_eq!(plot(&app).viewport.display_gain, 1.); + app.redo(); + assert_eq!(plot(&app).viewport.display_gain, 1.); +} + +#[test] +fn applying_a_stale_gain_to_a_two_axis_figure_restores_unamplified_geometry() { + let mut figure = two_axis_figure(); + let original = figure.clone(); + let mut viewport = CanvasViewport::from_figure(&figure); + viewport.display_gain = 2.; + viewport.apply_to(&mut figure); + assert_eq!(figure.display_gain, 2.); + split_y_axes(&mut figure); + viewport.apply_to(&mut figure); + assert_eq!(figure.display_gain, 1.); + for (actual, expected) in figure.series.iter().zip(&original.series) { + assert_eq!(actual.points, expected.points); + } + viewport.sync_full_from(&figure); + assert_eq!(viewport.display_gain, 1.); } #[test] diff --git a/crates/core/src/state/series_binding_style.rs b/crates/core/src/state/series_binding_style.rs index 43732fa4..5ff85ae2 100644 --- a/crates/core/src/state/series_binding_style.rs +++ b/crates/core/src/state/series_binding_style.rs @@ -27,6 +27,7 @@ impl PlotiumApp { series.appearance.marker_fill = None; series.appearance.marker_edge_color = None; } + series.display_baseline *= line.scale; for point in &mut series.points { point[0] += x_shift; point[1] *= line.scale; @@ -38,6 +39,7 @@ impl PlotiumApp { error_bar.color = color; } error_bar.width = line.width.get(); + error_bar.display_baseline *= line.scale; error_bar.center[0] += x_shift; error_bar.center[1] *= line.scale; error_bar.negative *= line.scale.abs(); diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 18331c62..24bc190e 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -181,6 +181,7 @@ impl PlotiumApp { x_min = x_min.min(part_x_bounds[0] + x_off); x_max = x_max.max(part_x_bounds[1] + x_off); for mut s in series.drain(..) { + s.display_baseline += y_off; for p in &mut s.points { p[0] += x_off; p[1] += y_off; @@ -195,6 +196,7 @@ impl PlotiumApp { fig.series.push(s); } for mut error_bar in error_bars.drain(..) { + error_bar.display_baseline += y_off; error_bar.center[0] += x_off; error_bar.center[1] += y_off; if active { diff --git a/crates/core/src/state/table_charts/pie.rs b/crates/core/src/state/table_charts/pie.rs index da2dcc5d..097228cf 100644 --- a/crates/core/src/state/table_charts/pie.rs +++ b/crates/core/src/state/table_charts/pie.rs @@ -79,6 +79,7 @@ pub(crate) fn pie_figure(dataset: &TableDataset, ctx: &ChartContext) -> Figure { let mid = start + sweep * 0.5; let [ax, ay] = arc_point_at(mid, RADIUS * 0.62); fig.annotations.push(Annotation { + display_baseline: 0.0, text: format!("{:.1}%", fraction * 100.0), at: [ax, ay], color: WHITE, diff --git a/crates/core/src/workflow/trace_collection.rs b/crates/core/src/workflow/trace_collection.rs index 736a941f..9183b690 100644 --- a/crates/core/src/workflow/trace_collection.rs +++ b/crates/core/src/workflow/trace_collection.rs @@ -39,6 +39,7 @@ pub(super) fn initial_figure( let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY); for (index, part) in parts.into_iter().enumerate() { for mut series in part.series { + series.display_baseline += index as f64 * StackSpec::default().spacing_y * peak; for point in &mut series.points { point[1] += index as f64 * StackSpec::default().spacing_y * peak; y_min = y_min.min(point[1]); diff --git a/crates/figure/src/display_gain.rs b/crates/figure/src/display_gain.rs new file mode 100644 index 00000000..c84bf69a --- /dev/null +++ b/crates/figure/src/display_gain.rs @@ -0,0 +1,154 @@ +//! Display-only amplification around each trace's own zero-signal baseline. +use crate::{AxisScale, Figure, Series, SeriesKind}; + +pub const MIN_DISPLAY_GAIN: f64 = 1e-6; +pub const MAX_DISPLAY_GAIN: f64 = 1e6; + +pub(crate) fn unit_gain() -> f64 { + 1.0 +} + +impl Figure { + /// Recover runtime source coordinates only after complete geometry equality. + /// Project snapshots carry the materialized geometry, not this cache. + pub fn inherit_display_gain_sources(&mut self, reference: &Self) { + for (series, original) in self.series.iter_mut().zip(&reference.series) { + if series.points == original.points + && series.display_baseline == original.display_baseline + { + series.unamplified_y.clone_from(&original.unamplified_y); + } + } + } + + pub fn supports_display_gain(&self) -> bool { + !self.series.is_empty() + && self.heatmap.is_none() + && self.contours.is_empty() + && self.polygons.is_empty() + && self.y2.is_none() + && self.x.categories.is_none() + && self.y.categories.is_none() + && self.y.scale == AxisScale::Linear + && self.axis_frame != crate::AxisFrame::Hidden + && self.series.iter().all(|s| { + matches!( + s.kind, + SeriesKind::Line | SeriesKind::Points | SeriesKind::Stick + ) + }) + } + + pub fn check_display_gain(&self, gain: f64) -> Result<(), &'static str> { + let valid = |value: f64| { + value.is_finite() && (MIN_DISPLAY_GAIN..=MAX_DISPLAY_GAIN).contains(&value) + }; + if !valid(gain) || !valid(self.display_gain) { + return Err("Display gain is outside the supported range."); + } + let ratio = gain / self.display_gain; + if !ratio.is_finite() + || self.series.iter().any(|series| { + !series.display_baseline.is_finite() + || series + .unamplified_y + .as_ref() + .is_some_and(|ys| ys.len() != series.points.len()) + || series.points.iter().enumerate().any(|(index, point)| { + point[1].is_finite() + && !amplify( + original_y(series, index, self.display_gain), + series.display_baseline, + gain, + ) + .is_finite() + }) + }) + || self.error_bars.iter().any(|error| { + !error.display_baseline.is_finite() + || !(error.display_baseline + + (error.center[1] - error.display_baseline) * ratio) + .is_finite() + || (!error.horizontal + && (!(error.negative * ratio).is_finite() + || !(error.positive * ratio).is_finite())) + }) + || self.annotations.iter().any(|annotation| { + !annotation.display_baseline.is_finite() + || !amplify(annotation.at[1], annotation.display_baseline, ratio).is_finite() + }) + { + return Err("This display gain would exceed the numeric range."); + } + Ok(()) + } + + /// The applied factor travels with materialized figures so restoring a + /// snapshot or applying the same viewport twice never amplifies it twice. + pub fn set_display_gain(&mut self, gain: f64) -> Result<(), &'static str> { + if gain == self.display_gain { + return Ok(()); + } + self.check_display_gain(gain)?; + let ratio = gain / self.display_gain; + for series in &mut self.series { + // Always derive from the same original coordinates. Repeated small + // wheel steps must not accumulate cancellation around large offsets. + if series.unamplified_y.is_none() { + series.unamplified_y = Some(std::sync::Arc::new( + (0..series.points.len()) + .map(|i| original_y(series, i, self.display_gain)) + .collect(), + )); + } + let source = series + .unamplified_y + .as_ref() + .expect("source coordinates were just captured"); + for (point, &original) in series.points.iter_mut().zip(source.iter()) { + point[1] = amplify(original, series.display_baseline, gain); + } + if gain == 1.0 { + series.unamplified_y = None; + } + } + for error in &mut self.error_bars { + error.center[1] = + error.display_baseline + (error.center[1] - error.display_baseline) * ratio; + if !error.horizontal { + error.negative *= ratio; + error.positive *= ratio; + } + } + for annotation in &mut self.annotations { + annotation.at[1] = amplify(annotation.at[1], annotation.display_baseline, ratio); + } + self.display_gain = gain; + Ok(()) + } +} + +fn original_y(series: &Series, index: usize, gain: f64) -> f64 { + series.unamplified_y.as_ref().map_or_else( + || { + if gain == 1.0 { + series.points[index][1] + } else { + series.display_baseline + (series.points[index][1] - series.display_baseline) / gain + } + }, + |source| source[index], + ) +} + +fn amplify(original: f64, baseline: f64, gain: f64) -> f64 { + if gain == 1.0 { + original + } else { + baseline + (original - baseline) * gain + } +} + +#[cfg(test)] +#[path = "display_gain_tests.rs"] +mod tests; diff --git a/crates/figure/src/display_gain_tests.rs b/crates/figure/src/display_gain_tests.rs new file mode 100644 index 00000000..903df991 --- /dev/null +++ b/crates/figure/src/display_gain_tests.rs @@ -0,0 +1,120 @@ +use super::*; +use crate::{Annotation, Axis, Color, ErrorBar, Series, TextAnchor}; + +fn signed_stack() -> Figure { + let mut figure = Figure::new("stack", Axis::new("x", 0., 2.), Axis::new("y", -3., 15.)); + figure + .series + .push(Series::line("first", vec![[0., 0.], [1., 2.], [2., -1.]])); + let mut second = Series::line("second", vec![[0., 10.], [1., 11.], [2., 9.5]]); + second.display_baseline = 10.; + figure.series.push(second); + let mut error = ErrorBar::symmetric([1., 11.], 0.5); + error.display_baseline = 10.; + figure.error_bars.push(error); + figure +} + +#[test] +fn gain_keeps_each_baseline_and_signed_relative_heights() { + let mut figure = signed_stack(); + figure.set_display_gain(2.).unwrap(); + assert_eq!(figure.series[0].points, [[0., 0.], [1., 4.], [2., -2.]]); + assert_eq!(figure.series[1].points, [[0., 10.], [1., 12.], [2., 9.]]); + assert_eq!(figure.error_bars[0].center, [1., 12.]); + assert_eq!(figure.error_bars[0].negative, 1.); + assert_eq!((figure.y.min, figure.y.max), (-3., 15.)); + figure.set_display_gain(2.).unwrap(); + assert_eq!(figure.series[1].points[1][1], 12.); + figure.set_display_gain(1.).unwrap(); + assert_eq!(figure.series[1].points, signed_stack().series[1].points); +} + +#[test] +fn materialized_gain_roundtrip_does_not_apply_twice() { + let mut figure = signed_stack(); + figure.set_display_gain(3.).unwrap(); + let mut restored: Figure = + serde_json::from_str(&serde_json::to_string(&figure).unwrap()).unwrap(); + restored.set_display_gain(3.).unwrap(); + assert_eq!(restored.series[1].points, figure.series[1].points); + restored.set_display_gain(1.).unwrap(); + assert_eq!(restored.series[1].points, signed_stack().series[1].points); +} + +#[test] +fn invalid_or_overflowing_gain_leaves_geometry_unchanged() { + let mut figure = signed_stack(); + for gain in [0., -1., f64::NAN, f64::INFINITY] { + assert!(figure.set_display_gain(gain).is_err()); + assert_eq!(figure.series[1].points, signed_stack().series[1].points); + } + figure.series[0].points[1][1] = f64::MAX; + assert!(figure.set_display_gain(2.).is_err()); + assert_eq!(figure.display_gain, 1.); + assert_eq!(figure.series[0].points[1][1], f64::MAX); +} + +#[test] +fn repeated_wheel_steps_and_reset_do_not_accumulate_roundoff() { + let mut stepped = signed_stack(); + stepped.series[1].points[1][1] = 10.123456789; + let original = stepped.clone(); + let mut direct = original.clone(); + for gain in [1.13, 2.63, 0.00001, 3.27] { + stepped.set_display_gain(gain).unwrap(); + } + direct.set_display_gain(3.27).unwrap(); + assert_eq!(stepped.series[1].points, direct.series[1].points); + stepped.set_display_gain(1.).unwrap(); + assert_eq!(stepped.series[1].points, original.series[1].points); +} + +#[test] +fn stacked_value_labels_follow_their_trace_through_gain_and_snapshot_restore() { + let mut figure = signed_stack(); + for y in [10., 11., 9.5] { + figure.annotations.push(Annotation { + text: (y - 10.).to_string(), + at: [1., y], + display_baseline: 10., + color: Color::BLACK, + size: 8., + anchor: TextAnchor::Above, + offset_pt: None, + }); + } + figure.set_display_gain(2.).unwrap(); + for (label, point) in figure.annotations.iter().zip(&figure.series[1].points) { + assert_eq!(label.at[1], point[1]); + } + assert_eq!(figure.annotations[1].text, "1"); + let mut restored: Figure = + serde_json::from_str(&serde_json::to_string(&figure).unwrap()).unwrap(); + for gain in [3., 0.5, 1.] { + restored.set_display_gain(gain).unwrap(); + for (label, point) in restored.annotations.iter().zip(&restored.series[1].points) { + assert_eq!(label.at[1], point[1]); + } + } +} + +#[test] +fn invalid_or_overflowing_label_gain_is_rejected_before_mutating_geometry() { + let mut figure = signed_stack(); + figure.annotations.push(Annotation { + text: "peak".into(), + at: [1., 1.], + display_baseline: f64::MAX, + color: Color::BLACK, + size: 8., + anchor: TextAnchor::Above, + offset_pt: None, + }); + assert!(figure.set_display_gain(3.).is_err()); + figure.annotations[0].display_baseline = f64::NAN; + assert!(figure.set_display_gain(2.).is_err()); + assert_eq!(figure.display_gain, 1.); + assert_eq!(figure.series[1].points, signed_stack().series[1].points); + assert_eq!(figure.annotations[0].at, [1., 1.]); +} diff --git a/crates/figure/src/error_bar.rs b/crates/figure/src/error_bar.rs index 42fa7df1..98117231 100644 --- a/crates/figure/src/error_bar.rs +++ b/crates/figure/src/error_bar.rs @@ -8,6 +8,8 @@ use crate::Color; /// belongs to a mark with a data-space width (see `cap_data_width`). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ErrorBar { + #[serde(default)] + pub display_baseline: f64, pub center: [f64; 2], pub negative: f64, pub positive: f64, @@ -36,6 +38,7 @@ pub struct ErrorBar { impl ErrorBar { pub fn symmetric(center: [f64; 2], error: f64) -> Self { Self { + display_baseline: 0.0, center, negative: error, positive: error, diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index 896ef5db..e2fefb5a 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -1,6 +1,9 @@ //! Figure model: a pure-data description of what to draw, in data-space //! coordinates. Both renderers (egui screen, SVG export) consume this model. +mod display_gain; +pub use display_gain::{MAX_DISPLAY_GAIN, MIN_DISPLAY_GAIN}; + mod axis_break; mod axis_scale; pub use axis_break::{AXIS_BREAK_GAP, AxisBreak}; @@ -327,6 +330,12 @@ pub struct SeriesOrigin(pub u64); #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Series { + /// Original display coordinates while gain is active; shared by figure clones. + #[serde(skip)] + pub(crate) unamplified_y: Option>>, + /// Zero-signal position after stacking, independent of display gain. + #[serde(default)] + pub display_baseline: f64, #[serde(skip)] pub origin: Option, #[serde(default)] @@ -373,6 +382,8 @@ pub enum SeriesKind { impl Series { pub fn line(name: impl Into, points: Vec<[f64; 2]>) -> Self { Self { + unamplified_y: None, + display_baseline: 0.0, origin: None, appearance: CurveAppearance::default(), name: name.into(), @@ -385,6 +396,8 @@ impl Series { pub fn points(name: impl Into, points: Vec<[f64; 2]>) -> Self { Self { + unamplified_y: None, + display_baseline: 0.0, origin: None, appearance: CurveAppearance::default(), name: name.into(), @@ -397,6 +410,8 @@ impl Series { pub fn sticks(name: impl Into, points: Vec<[f64; 2]>) -> Self { Self { + unamplified_y: None, + display_baseline: 0.0, origin: None, appearance: CurveAppearance::default(), name: name.into(), @@ -417,6 +432,9 @@ impl Series { pub struct Annotation { pub text: String, pub at: [f64; 2], + /// Zero-signal baseline of the trace that owns this label. + #[serde(default)] + pub display_baseline: f64, pub color: Color, pub size: f32, #[serde(default, skip_serializing_if = "TextAnchor::is_point")] @@ -553,6 +571,9 @@ pub enum AxisFrame { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Figure { + /// Display amplification already applied to this materialized geometry. + #[serde(default = "display_gain::unit_gain")] + pub display_gain: f64, pub title: String, pub x: Axis, pub y: Axis, @@ -638,6 +659,7 @@ pub struct Figure { impl Figure { pub fn new(title: impl Into, x: Axis, y: Axis) -> Self { Self { + display_gain: 1.0, title: title.into(), x, y, diff --git a/crates/render/src/label_placement_tests.rs b/crates/render/src/label_placement_tests.rs index a1a4d9a5..eec299ea 100644 --- a/crates/render/src/label_placement_tests.rs +++ b/crates/render/src/label_placement_tests.rs @@ -10,6 +10,7 @@ fn figure(series: Vec) -> Figure { fn text(at: [f64; 2], label: &str) -> Annotation { Annotation { + display_baseline: 0.0, text: label.into(), at, color: Color::BLACK, diff --git a/crates/render/src/legend_placement_tests.rs b/crates/render/src/legend_placement_tests.rs index cef8a311..3ab363c6 100644 --- a/crates/render/src/legend_placement_tests.rs +++ b/crates/render/src/legend_placement_tests.rs @@ -100,6 +100,7 @@ fn stems_and_error_bars_occupy_space() { assert_eq!(r.top, PLOT.top + 8.0); f.series[1].points.clear(); f.error_bars.push(ErrorBar { + display_baseline: 0.0, center: [0.5, 5.0], negative: 5.0, positive: 5.0, @@ -164,6 +165,7 @@ fn gaps_and_non_finite_samples_do_not_bridge_or_block() { Series::line("B", vec![[0.0, 1.0], [10.0, 1.0]]), ]); f.error_bars.push(ErrorBar { + display_baseline: 0.0, center: [f64::NAN, 5.0], negative: 1.0, positive: 1.0, diff --git a/crates/render/src/projected.rs b/crates/render/src/projected.rs index 7cae4034..327cc742 100644 --- a/crates/render/src/projected.rs +++ b/crates/render/src/projected.rs @@ -204,6 +204,7 @@ mod tests { let fig = log_figure(); let proj = Projector::new(&fig, Rect::new(0.0, 0.0, 200.0, 100.0), &Margins::default()); let bar = |center: f64, negative: f64| ErrorBar { + display_baseline: 0.0, center: [5.0, center], negative, positive: 5.0, diff --git a/docs/src/content/docs/getting-started/quick-tour.md b/docs/src/content/docs/getting-started/quick-tour.md index c08b0a28..68a2d1fd 100644 --- a/docs/src/content/docs/getting-started/quick-tour.md +++ b/docs/src/content/docs/getting-started/quick-tour.md @@ -97,14 +97,16 @@ steps with a real dataset. Pan and zoom are always available, in any tool: -- **Scroll wheel** — zoom the x axis of a 1D plot, or both axes of a 2D plot. +- **Scroll wheel** — adjust peak height on numeric 1D traces, keeping every + baseline fixed, or zoom both axes of a 2D plot. Use the X-axis strip for + horizontal zoom; other 1D charts retain X zoom. - **Two-finger swipe on a macOS trackpad** — pan the board. Click a plot to select it, then swipe over its data area to pan its data. Hold **Cmd** to zoom the board instead. - **Pinch** — zoom the board; over the selected plot's data area, zoom both of its axes. - **Alt + scroll wheel** — change what the plot shows rather than where you are - looking: the y intensity of a 1D plot, the lowest contour level, or a + looking: peak height on numeric 1D traces, the lowest contour level, or a heatmap's colour range. Hovering the plot names the setting it will change. - **Alt + drag** — rubber-band a box to zoom into, in any tool. - **Middle-drag** or **Space + drag** — pan. diff --git a/docs/src/content/docs/guides/pseudo-2d.md b/docs/src/content/docs/guides/pseudo-2d.md index 0438453d..724523aa 100644 --- a/docs/src/content/docs/guides/pseudo-2d.md +++ b/docs/src/content/docs/guides/pseudo-2d.md @@ -44,6 +44,14 @@ For a manual correction, edit **X shift (ppm)** on the increment's line row. These undoable shifts belong to the plot, so the processed spectra and DOSY or ILT maps remain unchanged. +Scroll over the stack to change the height of all traces by the same display +factor. Every zero-signal baseline and the spacing between baselines stays +fixed; negative peaks scale about their own baseline too. This preserves DOSY +attenuation ratios and leaves processing, integrations and fitting unchanged. +Use the X-axis strip or box zoom to inspect a chemical-shift range, and adjust +**Vertical spacing** separately when arranging the stack. **View → Reset Peak +Height** restores ×1 without resetting the axis ranges. + ## Workflow 1. Import the pseudo-2D dataset. diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index 835e74f4..f2bcdb23 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -49,10 +49,10 @@ and pinches move the board; over the selected plot's body they move its data. | Input | Action | | --- | --- | -| Scroll wheel over a plot body | Zoom the x axis of a 1D plot; both axes of a 2D plot | +| Scroll wheel over a plot body | Adjust peak height on numeric 1D traces, keeping each baseline fixed; zoom both axes of a 2D plot. Other 1D charts zoom X | | Two-finger swipe on a macOS trackpad | Pan the board; over the selected plot's body, pan its data | | Pinch | Zoom the board; over the selected plot's body, zoom both of its axes | -| `Alt` + scroll wheel over a plot body | Change what the plot shows: y intensity on a 1D plot, the lowest contour level, or a heatmap's colour range | +| `Alt` + scroll wheel over a plot body | Adjust peak height on numeric 1D traces, the lowest contour level, or a heatmap's colour range | | `Alt` + drag over a plot body | Rubber-band a box to zoom into, in any tool | | Scroll wheel over an axis strip | Zoom that axis only | | `Ctrl` + scroll wheel | Zoom the board instead of the plot | @@ -66,6 +66,16 @@ and pinches move the board; over the selected plot's body they move its data. | `F` elsewhere | Zoom the board to fit the selected frames (everything when nothing is selected) | | `Enter` | Zoom the board to the selected page or sheet | +**Peak height** uses one display multiplier for the whole plot. Stacked spectra +keep their individual zero-signal baselines and spacing, including negative +peaks; relative attenuation and scientific results stay unchanged. The hover +hint shows the multiplier. Use **View → Reset Peak Height** (also in the command +palette) to restore ×1 without changing axis ranges. Double-clicking Y or the +plot, and the `H` / plot `F` fits, also reset the multiplier. Horizontal zoom +remains available on the X-axis strip and with box zoom. Numeric curves with +logarithmic Y, two Y axes, transposed coordinates or filled marks keep their +coordinate-navigation behavior. + `H` acts on the plot under the pointer, or on the active plot when the pointer is elsewhere — the vertical-fit convention NMR software users expect. Both fits are single undoable steps, and both are available from the command palette as diff --git a/docs/src/content/docs/zh-cn/getting-started/quick-tour.md b/docs/src/content/docs/zh-cn/getting-started/quick-tour.md index c15f9c66..f624f258 100644 --- a/docs/src/content/docs/zh-cn/getting-started/quick-tour.md +++ b/docs/src/content/docs/zh-cn/getting-started/quick-tour.md @@ -79,11 +79,12 @@ Ribbon 是高频命令入口,不是完整菜单的重复。选择 **Data**、* 平移与缩放在任何工具下都可用: -- **滚轮**——缩放 1D 图的 X 轴;2D 图则同时缩放两个轴。 +- **滚轮**——调节数值型 1D 谱线的峰高,每条基线固定;2D 图同时缩放两个轴。 + 横向缩放请使用 X 轴带,其它 1D 图仍缩放 X 轴。 - **macOS 触控板双指滑动**——平移画板。单击选中一幅图后,在它的数据区内滑动可平移数据; 按住 **Cmd** 则改为缩放画板。 - **双指捏合**——缩放画板;在选中的图的数据区内则缩放它的两个轴。 -- **Alt + 鼠标滚轮**——改变图“显示什么”而不是“看哪里”:1D 图的 Y 强度、等高线最低层 +- **Alt + 鼠标滚轮**——改变图“显示什么”而不是“看哪里”:数值型 1D 谱线的峰高、等高线最低层 或热图的色阶范围。悬停时图上会写明将要调整哪一项。 - **Alt + 拖动**——在任何工具下框选一块区域放大。 - **中键拖动**或 **Space + 拖动**——平移。 diff --git a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md index 9e9a7959..9eee691b 100644 --- a/docs/src/content/docs/zh-cn/guides/pseudo-2d.md +++ b/docs/src/content/docs/zh-cn/guides/pseudo-2d.md @@ -36,6 +36,11 @@ traces…**。选择参考增量和峰区间后,在选择 **Apply** 前检查 若要手动校正,可在增量的线条行中编辑 **X shift (ppm)**。这些可撤销的位移只 属于图形,因此不会改变处理后的谱图,也不会改变 DOSY 或 ILT 图。 +在堆叠谱内滚动鼠标滚轮,会按同一显示倍率调整全部谱线的峰高。各条零信号基线及其间距 +保持固定,负峰同样绕自己的基线缩放,因此 DOSY 的衰减比例不变,处理、积分和拟合结果 +也不变。可使用 X 轴带或框选查看化学位移区间;排布谱线时单独调节 **Vertical spacing**。 +**View → Reset Peak Height** 可恢复 ×1,同时保持坐标轴范围。 + ## 工作流 1. 导入伪 2D 数据集。 diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index 34e4b6cd..b347213c 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -41,10 +41,10 @@ description: 键盘与鼠标快捷操作。 | 操作 | 效果 | | --- | --- | -| 在图内滚动鼠标滚轮 | 缩放 1D 图的 X 轴;2D 图同时缩放两个轴 | +| 在图内滚动鼠标滚轮 | 数值型 1D 谱线调节峰高,每条基线固定;2D 图同时缩放两个轴;其它 1D 图缩放 X 轴 | | 在 macOS 触控板上双指滑动 | 平移画板;在选中的图内时平移该图的数据 | | 双指捏合 | 缩放画板;在选中的图内时缩放该图的两个轴 | -| `Alt` + 在图内滚动鼠标滚轮 | 改变图显示什么:1D 的 Y 强度、等高线最低层,或热图的色阶范围 | +| `Alt` + 在图内滚动鼠标滚轮 | 调节数值型 1D 谱线的峰高、等高线最低层,或热图的色阶范围 | | `Alt` + 在图内拖动 | 在任何工具下框选一块区域放大 | | 在坐标轴带上滚动鼠标滚轮 | 仅缩放该轴 | | `Ctrl` + 滚轮 | 改为缩放画板 | @@ -58,6 +58,12 @@ description: 键盘与鼠标快捷操作。 | `F`(指针在其它位置) | 缩放画板以适配所选图框(未选中时适配全部) | | `Enter` | 缩放画板至所选页面或工作表 | +**峰高调节**对整幅图使用同一个显示倍率。堆叠谱的各条零信号基线及间距保持固定, +负峰同样绕自己的基线变化;相对衰减比例和科学分析结果不变。悬停提示会显示倍率。 +通过 **View → Reset Peak Height**(命令面板中也可搜索)可恢复 ×1,同时保持坐标轴范围。 +双击 Y 轴或图内,以及 `H` / 图内 `F` 适配,也会重置倍率。横向缩放可使用 X 轴带或框选。 +Y 轴为对数、使用双 Y 轴、交换了坐标方向或含填充标记的数值图保留坐标导航行为。 + `H` 作用于指针所在的图;指针不在任何图内时作用于当前活动的图——这正是 NMR 软件 用户熟悉的纵向适配习惯。两种适配都是单个可撤销步骤,也都能在命令面板中找到 (**Fit Plot Vertically** 与 **Fit Plot to Data**)。 From c3e6fc8c72b86ae13dc924638ebd3c4c2ab6a0d9 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sat, 24 Oct 2026 18:33:44 +0800 Subject: [PATCH 2/2] refactor(project): extract viewport restoration --- crates/core/src/project/convert_viewport.rs | 32 ++++++++++++++++++++ crates/core/src/project/convert_views.rs | 33 +++++---------------- 2 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 crates/core/src/project/convert_viewport.rs diff --git a/crates/core/src/project/convert_viewport.rs b/crates/core/src/project/convert_viewport.rs new file mode 100644 index 00000000..4b4da79c --- /dev/null +++ b/crates/core/src/project/convert_viewport.rs @@ -0,0 +1,32 @@ +use super::super::{CanvasViewport, ProjectError, Result, ViewportDto}; +use crate::state::AxisOverrides; +use plotium_figure::Figure; + +pub(super) fn restore( + dto: Option<&ViewportDto>, + figure: &mut Figure, + axis_overrides: &AxisOverrides, + snapshot_backed: bool, +) -> Result { + let mut viewport = dto + .map(ViewportDto::to_viewport) + .unwrap_or_else(|| CanvasViewport::from_figure(figure)); + viewport.reset_unsupported_gain(figure); + figure + .check_display_gain(viewport.display_gain) + .map_err(|error| ProjectError::Invalid(format!("Invalid plot display gain: {error}")))?; + if snapshot_backed { + figure + .set_display_gain(viewport.display_gain) + .map_err(|error| { + ProjectError::Invalid(format!("Invalid plot display gain: {error}")) + })?; + } else { + if axis_overrides.y_range.is_some() && figure.y.categories.is_none() { + viewport.auto_y = false; + } + viewport.sync_full_from(figure); + viewport.apply_to(figure); + } + Ok(viewport) +} diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index b0ce8735..2dffca08 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -8,6 +8,8 @@ use crate::state::{CanvasAnalysisBinding, CraftRunId, XViewportLinkGroup, XViewp mod panel_convert; #[path = "convert_series.rs"] mod series_convert; +#[path = "convert_viewport.rs"] +mod viewport_convert; use crate::state::{AxisProjection, AxisProjections, ProjectionSource}; use crate::state::{ GroupMember, LayoutGroup, Panel, PanelId, PanelLabelMode, PanelLabelSpec, PanelLayout, @@ -610,31 +612,12 @@ pub fn view_to_canvas( figure.author_annotations = axis_overrides.annotations.lower(); figure.reference_lines = axis_overrides.annotations.lower_reference_lines(); figure.author_arrows = axis_overrides.annotations.lower_arrows(); - let mut viewport = view_object - .viewport - .as_ref() - .map(ViewportDto::to_viewport) - .unwrap_or_else(|| CanvasViewport::from_figure(&figure)); - viewport.reset_unsupported_gain(&figure); - figure - .check_display_gain(viewport.display_gain) - .map_err(|error| { - ProjectError::Invalid(format!("Invalid plot display gain: {error}")) - })?; - if snapshot_backed { - figure - .set_display_gain(viewport.display_gain) - .map_err(|error| { - ProjectError::Invalid(format!("Invalid plot display gain: {error}")) - })?; - } - if !snapshot_backed { - if axis_overrides.y_range.is_some() && figure.y.categories.is_none() { - viewport.auto_y = false; - } - viewport.sync_full_from(&figure); - viewport.apply_to(&mut figure); - } + let viewport = viewport_convert::restore( + view_object.viewport.as_ref(), + &mut figure, + &axis_overrides, + snapshot_backed, + )?; figure.title.clear(); let mut plot = PlotObject::from_materialized_figure( display_owner,