From 840e403caa5407d59e91a6714fd9119a6aa6f45d Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 22 Jul 2026 16:35:27 +0800 Subject: [PATCH] feat: add per-plot axis overrides --- crates/app/src/ui/canvas/interactions.rs | 2 +- crates/app/src/ui/canvas/mod.rs | 2 + crates/app/src/ui/canvas/navigation.rs | 8 +- crates/app/src/ui/mod.rs | 3 + crates/app/src/ui/object_inspector.rs | 36 +- crates/app/src/ui/object_inspector/axes.rs | 309 ++++++++++++++++++ .../src/actions/app_impl/axis_overrides.rs | 191 +++++++++++ crates/core/src/actions/app_impl/mod.rs | 61 ++-- crates/core/src/actions/app_impl/revert.rs | 8 + crates/core/src/actions/app_impl/validate.rs | 1 + crates/core/src/actions/build.rs | 20 +- crates/core/src/actions/mod.rs | 13 +- crates/core/src/actions/tests/authoring.rs | 99 ++++++ crates/core/src/actions/tests/more.rs | 22 +- crates/core/src/export/precheck.rs | 5 +- crates/core/src/project/axis_overrides.rs | 36 ++ crates/core/src/project/convert.rs | 21 +- crates/core/src/project/dto.rs | 3 + crates/core/src/project/mod.rs | 1 + crates/core/src/project/tests.rs | 38 ++- crates/core/src/state/app_impl.rs | 11 +- crates/core/src/state/axis_overrides.rs | 253 ++++++++++++++ crates/core/src/state/datasets.rs | 146 --------- crates/core/src/state/document.rs | 7 +- crates/core/src/state/mod.rs | 3 + crates/core/src/state/plot_object.rs | 43 +++ crates/core/src/state/stack.rs | 1 + crates/core/src/state/ui_state.rs | 13 +- crates/core/src/workflow.rs | 7 +- .../content/docs/guides/layout-and-export.md | 18 +- .../docs/zh-cn/guides/layout-and-export.md | 6 +- 31 files changed, 1150 insertions(+), 237 deletions(-) create mode 100644 crates/app/src/ui/object_inspector/axes.rs create mode 100644 crates/core/src/actions/app_impl/axis_overrides.rs create mode 100644 crates/core/src/project/axis_overrides.rs create mode 100644 crates/core/src/state/axis_overrides.rs create mode 100644 crates/core/src/state/plot_object.rs diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index d9c3155..1079e82 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -205,7 +205,7 @@ pub(crate) fn finish_zoom_drag( let mut after = before.clone(); after.select(fig, x, y); - app.execute_action(Action::set_object_viewport(ci, object_id, before, after)); + app.commit_object_viewport(ci, object_id, before, after); app.session.status = "Zoomed selection.".into(); } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index ed108c7..c82d553 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -548,6 +548,7 @@ mod tests { chart: plotx_core::state::ChartSpec::default(), stack: plotx_core::state::StackSpec::default(), projections: plotx_core::state::AxisProjections::default(), + axis_overrides: plotx_core::state::AxisOverrides::default(), figure: Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)), viewport: CanvasViewport::from_figure(&Figure::new( "plot", @@ -579,6 +580,7 @@ mod tests { chart: plotx_core::state::ChartSpec::default(), stack: plotx_core::state::StackSpec::default(), projections: plotx_core::state::AxisProjections::default(), + axis_overrides: plotx_core::state::AxisOverrides::default(), figure: Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)), viewport: CanvasViewport::from_figure(&Figure::new( "plot", diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index b91ace5..4a0a55a 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -251,12 +251,12 @@ pub(crate) fn commit_data_pan(app: &mut PlotxApp) { .object(drag.object) .and_then(|object| object.plot()) { - app.execute_action(Action::set_object_viewport( + app.commit_object_viewport( drag.canvas, drag.object, drag.before, object.viewport.clone(), - )); + ); } } @@ -302,7 +302,7 @@ pub(crate) fn finish_axis_zoom( }; let mut after = before.clone(); after.select(fig, x, y); - app.execute_action(Action::set_object_viewport(ci, object_id, before, after)); + app.commit_object_viewport(ci, object_id, before, after); app.session.status = "Zoomed axis.".into(); } @@ -328,7 +328,7 @@ pub(crate) fn reset_plot_viewport( HitZone::Plot => after.reset_all(), HitZone::None => return, } - app.execute_action(Action::set_object_viewport(ci, object_id, before, after)); + app.commit_object_viewport(ci, object_id, before, after); } /// Zoom a plot's data viewport around the cursor. The axis is chosen by hit zone diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index eaeac93..4d6efcf 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -333,6 +333,9 @@ fn feedback_banner(app: &mut PlotxApp, ui: &mut Ui, dark: bool) { fn render_sidebars(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) { let compact = workspace_width < 1200.0; + if !app.session.secondary_sidebar_visible { + app.finish_axis_overrides_edit(); + } if app.session.primary_sidebar_visible { let panel = egui::Panel::left("primary_sidebar") .frame(egui::Frame::NONE.inner_margin(egui::Margin { diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index 01ff6e2..7d35815 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -1,9 +1,11 @@ //! The Object inspector: geometry + per-kind style editing for the current //! page-space selection, at the top of the Secondary Side Bar. +mod axes; mod chart_gallery; mod panel_note; +use axes::{axes_section, commit_if_target_changed}; use chart_gallery::chart_gallery; use egui::{DragValue, Ui}; use egui_phosphor::regular as icon; @@ -16,13 +18,24 @@ use plotx_core::state::{ use plotx_figure::Color; pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { + let ids: Vec = app.session.ui.selection.objects().to_vec(); + let axis_target = app.session.active_canvas.and_then(|ci| { + (ids.len() == 1 + && app + .doc + .canvases + .get(ci)? + .object(ids[0]) + .is_some_and(|object| object.plot().is_some())) + .then(|| (ci, ids[0])) + }); + commit_if_target_changed(app, axis_target); let Some(ci) = app.session.active_canvas else { return; }; if ci >= app.doc.canvases.len() { return; } - let ids: Vec = app.session.ui.selection.objects().to_vec(); if ids.is_empty() { commit_panel_note_edit(app); return; @@ -40,12 +53,15 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { geometry_section(app, ci, &ids, ui); let mut note_focused = false; + let mut axes_focused = false; if ids.len() == 1 && app.doc.canvases[ci] .object(ids[0]) .map(|o| o.plot().is_some()) .unwrap_or(false) { + ui.separator(); + axes_focused = axes_section(app, ci, ids[0], ui); ui.separator(); note_focused = panel_note_section(app, ci, ids[0], ui); data_section(app, ci, ids[0], ui); @@ -74,7 +90,7 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { format_once_section(app, ci, primary, ui); } - flush_inspector_edit(app, ui, text_focused || note_focused); + flush_inspector_edit(app, ui, text_focused || note_focused || axes_focused); ui.separator(); ui.add_space(2.0); } @@ -701,3 +717,19 @@ fn rgb_of(c: Color) -> [u8; 3] { fn color_of(rgb: [u8; 3]) -> Color { Color::rgb(rgb[0], rgb[1], rgb[2]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_safely_during_active_canvas_transition() { + let mut app = PlotxApp::new(); + app.session.active_canvas = Some(0); + assert!(app.doc.canvases.is_empty()); + assert!(app.session.ui.selection.objects().is_empty()); + + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| render(&mut app, ui)); + } +} diff --git a/crates/app/src/ui/object_inspector/axes.rs b/crates/app/src/ui/object_inspector/axes.rs new file mode 100644 index 0000000..908b182 --- /dev/null +++ b/crates/app/src/ui/object_inspector/axes.rs @@ -0,0 +1,309 @@ +use egui::{DragValue, Response, TextEdit, Ui}; +use plotx_core::actions::Action; +use plotx_core::state::{AxisOverrides, AxisRange, ObjectId, PlotxApp}; +use plotx_figure::AxisFrame; + +#[derive(Clone, Copy)] +enum AxisKind { + X, + Y, +} + +pub(super) fn commit_if_target_changed(app: &mut PlotxApp, target: Option<(usize, ObjectId)>) { + let pending_target = app + .session + .ui + .axis_overrides_before + .as_ref() + .map(|(canvas, object, _)| (*canvas, *object)); + if pending_target.is_some() && pending_target != target { + commit_edit(app); + } +} + +pub(super) fn axes_section( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + ui: &mut Ui, +) -> bool { + let Some((x_auto, y_auto, hidden, x_categorical, y_categorical)) = app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| { + ( + plot.figure.x.label.clone(), + plot.figure.y.label.clone(), + plot.figure.axis_frame == AxisFrame::Hidden, + plot.figure.x.categories.is_some(), + plot.figure.y.categories.is_some(), + ) + }) + else { + return false; + }; + + ui.strong("Axes"); + let hidden_reason = "Choose a chart with visible axes to edit axis settings."; + let mut focused = false; + egui::Grid::new("object_axis_labels") + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + focused |= label_row( + app, + canvas, + object, + AxisKind::X, + "X title", + &x_auto, + !hidden, + hidden_reason, + ui, + ); + ui.end_row(); + focused |= label_row( + app, + canvas, + object, + AxisKind::Y, + "Y title", + &y_auto, + !hidden, + hidden_reason, + ui, + ); + ui.end_row(); + }); + + let x_reason = if hidden { + hidden_reason + } else { + "Choose a chart with a numeric x axis to set its range." + }; + let y_reason = if hidden { + hidden_reason + } else { + "Choose a chart with a numeric y axis to set its range." + }; + range_row( + app, + canvas, + object, + AxisKind::X, + "X range", + !hidden && !x_categorical, + x_reason, + ui, + ); + range_row( + app, + canvas, + object, + AxisKind::Y, + "Y range", + !hidden && !y_categorical, + y_reason, + ui, + ); + + focused +} + +#[allow(clippy::too_many_arguments)] +fn label_row( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + axis: AxisKind, + label: &str, + automatic: &str, + enabled: bool, + disabled_reason: &str, + ui: &mut Ui, +) -> bool { + let current = current_overrides(app, canvas, object); + let mut text = match axis { + AxisKind::X => current.x_label.clone(), + AxisKind::Y => current.y_label.clone(), + } + .unwrap_or_default(); + ui.label(label); + let response = ui + .add_enabled( + enabled, + TextEdit::singleline(&mut text) + .hint_text(automatic) + .desired_width(132.0), + ) + .on_disabled_hover_text(disabled_reason); + if response.gained_focus() { + begin_edit(app, canvas, object); + } + if response.changed() { + let value = (!text.trim().is_empty()).then_some(text); + let mut after = current_overrides(app, canvas, object); + match axis { + AxisKind::X => after.x_label = value, + AxisKind::Y => after.y_label = value, + } + apply_live(app, canvas, object, after); + } + if response.lost_focus() { + commit_edit(app); + } + response.has_focus() +} + +#[allow(clippy::too_many_arguments)] +fn range_row( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + axis: AxisKind, + label: &str, + enabled: bool, + disabled_reason: &str, + ui: &mut Ui, +) { + let Some((overrides, view)) = app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .map(|plot| { + let view = match axis { + AxisKind::X => plot.viewport.view_x, + AxisKind::Y => plot.viewport.view_y, + }; + (plot.axis_overrides.clone(), view) + }) + else { + return; + }; + let manual = match axis { + AxisKind::X => overrides.x_range, + AxisKind::Y => overrides.y_range, + }; + let mut automatic = manual.is_none(); + ui.horizontal(|ui| { + ui.label(label); + // A range retained across a switch to a categorical/hidden axis stays + // inactive, but its Auto control remains available so it can be cleared. + let auto_enabled = enabled || manual.is_some(); + let auto_response = ui + .add_enabled(auto_enabled, egui::Checkbox::new(&mut automatic, "Auto")) + .on_disabled_hover_text(disabled_reason); + if auto_response.changed() { + commit_edit(app); + let before = current_overrides(app, canvas, object); + let mut after = before.clone(); + let value = (!automatic).then_some(view); + match axis { + AxisKind::X => after.x_range = value, + AxisKind::Y => after.y_range = value, + } + app.execute_action(Action::set_axis_overrides(canvas, object, before, after)); + } + }); + + let mut range = manual.unwrap_or(view); + let span = range.span(); + let min_span = range.min.abs().max(range.max.abs()).max(span).max(1.0) * 1e-12; + let min_upper = range.max - min_span; + let max_lower = range.min + min_span; + let auto_reason = match axis { + AxisKind::X => "Turn off Auto to set a manual x range.", + AxisKind::Y => "Turn off Auto to set a manual y range.", + }; + let drag_reason = if enabled && automatic { + auto_reason + } else { + disabled_reason + }; + ui.horizontal(|ui| { + ui.add_space(12.0); + let min_response = ui + .add_enabled( + enabled && !automatic, + range_drag(&mut range.min, span).range(f64::MIN..=min_upper), + ) + .on_hover_text("Minimum") + .on_disabled_hover_text(drag_reason); + let max_response = ui + .add_enabled( + enabled && !automatic, + range_drag(&mut range.max, span).range(max_lower..=f64::MAX), + ) + .on_hover_text("Maximum") + .on_disabled_hover_text(drag_reason); + handle_range_response( + app, + canvas, + object, + axis, + range, + &min_response, + &max_response, + ); + }); +} + +fn range_drag(value: &mut f64, span: f64) -> DragValue<'_> { + DragValue::new(value) + .speed((span / 200.0).max(1e-9)) + .max_decimals(8) +} + +fn handle_range_response( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + axis: AxisKind, + range: AxisRange, + min_response: &Response, + max_response: &Response, +) { + if min_response.drag_started() || max_response.drag_started() { + begin_edit(app, canvas, object); + } + let changed = min_response.changed() || max_response.changed(); + if changed && range.is_valid() { + let mut after = current_overrides(app, canvas, object); + match axis { + AxisKind::X => after.x_range = Some(range), + AxisKind::Y => after.y_range = Some(range), + } + apply_live(app, canvas, object, after); + } + let drag_stopped = min_response.drag_stopped() || max_response.drag_stopped(); + let typed_value = changed && !min_response.dragged() && !max_response.dragged(); + if drag_stopped || typed_value { + commit_edit(app); + } +} + +fn current_overrides(app: &PlotxApp, canvas: usize, object: ObjectId) -> AxisOverrides { + app.doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| object.plot()) + .map(|plot| plot.axis_overrides.clone()) + .unwrap_or_default() +} + +fn begin_edit(app: &mut PlotxApp, canvas: usize, object: ObjectId) { + if app.session.ui.axis_overrides_before.is_none() { + app.session.ui.axis_overrides_before = + Some((canvas, object, current_overrides(app, canvas, object))); + } +} + +fn apply_live(app: &mut PlotxApp, canvas: usize, object: ObjectId, after: AxisOverrides) { + begin_edit(app, canvas, object); + app.set_axis_overrides_value(canvas, object, &after); + app.doc.dirty = true; +} + +fn commit_edit(app: &mut PlotxApp) { + app.finish_axis_overrides_edit(); +} diff --git a/crates/core/src/actions/app_impl/axis_overrides.rs b/crates/core/src/actions/app_impl/axis_overrides.rs new file mode 100644 index 0000000..0f084a3 --- /dev/null +++ b/crates/core/src/actions/app_impl/axis_overrides.rs @@ -0,0 +1,191 @@ +use super::*; + +impl PlotxApp { + pub(super) fn set_object_viewport( + &mut self, + canvas: usize, + object: ObjectId, + viewport: &CanvasViewport, + ) { + let Some(plot) = self + .doc + .canvases + .get_mut(canvas) + .and_then(|canvas| canvas.object_mut(object)) + .and_then(|object| object.plot_mut()) + else { + return; + }; + plot.viewport = viewport.clone(); + plot.viewport.apply_to(&mut plot.figure); + } + + /// Finish a live Inspector edit before another command can change its target + /// or history position. + pub fn finish_axis_overrides_edit(&mut self) { + let Some((canvas, object, before)) = self.session.ui.axis_overrides_before.take() else { + return; + }; + let Some(after) = self + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| object.plot()) + .map(|plot| plot.axis_overrides.clone()) + else { + return; + }; + self.execute_action(Action::set_axis_overrides(canvas, object, before, after)); + } + + /// Record a viewport command only after applying plot-specific invariants, + /// so the action payload exactly matches the state that apply/undo stores. + pub fn commit_object_viewport( + &mut self, + canvas: usize, + object: ObjectId, + mut before: CanvasViewport, + mut after: CanvasViewport, + ) { + if let Some(plot) = self + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| object.plot()) + { + plot.normalize_viewport(&mut before); + plot.normalize_viewport(&mut after); + } + self.execute_action(Action::set_object_viewport(canvas, object, before, after)); + } + + /// Apply a live Inspector value. Transitions back to `None` rebuild once to + /// recover data-derived labels/ranges; edits among manual values only touch + /// the presentation model and viewport, avoiding expensive data rebuilds. + pub fn set_axis_overrides_value( + &mut self, + canvas: usize, + object: ObjectId, + after: &AxisOverrides, + ) { + let after = after.clone().normalized(); + let Some((before, binding, chart, stack, projections, frame)) = self + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| { + object.plot().map(|plot| { + ( + plot.axis_overrides.clone(), + plot.binding.clone(), + plot.chart.clone(), + plot.stack, + plot.projections.clone(), + object.frame, + ) + }) + }) + else { + return; + }; + if before == after { + return; + } + + let x_range_changed = before.x_range != after.x_range; + let y_range_changed = before.y_range != after.y_range; + let needs_automatic_rebuild = cleared(&before.x_label, &after.x_label) + || cleared(&before.y_label, &after.y_label) + || cleared(&before.x_range, &after.x_range) + || cleared(&before.y_range, &after.y_range); + + let rebuilt = needs_automatic_rebuild.then(|| { + let size = [ + frame.width / crate::state::MM_TO_PT, + frame.height / crate::state::MM_TO_PT, + ]; + self.build_object_figure(&binding, &chart, &stack, &projections, size) + }); + + let Some(plot) = self + .doc + .canvases + .get_mut(canvas) + .and_then(|canvas| canvas.object_mut(object)) + .and_then(|object| object.plot_mut()) + else { + return; + }; + plot.axis_overrides = after; + + if let Some(mut figure) = rebuilt { + plot.axis_overrides.apply_to(&mut figure); + let effective_y_range = + plot.axis_overrides.y_range.is_some() && figure.y.categories.is_none(); + if y_range_changed { + plot.viewport.auto_y = !effective_y_range; + } else if effective_y_range { + plot.viewport.auto_y = false; + } + plot.viewport.sync_full_from(&figure); + reset_changed_ranges( + plot, + &figure, + x_range_changed, + y_range_changed, + effective_y_range, + ); + plot.viewport.apply_to(&mut figure); + plot.figure = figure; + return; + } + + plot.axis_overrides.apply_to(&mut plot.figure); + if x_range_changed + && plot.figure.x.categories.is_none() + && let Some(range) = plot.axis_overrides.x_range + { + plot.viewport.full_x = range; + plot.viewport.view_x = range; + if plot.viewport.auto_y { + plot.viewport.reset_x(&plot.figure); + } + } + if y_range_changed + && plot.figure.y.categories.is_none() + && let Some(range) = plot.axis_overrides.y_range + { + plot.viewport.full_y = range; + plot.viewport.view_y = range; + plot.viewport.auto_y = false; + } + plot.viewport.apply_to(&mut plot.figure); + } +} + +fn reset_changed_ranges( + plot: &mut crate::state::PlotObject, + figure: &plotx_figure::Figure, + x_changed: bool, + y_changed: bool, + effective_y_range: bool, +) { + if x_changed { + plot.viewport.reset_x(figure); + } + if y_changed { + if effective_y_range { + plot.viewport.view_y = plot.viewport.full_y; + plot.viewport.auto_y = false; + } else { + plot.viewport.reset_y(figure); + } + } +} + +fn cleared(before: &Option, after: &Option) -> bool { + before.is_some() && after.is_none() +} diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index b714773..6f79e05 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -1,5 +1,6 @@ use super::*; +mod axis_overrides; mod meta_edits; mod processing; mod revert; @@ -11,6 +12,7 @@ use validate::{ValidationShape, validate_action}; impl PlotxApp { pub fn execute_action(&mut self, action: Action) { + self.finish_axis_overrides_edit(); if let Err(error) = self.try_execute_action(action) { self.session.status = error.to_string(); } @@ -36,6 +38,7 @@ impl PlotxApp { } pub fn undo(&mut self) { + self.finish_axis_overrides_edit(); self.reset_interaction(); let Some(action) = self.session.undo_stack.pop() else { return; @@ -48,6 +51,7 @@ impl PlotxApp { } pub fn redo(&mut self) { + self.finish_axis_overrides_edit(); self.reset_interaction(); let Some(action) = self.session.redo_stack.pop() else { return; @@ -76,6 +80,7 @@ impl PlotxApp { self.session.ui.processing_edit = None; self.session.ui.processing_session = None; self.session.ui.inspector_edit = None; + self.session.ui.axis_overrides_before = None; self.session.ui.selection = Selection::None; self.session.ui.panel_note_inline_edit = None; self.session.ui.panel_note_edit = None; @@ -118,12 +123,12 @@ impl PlotxApp { else { return; }; - self.execute_action(Action::set_object_viewport( + self.commit_object_viewport( pending.canvas, pending.object, pending.before, object.viewport.clone(), - )); + ); } } @@ -145,6 +150,14 @@ impl PlotxApp { } => { self.set_object_viewport(*canvas, *object, after); } + Action::SetAxisOverrides { + canvas, + object, + after, + .. + } => { + self.set_axis_overrides_value(*canvas, *object, after); + } Action::MoveResizeObject { canvas, object, @@ -349,6 +362,7 @@ impl PlotxApp { self.session.ui.selection = Selection::None; self.session.ui.panel_note_inline_edit = None; self.session.ui.panel_note_edit = None; + self.session.ui.axis_overrides_before = None; self.session.ui.canvas_settings = None; self.session.ui.rename = None; } @@ -423,22 +437,6 @@ impl PlotxApp { } } - fn set_object_viewport(&mut self, canvas: usize, object: ObjectId, viewport: &CanvasViewport) { - let Some(object) = self - .doc - .canvases - .get_mut(canvas) - .and_then(|canvas| canvas.object_mut(object)) - else { - return; - }; - let Some(plot) = object.plot_mut() else { - return; - }; - plot.viewport = viewport.clone(); - plot.viewport.apply_to(&mut plot.figure); - } - pub fn set_object_frame(&mut self, canvas: usize, object: ObjectId, frame: ObjectFrame) { let Some(o) = self .doc @@ -604,6 +602,10 @@ impl PlotxApp { { self.session.ui.panel_note_inline_edit = None; } + if matches!(self.session.ui.axis_overrides_before, Some((ci, object, _)) if ci == canvas && object == id) + { + self.session.ui.axis_overrides_before = None; + } } pub fn set_object_styles(&mut self, canvas: usize, styles: &[(ObjectId, ObjectStyle)]) { @@ -650,7 +652,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let mut fig = self.build_object_figure(binding, &chart, &stack, &projections, size); + let fig = self.build_object_figure(binding, &chart, &stack, &projections, size); if let Some(plot) = self .doc .canvases @@ -658,9 +660,7 @@ impl PlotxApp { .and_then(|c| c.object_mut(object)) .and_then(|o| o.plot_mut()) { - plot.viewport = CanvasViewport::from_figure(&fig); - plot.viewport.apply_to(&mut fig); - plot.figure = fig; + plot.reset_viewport_on_rebuild(fig); } } @@ -692,7 +692,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let mut fig = self.build_object_figure(&binding, chart, &stack, &projections, size); + let fig = self.build_object_figure(&binding, chart, &stack, &projections, size); if let Some(plot) = self .doc .canvases @@ -700,9 +700,7 @@ impl PlotxApp { .and_then(|c| c.object_mut(object)) .and_then(|o| o.plot_mut()) { - plot.viewport = CanvasViewport::from_figure(&fig); - plot.viewport.apply_to(&mut fig); - plot.figure = fig; + plot.reset_viewport_on_rebuild(fig); } } @@ -734,7 +732,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let mut fig = self.build_object_figure(&binding, &chart, stack, &projections, size); + let fig = self.build_object_figure(&binding, &chart, stack, &projections, size); if let Some(plot) = self .doc .canvases @@ -742,9 +740,7 @@ impl PlotxApp { .and_then(|c| c.object_mut(object)) .and_then(|o| o.plot_mut()) { - plot.viewport = CanvasViewport::from_figure(&fig); - plot.viewport.apply_to(&mut fig); - plot.figure = fig; + plot.reset_viewport_on_rebuild(fig); } } @@ -776,7 +772,7 @@ impl PlotxApp { frame.width / crate::state::MM_TO_PT, frame.height / crate::state::MM_TO_PT, ]; - let mut fig = self.build_object_figure(&binding, &chart, &stack, projections, size); + let fig = self.build_object_figure(&binding, &chart, &stack, projections, size); if let Some(plot) = self .doc .canvases @@ -784,8 +780,7 @@ impl PlotxApp { .and_then(|c| c.object_mut(object)) .and_then(|o| o.plot_mut()) { - plot.viewport.apply_to(&mut fig); - plot.figure = fig; + plot.preserve_viewport_on_rebuild(fig); } } } diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index f288158..890253e 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -22,6 +22,14 @@ impl PlotxApp { } => { self.set_object_viewport(*canvas, *object, before); } + Action::SetAxisOverrides { + canvas, + object, + before, + .. + } => { + self.set_axis_overrides_value(*canvas, *object, before); + } Action::MoveResizeObject { canvas, object, diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index 7c26346..5536f80 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -83,6 +83,7 @@ pub(super) fn validate_action( shape.canvases += 1; } Action::SetObjectViewport { canvas, object, .. } + | Action::SetAxisOverrides { canvas, object, .. } | Action::MoveResizeObject { canvas, object, .. } | Action::SetPanelMeta { canvas, object, .. } | Action::SetObjectFlags { canvas, object, .. } diff --git a/crates/core/src/actions/build.rs b/crates/core/src/actions/build.rs index 294169b..f6cbeeb 100644 --- a/crates/core/src/actions/build.rs +++ b/crates/core/src/actions/build.rs @@ -4,9 +4,9 @@ use super::*; use crate::layout::PageLayout; use crate::state::{ - CanvasObject, CanvasViewport, ChartSpec, CurveFitReference, DataBinding, Dataset, NamedView, - ObjectFrame, ObjectId, ObjectStyle, PanelMeta, PlotxApp, Region, Selection, StackSpec, - StoredCurveFitAnalysis, StoredLineFit, StoredMultiplet, TableEditDelta, TextBox, + AxisOverrides, CanvasObject, CanvasViewport, ChartSpec, CurveFitReference, DataBinding, + Dataset, NamedView, ObjectFrame, ObjectId, ObjectStyle, PanelMeta, PlotxApp, Region, Selection, + StackSpec, StoredCurveFitAnalysis, StoredLineFit, StoredMultiplet, TableEditDelta, TextBox, }; use crate::theme::ThemeSnapshot; use crate::{Integral2D, IntegralResult}; @@ -38,6 +38,20 @@ impl Action { } } + pub fn set_axis_overrides( + canvas: usize, + object: ObjectId, + before: AxisOverrides, + after: AxisOverrides, + ) -> Self { + Self::SetAxisOverrides { + canvas, + object, + before, + after, + } + } + pub fn move_resize_object( canvas: usize, object: ObjectId, diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index fe71f0f..bad2d25 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -1,8 +1,8 @@ use crate::layout::PageLayout; use crate::state::{ - AxisProjections, CanvasDocument, CanvasObject, CanvasViewport, ChartSpec, CurveFitReference, - DataBinding, Dataset, NamedView, ObjectFrame, ObjectId, ObjectStyle, PanelLabelStyle, - PanelMeta, PlotxApp, PrimaryView, Region, Selection, StackSpec, StatAnalysis, + AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasViewport, ChartSpec, + CurveFitReference, DataBinding, Dataset, NamedView, ObjectFrame, ObjectId, ObjectStyle, + PanelLabelStyle, PanelMeta, PlotxApp, PrimaryView, Region, Selection, StackSpec, StatAnalysis, StoredCurveFitAnalysis, StoredLineFit, StoredMultiplet, TableEditDelta, TextBox, TypedTableState, }; @@ -106,6 +106,12 @@ pub enum Action { before: CanvasViewport, after: CanvasViewport, }, + SetAxisOverrides { + canvas: usize, + object: ObjectId, + before: AxisOverrides, + after: AxisOverrides, + }, MoveResizeObject { canvas: usize, object: ObjectId, @@ -426,6 +432,7 @@ impl Action { // Inserting or removing a bookmark always changes the list. Self::BoardViewInsert { .. } | Self::BoardViewRemove { .. } => false, Self::SetDataBinding { before, after, .. } => before == after, + Self::SetAxisOverrides { before, after, .. } => before == after, Self::SetChartType { before, after, .. } => before == after, Self::SetStackSpec { before, after, .. } => before == after, Self::SetAxisProjections { before, after, .. } => before == after, diff --git a/crates/core/src/actions/tests/authoring.rs b/crates/core/src/actions/tests/authoring.rs index 6497af4..3e51128 100644 --- a/crates/core/src/actions/tests/authoring.rs +++ b/crates/core/src/actions/tests/authoring.rs @@ -126,6 +126,105 @@ fn set_figure_typography_restamps_plots_and_is_undoable() { assert_eq!(first_plot(&app).figure.typography, after); } +#[test] +fn axis_overrides_survive_rebuild_and_roundtrip_through_undo() { + let mut app = sample_app(); + let object = app.doc.canvases[0].objects[0].id; + let automatic_x_label = first_plot(&app).figure.x.label.clone(); + let automatic_y_label = first_plot(&app).figure.y.label.clone(); + let automatic_x = first_plot(&app).viewport.full_x; + let automatic_y = first_plot(&app).viewport.full_y; + let before = AxisOverrides::default(); + let after = AxisOverrides { + x_label: Some("Chemical shift".to_owned()), + y_label: Some("Response".to_owned()), + x_range: Some(AxisRange::new(1.0, 8.0)), + y_range: Some(AxisRange::new(-2.0, 12.0)), + }; + + app.execute_action(Action::set_axis_overrides( + 0, + object, + before.clone(), + after.clone(), + )); + 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).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); + + let zoomed_x = AxisRange::new(2.0, 4.0); + { + let plot = app.doc.canvases[0].objects[0].plot_mut().unwrap(); + plot.viewport.view_x = zoomed_x; + plot.viewport.apply_to(&mut plot.figure); + } + app.rebuild_canvases_for(0); + assert_eq!(first_plot(&app).axis_overrides, after); + assert_eq!(first_plot(&app).viewport.full_x, AxisRange::new(1.0, 8.0)); + assert_eq!(first_plot(&app).viewport.view_x, zoomed_x); + + app.undo(); + assert_eq!(first_plot(&app).axis_overrides, before); + assert_eq!(first_plot(&app).figure.x.label, automatic_x_label); + assert_eq!(first_plot(&app).figure.y.label, automatic_y_label); + assert_eq!(first_plot(&app).viewport.full_x, automatic_x); + assert_eq!(first_plot(&app).viewport.full_y, automatic_y); + assert!(first_plot(&app).viewport.auto_y); + + app.redo(); + assert_eq!(first_plot(&app).axis_overrides, after); + assert_eq!(first_plot(&app).viewport.view_x, AxisRange::new(1.0, 8.0)); + assert!(!first_plot(&app).viewport.auto_y); +} + +#[test] +fn viewport_reset_returns_to_manual_y_range_without_enabling_auto_y() { + let mut app = sample_app(); + let object = app.doc.canvases[0].objects[0].id; + let overrides = AxisOverrides { + y_range: Some(AxisRange::new(-3.0, 9.0)), + ..AxisOverrides::default() + }; + app.set_axis_overrides_value(0, object, &overrides); + + let undo_len = app.session.undo_stack.len(); + let before = first_plot(&app).viewport.clone(); + let mut reset = before.clone(); + reset.view_y = AxisRange::new(0.0, 1.0); + reset.auto_y = true; + app.commit_object_viewport(0, object, before, reset); + + let viewport = &first_plot(&app).viewport; + assert_eq!(viewport.full_y, AxisRange::new(-3.0, 9.0)); + assert_eq!(viewport.view_y, viewport.full_y); + assert!(!viewport.auto_y); + assert_eq!(app.session.undo_stack.len(), undo_len); +} + +#[test] +fn undo_finishes_and_reverts_a_live_axis_override_edit() { + let mut app = sample_app(); + let object = app.doc.canvases[0].objects[0].id; + let before = AxisOverrides::default(); + app.session.ui.axis_overrides_before = Some((0, object, before.clone())); + app.set_axis_overrides_value( + 0, + object, + &AxisOverrides { + x_label: Some("Edited title".to_owned()), + ..AxisOverrides::default() + }, + ); + + app.undo(); + + assert_eq!(first_plot(&app).axis_overrides, before); + assert!(app.session.ui.axis_overrides_before.is_none()); +} + #[test] fn reorder_z_front_and_back_preserve_relative_order() { let order = [1u64, 2, 3, 4]; diff --git a/crates/core/src/actions/tests/more.rs b/crates/core/src/actions/tests/more.rs index 6b72561..829ec96 100644 --- a/crates/core/src/actions/tests/more.rs +++ b/crates/core/src/actions/tests/more.rs @@ -91,32 +91,40 @@ fn single_table_color_override_recolors_bar_polygons() { } #[test] -fn set_chart_type_switches_table_to_bar_and_undoes() { - use crate::state::ChartSpec; +fn set_chart_type_switches_table_to_categorical_bars_and_undoes() { + use crate::state::{AxisOverrides, AxisRange, ChartSpec}; let (mut app, id) = table_app(); let before = first_plot(&app).chart.clone(); assert_eq!(before.type_id, "table_line"); let line_series = first_plot(&app).figure.series.len(); assert_eq!(line_series, 1, "line chart draws one series per column"); + let overrides = AxisOverrides { + x_range: Some(AxisRange::new(1.0, 8.0)), + ..AxisOverrides::default() + }; + app.set_axis_overrides_value(0, id, &overrides); - let selected_column = app.doc.datasets[0].as_table().unwrap().series_bindings[0].value_column; let after = ChartSpec { - type_id: "table_bar".to_owned(), - column: Some(selected_column), + type_id: "table_bar_grouped".to_owned(), ..ChartSpec::default() }; app.execute_action(Action::set_chart_type(0, id, before, after.clone())); assert_eq!(first_plot(&app).chart, after); - // The bar chart draws one filled rectangle per x row (3 rows). + // One-series grouped bars draw one filled rectangle per categorical row. assert_eq!(first_plot(&app).figure.polygons.len(), 3); + assert!(first_plot(&app).figure.x.categories.is_some()); + assert_eq!(first_plot(&app).figure.x.min, -0.5); + assert_eq!(first_plot(&app).figure.x.max, 2.5); + assert_eq!(first_plot(&app).axis_overrides.x_range, overrides.x_range); app.undo(); assert_eq!(first_plot(&app).chart.type_id, "table_line"); assert_eq!(first_plot(&app).figure.series.len(), line_series); + assert_eq!(first_plot(&app).viewport.full_x, AxisRange::new(1.0, 8.0)); app.redo(); - assert_eq!(first_plot(&app).chart.type_id, "table_bar"); + assert_eq!(first_plot(&app).chart.type_id, "table_bar_grouped"); assert_eq!(first_plot(&app).figure.polygons.len(), 3); } diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index 2eef989..c330d96 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -198,8 +198,8 @@ fn threshold_status(value: f32, min: f32) -> ComplianceStatus { mod tests { use super::*; use crate::state::{ - AxisProjections, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, DataBinding, - ObjectFrame, PanelMeta, PlotObject, StackSpec, + AxisOverrides, AxisProjections, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, + DataBinding, ObjectFrame, PanelMeta, PlotObject, StackSpec, }; use plotx_figure::{Axis, Figure}; @@ -278,6 +278,7 @@ mod tests { chart: ChartSpec::default(), stack: StackSpec::default(), projections: AxisProjections::default(), + axis_overrides: AxisOverrides::default(), figure, viewport, panel, diff --git a/crates/core/src/project/axis_overrides.rs b/crates/core/src/project/axis_overrides.rs new file mode 100644 index 0000000..608f3fb --- /dev/null +++ b/crates/core/src/project/axis_overrides.rs @@ -0,0 +1,36 @@ +use super::dto::RangeDto; +use crate::state::AxisOverrides; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct AxisOverridesDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + x_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + x_range: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y_range: Option, +} + +impl AxisOverridesDto { + pub(super) fn from_overrides(overrides: &AxisOverrides) -> Option { + (overrides != &AxisOverrides::default()).then(|| Self { + x_label: overrides.x_label.clone(), + y_label: overrides.y_label.clone(), + x_range: overrides.x_range.map(RangeDto::from_range), + y_range: overrides.y_range.map(RangeDto::from_range), + }) + } + + pub(super) fn to_overrides(&self) -> AxisOverrides { + AxisOverrides { + x_label: self.x_label.clone(), + y_label: self.y_label.clone(), + x_range: self.x_range.map(RangeDto::into_range), + y_range: self.y_range.map(RangeDto::into_range), + } + .normalized() + } +} diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 070a404..97a53f9 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -1,3 +1,4 @@ +use super::axis_overrides::AxisOverridesDto; use super::convert_recipes::{nmr2d_recipe_extensions, read_regions}; use super::electrophysiology_convert::{ electrophysiology_from_object, electrophysiology_to_objects, @@ -339,6 +340,7 @@ pub fn canvas_to_view( projections: None, frame: FrameDto::from_frame(object.frame), viewport: None, + axis_overrides: None, panel: None, title: None, text: None, @@ -403,6 +405,7 @@ pub fn canvas_to_view( stack, projections: projections_to_dto(&plot.projections, datasets)?, viewport: Some(ViewportDto::from_viewport(&plot.viewport)), + axis_overrides: AxisOverridesDto::from_overrides(&plot.axis_overrides), panel: Some(PanelDto::from_panel(&plot.panel)), ..base(kind) }) @@ -584,12 +587,25 @@ pub fn view_to_canvas( } else { app.build_object_figure(&binding, &chart, &stack, &projections, size_mm) }; - let viewport = view_object + let axis_overrides = view_object + .axis_overrides + .as_ref() + .map(AxisOverridesDto::to_overrides) + .unwrap_or_default(); + let snapshot_backed = view_object.snapshot.is_some(); + if !snapshot_backed { + axis_overrides.apply_to(&mut figure); + } + let mut viewport = view_object .viewport .as_ref() .map(ViewportDto::to_viewport) .unwrap_or_else(|| CanvasViewport::from_figure(&figure)); - if view_object.snapshot.is_none() { + 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); } figure.title.clear(); @@ -604,6 +620,7 @@ pub fn view_to_canvas( chart, stack, projections, + axis_overrides, figure, viewport, panel, diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index da9e607..c5db2a4 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -1,3 +1,4 @@ +use super::axis_overrides::AxisOverridesDto; use super::*; #[derive(Serialize, Deserialize)] @@ -359,6 +360,8 @@ pub struct ViewCanvasObject { #[serde(default, skip_serializing_if = "Option::is_none")] pub viewport: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub axis_overrides: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub panel: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index e0f5f19..07fc151 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -22,6 +22,7 @@ use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use zip::write::SimpleFileOptions; +mod axis_overrides; mod codec; mod convert; mod convert_recipes; diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index 1409774..f1ef8c9 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -1,6 +1,8 @@ use super::linefit_tests::sample_line_fit; use super::*; -use crate::state::{AnalysisSelection, CanvasDocument, CanvasViewport, Dataset, Tool}; +use crate::state::{ + AnalysisSelection, AxisOverrides, CanvasDocument, CanvasViewport, Dataset, Tool, +}; use crate::state::{PeakMark, PeakOrigin}; use crate::{DisplayModeLabel, IntegralResult}; use std::f64::consts::TAU; @@ -254,6 +256,14 @@ fn project_roundtrip_preserves_data_recipe_and_view() { first_plot_mut(&mut app).panel.user_note = "custom title\nHSQC summary".to_owned(); first_plot_mut(&mut app).panel.position = [33.0, 14.0]; first_plot_mut(&mut app).panel.visible = false; + let axis_overrides = AxisOverrides { + x_label: Some("Chemical shift".to_owned()), + y_label: Some("Response".to_owned()), + x_range: Some(AxisRange::new(1.0, 8.0)), + y_range: Some(AxisRange::new(-2.0, 12.0)), + }; + let plot_id = app.doc.canvases[0].objects[0].id; + app.set_axis_overrides_value(0, plot_id, &axis_overrides); let custom_typography = plotx_figure::FigureTypography { tick_pt: 9.5, label_pt: 10.0, @@ -333,6 +343,18 @@ fn project_roundtrip_preserves_data_recipe_and_view() { "custom title\nHSQC summary" ); assert_eq!(first_plot(&loaded).panel.position, [33.0, 14.0]); + assert_eq!(first_plot(&loaded).axis_overrides, axis_overrides); + assert_eq!(first_plot(&loaded).figure.x.label, "Chemical shift"); + assert_eq!(first_plot(&loaded).figure.y.label, "Response"); + assert_eq!( + first_plot(&loaded).viewport.full_x, + AxisRange::new(1.0, 8.0) + ); + assert_eq!( + first_plot(&loaded).viewport.full_y, + AxisRange::new(-2.0, 12.0) + ); + assert!(!first_plot(&loaded).viewport.auto_y); assert!(!first_plot(&loaded).panel.visible); let Dataset::Nmr(n) = &loaded.doc.datasets[0] else { @@ -649,6 +671,7 @@ fn legacy_single_input_loads_as_one_series_binding() { ) .unwrap(); assert!(view.series.is_empty(), "old files carry no series list"); + assert!(view.axis_overrides.is_none()); } #[test] @@ -740,7 +763,12 @@ fn scheme_save_load_apply_roundtrips() { #[test] fn snapshot_roundtrip_restores_materialized_figure() { let mut app = sample_app(); - first_plot_mut(&mut app).figure.x.label = "snapshot-only x label".to_owned(); + let plot = first_plot_mut(&mut app); + plot.figure.x.label = "snapshot-only x label".to_owned(); + plot.figure.x.min = 2.25; + plot.figure.x.max = 3.75; + plot.viewport.full_x = AxisRange::new(0.0, 10.0); + plot.viewport.view_x = AxisRange::new(5.0, 6.0); let path = temp_project("snapshot"); let _ = std::fs::remove_file(&path); @@ -750,4 +778,10 @@ fn snapshot_roundtrip_restores_materialized_figure() { assert!(loaded.doc.save_include_view_snapshots); assert_eq!(first_plot(&loaded).figure.x.label, "snapshot-only x label"); + assert_eq!(first_plot(&loaded).figure.x.min, 2.25); + assert_eq!(first_plot(&loaded).figure.x.max, 3.75); + assert_eq!( + first_plot(&loaded).viewport.view_x, + AxisRange::new(5.0, 6.0) + ); } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 65ebe67..ecf362d 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -272,12 +272,7 @@ impl PlotxApp { object } - pub fn apply_viewport_to_plot_object( - &mut self, - ci: usize, - object_id: ObjectId, - mut fig: Figure, - ) { + pub fn apply_viewport_to_plot_object(&mut self, ci: usize, object_id: ObjectId, fig: Figure) { let Some(object) = self .doc .canvases @@ -289,9 +284,7 @@ impl PlotxApp { let Some(plot) = object.plot_mut() else { return; }; - plot.viewport.sync_full_from(&fig); - plot.viewport.apply_to(&mut fig); - plot.figure = fig; + plot.preserve_viewport_on_rebuild(fig); } pub fn rebuild_canvases_for(&mut self, dataset: usize) { diff --git a/crates/core/src/state/axis_overrides.rs b/crates/core/src/state/axis_overrides.rs new file mode 100644 index 0000000..1dc2f25 --- /dev/null +++ b/crates/core/src/state/axis_overrides.rs @@ -0,0 +1,253 @@ +use super::visible_y_range; +use plotx_figure::{Axis, Figure}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AxisRange { + pub min: f64, + pub max: f64, +} + +impl AxisRange { + pub fn new(min: f64, max: f64) -> Self { + if min <= max { + Self { min, max } + } else { + Self { min: max, max: min } + } + } + + pub fn from_axis(axis: &Axis) -> Self { + Self::new(axis.min, axis.max) + } + + pub fn span(self) -> f64 { + (self.max - self.min).max(f64::MIN_POSITIVE) + } + + pub fn contains(self, value: f64) -> bool { + self.min <= value && value <= self.max + } + + pub fn is_valid(self) -> bool { + self.min.is_finite() && self.max.is_finite() && self.min < self.max + } + + pub fn clamp_to(self, full: Self) -> Self { + let full_span = full.span(); + let span = self.span().min(full_span); + if span >= full_span { + return full; + } + + let mut min = self.min.max(full.min).min(full.max - span); + let mut max = min + span; + if max > full.max { + max = full.max; + min = max - span; + } + Self { min, max } + } + + pub fn zoom_around(self, full: Self, anchor: f64, scale: f64) -> Self { + let span = self.span(); + let full_span = full.span(); + let new_span = (span * scale).clamp(full_span * 1e-6, full_span); + if new_span >= full_span { + return full; + } + + let anchor = anchor.clamp(self.min, self.max); + let frac = ((anchor - self.min) / span).clamp(0.0, 1.0); + let min = anchor - frac * new_span; + let max = min + new_span; + Self { min, max }.clamp_to(full) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CanvasViewport { + pub full_x: AxisRange, + pub full_y: AxisRange, + pub view_x: AxisRange, + pub view_y: AxisRange, + pub auto_y: bool, +} + +impl CanvasViewport { + pub fn from_figure(fig: &Figure) -> Self { + let full_x = AxisRange::from_axis(&fig.x); + let full_y = AxisRange::from_axis(&fig.y); + Self { + full_x, + full_y, + view_x: full_x, + view_y: full_y, + auto_y: true, + } + } + + pub fn sync_full_from(&mut self, fig: &Figure) { + self.full_x = AxisRange::from_axis(&fig.x); + self.full_y = AxisRange::from_axis(&fig.y); + self.view_x = self.view_x.clamp_to(self.full_x); + if self.auto_y { + self.refresh_auto_y(fig); + } else { + self.view_y = self.view_y.clamp_to(self.full_y); + } + } + + pub fn apply_to(&self, fig: &mut Figure) { + fig.x.min = self.view_x.min; + fig.x.max = self.view_x.max; + fig.y.min = self.view_y.min; + fig.y.max = self.view_y.max; + } + + pub fn reset_all(&mut self) { + self.view_x = self.full_x; + self.view_y = self.full_y; + self.auto_y = true; + } + + pub fn reset_x(&mut self, fig: &Figure) { + self.view_x = self.full_x; + if self.auto_y { + self.refresh_auto_y(fig); + } + } + + pub fn reset_y(&mut self, fig: &Figure) { + self.auto_y = true; + self.refresh_auto_y(fig); + } + + pub fn zoom_x(&mut self, fig: &Figure, anchor: f64, scale: f64) { + self.view_x = self.view_x.zoom_around(self.full_x, anchor, scale); + if self.auto_y { + self.refresh_auto_y(fig); + } + } + + pub fn zoom_y(&mut self, anchor: f64, scale: f64) { + self.view_y = self.view_y.zoom_around(self.full_y, anchor, scale); + self.auto_y = false; + } + + pub fn select(&mut self, fig: &Figure, x: Option, y: Option) { + if let Some(x) = x { + self.view_x = x.clamp_to(self.full_x); + } + if let Some(y) = y { + self.view_y = y.clamp_to(self.full_y); + self.auto_y = false; + } else if self.auto_y { + self.refresh_auto_y(fig); + } + } + + fn refresh_auto_y(&mut self, fig: &Figure) { + self.view_y = visible_y_range(fig, self.view_x).unwrap_or(self.full_y); + } +} + +/// Per-plot author overrides applied after rebuilding a figure from its data. +/// `None` keeps the corresponding value derived by the chart builder. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AxisOverrides { + pub x_label: Option, + pub y_label: Option, + pub x_range: Option, + pub y_range: Option, +} + +impl AxisOverrides { + pub fn apply_to(&self, figure: &mut Figure) { + if let Some(label) = &self.x_label { + figure.x.label.clone_from(label); + } + if let Some(label) = &self.y_label { + figure.y.label.clone_from(label); + } + if let Some(range) = self.x_range + && figure.x.categories.is_none() + { + figure.x.min = range.min; + figure.x.max = range.max; + } + if let Some(range) = self.y_range + && figure.y.categories.is_none() + { + figure.y.min = range.min; + figure.y.max = range.max; + } + } + + pub fn normalized(mut self) -> Self { + self.x_label = normalize_label(self.x_label); + 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 + } +} + +fn normalize_label(label: Option) -> Option { + label.filter(|text| !text.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_figure::{Axis, Figure}; + + #[test] + fn empty_labels_and_invalid_ranges_normalize_to_auto() { + let overrides = AxisOverrides { + x_label: Some(" ".to_owned()), + y_label: Some("Signal".to_owned()), + x_range: Some(AxisRange { min: 1.0, max: 1.0 }), + y_range: Some(AxisRange { + min: 5.0, + max: -5.0, + }), + } + .normalized(); + assert_eq!(overrides.x_label, None); + assert_eq!(overrides.y_label.as_deref(), Some("Signal")); + assert_eq!(overrides.x_range, None); + assert_eq!(overrides.y_range, None); + } + + #[test] + fn overrides_apply_without_changing_unspecified_axes() { + let mut figure = Figure::new( + "", + Axis::new("automatic x", 0.0, 10.0), + Axis::new("automatic y", -1.0, 1.0), + ); + AxisOverrides { + x_label: Some("Time".to_owned()), + y_range: Some(AxisRange::new(-2.0, 2.0)), + ..AxisOverrides::default() + } + .apply_to(&mut figure); + assert_eq!(figure.x.label, "Time"); + assert_eq!(figure.y.label, "automatic y"); + assert_eq!(AxisRange::from_axis(&figure.x), AxisRange::new(0.0, 10.0)); + assert_eq!(AxisRange::from_axis(&figure.y), AxisRange::new(-2.0, 2.0)); + } + + #[test] + fn numeric_ranges_do_not_replace_categorical_axis_windows() { + let mut figure = Figure::new("", Axis::new("x", -0.5, 2.5), Axis::new("y", 0.0, 1.0)); + figure.x.categories = Some(vec!["A".to_owned(), "B".to_owned(), "C".to_owned()]); + AxisOverrides { + x_range: Some(AxisRange::new(1.0, 8.0)), + ..AxisOverrides::default() + } + .apply_to(&mut figure); + + assert_eq!(AxisRange::from_axis(&figure.x), AxisRange::new(-0.5, 2.5)); + } +} diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 4185bfa..bc9578a 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -20,152 +20,6 @@ pub struct PhaseDrag { pub gesture_before: DatasetProcessingState, } -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct AxisRange { - pub min: f64, - pub max: f64, -} - -impl AxisRange { - pub fn new(min: f64, max: f64) -> Self { - if min <= max { - Self { min, max } - } else { - Self { min: max, max: min } - } - } - - pub fn from_axis(axis: &Axis) -> Self { - Self::new(axis.min, axis.max) - } - - pub fn span(self) -> f64 { - (self.max - self.min).max(f64::MIN_POSITIVE) - } - - pub fn contains(self, value: f64) -> bool { - self.min <= value && value <= self.max - } - - pub fn clamp_to(self, full: Self) -> Self { - let full_span = full.span(); - let span = self.span().min(full_span); - if span >= full_span { - return full; - } - - let mut min = self.min.max(full.min).min(full.max - span); - let mut max = min + span; - if max > full.max { - max = full.max; - min = max - span; - } - Self { min, max } - } - - pub fn zoom_around(self, full: Self, anchor: f64, scale: f64) -> Self { - let span = self.span(); - let full_span = full.span(); - let new_span = (span * scale).clamp(full_span * 1e-6, full_span); - if new_span >= full_span { - return full; - } - - let anchor = anchor.clamp(self.min, self.max); - let frac = ((anchor - self.min) / span).clamp(0.0, 1.0); - let min = anchor - frac * new_span; - let max = min + new_span; - Self { min, max }.clamp_to(full) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CanvasViewport { - pub full_x: AxisRange, - pub full_y: AxisRange, - pub view_x: AxisRange, - pub view_y: AxisRange, - pub auto_y: bool, -} - -impl CanvasViewport { - pub fn from_figure(fig: &Figure) -> Self { - let full_x = AxisRange::from_axis(&fig.x); - let full_y = AxisRange::from_axis(&fig.y); - Self { - full_x, - full_y, - view_x: full_x, - view_y: full_y, - auto_y: true, - } - } - - pub fn sync_full_from(&mut self, fig: &Figure) { - self.full_x = AxisRange::from_axis(&fig.x); - self.full_y = AxisRange::from_axis(&fig.y); - self.view_x = self.view_x.clamp_to(self.full_x); - if self.auto_y { - self.refresh_auto_y(fig); - } else { - self.view_y = self.view_y.clamp_to(self.full_y); - } - } - - pub fn apply_to(&self, fig: &mut Figure) { - fig.x.min = self.view_x.min; - fig.x.max = self.view_x.max; - fig.y.min = self.view_y.min; - fig.y.max = self.view_y.max; - } - - pub fn reset_all(&mut self) { - self.view_x = self.full_x; - self.view_y = self.full_y; - self.auto_y = true; - } - - pub fn reset_x(&mut self, fig: &Figure) { - self.view_x = self.full_x; - if self.auto_y { - self.refresh_auto_y(fig); - } - } - - pub fn reset_y(&mut self, fig: &Figure) { - self.auto_y = true; - self.refresh_auto_y(fig); - } - - pub fn zoom_x(&mut self, fig: &Figure, anchor: f64, scale: f64) { - self.view_x = self.view_x.zoom_around(self.full_x, anchor, scale); - if self.auto_y { - self.refresh_auto_y(fig); - } - } - - pub fn zoom_y(&mut self, anchor: f64, scale: f64) { - self.view_y = self.view_y.zoom_around(self.full_y, anchor, scale); - self.auto_y = false; - } - - pub fn select(&mut self, fig: &Figure, x: Option, y: Option) { - if let Some(x) = x { - self.view_x = x.clamp_to(self.full_x); - } - if let Some(y) = y { - self.view_y = y.clamp_to(self.full_y); - self.auto_y = false; - } else if self.auto_y { - self.refresh_auto_y(fig); - } - } - - fn refresh_auto_y(&mut self, fig: &Figure) { - self.view_y = visible_y_range(fig, self.view_x).unwrap_or(self.full_y); - } -} - /// A loaded acquisition and its processing recipe. `base` is the post-FFT, /// pre-phase spectrum cached at load; `spectrum` is the current view from it. #[derive(Clone)] diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index 636d9d2..8550e00 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -328,17 +328,12 @@ pub struct PlotObject { pub stack: StackSpec, /// Marginal 1D axis projections for a 2D contour (empty for other plots). pub projections: AxisProjections, + pub axis_overrides: AxisOverrides, pub figure: Figure, pub viewport: CanvasViewport, pub panel: PanelMeta, } -impl PlotObject { - pub fn primary_dataset(&self) -> usize { - self.binding.primary_dataset() - } -} - /// Horizontal alignment of a text box's lines within its frame. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TextAlign { diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 0d87813..8a05ab6 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -35,6 +35,7 @@ mod app_impl_statistics; #[cfg(test)] mod app_impl_statistics_tests; mod app_state; +mod axis_overrides; mod board; mod charts; mod compute; @@ -56,6 +57,7 @@ mod nus; mod page_fit; mod panel_label; mod peaks; +mod plot_object; mod region; mod size_presets; mod stack; @@ -78,6 +80,7 @@ pub use app_impl::*; pub use app_impl_align::*; pub use app_impl_linefit::LineFitJob; pub use app_state::*; +pub use axis_overrides::*; pub use board::*; pub use charts::*; pub use compute::*; diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs new file mode 100644 index 0000000..ba7e1d1 --- /dev/null +++ b/crates/core/src/state/plot_object.rs @@ -0,0 +1,43 @@ +use super::{CanvasViewport, PlotObject}; +use plotx_figure::Figure; + +impl PlotObject { + pub fn primary_dataset(&self) -> usize { + self.binding.primary_dataset() + } + + /// Rebuild → overrides → viewport sync/apply. Effective range overrides + /// replace the full data bounds; zoom and pan remain constrained within them. + pub(crate) fn preserve_viewport_on_rebuild(&mut self, mut figure: Figure) { + self.axis_overrides.apply_to(&mut figure); + if self.has_manual_y_range(&figure) { + self.viewport.auto_y = false; + } + self.viewport.sync_full_from(&figure); + self.viewport.apply_to(&mut figure); + self.figure = figure; + } + + /// Rebuild a plot whose chart semantics changed, starting its viewport at + /// the effective overridden ranges rather than retaining an incompatible view. + pub(crate) fn reset_viewport_on_rebuild(&mut self, mut figure: Figure) { + self.axis_overrides.apply_to(&mut figure); + self.viewport = CanvasViewport::from_figure(&figure); + if self.has_manual_y_range(&figure) { + self.viewport.auto_y = false; + } + self.viewport.apply_to(&mut figure); + self.figure = figure; + } + + pub(crate) fn normalize_viewport(&self, viewport: &mut CanvasViewport) { + if self.has_manual_y_range(&self.figure) && viewport.auto_y { + viewport.view_y = viewport.full_y; + viewport.auto_y = false; + } + } + + fn has_manual_y_range(&self, figure: &Figure) -> bool { + self.axis_overrides.y_range.is_some() && figure.y.categories.is_none() + } +} diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index d7364ba..1b46962 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -345,6 +345,7 @@ impl PlotxApp { chart, stack, projections: AxisProjections::default(), + axis_overrides: AxisOverrides::default(), figure, viewport, panel, diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index b273700..1a03dda 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -321,11 +321,9 @@ pub struct FitEditorValidation { } pub struct UiState { - /// The single in-flight direct-manipulation gesture (drag/select/phase/etc.); - /// see [`Interaction`]. + /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, - /// Which 2D axis the Phase panel and canvas drag target. Re-clamped to a valid - /// axis for the active dataset each time the phase panel renders. + /// 2D axis targeted by the Phase panel and canvas drag; re-clamped when rendered. pub phase_axis: PhaseAxis, pub analysis_selection: Option, /// Which table column the Peaks tool targets (ignored by single-trace domains). @@ -336,6 +334,8 @@ pub struct UiState { pub processing_edit: Option, pub processing_session: Option, pub inspector_edit: Option, + /// Pre-edit snapshot for a plot-local axis text/range gesture. + pub axis_overrides_before: Option<(usize, ObjectId, AxisOverrides)>, pub canvas_settings: Option, /// Whether the document-level Figure Typography window is open. pub figure_typography_open: bool, @@ -346,9 +346,7 @@ pub struct UiState { /// (canvas index, caption, visibility), coalescing a typing run into one undo /// step committed on focus loss. pub caption_edit_before: Option<(usize, String, bool)>, - /// Pre-edit snapshot for an in-progress per-panel note edit - /// (canvas index, object id, panel metadata), coalescing a typing run into - /// one undo step. + /// Pre-edit panel note, coalescing a typing run into one undo step. pub note_edit_before: Option<(usize, ObjectId, PanelMeta)>, pub sheet_open: Option, pub rename: Option, @@ -504,6 +502,7 @@ impl Default for UiState { processing_edit: None, processing_session: None, inspector_edit: None, + axis_overrides_before: None, canvas_settings: None, figure_typography_open: false, figure_typography_before: None, diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index b2527d2..e9c5b59 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -9,9 +9,9 @@ use crate::export::{ DEFAULT_BITMAP_DPI, ExportError, ExportFormat, ExportPageScope, ExportSettings, export_canvases, }; use crate::state::{ - AxisProjections, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, ChartSpec, - DEFAULT_CANVAS_SIZE_MM, DataBinding, Dataset, MM_TO_PT, Nmr2DDataset, NmrDataset, ObjectFrame, - ObjectId, PanelMeta, PlotObject, StackSpec, default_chart_type, + AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, + ChartSpec, DEFAULT_CANVAS_SIZE_MM, DataBinding, Dataset, MM_TO_PT, Nmr2DDataset, NmrDataset, + ObjectFrame, ObjectId, PanelMeta, PlotObject, StackSpec, default_chart_type, }; use plotx_figure::{Axis, Figure}; use plotx_io::{Acquisition, DataFormat, Domain, LoadWarning, LoadWarningCode, Provenance}; @@ -235,6 +235,7 @@ pub fn build_plot_object( chart, stack: StackSpec::default(), projections: AxisProjections::default(), + axis_overrides: AxisOverrides::default(), figure, viewport, panel, diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 06d6772..04fec47 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -53,15 +53,21 @@ as a color overlay. ## Plot styling and typography PlotX styles plots for print automatically: clean bottom-and-left axes with -outward ticks, tick precision that follows the data range, tick density that -automatically thins as a panel narrows, and NMR isotope numbers set as -superscripts. New dataset pages start at the 89 × 60 mm single-column size, so -a plot spanning the page already shows text at its printed journal size; -assemble multi-panel figures on a wider page later, keeping each panel at its -natural size. +outward ticks, tick precision that follows the displayed range, tick density +that thins as a panel narrows, and NMR isotope numbers set as superscripts. +New dataset pages start at the 89 × 60 mm single-column size, so a plot +spanning the page already shows text at its printed journal size; assemble +multi-panel figures on a wider page later, keeping each panel at its natural +size. What you control directly: +- Select one plot and use **Axes** in the Object inspector to override its X + and Y titles or numeric ranges. Leave a title blank, or keep a range on + **Auto**, to use the value derived from the data. A manual range becomes that + axis's full 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 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 9572496..320019a 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 @@ -46,13 +46,17 @@ ACS、Elsevier、PNAS 和 IEEE,数值取自各出版社的作图规范)、 ## 作图样式与排印 PlotX 自动按印刷习惯设定图形样式:只保留左轴和底轴、向外的短刻度、随 -数据范围调整的刻度精度、图框变窄时自动抽稀的刻度密度,以及以上标显示的 +显示范围调整的刻度精度、图框变窄时抽稀的刻度密度,以及以上标显示的 NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个图占满画布 时,屏幕上看到的字号就是期刊印出的字号;多分图的组合图之后再拼合到更宽 的页面上,各分图保持原始尺寸。 你可以直接控制的部分: +- 选中单个图形后,可在对象检查器的 **Axes** 区域覆盖 X/Y 轴标题或数值 + 范围。标题留空或范围保持 **Auto**,即可继续使用由数据自动推导的值。 + 手动范围会成为该轴的完整范围:缩放和平移仍限制在其中,双击图内即回到 + 这个手动范围。不显示坐标轴的图表没有轴设置;分类轴不提供范围控制。 - Figure Ribbon 选项卡的 **Figure Typography…** 一次设定文档内所有图的 坐标轴文字尺寸(刻度标签、轴标题与图标题),单位为绝对磅值——这是 文档级样式,缩放分图不会改变字号。