diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 6af1bd04..95b72bf5 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -92,8 +92,9 @@ pub(crate) fn clear_canvas_interaction_state( ci: usize, scope: CanvasInteractionClearScope, ) { + app.finish_pending_wheel_zoom(f64::INFINITY, true); + app.finish_pending_wheel_property(f64::INFINITY, true); app.reset_interaction(); - app.session.ui.wheel_zoom = None; if matches!( scope, diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 92873c3b..a3a36fc9 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -1,5 +1,5 @@ use egui::{Color32, Pos2, Rect as EguiRect, Sense, Stroke, StrokeKind, Ui, Vec2}; -use plotx_core::actions::{Action, PendingViewportEdit}; +use plotx_core::actions::{Action, PendingViewportEdit, PendingWheelPropertyEdit}; use plotx_core::layout::{self, MovableEdges, SnapGuide, SnapTargets}; use plotx_core::state::region_color; use plotx_core::state::{ @@ -232,6 +232,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_panel_label_selection(app, ci, rect, &painter, chrome); paint_object_selection(app, ci, rect, page, &painter, chrome); paint_property_readouts(app, ci, rect, &painter, chrome, ui.visuals().dark_mode); + paint_wheel_target_hint(app, ci, rect, ui, &painter, chrome, ui.visuals().dark_mode); paint_tile_ghost(app, &painter, chrome); paint_tile_preview(app, rect, &painter, chrome); super::canvas_size::page_size_chrome(app, ci, page, rect, ui); diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index 84ea79c2..0afd3961 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -32,24 +32,23 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, i.pointer.button_released(egui::PointerButton::Middle), ) }); - let (command, space_down, shift, alt, now) = ui.input(|i| { + let (command, space_down, alt, now) = ui.input(|i| { ( i.modifiers.command || i.modifiers.ctrl, i.key_down(egui::Key::Space), - i.modifiers.shift, i.modifiers.alt, i.time, ) }); let typing = ui.ctx().egui_wants_keyboard_input(); - // A single-axis strip zoom is owned here start-to-finish so it rides under - // any tool: track its band and commit on release, even off the canvas. - let axis_zoom = match &app.session.ui.interaction { - Interaction::Zoom(d) if d.axis != ZoomAxis::Box => Some(*d), + // Every viewport zoom drag is owned here start-to-finish so the ambient + // Alt+drag box gesture and the dedicated Browse Zoom tool share one path. + let active_zoom = match &app.session.ui.interaction { + Interaction::Zoom(d) => Some(*d), _ => None, }; - if let Some(drag) = axis_zoom { + if let Some(drag) = active_zoom { if let Some(pp) = hover && let Interaction::Zoom(d) = &mut app.session.ui.interaction { @@ -59,7 +58,11 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, if let Some(plot) = plot_inner_rect(app, drag.canvas, drag.object, rect) && let Interaction::Zoom(d) = app.take_interaction() { - finish_axis_zoom(app, drag.canvas, drag.object, plot, d); + if d.axis == ZoomAxis::Box { + finish_zoom_drag(app, drag.canvas, drag.object, plot, d); + } else { + finish_axis_zoom(app, drag.canvas, drag.object, plot, d); + } } else { app.reset_interaction(); } @@ -116,17 +119,28 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, let pinch = (zoom_delta - 1.0).abs() > 0.001; let wheel = scroll.y.abs() > 0.0; if !typing && (pinch || wheel) { - match data_target { + let consumed = match data_target { Some((id, outer, plot)) => { - let scale = if pinch { - (1.0 / f64::from(zoom_delta)).clamp(0.2, 5.0) + if pinch { + app.finish_pending_wheel_property(now, true); + let scale = (1.0 / f64::from(zoom_delta)).clamp(0.2, 5.0); + app.session.board_fit = None; + zoom_plot_viewport(app, ci, id, outer, plot, p, scale, (true, true), now, ui); + true + } else if alt && hit_zone(p, outer, plot) == HitZone::Plot { + app.finish_pending_wheel_zoom(now, true); + adjust_plot_display(app, ci, id, plot, p, scroll.y, now, ui) } else { - f64::from((-scroll.y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0) - }; - app.session.board_fit = None; - zoom_plot_viewport( - app, ci, id, outer, plot, p, scale, pinch, shift, alt, now, ui, - ); + app.finish_pending_wheel_property(now, true); + let axes = wheel_zoom_axes(app, ci, id, hit_zone(p, outer, plot)); + let Some(axes) = axes else { + return false; + }; + let scale = f64::from((-scroll.y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0); + app.session.board_fit = None; + zoom_plot_viewport(app, ci, id, outer, plot, p, scale, axes, now, ui); + true + } } None => { let factor = if pinch { @@ -137,9 +151,10 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, app.session.board_fit = None; zoom_board_view(app, rect, p, factor); ui.ctx().request_repaint(); + true } - } - return true; + }; + return consumed; } if !typing @@ -151,6 +166,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, let axis = match hit_zone(p, outer, plot) { HitZone::XAxis => Some(ZoomAxis::X), HitZone::YAxis => Some(ZoomAxis::Y), + HitZone::Plot if alt => Some(ZoomAxis::Box), HitZone::Plot | HitZone::None => None, }; if let Some(axis) = axis { @@ -330,31 +346,252 @@ pub(crate) fn reset_plot_viewport( 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 -/// and modifiers: over the body Shift constrains to x and Alt to y; over an axis -/// strip only that axis. Coalesces into one undo step via the pending wheel edit. +/// The axes a plain wheel gesture addresses. A line plot is conventionally +/// navigated along its independent x coordinate; a raster-like field has two +/// spatial coordinates and navigates both. Axis strips always override that +/// body convention with the one axis they explicitly name. +fn wheel_zoom_axes( + app: &PlotxApp, + canvas: usize, + object: ObjectId, + zone: HitZone, +) -> Option<(bool, bool)> { + match zone { + HitZone::XAxis => Some((true, false)), + HitZone::YAxis => Some((false, true)), + HitZone::Plot => { + let two_dimensional = app.doc.canvases[canvas] + .object(object) + .and_then(|object| object.plot()) + .is_some_and(|plot| { + plot.binding.series.iter().any(|series| { + matches!( + &series.encoding, + plotx_figure::SeriesEncoding::Contour(_) + | plotx_figure::SeriesEncoding::Heatmap(_) + | plotx_figure::SeriesEncoding::Image(_) + ) + }) + }); + Some((true, two_dimensional)) + } + HitZone::None => None, + } +} + +/// Alt+wheel changes the unique display-sensitivity property exposed by the +/// hovered series. If there is no such property, a numeric 1D plot falls back +/// to y-scale intensity. Different eligible encodings are refused explicitly. +#[allow(clippy::too_many_arguments)] +fn adjust_plot_display( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + plot: PlotRect, + pointer: Pos2, + scroll_y: f32, + now: f64, + ui: &Ui, +) -> bool { + use crate::ui::properties::discovery::{CanvasStepTarget, canvas_step_target}; + + if app.doc.canvases[canvas] + .object(object) + .is_some_and(|object| object.locked) + { + app.session.status = crate::ui::properties::discovery::LOCKED_REASON.to_owned(); + return true; + } + + match canvas_step_target(app, canvas, object) { + CanvasStepTarget::Ambiguous { labels } => { + app.finish_pending_wheel_property(now, true); + app.session.status = format!( + "Alt+scroll is ambiguous here ({}). Choose a layer in the Object inspector.", + labels.join(" / ") + ); + true + } + CanvasStepTarget::Unique { + property, + label, + targets, + } => { + step_wheel_property( + app, canvas, object, property, label, targets, scroll_y, now, ui, + ); + true + } + CanvasStepTarget::None => { + app.finish_pending_wheel_property(now, true); + scale_line_intensity(app, canvas, object, plot, pointer, scroll_y, now, ui) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn step_wheel_property( + app: &mut PlotxApp, + canvas: usize, + object: ObjectId, + property: plotx_core::properties::PropertyId, + label: &'static str, + targets: Vec, + scroll_y: f32, + now: f64, + ui: &Ui, +) { + let step_delta = ui + .ctx() + .options(|options| options.input_options.line_scroll_speed) + .max(1.0); + let changed_target = app + .session + .ui + .wheel_property + .as_ref() + .is_some_and(|pending| { + pending.canvas != canvas + || pending.object != object + || pending.property != property + || pending.targets != targets + }); + if changed_target { + app.finish_pending_wheel_property(now, true); + } + if app.session.ui.wheel_property.is_none() { + app.session.ui.wheel_property = Some(PendingWheelPropertyEdit { + canvas, + object, + property, + targets: targets.clone(), + accumulator: 0.0, + last_input_time: now, + gesture_started: false, + }); + } + let pending = app.session.ui.wheel_property.as_mut().unwrap(); + pending.accumulator += scroll_y; + pending.last_input_time = now; + let steps = (pending.accumulator.abs() / step_delta).floor() as usize; + if steps == 0 { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); + return; + } + let direction = if pending.accumulator > 0.0 { + plotx_core::properties::PropertyStep::Lower + } else { + plotx_core::properties::PropertyStep::Raise + }; + let signed_step = if pending.accumulator > 0.0 { + step_delta + } else { + -step_delta + }; + pending.accumulator -= signed_step * steps as f32; + + for _ in 0..steps.min(8) { + match app.plan_property_step(property, &targets, direction) { + Ok(commit) => { + if !app + .session + .ui + .wheel_property + .as_ref() + .is_some_and(|pending| pending.gesture_started) + { + app.begin_property_gesture(property); + if let Some(pending) = app.session.ui.wheel_property.as_mut() { + pending.gesture_started = true; + } + } + let applied = app.commit_property(commit); + app.session.status = format!( + "Adjusted {label} on {applied} series. Alt+scroll controls display sensitivity." + ); + } + Err(error) => { + app.session.status = format!("Could not adjust {label}: {error}"); + break; + } + } + } + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); +} + +#[allow(clippy::too_many_arguments)] +fn scale_line_intensity( + app: &mut PlotxApp, + 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 { + (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, + y_to_screen(anchor, plot, y_axis.min, y_axis.span(), y_axis.reversed), + ), + 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)] pub(crate) fn zoom_plot_viewport( app: &mut PlotxApp, ci: usize, object_id: ObjectId, - outer_rect: EguiRect, + _outer_rect: EguiRect, plot: PlotRect, p: Pos2, scale: f64, - both_axes: bool, - shift: bool, - alt: bool, + axes: (bool, bool), now: f64, ui: &Ui, ) { - let (zoom_x, zoom_y) = match hit_zone(p, outer_rect, plot) { - HitZone::XAxis => (true, false), - HitZone::YAxis => (false, true), - HitZone::Plot => (!alt, !shift), - HitZone::None if both_axes => (true, true), - HitZone::None => return, - }; + let (zoom_x, zoom_y) = axes; + if !zoom_x && !zoom_y { + return; + } if app .session diff --git a/crates/app/src/ui/canvas/readout.rs b/crates/app/src/ui/canvas/readout.rs index 594c5870..23ffc853 100644 --- a/crates/app/src/ui/canvas/readout.rs +++ b/crates/app/src/ui/canvas/readout.rs @@ -9,7 +9,7 @@ //! [`PlotxApp::property_readout`] and never resolves, measures or queues //! anything. A plot whose estimate has not arrived says so. -use super::{object_screen_rect, plot_rect}; +use super::{HitZone, hit_zone, object_screen_rect, plot_rect, plot_under_cursor}; use crate::ui::properties; use egui::{Align2, Color32, FontId}; use plotx_core::properties::PropertyReadout; @@ -76,3 +76,102 @@ pub(crate) fn paint_property_readouts( painter.galley(text_rect.min, galley, chrome.selection_stroke); } } + +/// Paint the exact wheel target under the pointer. The hint is transient editor +/// chrome and never enters exported figures. +pub(crate) fn paint_wheel_target_hint( + app: &PlotxApp, + ci: usize, + rect: egui::Rect, + ui: &egui::Ui, + painter: &egui::Painter, + chrome: super::ChromeStyle, + dark_mode: bool, +) { + let Some(pointer) = ui.input(|input| input.pointer.hover_pos()) else { + return; + }; + let Some((object, outer, plot)) = plot_under_cursor(app, ci, rect, pointer) else { + return; + }; + let zone = hit_zone(pointer, outer, plot); + let target_rect = match zone { + HitZone::Plot => plot_rect(plot), + HitZone::XAxis => egui::Rect::from_min_max( + egui::pos2(plot.left, plot.bottom()), + egui::pos2(plot.right(), outer.bottom()), + ), + HitZone::YAxis => egui::Rect::from_min_max( + egui::pos2(outer.left(), plot.top), + egui::pos2(plot.left, plot.bottom()), + ), + HitZone::None => return, + }; + painter.rect_filled(target_rect, 0.0, chrome.selection_fill); + painter.rect_stroke( + target_rect, + 0.0, + egui::Stroke::new(1.0_f32, chrome.selection_stroke), + egui::StrokeKind::Inside, + ); + + let text = match zone { + HitZone::XAxis => "Scroll: zoom X · Double-click: reset X".to_owned(), + HitZone::YAxis => "Scroll: zoom Y · Double-click: reset Y".to_owned(), + HitZone::Plot => { + use properties::discovery::{CanvasStepTarget, canvas_step_target}; + let two_dimensional = app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .is_some_and(|plot| { + plot.binding.series.iter().any(|series| { + matches!( + &series.encoding, + plotx_figure::SeriesEncoding::Contour(_) + | plotx_figure::SeriesEncoding::Heatmap(_) + | plotx_figure::SeriesEncoding::Image(_) + ) + }) + }); + let navigation = 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(" / ")) + } + CanvasStepTarget::None => "Alt+scroll: Y intensity".to_owned(), + }; + let aspect = if app.doc.canvases[ci] + .object(object) + .and_then(|object| object.plot()) + .is_some_and(|plot| plot.figure().lock_aspect) + { + " · aspect locked" + } else { + "" + }; + format!("{navigation} · {display} · Alt+drag: box zoom{aspect}") + } + HitZone::None => return, + }; + let anchor = target_rect.left_top() + egui::vec2(READOUT_INSET_PX, READOUT_INSET_PX); + let galley = painter.layout( + text, + FontId::proportional(READOUT_FONT_PT), + chrome.selection_stroke, + (target_rect.width() - 2.0 * READOUT_INSET_PX).max(40.0), + ); + let text_rect = Align2::LEFT_TOP.anchor_size(anchor, galley.size()); + painter.rect_filled( + text_rect.expand(3.0), + 3.0, + Color32::from_black_alpha(if dark_mode { 170 } else { 28 }), + ); + painter.galley(text_rect.min, galley, chrome.selection_stroke); +} diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 8b3e6924..cc3fe7d7 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -20,6 +20,7 @@ pub fn execute( // menu bar, the palette and the Ribbon). let now = ctx.input(|input| input.time); app.finish_pending_wheel_zoom(now, true); + app.finish_pending_wheel_property(now, true); } if !commands::describe(app, id).enabled { return; diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 52447c55..ed17ba40 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -183,6 +183,7 @@ pub fn render( let now = ctx.input(|i| i.time); app.finish_pending_wheel_zoom(now, false); + app.finish_pending_wheel_property(now, false); } fn copy_table_export(ctx: &egui::Context, payload: plotx_core::data_export::ClipboardExport) { diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index 1cdf229a..46266fe7 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -130,6 +130,7 @@ fn property_sections(app: &mut PlotxApp, ci: usize, include_axes: bool, ui: &mut crate::ui::properties::panel::axis_section(app, ci, &objects, ui); } crate::ui::properties::panel::contour_section(app, ci, &objects, ui); + crate::ui::properties::panel::heatmap_section(app, ci, &objects, ui); crate::ui::properties::panel::line_section(app, ci, &objects, ui); crate::ui::properties::panel::typography_section(app, ui); } diff --git a/crates/app/src/ui/properties/discovery.rs b/crates/app/src/ui/properties/discovery.rs index 63723191..be2cf2d4 100644 --- a/crates/app/src/ui/properties/discovery.rs +++ b/crates/app/src/ui/properties/discovery.rs @@ -72,6 +72,59 @@ pub(crate) fn steppable_in( presentations.iter().find(|entry| entry.canvas_step) } +/// Resolution of the display setting addressed by a wheel gesture over one +/// plot body. +/// +/// Multiple series of the same encoding remain one target with several +/// recipients. Different steppable properties are ambiguous: an overlaid +/// contour and heatmap must never depend on a hidden priority rule. +#[derive(Clone, Debug)] +pub(crate) enum CanvasStepTarget { + None, + Unique { + property: PropertyId, + label: &'static str, + targets: Vec, + }, + Ambiguous { + labels: Vec<&'static str>, + }, +} + +pub(crate) fn canvas_step_target( + app: &PlotxApp, + canvas: usize, + object: ObjectId, +) -> CanvasStepTarget { + let targets = app.series_targets(canvas, object); + let mut candidates = PRESENTATIONS + .iter() + .filter(|entry| entry.canvas_step) + .filter_map(|entry| { + let applicable = app + .resolve_property_set(entry.id, &targets) + .applicable_targets + .into_iter() + .map(|address| address.target) + .collect::>(); + (!applicable.is_empty()).then_some((entry.id, entry.localized_label.get(), applicable)) + }); + let Some((property, label, targets)) = candidates.next() else { + return CanvasStepTarget::None; + }; + let rest: Vec<_> = candidates.collect(); + if rest.is_empty() { + return CanvasStepTarget::Unique { + property, + label, + targets, + }; + } + let mut labels = vec![label]; + labels.extend(rest.into_iter().map(|(_, label, _)| label)); + CanvasStepTarget::Ambiguous { labels } +} + /// What to say when the only plots a setting applies to are locked. It names /// the state and the way out, per the crate's hide-vs-disable rule. pub(crate) const LOCKED_REASON: &str = diff --git a/crates/app/src/ui/properties/discovery_tests.rs b/crates/app/src/ui/properties/discovery_tests.rs index ceb446c6..f325ae8a 100644 --- a/crates/app/src/ui/properties/discovery_tests.rs +++ b/crates/app/src/ui/properties/discovery_tests.rs @@ -21,6 +21,7 @@ use plotx_core::properties::{ PropertyId, PropertyStep, ScopeKind, Tier, ValueCopies, ValueSchema, }; use plotx_core::state::PlotxApp; +use plotx_figure::{HeatmapSpec, SeriesEncoding}; /// A property registered nowhere but here. It shares the contour section's /// home, which is the ordinary case: a new setting joins a group that already @@ -92,7 +93,7 @@ fn one_registration_joins_its_group_without_a_second_entry() { "membership is derived, so it grows with the table and nothing else" ); // The group table itself is untouched: the newcomer contributed no entry. - assert_eq!(GROUPS.len(), 24); + assert_eq!(GROUPS.len(), 25); } /// Channel 3: the gesture picks up whichever property declared itself @@ -107,6 +108,27 @@ fn one_registration_claims_the_canvas_gesture() { ); } +#[test] +fn overlaid_display_encodings_are_reported_as_ambiguous() { + let (mut app, objects) = crate::ui::properties::fixture::contour_page(1); + let object = objects[0]; + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .unwrap(); + let mut heatmap = plot.binding.series[0].clone(); + heatmap.id = plot.allocate_series_id(); + heatmap.encoding = SeriesEncoding::Heatmap(HeatmapSpec::default()); + plot.binding.series.push(heatmap); + + let discovery::CanvasStepTarget::Ambiguous { labels } = + discovery::canvas_step_target(&app, 0, object) + else { + panic!("contour + heatmap must not acquire an implicit wheel priority"); + }; + assert_eq!(labels, vec!["Lowest level", "Colour range"]); +} + /// Every declared group has a Ribbon button, a menu entry and a palette hit — /// derived from `GROUPS`, so declaring a group is the whole registration. #[test] @@ -194,20 +216,23 @@ fn every_home_section_has_a_group_or_the_explicit_preferences_entry() { } } -/// The gesture drives one setting at a time. Two steppable properties would -/// make `+` mean different things depending on table order, which is precisely -/// the kind of hidden ambiguity a derived channel must not introduce. +/// One encoding exposes at most one direct display-sensitivity setting. An +/// overlay may expose one per encoding; runtime discovery reports that as +/// ambiguous instead of choosing by table order. #[test] -fn at_most_one_property_claims_the_canvas_gesture() { - let claiming: Vec<&str> = PRESENTATIONS - .iter() - .filter(|entry| entry.canvas_step) - .map(|entry| entry.id.as_str()) - .collect(); - assert!( - claiming.len() <= 1, - "these properties all claim the `+`/`-` gesture: {claiming:?}" - ); +fn at_most_one_property_per_encoding_claims_display_sensitivity() { + let mut claiming = Vec::new(); + for entry in PRESENTATIONS.iter().filter(|entry| entry.canvas_step) { + let encoding = entry + .definition() + .and_then(|definition| definition.applicability.encoding) + .expect("a canvas display gesture belongs to an encoding"); + assert!( + !claiming.iter().any(|(claimed, _)| *claimed == encoding), + "encoding {encoding:?} has more than one display-sensitivity property" + ); + claiming.push((encoding, entry.id)); + } } /// The gesture is registered as a command, so it is searchable, appears in diff --git a/crates/app/src/ui/properties/groups.rs b/crates/app/src/ui/properties/groups.rs index fe57d75b..7bd3fcf7 100644 --- a/crates/app/src/ui/properties/groups.rs +++ b/crates/app/src/ui/properties/groups.rs @@ -89,6 +89,17 @@ pub(crate) const GROUPS: &[PropertyGroup] = &[ }, unavailable_reason: "Select a plot whose series draws contours before changing contour levels.", }, + PropertyGroup { + section: panel::HEATMAP_SECTION, + label: LocalizedText("Heatmap"), + icon: egui_phosphor::regular::GRID_FOUR, + ribbon: RibbonSpot { + tab: WorkflowTab::Figure, + group: "Style", + priority: 2, + }, + unavailable_reason: "Select a plot whose series draws a scalar heatmap before changing its colour range.", + }, PropertyGroup { section: panel::LINE_SECTION, label: LocalizedText("Line"), diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index 27cbd2a6..4ca7b950 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod discovery; mod groups; pub(crate) mod panel; pub(crate) mod readout; +mod routes; mod search; mod types; @@ -19,6 +20,7 @@ mod types; pub(crate) mod fixture; pub(crate) use groups::GROUPS; +use routes::*; pub(crate) use search::property_hits; pub use types::*; @@ -26,165 +28,12 @@ pub use types::*; use plotx_core::properties::definition; use plotx_core::properties::{ PropertyId, Tier, apodization, app_preferences, axis, baseline, bin, canvas, contour, - export_dpi, group_delay, ilt, line, normalize, object, phase, reference, smooth, step_enabled, - typography, zero_fill, -}; -use plotx_core::state::{SettingsCategory, WorkflowTab}; - -const CONTOUR_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::CONTOUR_SECTION, -}; - -const LINE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::LINE_SECTION, -}; - -const AXIS_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::AXIS_SECTION, -}; - -const STACK_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::STACK_SECTION, -}; -const CHART_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::CHART_SECTION, -}; -const TEXT_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::TEXT_SECTION, -}; -const SHAPE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::SHAPE_SECTION, -}; -const PANEL_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::PANEL_SECTION, -}; -const OBJECT_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::OBJECT_SECTION, -}; - -const fn object_entry( - id: PropertyId, - label: &'static str, - home_route: HomeRoute, -) -> PropertyPresentation { - PropertyPresentation { - id, - localized_label: LocalizedText(label), - localized_aliases: &[], - home_route, - canvas_step: false, - uses_canvas_length_unit: false, - } -} - -const TYPOGRAPHY_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::SecondarySidebar, - section: panel::TYPOGRAPHY_SECTION, -}; - -const CANVAS_MARGINS_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::CanvasSettings, - section: panel::CANVAS_MARGINS_SECTION, -}; - -const CANVAS_GRID_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::CanvasSettings, - section: panel::CANVAS_GRID_SECTION, -}; - -const CANVAS_SIZE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::CanvasSettings, - section: panel::CANVAS_SIZE_SECTION, -}; - -const CANVAS_CAPTION_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::CanvasSettings, - section: panel::CANVAS_CAPTION_SECTION, -}; - -const APODIZATION_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::APODIZATION_SECTION, -}; - -const ZERO_FILL_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::ZERO_FILL_SECTION, -}; - -const PHASE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::PHASE_SECTION, -}; - -const BASELINE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::BASELINE_SECTION, -}; - -const REFERENCE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::REFERENCE_SECTION, -}; - -const SMOOTH_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::SMOOTH_SECTION, -}; - -const NORMALIZE_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::NORMALIZE_SECTION, -}; - -const BIN_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::BIN_SECTION, -}; - -const PROCESSING_STEP_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::PROCESSING_STEP_SECTION, -}; - -const PROCESSING_ADVANCED_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Processing, - section: panel::PROCESSING_ADVANCED_SECTION, -}; - -const EXPORT_PREFERENCES_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Preferences, - section: SettingsCategory::Export.section_id(), -}; - -const GENERAL_PREFERENCES_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Preferences, - section: SettingsCategory::General.section_id(), -}; - -const APPEARANCE_PREFERENCES_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Preferences, - section: SettingsCategory::Appearance.section_id(), -}; - -const UPDATES_PREFERENCES_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Preferences, - section: panel::PREFERENCES_UPDATES_SECTION, -}; - -const PROCESSING_PREFERENCES_HOME: HomeRoute = HomeRoute { - panel: PanelRoute::Preferences, - section: SettingsCategory::Processing.section_id(), + export_dpi, group_delay, heatmap, ilt, line, normalize, object, phase, reference, smooth, + step_enabled, typography, zero_fill, }; +#[cfg(test)] +use plotx_core::state::SettingsCategory; +use plotx_core::state::WorkflowTab; pub const PRESENTATIONS: &[PropertyPresentation] = &[ object_entry(object::STACK_MODE, "Mode", STACK_HOME), @@ -333,6 +182,26 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ canvas_step: false, uses_canvas_length_unit: false, }, + PropertyPresentation { + id: heatmap::RANGE_SPAN, + localized_label: LocalizedText("Colour range"), + localized_aliases: &[ + LocalizedText("heatmap contrast"), + LocalizedText("colour scale"), + LocalizedText("color scale"), + ], + home_route: HEATMAP_HOME, + canvas_step: true, + uses_canvas_length_unit: false, + }, + PropertyPresentation { + id: heatmap::RANGE_CENTER, + localized_label: LocalizedText("Range centre"), + localized_aliases: &[LocalizedText("colour scale midpoint")], + home_route: HEATMAP_HOME, + canvas_step: false, + uses_canvas_length_unit: false, + }, PropertyPresentation { id: line::STROKE_WIDTH, localized_label: LocalizedText("Stroke width"), diff --git a/crates/app/src/ui/properties/panel.rs b/crates/app/src/ui/properties/panel.rs index 205770ed..c217bd59 100644 --- a/crates/app/src/ui/properties/panel.rs +++ b/crates/app/src/ui/properties/panel.rs @@ -25,6 +25,8 @@ use plotx_core::state::{ObjectId, PlotxApp, PropertyFocus, PropertyTextEditState /// The home-route section id of the contour rows. The route table and the /// collapsing header below must agree on it, so both read this constant. pub(crate) const CONTOUR_SECTION: &str = "object.contour"; +/// The home section for scalar heatmap colour-range rows. +pub(crate) const HEATMAP_SECTION: &str = "object.heatmap"; /// The home section for line-encoding rows on selected plot objects. pub(crate) const LINE_SECTION: &str = "object.line"; pub(crate) const AXIS_SECTION: &str = "object.axes"; @@ -170,10 +172,10 @@ enum GestureEdge { pub(crate) use self::sections::{ apodization_section, axis_section, baseline_section, bin_section, canvas_caption_section, canvas_grid_section, canvas_margins_section, canvas_size_section, chart_section, - contour_section, line_section, normalize_section, panel_inline_section, panel_section, - phase_section, preferences_section, processing_advanced_section, processing_step_section, - reference_section, shape_section, smooth_section, stack_section, text_section, - typography_section, zero_fill_section, + contour_section, heatmap_section, line_section, normalize_section, panel_inline_section, + panel_section, phase_section, preferences_section, processing_advanced_section, + processing_step_section, reference_section, shape_section, smooth_section, stack_section, + text_section, typography_section, zero_fill_section, }; #[cfg(test)] diff --git a/crates/app/src/ui/properties/routes.rs b/crates/app/src/ui/properties/routes.rs new file mode 100644 index 00000000..5aa5a5ff --- /dev/null +++ b/crates/app/src/ui/properties/routes.rs @@ -0,0 +1,140 @@ +//! Canonical homes for property presentations. + +use super::*; +use plotx_core::state::SettingsCategory; + +pub(super) const CONTOUR_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::CONTOUR_SECTION, +}; +pub(super) const HEATMAP_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::HEATMAP_SECTION, +}; +pub(super) const LINE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::LINE_SECTION, +}; +pub(super) const AXIS_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::AXIS_SECTION, +}; +pub(super) const STACK_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::STACK_SECTION, +}; +pub(super) const CHART_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::CHART_SECTION, +}; +pub(super) const TEXT_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::TEXT_SECTION, +}; +pub(super) const SHAPE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::SHAPE_SECTION, +}; +pub(super) const PANEL_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::PANEL_SECTION, +}; +pub(super) const OBJECT_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::OBJECT_SECTION, +}; +pub(super) const TYPOGRAPHY_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::SecondarySidebar, + section: panel::TYPOGRAPHY_SECTION, +}; +pub(super) const CANVAS_MARGINS_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_MARGINS_SECTION, +}; +pub(super) const CANVAS_GRID_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_GRID_SECTION, +}; +pub(super) const CANVAS_SIZE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_SIZE_SECTION, +}; +pub(super) const CANVAS_CAPTION_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::CanvasSettings, + section: panel::CANVAS_CAPTION_SECTION, +}; +pub(super) const APODIZATION_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::APODIZATION_SECTION, +}; +pub(super) const ZERO_FILL_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::ZERO_FILL_SECTION, +}; +pub(super) const PHASE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::PHASE_SECTION, +}; +pub(super) const BASELINE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::BASELINE_SECTION, +}; +pub(super) const REFERENCE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::REFERENCE_SECTION, +}; +pub(super) const SMOOTH_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::SMOOTH_SECTION, +}; +pub(super) const NORMALIZE_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::NORMALIZE_SECTION, +}; +pub(super) const BIN_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::BIN_SECTION, +}; +pub(super) const PROCESSING_STEP_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::PROCESSING_STEP_SECTION, +}; +pub(super) const PROCESSING_ADVANCED_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Processing, + section: panel::PROCESSING_ADVANCED_SECTION, +}; +pub(super) const EXPORT_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::Export.section_id(), +}; +pub(super) const GENERAL_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::General.section_id(), +}; +pub(super) const APPEARANCE_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::Appearance.section_id(), +}; +pub(super) const UPDATES_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: panel::PREFERENCES_UPDATES_SECTION, +}; +pub(super) const PROCESSING_PREFERENCES_HOME: HomeRoute = HomeRoute { + panel: PanelRoute::Preferences, + section: SettingsCategory::Processing.section_id(), +}; + +pub(super) const fn object_entry( + id: PropertyId, + label: &'static str, + home_route: HomeRoute, +) -> PropertyPresentation { + PropertyPresentation { + id, + localized_label: LocalizedText(label), + localized_aliases: &[], + home_route, + canvas_step: false, + uses_canvas_length_unit: false, + } +} diff --git a/crates/app/src/ui/properties/sections.rs b/crates/app/src/ui/properties/sections.rs index 57e47603..62caf1bf 100644 --- a/crates/app/src/ui/properties/sections.rs +++ b/crates/app/src/ui/properties/sections.rs @@ -36,6 +36,29 @@ pub(crate) fn contour_section( ) } +/// Render scalar heatmap display-range properties over the current selection. +pub(crate) fn heatmap_section( + app: &mut PlotxApp, + canvas: usize, + objects: &[ObjectId], + ui: &mut Ui, +) -> bool { + let targets: Vec = objects + .iter() + .flat_map(|&object| app.series_targets(canvas, object)) + .collect(); + render_section( + app, + HEATMAP_SECTION, + "Heatmap", + SectionNoun::new("heatmap series", "heatmap series"), + &targets, + Some(EncodingKind::Heatmap), + SectionLayout::Standard, + ui, + ) +} + /// Render line properties over the current plot selection. pub(crate) fn line_section( app: &mut PlotxApp, @@ -476,13 +499,19 @@ fn render_section( }); } - if let Some(encoding) = reset_encoding - && ui - .small_button("Reset contour") + if let Some(encoding) = reset_encoding { + let label = match encoding { + EncodingKind::Contour => "Reset contour", + EncodingKind::Heatmap => "Reset heatmap", + _ => "Reset series style", + }; + if ui + .small_button(label) .on_hover_text("Rebuild this series' encoding from its defaults") .clicked() - { - pending = Some(Pending::ResetEncoding(encoding)); + { + pending = Some(Pending::ResetEncoding(encoding)); + } } // The reveal is one-shot: once the section has been drawn with the row in diff --git a/crates/app/src/ui/properties/types.rs b/crates/app/src/ui/properties/types.rs index 54505bea..3fbdac05 100644 --- a/crates/app/src/ui/properties/types.rs +++ b/crates/app/src/ui/properties/types.rs @@ -33,6 +33,7 @@ impl PanelRoute { match self { Self::SecondarySidebar => &[ panel::CONTOUR_SECTION, + panel::HEATMAP_SECTION, panel::LINE_SECTION, panel::AXIS_SECTION, panel::STACK_SECTION, diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index 0d4719d7..146a3dbb 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -304,8 +304,9 @@ fn handle_escape(app: &mut PlotxApp, now: f64) { return; } - if app.session.ui.wheel_zoom.is_some() { + if app.session.ui.wheel_zoom.is_some() || app.session.ui.wheel_property.is_some() { app.finish_pending_wheel_zoom(now, true); + app.finish_pending_wheel_property(now, true); app.session.status = "Cancelled interaction.".to_owned(); return; } @@ -577,4 +578,23 @@ mod tests { assert_eq!(app.session.tool, Tool::BrowseZoom); assert_eq!(app.session.status, "Exited tool mode."); } + + #[test] + fn escape_finishes_a_pending_wheel_property_gesture() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.session.ui.wheel_property = Some(plotx_core::actions::PendingWheelPropertyEdit { + canvas: 0, + object: plotx_core::state::ObjectId::new(1), + property: plotx_core::properties::contour::BASE_MAGNITUDE, + targets: Vec::new(), + accumulator: 0.0, + last_input_time: 0.0, + gesture_started: false, + }); + + handle_escape(&mut app, 1.0); + + assert!(app.session.ui.wheel_property.is_none()); + assert_eq!(app.session.status, "Cancelled interaction."); + } } diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index 175247ae..59ddf301 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -124,6 +124,14 @@ impl PlotxApp { } => { self.set_object_binding(*canvas, *object, after); } + Action::SetSeriesPresentation { + canvas, + object, + after, + .. + } => { + self.set_object_presentation(*canvas, *object, after); + } Action::SetChartType { canvas, object, @@ -245,6 +253,7 @@ impl PlotxApp { } self.reset_interaction(); self.session.ui.wheel_zoom = None; + self.session.ui.wheel_property = None; self.session.ui.selection = Selection::None; self.session.ui.panel_note_inline_edit = None; self.session.ui.panel_note_edit = None; diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 82995963..e38fc4df 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -35,29 +35,35 @@ impl PlotxApp { } pub fn undo(&mut self) { + self.finish_pending_wheel_zoom(f64::INFINITY, true); + self.finish_pending_wheel_property(f64::INFINITY, true); self.finish_axis_overrides_edit(); self.reset_interaction(); let Some(action) = self.session.undo_stack.pop() else { return; }; + let label = action.undo_label(); self.revert_action(&action); self.session.redo_stack.push(action); self.doc.dirty = true; self.doc.automation_revision = self.doc.automation_revision.saturating_add(1); - self.session.status = "Undid last edit.".to_owned(); + self.session.status = format!("Undid {label}."); } pub fn redo(&mut self) { + self.finish_pending_wheel_zoom(f64::INFINITY, true); + self.finish_pending_wheel_property(f64::INFINITY, true); self.finish_axis_overrides_edit(); self.reset_interaction(); let Some(action) = self.session.redo_stack.pop() else { return; }; + let label = action.undo_label(); self.apply_action(&action); self.session.undo_stack.push(action); self.doc.dirty = true; self.doc.automation_revision = self.doc.automation_revision.saturating_add(1); - self.session.status = "Redid edit.".to_owned(); + self.session.status = format!("Redid {label}."); } pub fn can_undo(&self) -> bool { @@ -73,6 +79,7 @@ impl PlotxApp { self.session.redo_stack.clear(); self.reset_interaction(); self.session.ui.wheel_zoom = None; + self.session.ui.wheel_property = None; self.session.ui.canvas_size_edit = None; self.session.ui.processing_session = None; self.session.ui.property_gesture = None; @@ -108,6 +115,26 @@ impl PlotxApp { ); } } + + pub fn finish_pending_wheel_property(&mut self, now: f64, force: bool) { + let Some(pending) = self.session.ui.wheel_property.as_ref() else { + return; + }; + if !force && now - pending.last_input_time < 0.18 { + return; + } + let gesture_started = pending.gesture_started; + let deferred_contour = + gesture_started && pending.property == crate::properties::contour::BASE_MAGNITUDE; + let target = (pending.canvas, pending.object); + self.session.ui.wheel_property = None; + if gesture_started { + self.end_property_gesture(); + } + if deferred_contour { + self.rebuild_plot_presentation(target.0, target.1); + } + } pub fn set_object_frame(&mut self, canvas: usize, object: ObjectId, frame: ObjectFrame) { let Some(o) = self .doc @@ -306,6 +333,87 @@ impl PlotxApp { } fn set_object_binding(&mut self, canvas: usize, object: ObjectId, binding: &DataBinding) { + self.set_object_binding_with_viewport(canvas, object, binding, false); + } + + fn set_object_presentation(&mut self, canvas: usize, object: ObjectId, binding: &DataBinding) { + // A contour wheel gesture can emit many catalog commits in a fraction + // of a second. Persist every value for accurate readout/undo, but retain + // the last complete geometry and rebuild only once when the gesture + // closes. This bounds background work without weakening ordinary panel + // edits or starving other plots that share the same source field. + let defer_contour = self + .session + .ui + .wheel_property + .as_ref() + .is_some_and(|pending| { + pending.canvas == canvas + && pending.object == object + && pending.gesture_started + && pending.property == crate::properties::contour::BASE_MAGNITUDE + }); + if defer_contour { + if let Some(plot) = self + .doc + .canvases + .get_mut(canvas) + .and_then(|canvas| canvas.object_mut(object)) + .and_then(|object| object.plot_mut()) + { + plot.binding = binding.clone(); + } + return; + } + self.set_object_binding_with_viewport(canvas, object, binding, true); + } + + fn rebuild_plot_presentation(&mut self, canvas: usize, object: ObjectId) { + let Some((binding, chart, stack, projections, frame, previous_contours)) = self + .doc + .canvases + .get(canvas) + .and_then(|canvas| canvas.object(object)) + .and_then(|object| { + let plot = object.plot()?; + Some(( + plot.binding.clone(), + plot.chart.clone(), + plot.stack, + plot.projections.clone(), + object.frame, + plot.figure().contours.clone(), + )) + }) + else { + return; + }; + let size = [ + frame.width / crate::state::MM_TO_PT, + frame.height / crate::state::MM_TO_PT, + ]; + let mut figure = self.build_object_figure(&binding, &chart, &stack, &projections, size); + if figure.contours.len() < previous_contours.len() { + figure.contours = previous_contours; + } + if let Some(plot) = self + .doc + .canvases + .get_mut(canvas) + .and_then(|canvas| canvas.object_mut(object)) + .and_then(|object| object.plot_mut()) + { + plot.preserve_viewport_on_rebuild(figure); + } + } + + fn set_object_binding_with_viewport( + &mut self, + canvas: usize, + object: ObjectId, + binding: &DataBinding, + preserve_viewport: bool, + ) { let Some(o) = self .doc .canvases @@ -334,7 +442,11 @@ impl PlotxApp { .and_then(|c| c.object_mut(object)) .and_then(|o| o.plot_mut()) { - plot.reset_viewport_on_rebuild(fig); + if preserve_viewport { + plot.preserve_viewport_on_rebuild(fig); + } else { + plot.reset_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 509aa507..2515c849 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -124,6 +124,14 @@ impl PlotxApp { } => { self.set_object_binding(*canvas, *object, before); } + Action::SetSeriesPresentation { + canvas, + object, + before, + .. + } => { + self.set_object_presentation(*canvas, *object, before); + } Action::SetChartType { canvas, object, diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index 1ce5979d..f40599ef 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -100,6 +100,11 @@ pub(super) fn validate_action( | Action::MoveResizeObject { canvas, object, .. } | Action::SetPanelMeta { canvas, object, .. } | Action::SetObjectFlags { canvas, object, .. } + | Action::SetDataBinding { canvas, object, .. } + | Action::SetSeriesPresentation { canvas, object, .. } + | Action::SetChartType { canvas, object, .. } + | Action::SetStackSpec { canvas, object, .. } + | Action::SetAxisProjections { canvas, object, .. } | Action::SetObjectText { canvas, object, .. } | Action::RenameObject { canvas, object, .. } => { let valid = app diff --git a/crates/core/src/actions/build.rs b/crates/core/src/actions/build.rs index fbd698e4..b9e0b896 100644 --- a/crates/core/src/actions/build.rs +++ b/crates/core/src/actions/build.rs @@ -211,6 +211,20 @@ impl Action { } } + pub fn set_series_presentation( + canvas: usize, + object: ObjectId, + before: DataBinding, + after: DataBinding, + ) -> Self { + Self::SetSeriesPresentation { + canvas, + object, + before, + after, + } + } + pub fn set_chart_type( canvas: usize, object: ObjectId, diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 4b4fb65e..db8120c5 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -48,6 +48,19 @@ pub struct PendingViewportEdit { pub last_input_time: f64, } +/// Accumulates high-resolution wheel input into discrete catalog steps while +/// keeping the exact hovered series targets fixed for the gesture. +#[derive(Clone)] +pub struct PendingWheelPropertyEdit { + pub canvas: usize, + pub object: ObjectId, + pub property: crate::properties::PropertyId, + pub targets: Vec, + pub accumulator: f32, + pub last_input_time: f64, + pub gesture_started: bool, +} + /// A page's size selection: the physical dimensions together with the preset /// identity the user picked. Kept as one value so undo/redo restores both — the /// id is what disambiguates journal widths shared by two publishers. @@ -235,6 +248,15 @@ pub enum Action { before: DataBinding, after: DataBinding, }, + /// Change persisted series presentation while retaining the user's spatial + /// viewport. Property-catalog edits use this boundary; adding, removing or + /// reordering data still uses `SetDataBinding` and refits the plot. + SetSeriesPresentation { + canvas: usize, + object: ObjectId, + before: DataBinding, + after: DataBinding, + }, /// Switch a plot's chart type (and its column selection), rebuilding the /// figure through the chart registry and re-fitting the viewport. SetChartType { @@ -443,6 +465,28 @@ pub enum Action { mod build; impl Action { + pub fn undo_label(&self) -> &'static str { + match self { + Self::Composite(actions) => actions + .iter() + .find(|action| !action.is_noop()) + .map(Self::undo_label) + .unwrap_or("edit"), + Self::SetObjectViewport { .. } => "plot navigation", + Self::SetSeriesPresentation { .. } => "display setting", + Self::SetAxisOverrides { .. } => "axis setting", + Self::UpdateDatasetProcessing { .. } => "data processing", + Self::MoveResizeObject { .. } + | Self::SetObjectFrames { .. } + | Self::ArrangeObjects { .. } => "object layout", + Self::SetDataBinding { .. } => "plot data", + Self::SetChartType { .. } => "chart type", + Self::SetStackSpec { .. } => "stack setting", + Self::SetCanvasSize { .. } | Self::SetPageLayout { .. } => "page layout", + _ => "edit", + } + } + fn is_noop(&self) -> bool { match self { Self::Composite(actions) => actions.iter().all(Self::is_noop), @@ -469,6 +513,7 @@ impl Action { // Inserting or removing a bookmark always changes the list. Self::BoardViewInsert { .. } | Self::BoardViewRemove { .. } => false, Self::SetDataBinding { before, after, .. } => before == after, + Self::SetSeriesPresentation { before, after, .. } => before == after, Self::SetAxisOverrides { before, after, .. } => before == after, Self::SetChartType { before, after, .. } => before == after, Self::SetStackSpec { before, after, .. } => before == after, diff --git a/crates/core/src/contour_ladder.rs b/crates/core/src/contour_ladder.rs index a5d7b92f..b74e26ed 100644 --- a/crates/core/src/contour_ladder.rs +++ b/crates/core/src/contour_ladder.rs @@ -24,16 +24,18 @@ pub(crate) struct ContourLadder { /// policy never produces a signed absolute value, and the caller applies its /// half's sign to the returned magnitudes. /// -/// An unusable base is handled according to *where the base came from*, because -/// the two cases mean opposite things: +/// A base that cannot produce a crossing is handled according to *why*: /// -/// - A base a policy *derived* — most often a zero scale estimate on a flat or -/// ideal synthetic grid — is not something the user typed, and would otherwise -/// leave a permanently blank plot with no indication of why. It falls back to -/// a base derived from the spec the user actually selected, never to a hidden -/// peak fraction, so `count` and `ratio` still control the output. A fallback -/// that is itself unusable (a non-finite peak, or a ratio ladder that -/// overflows) draws nothing. +/// - A positive base at or above this half's peak means the threshold has +/// deliberately excluded that half. It draws nothing for every policy. In +/// particular, raising a shared signed ladder past the weaker half must not +/// wrap that half around to a new ladder near zero. +/// - A base a policy could not derive — most often a zero scale estimate on a +/// flat or ideal synthetic grid — is not a threshold at all. It falls back to +/// a base derived from the spec the user selected, never to a hidden peak +/// fraction, so `count` and `ratio` still control the output. A fallback that +/// is itself unusable (a non-finite peak, or a ratio ladder that overflows) +/// draws nothing. /// - [`ContourBasePolicy::Absolute`] *is* the user's explicit input, the /// strongest term of the value-resolution order. Rewriting it would silently /// draw a ladder at levels the user never asked for, so it is obeyed @@ -45,17 +47,27 @@ pub(crate) fn contour_level_ladder( level: &ContourLevelSpec, ) -> ContourLadder { let usable = |value: f64| value.is_finite() && value > 0.0 && value < peak; + let positive_base = base > 0.0; let base = if usable(base) { base + } else if positive_base { + // A positive threshold at or above the peak is a valid request for an + // empty half, not a failed derivation. Only an explicit absolute value + // is reported as unreachable; a raised noise/background multiple may + // legitimately suppress the weaker sign without an error message. + return ContourLadder { + levels: Vec::new(), + threshold_above_peak: (matches!(level.base, ContourBasePolicy::Absolute(_)) + && base >= peak) + .then_some(base), + }; } else if matches!(level.base, ContourBasePolicy::Absolute(_)) { - // `Absolute` wraps a `PositiveFiniteF64`, and both callers reject a - // non-positive peak before reaching here, so the only way an explicit - // threshold is unusable is that it sits at or above the peak. The - // comparison is still written out rather than assumed, so a future - // caller with a non-finite peak reports nothing instead of a bad number. + // `Absolute` always wraps a positive finite number today. Keep a + // defensive empty result if that invariant ever changes rather than + // inventing a replacement for explicit input. return ContourLadder { levels: Vec::new(), - threshold_above_peak: (base >= peak).then_some(base), + threshold_above_peak: None, }; } else if level.count == 1 { // One level always means one visible, interior contour: a lone level at diff --git a/crates/core/src/properties/heatmap.rs b/crates/core/src/properties/heatmap.rs new file mode 100644 index 00000000..3719210d --- /dev/null +++ b/crates/core/src/properties/heatmap.rs @@ -0,0 +1,245 @@ +//! Scalar heatmap display-range properties. +//! +//! The range belongs to the series encoding, not to the source field. `None` +//! keeps the encoding data-driven by using the field summary; an explicit range +//! is a presentation override and is therefore safe to edit without changing +//! the scientific data. + +use super::provider::PropertyProvider; +use super::target::{not_applicable_encoding, resolved_schema, series_context}; +use super::{ + AggregateValue, Applicability, Availability, ComponentKind, DefaultPolicy, EditOp, + EncodingKind, FloatBounds, FloatDisplay, PropertyAccess, PropertyAddress, PropertyDefinition, + PropertyError, PropertyId, PropertyStep, PropertyTransaction, PropertyValue, ResolvedProperty, + ScopeKind, Tier, ValueCopies, ValueSchema, definition, +}; +use crate::automation::CAP_FIELD_SCALAR_GRID_2D_REGULAR; +use crate::state::{Dataset, FieldId, PlotxApp}; +use plotx_figure::{HeatmapSpec, SeriesEncoding}; + +pub const RANGE_SPAN: PropertyId = PropertyId("series.heatmap.range_span"); +pub const RANGE_CENTER: PropertyId = PropertyId("series.heatmap.range_center"); + +const MAX_VALUE: f64 = f32::MAX as f64; +const SPAN_BOUNDS: FloatBounds = FloatBounds::above(0.0, MAX_VALUE); +const CENTER_BOUNDS: FloatBounds = FloatBounds::inclusive(-MAX_VALUE, MAX_VALUE); +const SPAN_STEP_RATIO: f64 = 1.2; + +const HEATMAP: Applicability = + Applicability::encoding(ComponentKind::Series, EncodingKind::Heatmap) + .requiring(&[CAP_FIELD_SCALAR_GRID_2D_REGULAR]); + +pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ + PropertyDefinition { + id: RANGE_SPAN, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Float { + bounds: SPAN_BOUNDS, + display: FloatDisplay::Linear("intensity"), + drag_step: None, + }, + access: PropertyAccess::ReadWrite, + applicability: HEATMAP, + default_policy: DefaultPolicy::Derived, + tier: Tier::Essential, + copies: ValueCopies::PerTarget, + canonical_label: "Colour range span", + canonical_aliases: &[ + "heatmap range", + "colour scale", + "color scale", + "contrast", + "dynamic range", + ], + }, + PropertyDefinition { + id: RANGE_CENTER, + scope_kind: ScopeKind::Object, + value_schema: ValueSchema::Float { + bounds: CENTER_BOUNDS, + display: FloatDisplay::Linear("intensity"), + drag_step: None, + }, + access: PropertyAccess::ReadWrite, + applicability: HEATMAP, + default_policy: DefaultPolicy::Derived, + tier: Tier::Advanced, + copies: ValueCopies::PerTarget, + canonical_label: "Colour range centre", + canonical_aliases: &[ + "heatmap centre", + "color range center", + "colour scale midpoint", + ], + }, +]; + +pub(crate) struct HeatmapProvider; + +pub(crate) static PROVIDER: HeatmapProvider = HeatmapProvider; + +impl PropertyProvider for HeatmapProvider { + fn definitions(&self) -> &'static [PropertyDefinition] { + DEFINITIONS + } + + fn read( + &self, + app: &PlotxApp, + address: &PropertyAddress, + ) -> Result { + let definition = definition(address.definition).ok_or_else(|| { + PropertyError::UnknownProperty(address.definition.as_str().to_owned()) + })?; + let context = series_context(app, &address.target, definition)?; + let SeriesEncoding::Heatmap(spec) = context.encoding else { + return Err(not_applicable_encoding(definition, context.encoding)); + }; + let range = effective_range(spec, context.dataset, context.field, definition.id)?; + let summary = summary_range(context.dataset, context.field, definition.id)?; + Ok(ResolvedProperty { + address: address.clone(), + modified: Some(spec.value_range.is_some()), + value: AggregateValue::Uniform(read_value(definition.id, range)?), + default_value: Some(read_value(definition.id, summary)?), + availability: Availability::Editable, + schema: resolved_schema(definition, &context.capabilities), + }) + } + + fn edit( + &self, + app: &PlotxApp, + transaction: &mut PropertyTransaction, + address: &PropertyAddress, + operation: &EditOp<'_>, + ) -> Result<(), PropertyError> { + let definition = definition(address.definition).ok_or_else(|| { + PropertyError::UnknownProperty(address.definition.as_str().to_owned()) + })?; + let context = series_context(app, &address.target, definition)?; + let SeriesEncoding::Heatmap(current) = context.encoding else { + return Err(not_applicable_encoding(definition, context.encoding)); + }; + let current_range = + effective_range(current, context.dataset, context.field, definition.id)?; + + let next_range = match operation { + EditOp::Reset => None, + EditOp::Set(value) => { + let value = value + .as_float() + .ok_or_else(|| PropertyError::InvalidValue { + property: definition.id, + message: format!("expected a number, got {}", value.kind()), + })?; + Some(range_with_value(definition.id, current_range, value)?) + } + EditOp::Step(direction) => { + if definition.id != RANGE_SPAN { + return Err(PropertyError::InvalidValue { + property: definition.id, + message: "this setting has no step gesture".to_owned(), + }); + } + let span = f64::from(current_range[1]) - f64::from(current_range[0]); + let stepped = match direction { + PropertyStep::Raise => span * SPAN_STEP_RATIO, + PropertyStep::Lower => span / SPAN_STEP_RATIO, + }; + Some(range_with_value(definition.id, current_range, stepped)?) + } + }; + + let binding = transaction.data_binding(app, context.canvas, context.object)?; + let series = binding + .series + .iter_mut() + .find(|series| series.id == context.series) + .ok_or_else(|| PropertyError::UnknownTarget(address.target.describe()))?; + let SeriesEncoding::Heatmap(spec) = &mut series.encoding else { + return Err(PropertyError::NotApplicable( + "the series is no longer a heatmap".to_owned(), + )); + }; + spec.value_range = next_range; + Ok(()) + } +} + +fn summary_range( + dataset: &Dataset, + field: FieldId, + property: PropertyId, +) -> Result<[f32; 2], PropertyError> { + let summary = dataset + .field_payload(field) + .and_then(|payload| payload.summary()) + .ok_or_else(|| { + PropertyError::NotApplicable( + "the scalar field has no finite range to drive a colour scale".to_owned(), + ) + })?; + checked_range([summary.min.get(), summary.max.get()], property) +} + +fn effective_range( + spec: &HeatmapSpec, + dataset: &Dataset, + field: FieldId, + property: PropertyId, +) -> Result<[f32; 2], PropertyError> { + match spec.value_range { + Some([lo, hi]) => checked_range([f64::from(lo), f64::from(hi)], property), + None => summary_range(dataset, field, property), + } +} + +fn checked_range(range: [f64; 2], property: PropertyId) -> Result<[f32; 2], PropertyError> { + let [lo, hi] = range; + if !lo.is_finite() || !hi.is_finite() || lo < -MAX_VALUE || hi > MAX_VALUE || lo >= hi { + return Err(PropertyError::InvalidValue { + property, + message: format!( + "colour range [{lo}, {hi}] must contain two finite, increasing f32 values" + ), + }); + } + let range = [lo as f32, hi as f32]; + if range[0] >= range[1] { + return Err(PropertyError::InvalidValue { + property, + message: "colour range is too narrow to represent with f32 field values".to_owned(), + }); + } + Ok(range) +} + +fn read_value(property: PropertyId, [lo, hi]: [f32; 2]) -> Result { + match property { + RANGE_SPAN => Ok(PropertyValue::Float(f64::from(hi) - f64::from(lo))), + RANGE_CENTER => Ok(PropertyValue::Float((f64::from(lo) + f64::from(hi)) * 0.5)), + _ => Err(PropertyError::UnknownProperty(property.as_str().to_owned())), + } +} + +fn range_with_value( + property: PropertyId, + current: [f32; 2], + value: f64, +) -> Result<[f32; 2], PropertyError> { + let center = (f64::from(current[0]) + f64::from(current[1])) * 0.5; + let span = f64::from(current[1]) - f64::from(current[0]); + let (center, span) = match property { + RANGE_SPAN => ( + center, + SPAN_BOUNDS.check(property, "colour range span", value)?, + ), + RANGE_CENTER => ( + CENTER_BOUNDS.check(property, "colour range centre", value)?, + span, + ), + _ => return Err(PropertyError::UnknownProperty(property.as_str().to_owned())), + }; + checked_range([center - span * 0.5, center + span * 0.5], property) +} diff --git a/crates/core/src/properties/heatmap_tests.rs b/crates/core/src/properties/heatmap_tests.rs new file mode 100644 index 00000000..243934ab --- /dev/null +++ b/crates/core/src/properties/heatmap_tests.rs @@ -0,0 +1,159 @@ +use super::tests::contour_app; +use super::*; +use crate::automation::{ComponentRef, TargetRef}; +use crate::state::PlotxApp; +use plotx_figure::{HeatmapSpec, SeriesEncoding}; + +fn heatmap_app() -> (PlotxApp, TargetRef) { + let (mut app, target) = contour_app(); + let Some(ComponentRef::Series(series_id)) = target.component else { + panic!("fixture target is a series"); + }; + let object = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + let series = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .unwrap() + .binding + .series + .iter_mut() + .find(|series| series.id == series_id) + .unwrap(); + series.encoding = SeriesEncoding::Heatmap(HeatmapSpec::default()); + (app, target) +} + +fn heatmap_spec(app: &PlotxApp, target: &TargetRef) -> HeatmapSpec { + let Some(ComponentRef::Series(series_id)) = target.component else { + panic!("target is a series"); + }; + let object = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + let series = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .unwrap() + .binding + .series + .iter() + .find(|series| series.id == series_id) + .unwrap(); + let SeriesEncoding::Heatmap(spec) = &series.encoding else { + panic!("series is a heatmap"); + }; + spec.clone() +} + +#[test] +fn automatic_range_is_read_from_the_scalar_field_summary() { + let (app, target) = heatmap_app(); + let span = app + .resolve_property(&PropertyAddress::new(target.clone(), heatmap::RANGE_SPAN)) + .unwrap(); + let center = app + .resolve_property(&PropertyAddress::new(target, heatmap::RANGE_CENTER)) + .unwrap(); + let Some(PropertyValue::Float(span_value)) = span.value.uniform() else { + panic!("span resolves to a number"); + }; + let Some(PropertyValue::Float(center_value)) = center.value.uniform() else { + panic!("centre resolves to a number"); + }; + assert!(*span_value > 0.0); + assert!(center_value.is_finite()); + assert_eq!(span.default_value, Some(PropertyValue::Float(*span_value))); + assert!(!span.is_modified()); +} + +#[test] +fn stepping_span_preserves_center_and_reset_restores_auto_range() { + let (mut app, target) = heatmap_app(); + let before_span = app + .resolve_property(&PropertyAddress::new(target.clone(), heatmap::RANGE_SPAN)) + .unwrap() + .value + .uniform() + .and_then(PropertyValue::as_float) + .unwrap(); + let before_center = app + .resolve_property(&PropertyAddress::new(target.clone(), heatmap::RANGE_CENTER)) + .unwrap() + .value + .uniform() + .and_then(PropertyValue::as_float) + .unwrap(); + let commit = app + .plan_property_step( + heatmap::RANGE_SPAN, + std::slice::from_ref(&target), + PropertyStep::Lower, + ) + .unwrap(); + assert_eq!(app.commit_property(commit), 1); + let [lo, hi] = heatmap_spec(&app, &target).value_range.unwrap(); + assert!(((f64::from(lo) + f64::from(hi)) * 0.5 - before_center).abs() < 1.0e-5); + assert!((f64::from(hi) - f64::from(lo) - before_span / 1.2).abs() < 1.0e-5); + assert!( + app.resolve_property(&PropertyAddress::new(target.clone(), heatmap::RANGE_SPAN)) + .unwrap() + .is_modified() + ); + + let commit = app + .plan_property_reset(heatmap::RANGE_SPAN, std::slice::from_ref(&target)) + .unwrap(); + app.commit_property(commit); + assert_eq!(heatmap_spec(&app, &target).value_range, None); +} + +#[test] +fn presentation_edits_preserve_the_spatial_viewport_and_name_undo() { + let (mut app, target) = heatmap_app(); + let object = target + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + let plot = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .unwrap(); + let figure = plot.figure().clone(); + let anchor = (figure.x.min + figure.x.max) * 0.5; + plot.viewport.zoom_x(&figure, anchor, 0.5); + plot.apply_viewport(); + let viewport = plot.viewport.clone(); + + let commit = app + .plan_property_write( + heatmap::RANGE_CENTER, + std::slice::from_ref(&target), + &PropertyValue::Float(1.0), + ) + .unwrap(); + app.commit_property(commit); + assert_eq!( + app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .unwrap() + .viewport, + viewport + ); + + app.undo(); + assert_eq!(app.session.status, "Undid display setting."); +} diff --git a/crates/core/src/properties/mod.rs b/crates/core/src/properties/mod.rs index a9f202c2..b0a605ad 100644 --- a/crates/core/src/properties/mod.rs +++ b/crates/core/src/properties/mod.rs @@ -17,6 +17,7 @@ pub mod canvas; pub mod contour; pub mod export_dpi; pub mod group_delay; +pub mod heatmap; pub mod ilt; pub mod line; mod model; @@ -75,6 +76,9 @@ pub(crate) static GROUPS: &[PropertyProviderGroup] = &[ PropertyProviderGroup { provider: &group_delay::PROVIDER, }, + PropertyProviderGroup { + provider: &heatmap::PROVIDER, + }, PropertyProviderGroup { provider: &ilt::PROVIDER, }, @@ -218,6 +222,10 @@ mod step_tests; #[path = "scope_tests.rs"] mod scope_tests; +#[cfg(test)] +#[path = "heatmap_tests.rs"] +mod heatmap_tests; + #[cfg(test)] #[path = "apodization_tests.rs"] mod apodization_tests; diff --git a/crates/core/src/properties/provider_tests.rs b/crates/core/src/properties/provider_tests.rs index fa9fbdb5..d65e379e 100644 --- a/crates/core/src/properties/provider_tests.rs +++ b/crates/core/src/properties/provider_tests.rs @@ -208,7 +208,7 @@ fn line_stroke_width_reports_mixed_values_and_skips_other_encodings() { assert!( actions .iter() - .all(|action| matches!(action, Action::SetDataBinding { .. })) + .all(|action| matches!(action, Action::SetSeriesPresentation { .. })) ); app.commit_property(commit); for target in line_targets { diff --git a/crates/core/src/properties/tests_catalog.rs b/crates/core/src/properties/tests_catalog.rs index 44130d8b..8a7c67ef 100644 --- a/crates/core/src/properties/tests_catalog.rs +++ b/crates/core/src/properties/tests_catalog.rs @@ -65,6 +65,29 @@ fn every_derived_default_read_reports_a_reset_target() { use crate::state::{CanvasObject, CanvasObjectKind, TextBox}; let (mut app, series) = super::contour_app(); + let plot_id: crate::state::ObjectId = series + .resource + .local_id + .as_deref() + .unwrap() + .parse() + .unwrap(); + let heatmap_series = { + let plot = app.doc.canvases[0] + .object_mut(plot_id) + .and_then(|object| object.plot_mut()) + .unwrap(); + let id = plot.allocate_series_id(); + let mut binding = plot.binding.series[0].clone(); + binding.id = id; + binding.encoding = + plotx_figure::SeriesEncoding::Heatmap(plotx_figure::HeatmapSpec::default()); + plot.binding.series.push(binding); + id + }; + let heatmap = app + .series_target(0, plot_id, heatmap_series) + .expect("heatmap target"); let canvas = &mut app.doc.canvases[0]; let text_id = canvas.allocate_object_id(); canvas.objects.push(CanvasObject { @@ -76,14 +99,16 @@ fn every_derived_default_read_reports_a_reset_target() { group: None, kind: CanvasObjectKind::Text(TextBox::label("derived default".to_owned())), }); - let object = crate::automation::TargetRef::resource(series.resource); + let object = crate::automation::TargetRef::resource(series.resource.clone()); let text = app.object_target(0, text_id).expect("text target"); let application = app.app_target(); for definition in catalog() .iter() .filter(|definition| matches!(&definition.default_policy, DefaultPolicy::Derived)) { - let target = if definition.scope_kind == ScopeKind::App { + let target = if definition.applicability.encoding == Some(EncodingKind::Heatmap) { + heatmap.clone() + } else if definition.scope_kind == ScopeKind::App { application.clone() } else if matches!( definition.id, diff --git a/crates/core/src/properties/tests_editing.rs b/crates/core/src/properties/tests_editing.rs index a31a99aa..74cad6e3 100644 --- a/crates/core/src/properties/tests_editing.rs +++ b/crates/core/src/properties/tests_editing.rs @@ -2,8 +2,8 @@ use super::*; -/// A panel edit must reach the document as the ordinary typed binding action, -/// so it undoes, redoes and rebuilds like every other binding change. +/// A panel edit must reach the document as the typed series-presentation +/// action, so it undoes and redoes without resetting the spatial viewport. #[test] fn an_edit_compiles_into_a_typed_binding_action() { let (mut app, target) = contour_app(); @@ -21,7 +21,7 @@ fn an_edit_compiles_into_a_typed_binding_action() { panic!("a commit is always one atomic composite"); }; assert_eq!(actions.len(), 1); - assert!(matches!(actions[0], Action::SetDataBinding { .. })); + assert!(matches!(actions[0], Action::SetSeriesPresentation { .. })); app.commit_property(commit); let after = contour_spec(&app, &target); diff --git a/crates/core/src/properties/transaction.rs b/crates/core/src/properties/transaction.rs index bd9d7125..dfd67e5e 100644 --- a/crates/core/src/properties/transaction.rs +++ b/crates/core/src/properties/transaction.rs @@ -614,7 +614,7 @@ impl BindingPlan { .into_iter() .filter(|(_, _, before, after)| before != after) .map(|(canvas, object, before, after)| { - Action::set_data_binding(canvas, object, before, after) + Action::set_series_presentation(canvas, object, before, after) }) .collect() } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 352e668e..c77ff147 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -357,6 +357,7 @@ impl PlotxApp { self.session.tool = tool; self.reset_interaction(); self.finish_pending_wheel_zoom(f64::INFINITY, true); + self.finish_pending_wheel_property(f64::INFINITY, true); // A data tool operates directly on the selected plot, so give it a target: // if the active canvas has no selected plot yet, select the active one. if tool.is_data_tool() diff --git a/crates/core/src/state/field_runtime_threshold_tests.rs b/crates/core/src/state/field_runtime_threshold_tests.rs index a17a4400..4e1c6b61 100644 --- a/crates/core/src/state/field_runtime_threshold_tests.rs +++ b/crates/core/src/state/field_runtime_threshold_tests.rs @@ -100,10 +100,7 @@ fn a_half_with_no_signal_of_its_sign_is_not_a_threshold_problem() { } #[test] -fn a_policy_base_above_the_peak_falls_back_to_the_selected_ladder_span() { - // A degenerate (zero) noise estimate is not something the user typed, so it - // still falls back to the ladder the spec selected — and that fallback is a - // successful resolution, never a threshold report. +fn a_policy_base_above_the_peak_suppresses_that_half_without_wrapping_to_zero() { let spec = ContourSpec { positive: ContourLevelSpec { base: ContourBasePolicy::NoiseFloor { @@ -114,7 +111,15 @@ fn a_policy_base_above_the_peak_falls_back_to_the_selected_ladder_span() { count: 3, ratio: PositiveFiniteF64::new(1.5).unwrap(), }, - negative: None, + negative: Some(ContourLevelSpec { + base: ContourBasePolicy::NoiseFloor { + multiplier: PositiveFiniteF64::new(5.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), + estimator: EstimatorSelection::FollowLatest, + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }), style: ContourStyle::default(), }; let ContourResolution::Ready { @@ -122,7 +127,7 @@ fn a_policy_base_above_the_peak_falls_back_to_the_selected_ladder_span() { unreachable, } = resolve_contour_levels(source(32, 1, 1), &spec, summary(), |_| { Some(EstimateResult::Scale(ScaleEstimate { - scale: EstimatedScale::Degenerate, + scale: EstimatedScale::new(3.0).expect("positive scale"), provenance: EstimateProvenance { estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, @@ -132,12 +137,60 @@ fn a_policy_base_above_the_peak_falls_back_to_the_selected_ladder_span() { else { panic!("the estimate is supplied, so resolution is not pending"); }; - assert_eq!(levels.positive.len(), 3); - assert!((levels.positive[0].get() - 10.0 / 1.5f64.powi(2)).abs() < 1e-9); + assert!(levels.positive.is_empty()); + assert!(levels.negative.is_empty()); assert!( unreachable.is_empty(), - "a policy that recovered drew levels; there is nothing to explain: {unreachable:?}" + "a raised derived threshold is a valid suppression request, not a mistyped absolute value" + ); +} + +#[test] +fn raising_a_shared_policy_past_only_the_weaker_half_never_reveals_negative_levels() { + let level = ContourLevelSpec { + base: ContourBasePolicy::NoiseFloor { + multiplier: PositiveFiniteF64::new(6.0).unwrap(), + peak_fraction: plotx_figure::UnitInterval::new(0.0).expect("a zero floor is valid"), + estimator: EstimatorSelection::FollowLatest, + }, + count: 3, + ratio: PositiveFiniteF64::new(1.5).unwrap(), + }; + let spec = ContourSpec { + positive: level.clone(), + negative: Some(level), + style: ContourStyle::default(), + }; + let asymmetric = FieldSummary { + min: finite(-5.0), + max: finite(20.0), + }; + let ContourResolution::Ready { + levels, + unreachable, + } = resolve_contour_levels(source(33, 1, 1), &spec, asymmetric, |_| { + Some(EstimateResult::Scale(ScaleEstimate { + scale: EstimatedScale::new(1.0).expect("positive scale"), + provenance: EstimateProvenance { + estimator: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_ID.to_owned(), + version: plotx_analysis::robust::ROBUST_DIFFERENCE_MAD_VERSION, + }, + })) + }) + else { + panic!("the estimate is supplied, so resolution is not pending"); + }; + + assert_eq!( + levels.positive.first().map(|level| level.get()), + Some(6.0), + "the stronger positive half keeps the raised threshold" + ); + assert!( + levels.negative.is_empty(), + "a threshold above the negative peak must remove negative contours instead of generating a replacement ladder" ); + assert!(unreachable.is_empty()); } /// A 4×4 plane whose real values run 0..=10 and never go negative: the positive diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 28c26b83..e3acffa8 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -1,4 +1,5 @@ use super::*; +use crate::actions::PendingWheelPropertyEdit; use crate::operation::{OperationHistory, OperationId, OperationReport}; use std::collections::HashSet; use std::ops::{Deref, DerefMut}; @@ -292,6 +293,7 @@ pub struct UiState { /// Which table column the Peaks tool targets (ignored by single-trace domains). pub peak_column: Option, pub wheel_zoom: Option, + pub wheel_property: Option, pub canvas_size_edit: Option, pub page_layout_edit: Option, pub processing_session: Option, @@ -473,6 +475,7 @@ impl Default for UiState { analysis_selection: None, peak_column: None, wheel_zoom: None, + wheel_property: None, canvas_size_edit: None, page_layout_edit: None, processing_session: None, diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 7e4c56bb..405f00f9 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -65,6 +65,7 @@ export default defineConfig({ items: [ { slug: 'guides/layout-and-export' }, { slug: 'guides/contour-levels' }, + { slug: 'guides/heatmap-range' }, { slug: 'guides/annotations' }, { slug: 'guides/exporting' }, { slug: 'guides/present-mode' }, diff --git a/docs/src/content/docs/getting-started/quick-tour.md b/docs/src/content/docs/getting-started/quick-tour.md index 36cb75aa..63f2bf6d 100644 --- a/docs/src/content/docs/getting-started/quick-tour.md +++ b/docs/src/content/docs/getting-started/quick-tour.md @@ -69,6 +69,11 @@ steps with a real dataset. Pan and zoom are always available, in any tool: -- **Scroll wheel** — zoom the plot under the cursor. +- **Scroll wheel** — zoom the x axis of a 1D plot, or both axes of a 2D plot. +- **Pinch** — zoom both axes, whatever the plot draws. +- **Alt + scroll** — change what the plot shows rather than where you are + looking: the y intensity of a 1D plot, 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. - **Double-click** — auto-range the axes. diff --git a/docs/src/content/docs/guides/contour-levels.md b/docs/src/content/docs/guides/contour-levels.md index 0bab29d8..10662a2d 100644 --- a/docs/src/content/docs/guides/contour-levels.md +++ b/docs/src/content/docs/guides/contour-levels.md @@ -69,6 +69,19 @@ level by one rung of that plot's own **Level ratio**, so one press adds or removes roughly one contour ring whatever the intensity scale: the same gesture works on a spectrum whose peak is 100 and one whose peak is a billion. +Hovering the plot body and holding `Alt` while you scroll does the same thing +without the keyboard. One wheel notch moves one rung, and the finer motion of a +trackpad accumulates into the same rungs. The contours already on screen stay +there while the new ones are computed, so sweeping through several rungs never +leaves you reading a half-drawn ladder. + +A hint in the top-left corner of the hovered plot names the setting `Alt` + +scroll will change and how many series it will change — *Alt+scroll: Lowest +level (3 series)*. If the plot also draws something else with a display setting +of its own, such as a heatmap under the contours, `Alt` + scroll does nothing +rather than guessing which layer you meant; change the layer you want from the +Object inspector. + Whenever the keys apply, the current lowest level is shown in the top-right corner of the plot, resolved the same way as in the panel — `5 × σ = 1.2e4`. A plot whose contour series do not all sit at the same level says so instead of @@ -107,6 +120,10 @@ The ladder stops early when the next level would be above the data's peak, so a plot can show fewer levels than **Levels** asks for. That is not an error: the remaining levels would draw nothing. +Raise the lowest level past one sign's peak and that half stops drawing +altogether. It is never quietly redrawn at some lower ladder instead, so +raising the level can only ever remove contours, never add them. + A ladder can also stop early at the bottom. A level that falls inside the noise crosses most of the grid, and there is a limit to how much line one plot can draw. Past that limit PlotX drops the remaining levels whole — never cutting a @@ -181,4 +198,5 @@ Four routes reach a contour setting, and they all end at the same control: - **Contour** in the **Style** group of the Ribbon's **Figure** tab jumps to the same place. - Right-click the plot and choose **Contour settings…**. -- `+` and `-` change the lowest level directly on the plot. +- `+` and `-`, or `Alt` + scroll over the plot body, change the lowest level + directly on the plot. diff --git a/docs/src/content/docs/guides/heatmap-range.md b/docs/src/content/docs/guides/heatmap-range.md new file mode 100644 index 00000000..21ace0c2 --- /dev/null +++ b/docs/src/content/docs/guides/heatmap-range.md @@ -0,0 +1,65 @@ +--- +title: Heatmap colour range +description: Control which values a scalar heatmap's colours span, without touching the data underneath. +--- + +A heatmap paints a 2D scalar field by mapping values to colours. Which values +the colours span is a display choice: tighten it and weak features stand out, +widen it and the full dynamic range fits without saturating. + +Select the plot on the page. The **Heatmap** section appears in the Object +inspector at the top of the Secondary Side Bar, and only when something in the +selection draws a heatmap. Its header counts the series you are about to edit, +so a change you meant for one plot never lands on three unnoticed. + +## Colour range + +- **Colour range** is the distance between the lowest and highest coloured + value. Reduce it for contrast, raise it to keep more of the range visible. +- **Range centre**, under **Advanced**, is the value halfway between those two + limits. Move it to shift the colours up or down the scale without changing + how wide the span is. + +Until you set either one, the colours span the field's own smallest and largest +finite values. Setting a row stores an explicit range on that series; it never +normalizes, clips, or otherwise alters the data the plot was built from. + +A changed row is marked with a dot. Hover the dot to see the value PlotX would +choose, and use the reset button beside it to go back. Resetting either row +returns the whole range to the field's finite minimum and maximum as they are +now, rather than restoring an older number. +**Reset heatmap** rebuilds the whole heatmap encoding from its defaults, and +touches only the series drawn as heatmaps: contours in the same plot are left +alone and reported as skipped in the status bar. + +A field with no finite values has nothing to derive a scale from, and the rows +say so instead of showing a number. + +## From the plot + +Hover the plot body and hold `Alt` while you scroll to change **Colour range** +without leaving the canvas. Each wheel notch narrows or widens the span by +about 20% around its current centre, so the gesture behaves the same on a field +whose values run to 100 and one whose values run to a billion. Scroll up to +tighten the range and bring out weak features, down to widen it. + +A hint in the top-left corner of the hovered plot names what the gesture will +change and how many series it will change before you commit to it. A pinch +always zooms the axes instead, so you can navigate the plot without disturbing +its colours. + +If the same plot draws contours over the heatmap, both layers have a display +setting `Alt` + scroll could plausibly mean. PlotX does nothing rather than +picking one by drawing order; use the **Heatmap** or **Contour** section to +change the layer you meant. + +## Finding a setting + +- Ctrl+K (Cmd on macOS) searches settings as + well as commands and data. Typing `colour scale`, `contrast` or `heatmap` + finds the row, opens the panel it lives in, expands its section and + highlights it. See [Command palette](/reference/command-palette/). +- **Heatmap** in the **Style** group of the Ribbon's **Figure** tab jumps to + the same place. +- Right-click the plot and choose **Heatmap settings…**. +- `Alt` + scroll over the plot body changes **Colour range** directly. diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index a17b34c9..0e0ece33 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -34,9 +34,10 @@ the cursor, or on the board when the cursor is over empty space. | Input | Action | | --- | --- | -| Scroll wheel / pinch | Zoom the plot under the cursor (both axes) | -| `Shift` + scroll | Zoom the x axis only | -| `Alt` + scroll | Zoom the y axis only | +| Scroll over a plot body | Zoom the x axis of a 1D plot; both axes of a 2D plot | +| Pinch over a plot | Zoom both axes, whatever the plot draws | +| `Alt` + scroll 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` + drag over a plot body | Rubber-band a box to zoom into, in any tool | | Scroll over an axis strip | Zoom that axis only | | `Ctrl` + scroll / pinch | Zoom the board instead of the plot | | Middle-drag or `Space` + drag | Pan the plot (the board when over empty space or holding `Ctrl`) | @@ -46,6 +47,13 @@ the cursor, or on the board when the cursor is over empty space. | `F` | Zoom the board to fit the selected frames (everything when nothing is selected) | | `Enter` | Zoom the board to the selected page or sheet | +Hovering a plot body or an axis strip outlines the area the wheel will act on +and names the action in its top-left corner, including which setting `Alt` + +scroll would change and on how many series. Where one plot draws two things +with display settings of their own — contours over a heatmap — `Alt` + scroll +does nothing rather than guessing which you meant; change the layer you want +from the Object inspector. + ## Selection and editing | Input | Action | 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 83e4a420..3990730a 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 @@ -56,6 +56,10 @@ macOS 使用系统全局菜单栏,并包含符合平台习惯的 PlotX 应用 平移与缩放在任何工具下都可用: -- **滚轮**——缩放光标所在的图。 +- **滚轮**——缩放 1D 图的 X 轴;2D 图则同时缩放两个轴。 +- **双指捏合**——无论图画的是什么,都缩放两个轴。 +- **Alt + 滚轮**——改变图“显示什么”而不是“看哪里”:1D 图的 Y 强度、等高线最低层 + 或热图的色阶范围。悬停时图上会写明将要调整哪一项。 +- **Alt + 拖动**——在任何工具下框选一块区域放大。 - **中键拖动**或 **Space + 拖动**——平移。 - **双击**——坐标轴自动适配范围。 diff --git a/docs/src/content/docs/zh-cn/guides/contour-levels.md b/docs/src/content/docs/zh-cn/guides/contour-levels.md index 1bed555d..6e1007db 100644 --- a/docs/src/content/docs/zh-cn/guides/contour-levels.md +++ b/docs/src/content/docs/zh-cn/guides/contour-levels.md @@ -56,6 +56,15 @@ threshold below 1800.*(正半区阈值 20000 高于该场的正向峰值 1800 按该图自身的 **Level ratio**(层间比值)移动一级,因此无论强度量级如何,一次按键 大致增减一圈等高线——峰值是 100 的谱和峰值是十亿的谱,手感完全相同。 +不用键盘也可以:把光标悬停在图内,按住 `Alt` 滚动即可。滚轮每格移动一级,触控板 +更细腻的位移也会累积成同样的级差。新的等高线算好之前,屏幕上原有的那一份仍然保留, +因此连续拨过好几级也不会让你盯着一份画到一半的阶梯。 + +悬停时,图的左上角会给出提示,写明 `Alt` + 滚轮将调整哪一项、涉及多少条谱线—— +例如 *Alt+scroll: Lowest level (3 series)*。若同一幅图还画了另一个自带显示参数的 +图层,例如等高线下方的热图,PlotX 不会去猜你指的是哪一层,`Alt` + 滚轮索性不动作; +请在对象检查器中修改你真正想改的图层。 + 只要这两个键可用,图的右上角就会显示当前最低层,解析方式与面板一致—— `5 × σ = 1.2e4`;若图中多条等高线谱线的最低层并不一致,它会如实说明,而不是拿 其中一条冒充整体。这种调整就是一次普通 @@ -88,6 +97,9 @@ threshold below 1800.*(正半区阈值 20000 高于该场的正向峰值 1800 当下一层会超过数据峰值时,阶梯会提前终止,因此图上实际显示的层数可能少于 **Levels** 所要求的。这不是错误:多出的层本来也画不出任何东西。 +把最低层提高到超过某个符号半区的峰值,该半区就整个不再绘制,也不会被悄悄换成一组 +更低的阶梯。因此提高最低层只可能减少等高线,绝不会反而多画出几圈。 + 阶梯也可能从下端提前终止。落在噪声里的层会穿过网格的大部分区域,而一幅图能绘制 的线量是有上限的。超过该上限后,PlotX 会整层丢弃剩余层级——绝不会把某条等高线 从中途截断——并且正负两个半区同时丢弃同样的层,因此你看到的始终是一组完整的、 @@ -150,4 +162,4 @@ see every level the panel lists.*(最低的 14 层未绘制:在 5.052e4 及 打开它所在的面板、展开其分节并高亮该行。参见[命令面板](/zh-cn/reference/command-palette/)。 - Ribbon **Figure** 页签 **Style** 组里的 **Contour** 按钮跳转到同一处。 - 在图上点击右键,选择 **Contour settings…**。 -- `+` 与 `-` 直接在图上调整最低层。 +- `+` 与 `-`,或在图内使用 `Alt` + 滚轮,直接在图上调整最低层。 diff --git a/docs/src/content/docs/zh-cn/guides/heatmap-range.md b/docs/src/content/docs/zh-cn/guides/heatmap-range.md new file mode 100644 index 00000000..bfb6a56e --- /dev/null +++ b/docs/src/content/docs/zh-cn/guides/heatmap-range.md @@ -0,0 +1,51 @@ +--- +title: 热图色阶范围 +description: 在不改动底层数据的前提下,控制标量热图用颜色覆盖的取值区间。 +--- + +热图把二维标量场的数值映射成颜色。颜色覆盖多大的取值区间纯属显示选择:区间收窄 +则弱特征更醒目,区间放宽则完整的动态范围都能容纳而不至于饱和。 + +在页面上选中该图。只要选中内容里有热图,**Heatmap**(热图)分节就会出现在 +Secondary Side Bar 顶部的对象检查器中。分节标题会写明即将编辑的谱线数量, +免得本想改一幅图却不知不觉改了三幅。 + +## 色阶范围 + +- **Colour range**(色阶范围)是着色区间上下限之差。调小可提升对比度,调大则能 + 在一幅图里保留更宽的取值范围。 +- **Range centre**(范围中心)在 **Advanced**(高级)中,是上下限的中点。移动它 + 可以在不改变区间宽度的前提下,把颜色整体上移或下移。 + +在你设定之前,颜色区间取自该场自身有限值的最小值与最大值。修改任一行都会在这条 +谱线上保存一个明确的显示区间;它不会归一化、裁剪或以任何方式改动绘图所依据的 +数据。 + +被改过的行会带一个圆点标记。悬停圆点可查看 PlotX 会取的值,点旁边的重置按钮即可 +还原。重置任一行都会让整个区间回到该场当前的有限最小值与最大值,而不是恢复某个 +旧数值。**Reset heatmap**(重置热图)则按默认值重建整个热图编码,并且只作用于以 +热图绘制的谱线:同一幅图里的等高线不受影响,并在状态栏中报告为已跳过。 + +若某个场没有有限值,就无从推导色阶,相关行会如实说明,而不是给出一个数字。 + +## 直接在图上调整 + +把光标悬停在图内,按住 `Alt` 滚动即可直接调整 **Colour range**,无需离开画布。 +滚轮每格围绕当前中心把区间收窄或放宽约 20%,因此无论场值量级是 100 还是十亿, +手感都一样。向上滚动收紧区间、突出弱特征,向下滚动则放宽区间。 + +悬停时,图的左上角会给出提示,在你真正动手前写明这个手势将调整什么、涉及多少条 +谱线。双指捏合始终缩放坐标轴,因此浏览图形不会打乱它的配色。 + +如果同一幅图在热图上又叠了等高线,两个图层都有 `Alt` + 滚轮可能指向的显示参数。 +PlotX 不会按绘制顺序替你挑一个,而是干脆不动作;请到 **Heatmap** 或 **Contour** +分节中修改你真正想改的图层。 + +## 找到某项设置 + +- Ctrl+K(macOS 为 Cmd)既搜命令和数据,也搜 + 设置。输入 `colour scale`、`contrast` 或 `heatmap` 就能找到对应行,打开它所在 + 的面板、展开其分节并高亮该行。参见[命令面板](/zh-cn/reference/command-palette/)。 +- Ribbon **Figure** 页签 **Style** 组里的 **Heatmap** 按钮跳转到同一处。 +- 在图上点击右键,选择 **Heatmap settings…**。 +- 在图内使用 `Alt` + 滚轮直接调整 **Colour range**。 diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index f66cee02..72b92d6e 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -33,9 +33,10 @@ description: 键盘与鼠标快捷操作。 | 操作 | 效果 | | --- | --- | -| 滚轮 / 双指捏合 | 缩放光标所在的图(双轴) | -| `Shift` + 滚轮 | 仅缩放 x 轴 | -| `Alt` + 滚轮 | 仅缩放 y 轴 | +| 在图内滚动 | 缩放 1D 图的 X 轴;2D 图同时缩放两个轴 | +| 在图上双指捏合 | 无论图画的是什么,都缩放两个轴 | +| `Alt` + 在图内滚动 | 改变图显示什么:1D 的 Y 强度、等高线最低层,或热图的色阶范围 | +| `Alt` + 在图内拖动 | 在任何工具下框选一块区域放大 | | 在坐标轴带上滚动 | 仅缩放该轴 | | `Ctrl` + 滚轮 / 捏合 | 改为缩放画板 | | 中键拖动或 `Space` + 拖动 | 平移图(位于空白处或按住 `Ctrl` 时平移画板) | @@ -45,6 +46,11 @@ description: 键盘与鼠标快捷操作。 | `F` | 缩放画板以适配所选图框(未选中时适配全部) | | `Enter` | 缩放画板至所选页面或工作表 | +光标悬停在图内或坐标轴带上时,PlotX 会勾出滚轮将要作用的区域,并在其左上角写明 +操作,包括 `Alt` + 滚轮会改哪一项设置、涉及多少条谱线。若同一幅图画了两个各自带 +显示参数的图层(例如等高线覆盖在热图上),`Alt` + 滚轮不会去猜你指的是哪一层, +索性不动作;请在对象检查器中修改你真正想改的图层。 + ## 选择与编辑 | 操作 | 效果 |