From bd5f79c70bf9bf8cd061bbfdef0a166b5f7a945f Mon Sep 17 00:00:00 2001 From: "J.A. de Jong" Date: Tue, 26 May 2026 21:15:10 +0200 Subject: [PATCH 1/3] Log scale implementation for x and y axis --- Cargo.lock | 9 + Cargo.toml | 1 + egui_plot/src/axis.rs | 263 ++++++++++++++++++-- egui_plot/src/axis_transform.rs | 354 +++++++++++++++++++++++++++ egui_plot/src/grid.rs | 21 +- egui_plot/src/items/series.rs | 2 +- egui_plot/src/lib.rs | 11 + egui_plot/src/log_helper.rs | 408 ++++++++++++++++++++++++++++++++ egui_plot/src/memory.rs | 12 +- egui_plot/src/plot.rs | 166 ++++++++++++- examples/log_scale/Cargo.toml | 22 ++ examples/log_scale/src/main.rs | 122 ++++++++++ 12 files changed, 1341 insertions(+), 50 deletions(-) create mode 100644 egui_plot/src/axis_transform.rs create mode 100644 egui_plot/src/log_helper.rs create mode 100644 examples/log_scale/Cargo.toml create mode 100644 examples/log_scale/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 82ab16c7..52a22189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2160,6 +2160,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "log_scale" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_plot", + "env_logger", +] + [[package]] name = "markers" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0d02477a..3481153a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ legend = { version = "0.1.0", path = "examples/legend" } legend_sort = { version = "0.1.0", path = "examples/legend_sort" } lines = { version = "0.1.0", path = "examples/lines" } linked_axes = { version = "0.1.0", path = "examples/linked_axes" } +log_scale = { version = "0.1.0", path = "examples/log_scale" } markers = { version = "0.1.0", path = "examples/markers" } performance = { version = "0.1.0", path = "examples/performance" } plot_span = { version = "0.1.0", path = "examples/plot_span" } diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis.rs index c22acaa8..6a7cd45f 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis.rs @@ -210,7 +210,7 @@ impl<'a> AxisWidget<'a> { return (response, 0.0); } - let Some(transform) = self.transform else { + let Some(transform) = &self.transform else { return (response, 0.0); }; let tick_labels_thickness = self.add_tick_labels(ui, transform, axis); @@ -271,19 +271,116 @@ impl<'a> AxisWidget<'a> { } /// Add tick labels to the axis. Returns the thickness of the axis. - fn add_tick_labels(&self, ui: &Ui, transform: PlotTransform, axis: Axis) -> f32 { + /// Count how many labels would be shown with a given `step_size` threshold. + /// This is used to ensure we always show a minimum number of labels. + fn count_labels_with_threshold( + &self, + _ui: &Ui, + transform: &PlotTransform, + axis: Axis, + step_size_threshold: f64, + ) -> usize { + let label_spacing = self.hints.label_spacing; + let mut count = 0; + let mut last_shown_pos: Option = None; + + let any_large_step = self.steps.iter().any(|s| s.step_size >= 5.0); + + for step in self.steps.iter() { + let text = (self.hints.formatter)(*step, &self.range); + if text.is_empty() { + continue; + } + + // Apply step-size filtering + if any_large_step && step.step_size < step_size_threshold { + continue; + } + + // Calculate position in screen space + let current_pos = match axis { + Axis::X => transform.position_from_point(&super::PlotPoint::new(step.value, 0.0)), + Axis::Y => transform.position_from_point(&super::PlotPoint::new(0.0, step.value)), + }; + let current_coord = current_pos[usize::from(axis)]; + + // Apply spacing filtering + let spacing_in_points = if let Some(last_coord) = last_shown_pos { + (current_coord - last_coord).abs() + } else { + f32::INFINITY + }; + + if spacing_in_points <= label_spacing.min { + continue; + } + + count += 1; + last_shown_pos = Some(current_coord); + } + + count + } + + fn add_tick_labels(&self, ui: &Ui, transform: &PlotTransform, axis: Axis) -> f32 { let font_id = TextStyle::Body.resolve(ui.style()); let label_spacing = self.hints.label_spacing; let mut thickness: f32 = 0.0; const SIDE_MARGIN: f32 = 4.0; // Add some margin to both sides of the text on the Y axis. + const MIN_LABEL_COUNT: usize = 3; // Minimum number of labels to show on an axis let painter = ui.painter(); + // Determine the step_size threshold to use + // Try progressively more permissive thresholds until we get enough labels + let any_large_step = self.steps.iter().any(|s| s.step_size >= 5.0); + + let step_size_threshold = if !any_large_step { + 0.0 // Linear mode - don't filter by step_size + } else { + // Try threshold 1.0 first (only major marks) + let count_with_1_0 = self.count_labels_with_threshold(ui, transform, axis, 1.0); + if count_with_1_0 >= MIN_LABEL_COUNT { + 1.0 // Enough labels with strict filtering + } else { + // Try threshold 0.5 (include tier 3: 2×, 5× marks) + let count_with_0_5 = self.count_labels_with_threshold(ui, transform, axis, 0.5); + if count_with_0_5 >= MIN_LABEL_COUNT { + 0.5 // Need tier 3 marks + } else { + 0.0 // Show all marks, no step_size filtering + } + } + }; + + // Track the last shown label position to calculate spacing correctly + let mut last_shown_pos: Option = None; + // Add tick labels: for step in self.steps.iter() { let text = (self.hints.formatter)(*step, &self.range); if !text.is_empty() { - let spacing_in_points = (transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32; + // For log scales, use step_size to determine label importance + // Only label marks that are "significant enough" based on + // `step_size` This prevents labeling every minor grid line But + // skip this filtering if we seem to be in linear mode (all + // step_sizes are small) + if any_large_step && step.step_size < step_size_threshold { + continue; // Show as grid line only, no label (log scale filtering) + } + // Calculate current label position in screen space + let current_pos = match axis { + Axis::X => transform.position_from_point(&super::PlotPoint::new(step.value, 0.0)), + Axis::Y => transform.position_from_point(&super::PlotPoint::new(0.0, step.value)), + }; + let current_coord = current_pos[usize::from(axis)]; + + // Calculate spacing from the last shown label (if any) + let spacing_in_points = if let Some(last_coord) = last_shown_pos { + (current_coord - last_coord).abs() + } else { + f32::INFINITY // First label always has enough space + }; if spacing_in_points <= label_spacing.min { // Labels are too close together - don't paint them. @@ -311,6 +408,9 @@ impl<'a> AxisWidget<'a> { continue; // the galley won't fit (likely too wide on the X axis). } + // We're going to show this label - update the last shown position + last_shown_pos = Some(current_coord); + match axis { Axis::X => { thickness = thickness.max(galley_size.y); @@ -362,15 +462,23 @@ impl<'a> AxisWidget<'a> { /// Contains the screen rectangle and the plot bounds and provides methods to /// transform between them. -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct PlotTransform { /// The screen rectangle. frame: Rect, - /// The plot bounds. + /// The plot bounds in data space. bounds: PlotBounds, + /// The plot bounds in plot space (after applying axis transforms). + plot_bounds: PlotBounds, + + /// Transform for the x-axis (data space -> plot space). + x_transform: Box, + + /// Transform for the y-axis (data space -> plot space). + y_transform: Box, + /// Whether to always center the x-range or y-range of the bounds. centered: Vec2b, @@ -379,7 +487,31 @@ pub struct PlotTransform { } impl PlotTransform { + /// Create a new transform with linear axes + /// + /// # Arguments + /// + /// * `frame` - The screen rectangle. + /// * `bounds` - The plot bounds in data space. + /// * `center_axis` - Whether to always center the x-range or y-range of the bounds. pub fn new(frame: Rect, bounds: PlotBounds, center_axis: impl Into) -> Self { + Self::new_with_transforms( + frame, + bounds, + center_axis, + Box::new(crate::axis_transform::LinearAxisTransform), + Box::new(crate::axis_transform::LinearAxisTransform), + ) + } + + /// Create a new transform with custom axis transforms. + pub fn new_with_transforms( + frame: Rect, + bounds: PlotBounds, + center_axis: impl Into, + x_transform: Box, + y_transform: Box, + ) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), "Bad plot frame: {frame:?}" @@ -424,9 +556,18 @@ impl PlotTransform { debug_assert!(new_bounds.is_valid(), "Bad final plot bounds: {new_bounds:?}"); + // Transform the bounds to plot space using bounds_to_plot which handles edge cases + let (plot_min_x, plot_max_x) = x_transform.bounds_to_plot(new_bounds.min[0], new_bounds.max[0]); + let (plot_min_y, plot_max_y) = y_transform.bounds_to_plot(new_bounds.min[1], new_bounds.max[1]); + + let plot_bounds = PlotBounds::from_min_max([plot_min_x, plot_min_y], [plot_max_x, plot_max_y]); + Self { frame, bounds: new_bounds, + plot_bounds, + x_transform, + y_transform, centered: center_axis, inverted_axis: Vec2b::new(false, false), } @@ -443,6 +584,20 @@ impl PlotTransform { new } + /// Create a new transform with custom axis transforms and inversion. + pub fn new_with_transforms_and_invert( + frame: Rect, + bounds: PlotBounds, + center_axis: impl Into, + invert_axis: impl Into, + x_transform: Box, + y_transform: Box, + ) -> Self { + let mut new = Self::new_with_transforms(frame, bounds, center_axis, x_transform, y_transform); + new.inverted_axis = invert_axis.into(); + new + } + /// ui-space rectangle. #[inline] pub fn frame(&self) -> &Rect { @@ -458,6 +613,10 @@ impl PlotTransform { #[inline] pub fn set_bounds(&mut self, bounds: PlotBounds) { self.bounds = bounds; + // Update plot bounds using bounds_to_plot + let (plot_min_x, plot_max_x) = self.x_transform.bounds_to_plot(bounds.min[0], bounds.max[0]); + let (plot_min_y, plot_max_y) = self.y_transform.bounds_to_plot(bounds.min[1], bounds.max[1]); + self.plot_bounds = PlotBounds::from_min_max([plot_min_x, plot_min_y], [plot_max_x, plot_max_y]); } pub fn translate_bounds(&mut self, mut delta_pos: (f64, f64)) { @@ -467,27 +626,66 @@ impl PlotTransform { if self.centered.y { delta_pos.1 = 0.; } - delta_pos.0 *= self.dvalue_dpos()[0]; - delta_pos.1 *= self.dvalue_dpos()[1]; - self.bounds.translate((delta_pos.0, delta_pos.1)); + + // Use transform-aware pan for each axis + let (new_min_x, new_max_x) = self.x_transform.pan_bounds( + self.bounds.min[0], + self.bounds.max[0], + delta_pos.0, + self.dvalue_dpos()[0], + ); + + let (new_min_y, new_max_y) = self.y_transform.pan_bounds( + self.bounds.min[1], + self.bounds.max[1], + delta_pos.1, + self.dvalue_dpos()[1], + ); + + self.bounds = PlotBounds::from_min_max([new_min_x, new_min_y], [new_max_x, new_max_y]); + + // Update plot bounds + let (plot_min_x, plot_max_x) = self.x_transform.bounds_to_plot(new_min_x, new_max_x); + let (plot_min_y, plot_max_y) = self.y_transform.bounds_to_plot(new_min_y, new_max_y); + self.plot_bounds = PlotBounds::from_min_max([plot_min_x, plot_min_y], [plot_max_x, plot_max_y]); } /// Zoom by a relative factor with the given screen position as center. pub fn zoom(&mut self, zoom_factor: Vec2, center: Pos2) { - let center = self.value_from_position(center); + let center_data = self.value_from_position(center); + + // Use transform-aware zoom for each axis + let (new_min_x, new_max_x) = self.x_transform.zoom_bounds( + self.bounds.min[0], + self.bounds.max[0], + zoom_factor.x as f64, + center_data.x, + ); + + let (new_min_y, new_max_y) = self.y_transform.zoom_bounds( + self.bounds.min[1], + self.bounds.max[1], + zoom_factor.y as f64, + center_data.y, + ); - let mut new_bounds = self.bounds; - new_bounds.zoom(zoom_factor, center); + let new_data_bounds = PlotBounds::from_min_max([new_min_x, new_min_y], [new_max_x, new_max_y]); - if new_bounds.is_valid() { - self.bounds = new_bounds; + if new_data_bounds.is_valid() { + self.bounds = new_data_bounds; + // Update plot bounds + let (plot_min_x, plot_max_x) = self.x_transform.bounds_to_plot(new_min_x, new_max_x); + let (plot_min_y, plot_max_y) = self.y_transform.bounds_to_plot(new_min_y, new_max_y); + self.plot_bounds = PlotBounds::from_min_max([plot_min_x, plot_min_y], [plot_max_x, plot_max_y]); } } pub fn position_from_point_x(&self, value: f64) -> f32 { + // Data space -> Plot space -> Screen space + let plot_value = self.x_transform.transform_to_plot(value); remap( - value, - self.bounds.min[0]..=self.bounds.max[0], + plot_value, + self.plot_bounds.min[0]..=self.plot_bounds.max[0], if self.inverted_axis[0] { (self.frame.right() as f64)..=(self.frame.left() as f64) } else { @@ -497,9 +695,11 @@ impl PlotTransform { } pub fn position_from_point_y(&self, value: f64) -> f32 { + // Data space -> Plot space -> Screen space + let plot_value = self.y_transform.transform_to_plot(value); remap( - value, - self.bounds.min[1]..=self.bounds.max[1], + plot_value, + self.plot_bounds.min[1]..=self.plot_bounds.max[1], // negated y axis by default if self.inverted_axis[1] { (self.frame.top() as f64)..=(self.frame.bottom() as f64) @@ -516,16 +716,17 @@ impl PlotTransform { /// Plot point from screen/ui position. pub fn value_from_position(&self, pos: Pos2) -> PlotPoint { - let x = remap( + // Screen space -> Plot space -> Data space + let plot_x = remap( pos.x as f64, if self.inverted_axis[0] { (self.frame.right() as f64)..=(self.frame.left() as f64) } else { (self.frame.left() as f64)..=(self.frame.right() as f64) }, - self.bounds.range_x(), + self.plot_bounds.range_x(), ); - let y = remap( + let plot_y = remap( pos.y as f64, // negated y axis by default if self.inverted_axis[1] { @@ -533,9 +734,13 @@ impl PlotTransform { } else { (self.frame.bottom() as f64)..=(self.frame.top() as f64) }, - self.bounds.range_y(), + self.plot_bounds.range_y(), ); + // Convert from plot space back to data space + let x = self.x_transform.transform_from_plot(plot_x); + let y = self.y_transform.transform_from_plot(plot_y); + PlotPoint::new(x, y) } @@ -555,17 +760,23 @@ impl PlotTransform { } /// delta position / delta value = how many ui points per step in the X axis - /// in "plot space" + /// + /// Note: This is computed in plot space, so it represents the linear relationship + /// between plot coordinates and screen coordinates. For non-linear transforms + /// (like log scale), the derivative in data space is not constant. pub fn dpos_dvalue_x(&self) -> f64 { let flip = if self.inverted_axis[0] { -1.0 } else { 1.0 }; - flip * (self.frame.width() as f64) / self.bounds.width() + flip * (self.frame.width() as f64) / self.plot_bounds.width() } /// delta position / delta value = how many ui points per step in the Y axis - /// in "plot space" + /// + /// Note: This is computed in plot space, so it represents the linear relationship + /// between plot coordinates and screen coordinates. For non-linear transforms + /// (like log scale), the derivative in data space is not constant. pub fn dpos_dvalue_y(&self) -> f64 { let flip = if self.inverted_axis[1] { 1.0 } else { -1.0 }; - flip * (self.frame.height() as f64) / self.bounds.height() + flip * (self.frame.height() as f64) / self.plot_bounds.height() } /// delta position / delta value = how many ui points per step in "plot diff --git a/egui_plot/src/axis_transform.rs b/egui_plot/src/axis_transform.rs new file mode 100644 index 00000000..fd300bdf --- /dev/null +++ b/egui_plot/src/axis_transform.rs @@ -0,0 +1,354 @@ +use crate::grid::{GridInput, GridMark, generate_marks_linear, uniform_grid_spacer}; + +/// Defines how data coordinates are transformed to plot coordinates. +/// +/// This enables non-linear scales like logarithmic axes. +/// The transformation is one-dimensional (operates on a single axis). +pub trait AxisTransform: Send + Sync + std::fmt::Debug { + /// Transform a data value to plot space. + /// + /// Plot space is a linear coordinate system that gets mapped to screen space. + fn transform_to_plot(&self, data_value: f64) -> f64; + + /// Transform a plot space value back to data space. + fn transform_from_plot(&self, plot_value: f64) -> f64; + + /// Returns the bounds in plot space for the given data space bounds. + /// + /// This is not simply `(to_plot(min), to_plot(max))` because for some transforms + /// (like log), the order might flip or we need special handling. + fn bounds_to_plot(&self, data_min: f64, data_max: f64) -> (f64, f64) { + (self.transform_to_plot(data_min), self.transform_to_plot(data_max)) + } + + /// Generate grid marks for this transform. + /// + /// The input bounds are in data space, and returned marks are also in data space. + fn generate_marks(&self, input: GridInput) -> Vec; + + /// Range of valid values in data space. + /// + /// For example, logarithmic scales are only valid for positive values. + /// Returns `None` if all values are valid. + fn valid_range(&self) -> Option<(f64, f64)> { + None + } + + /// Returns a boxed clone of this transform. + fn boxed_clone(&self) -> Box; + + /// Zoom the bounds by a factor around a center point. + /// + /// All values are in data space. + /// Returns the new (min, max) bounds. + fn zoom_bounds(&self, min: f64, max: f64, zoom_factor: f64, center: f64) -> (f64, f64); + + /// Pan the bounds by a delta in screen space. + /// + /// # Arguments + /// + /// - `min`, `max`: current bounds in data space + /// - `delta_pixels`: how many pixels to pan + /// - `dvalue_dpos`: how much plot space per pixel (from transform) + /// + /// # Returns + /// - The new (min, max) bounds in data space. + fn pan_bounds(&self, min: f64, max: f64, delta_pixels: f64, dvalue_dpos: f64) -> (f64, f64); +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.boxed_clone() + } +} + +/// Linear axis transform (identity transform). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LinearAxisTransform; + +impl AxisTransform for LinearAxisTransform { + #[inline] + fn transform_to_plot(&self, data_value: f64) -> f64 { + data_value + } + + #[inline] + fn transform_from_plot(&self, plot_value: f64) -> f64 { + plot_value + } + + fn generate_marks(&self, input: GridInput) -> Vec { + // Use the default uniform grid spacer + let spacer = uniform_grid_spacer(|grid_input| { + let step_size = grid_input.base_step_size; + [step_size, step_size * 2.5, step_size * 10.0] + }); + spacer(input) + } + + fn boxed_clone(&self) -> Box { + Box::new(*self) + } + + fn zoom_bounds(&self, min: f64, max: f64, zoom_factor: f64, center: f64) -> (f64, f64) { + // Linear zoom: standard formula + let new_min = center + (min - center) / zoom_factor; + let new_max = center + (max - center) / zoom_factor; + (new_min, new_max) + } + + fn pan_bounds(&self, min: f64, max: f64, delta_pixels: f64, dvalue_dpos: f64) -> (f64, f64) { + // Linear pan: translate directly in data space + let delta_data = delta_pixels * dvalue_dpos; + (min + delta_data, max + delta_data) + } +} + +/// Logarithmic axis transform (base 10). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LogAxisTransform; + +impl LogAxisTransform { + // Minimum positive value that is used to clamp the view + const MIN_POSITIVE: f64 = 1e-200; + // Maximum positive value that is used to clamp the view + const MAX_POSITIVE: f64 = 1e200; + + const BASE: f64 = 10.0; +} + +impl AxisTransform for LogAxisTransform { + fn transform_to_plot(&self, data_value: f64) -> f64 { + if data_value <= 0.0 { + // Handle invalid values gracefully + f64::NEG_INFINITY + } else { + data_value.log10() + } + } + + fn transform_from_plot(&self, plot_value: f64) -> f64 { + Self::BASE.powf(plot_value) + } + + fn bounds_to_plot(&self, data_min: f64, data_max: f64) -> (f64, f64) { + // For log scale, we need positive values + + let safe_min = data_min.clamp(Self::MIN_POSITIVE, Self::MAX_POSITIVE / 1.01); + let safe_max = data_max.clamp(safe_min * 1.01, Self::MAX_POSITIVE); + + (self.transform_to_plot(safe_min), self.transform_to_plot(safe_max)) + } + + fn generate_marks(&self, input: GridInput) -> Vec { + // For logarithmic scale, generate marks at powers of the base + let (data_min, data_max) = input.bounds; + + // If the data range is invalid, return an empty mark list + if data_min <= 0.0 || data_max <= 0.0 || data_min >= data_max { + return Vec::new(); + } + + // Create some space for marks. + let mut marks = Vec::with_capacity(10); + + // Calculate the actual logarithmic range (in decades) + let log_range = (data_max / data_min).log10(); + + // Special case: VERY tight zoom (< 0.05 decades, i.e., range is < 1.12x) + // Use adaptive linear spacing to ensure we always have enough marks + if log_range < 0.05 { + let data_range = data_max - data_min; + + // Find the major power in or near the range + let mid_point = f64::midpoint(data_min, data_max); + let major_exp = mid_point.log10().round() as i32; + let major_value = Self::BASE.powi(major_exp); + + // Add the major power if it's visible + if major_value >= data_min && major_value <= data_max { + marks.push(GridMark { + value: major_value, + step_size: Self::BASE, + }); + } + + // Calculate adaptive step size to get ~10 marks across the range + // Use a "nice" step size based on order of magnitude + let target_step = data_range / 10.0; + let magnitude = 10_f64.powi(target_step.log10().floor() as i32); + + // Choose step from nice values: 1, 2, 5 times the magnitude + let step = if target_step >= magnitude * 5.0 { + magnitude * 5.0 + } else if target_step >= magnitude * 2.0 { + magnitude * 2.0 + } else { + magnitude + }; + + // Generate marks using the three-tier system: step, step*2.5, step*10 + let step_sizes = [step, step * 2.5, step * 10.0]; + + // Use the standard linear mark generator + marks.extend(generate_marks_linear(step_sizes, (data_min, data_max))); + + return marks; + } + + // Find the range of exponents we need to cover + let min_exp = (data_min.log10()).floor() as i32; + let max_exp = (data_max.log10()).ceil() as i32; + + // Strategy: Always show major powers, add intermediate marks based on + // available space step_size indicates mark importance for rendering + // (larger = more important) + + // ALWAYS show major powers (1, 10, 100, 1000, ...) - these are essential + for exp in min_exp..=max_exp { + let value = Self::BASE.powi(exp); + if value >= data_min && value <= data_max { + marks.push(GridMark { + value, + step_size: Self::BASE, // Major power + }); + } + } + + // Add intermediate marks based on available space + + // Tier 3: Show 2× and 5× marks (but only if BOTH fit) + // The tightest spacing is between 2 and 5: log10(5/2) ≈ 0.398 + let tier_3_min_spacing = (5.0_f64 / 2.0_f64).log10(); + let include_tier_3 = tier_3_min_spacing >= input.base_step_size * 0.5; // More permissive! + + // Tier 4: Show additional subdivisions 3,4,6,7,8,9 when VERY zoomed in + // Tightest spacing is between 1 and 2: log10(2) ≈ 0.301 + let tier_4_min_spacing = (2.0_f64).log10(); + let include_tier_4 = tier_4_min_spacing >= input.base_step_size * 0.2; + + // Always add tier 3 (2, 5) if there's room + if include_tier_3 { + // Decide if these should be labeled or just grid lines + // When showing many decades (> 3), don't label tier 3, just show as grid + let num_decades = max_exp - min_exp; + let tier_3_step_size = if num_decades > 3 { + 0.5 // Too many decades - show as grid only, no label + } else { + 1.0 // Few decades - show with labels + }; + + // Extend range by 1 in both directions to catch marks near boundaries + for exp in (min_exp - 1)..=(max_exp + 1) { + let major_value = Self::BASE.powi(exp); + let next_major = major_value * Self::BASE; + + // Standard practice: only show 2× and 5× multiples + for &multiplier in &[2.0, 5.0] { + let value = major_value * multiplier; + + if value > data_min && value < data_max && value < next_major { + marks.push(GridMark { + value, + step_size: tier_3_step_size, + }); + } + } + } + + // Tier 4: Add the ADDITIONAL marks (3,4,6,7,8,9) - not including 2,5 which are in tier 3 + if include_tier_4 { + // Extend range by 1 in both directions to catch marks near boundaries + for exp in (min_exp - 1)..=(max_exp + 1) { + let major_value = Self::BASE.powi(exp); + let next_major = major_value * Self::BASE; + + // Add 3, 4, 6, 7, 8, 9 (NOT 2 and 5, those are tier 3) + for multiplier in [3, 4, 6, 7, 8, 9] { + let value = major_value * (multiplier as f64); + + if value > data_min && value < data_max && value < next_major { + marks.push(GridMark { + value, + step_size: 0.5, // Fine subdivisions, grid only + }); + } + } + } + } + } + + marks + } + + fn valid_range(&self) -> Option<(f64, f64)> { + // Log scale only valid for positive values + Some((0f64.next_up(), f64::INFINITY)) + } + + fn boxed_clone(&self) -> Box { + Box::new(*self) + } + + fn zoom_bounds(&self, min: f64, max: f64, zoom_factor: f64, center: f64) -> (f64, f64) { + // For log scales, zoom multiplicatively in data space + // This makes zoom feel natural regardless of the magnitude + + // Clamp to valid range for log scale + let min = min.max(Self::MIN_POSITIVE); + let max = max.max(min * 1.01); + let center = center.clamp(min, max); + + // Calculate ratios from center + let ratio_min = min / center; + let ratio_max = max / center; + + // Apply zoom by taking the ratio to a power + // zoom_factor > 1 means zoom in, < 1 means zoom out + let power = 1.0 / zoom_factor; + let new_min = center * ratio_min.powf(power); + let new_max = center * ratio_max.powf(power); + + (new_min, new_max) + } + + fn pan_bounds(&self, min: f64, max: f64, delta_pixels: f64, dvalue_dpos: f64) -> (f64, f64) { + // For log scales, pan in plot space then convert back + // This ensures consistent pan speed across magnitudes + + // Convert to plot space + let plot_min = self.transform_to_plot(min.max(Self::MIN_POSITIVE)); + let plot_max = self.transform_to_plot(max.max(Self::MIN_POSITIVE)); + + // Pan in plot space + let delta_plot = delta_pixels * dvalue_dpos; + let new_plot_min = plot_min + delta_plot; + let new_plot_max = plot_max + delta_plot; + + // Convert back to data space + let new_min = self.transform_from_plot(new_plot_min); + let new_max = self.transform_from_plot(new_plot_max); + + (new_min, new_max) + } +} + +/// Default axis transform configuration for an axis. +#[derive(Clone, Copy, Default, Debug)] +pub enum AxisTransformKind { + /// Linear scale (default). + #[default] + Linear, + /// Logarithmic scale. + Log, +} + +impl AxisTransformKind { + /// Create the actual transform implementation. + pub fn make_transform(&self) -> Box { + match self { + Self::Linear => Box::new(LinearAxisTransform), + Self::Log => Box::new(LogAxisTransform), + } + } +} diff --git a/egui_plot/src/grid.rs b/egui_plot/src/grid.rs index e6334d64..18aeeb70 100644 --- a/egui_plot/src/grid.rs +++ b/egui_plot/src/grid.rs @@ -3,7 +3,7 @@ use std::cmp::Ordering; type GridSpacerFn<'a> = dyn Fn(GridInput) -> Vec + 'a; pub type GridSpacer<'a> = Box>; -/// Input for "grid spacer" functions. +/// Input for "grid spacer" functions. These values live in the data space. /// /// See [`crate::Plot::x_grid_spacer()`] and [`crate::Plot::y_grid_spacer()`]. pub struct GridInput { @@ -14,9 +14,9 @@ pub struct GridInput { /// Recommended (but not required) lower-bound on the step size returned by /// custom grid spacers. /// - /// Computed as the ratio between the diagram's bounds (in plot coordinates) - /// and the viewport (in frame/window coordinates), scaled up to - /// represent the minimal possible step. + /// Computed as the ratio between the diagram's bounds (in dataspace + /// coordinates) and the viewport (in frame/window coordinates), scaled up + /// to represent the minimal possible step. /// /// Always positive. pub base_step_size: f64, @@ -60,7 +60,7 @@ pub fn log_grid_spacer(log_base: i64) -> GridSpacer<'static> { smallest_visible_unit * log_base * log_base, ]; - generate_marks(step_sizes, input.bounds) + generate_marks_linear(step_sizes, input.bounds) }; Box::new(step_sizes) @@ -79,7 +79,7 @@ pub fn uniform_grid_spacer<'a>(spacer: impl Fn(GridInput) -> [f64; 3] + 'a) -> G let get_marks = move |input: GridInput| -> Vec { let bounds = input.bounds; let step_sizes = spacer(input); - generate_marks(step_sizes, bounds) + generate_marks_linear(step_sizes, bounds) }; Box::new(get_marks) @@ -99,7 +99,10 @@ fn next_power(value: f64, base: f64) -> f64 { } /// Fill in all values between [min, max] which are a multiple of `step_size` -fn generate_marks(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec { +/// +/// This is used for linear gridding. For logarithmic scales with < 1 decade visible, +/// we switch to linear marks for better readability. +pub(crate) fn generate_marks_linear(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec { let mut steps = vec![]; fill_marks_between(&mut steps, step_sizes[0], bounds); fill_marks_between(&mut steps, step_sizes[1], bounds); @@ -135,14 +138,14 @@ fn generate_marks(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec { } #[test] -fn test_generate_marks() { +fn test_generate_marks_linear() { fn approx_eq(a: &GridMark, b: &GridMark) -> bool { (a.value - b.value).abs() < 1e-10 && a.step_size == b.step_size } let gm = |value, step_size| GridMark { value, step_size }; - let marks = generate_marks([0.01, 0.1, 1.0], (2.855, 3.015)); + let marks = generate_marks_linear([0.01, 0.1, 1.0], (2.855, 3.015)); let expected = vec![ gm(2.86, 0.01), gm(2.87, 0.01), diff --git a/egui_plot/src/items/series.rs b/egui_plot/src/items/series.rs index 9ae9f65b..944739c0 100644 --- a/egui_plot/src/items/series.rs +++ b/egui_plot/src/items/series.rs @@ -174,7 +174,7 @@ impl PlotItem for Line<'_> { let final_stroke: PathStroke = if let Some(gradient_callback) = self.gradient_color.clone() { // if we have a gradient color, we need to wrap the stroke callback to transpose // the position to a value the caller can reason about - let local_transform = *transform; + let local_transform = transform.clone(); let wrapped_callback = move |_rec: Rect, pos: Pos2| -> Color32 { let point = local_transform.value_from_position(pos); gradient_callback(point) diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 7ed664c2..9d12cf87 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -10,6 +10,7 @@ mod aesthetics; mod axis; +mod axis_transform; mod bounds; mod colors; mod cursor; @@ -17,6 +18,7 @@ mod data; mod grid; mod items; mod label; +mod log_helper; mod math; mod memory; mod overlays; @@ -31,6 +33,10 @@ pub use crate::aesthetics::Orientation; pub use crate::axis::Axis; pub use crate::axis::AxisHints; pub use crate::axis::PlotTransform; +pub use crate::axis_transform::AxisTransform; +pub use crate::axis_transform::AxisTransformKind; +pub use crate::axis_transform::LinearAxisTransform; +pub use crate::axis_transform::LogAxisTransform; pub use crate::bounds::PlotBounds; pub use crate::bounds::PlotPoint; pub use crate::colors::color_from_strength; @@ -76,3 +82,8 @@ pub use crate::placement::VPlacement; pub use crate::plot::Plot; pub use crate::plot::PlotResponse; pub use crate::plot::PlotUi; + +// Re-export log scale formatters +pub use crate::log_helper::log_formatter_computer; +pub use crate::log_helper::log_formatter_engineering; +pub use crate::log_helper::log_formatter_superscript; diff --git a/egui_plot/src/log_helper.rs b/egui_plot/src/log_helper.rs new file mode 100644 index 00000000..6d53b48d --- /dev/null +++ b/egui_plot/src/log_helper.rs @@ -0,0 +1,408 @@ +//! Helper functions for logarithmic scale formatting. + +use crate::grid::GridMark; + +/// Helper function to convert a positive number to superscript characters. +fn to_superscript(num: u32) -> String { + // FIXME(asceenl) This is a quick-and-dirty implementation, that allocates + // twice. It could be implemented as an iterator without allocating. + let s = num.to_string(); + s.chars() + .map(|c| match c { + '0' => '⁰', + '1' => '¹', + '2' => '²', + '3' => '³', + '4' => '⁴', + '5' => '⁵', + '6' => '⁶', + '7' => '⁷', + '8' => '⁸', + '9' => '⁹', + _ => c, + }) + .collect() +} + +/// Splits a float into its mantissa and exponent parts. For any normal number, +/// the absolute value of the mantissa is in the range `[1, 10)`, and the +/// exponent is an integer. +/// +/// Edge cases: +/// - If the value is zero, returns `(0.0, 0)`. +/// - For NaN, returns `(NaN, 0)`. +/// - For infinities, returns `(+/- Inf, 0)`. +#[inline] +fn split_float(value: f64) -> (f64, i16) { + if value == 0.0 { + return (0.0, 0); + } + let abs = value.abs(); + if !abs.is_finite() { + // Handle NaN and infinity + return (value, 0); + } + let log_abs = abs.log10(); + let exponent = log_abs.floor() as i16; + let mantissa = value.signum() * 10.0_f64.powf(log_abs - exponent as f64); + + (mantissa, exponent) +} + +/// Returns a formatter for logarithmic axes that displays values as powers with +/// superscripts. +/// +/// The problem is that Egui does not render a superscript minus +/// sign, resulting in a fallback here to use 1/10², etc. I do not like this, +/// but it is the best I could come up with. +/// +/// - Positive exponents: `10⁰`, `10¹`, `10²`, `10³`, etc. +/// - Negative exponents: `1/10¹`, `1/10²`, `1/10³`, etc. +/// +/// # Example +/// ```ignore +/// Plot::new("my_plot") +/// .log_y(10.0) +/// .y_axis_formatter(log_formatter_superscript()) +/// .show(ui, |plot_ui| { /* ... */ }); +/// ``` +pub fn log_formatter_superscript() -> impl Fn(GridMark, &std::ops::RangeInclusive) -> String { + move |mark, _range| { + let value = mark.value; + + const BASE_INT: i32 = 10; + + // Calculate the mantissa and exponent, with absolute value of mantissa + // normalized to the range of [1, 10) + let (mantissa, exponent) = split_float(value); + + // Round the mantissa to the nearest integer + let mantissa_rounded = mantissa.round(); + + // Check if it's a clean power of the base (mantissa ≈ 1, or mantissa ≈ 10) + let mantissa_close_to_1 = (mantissa - 1.0).abs() < 0.01; + let mantissa_close_to_10 = (mantissa - 10.0).abs() < 0.01; + let clean_power = mantissa_close_to_1 || mantissa_close_to_10; + + if clean_power { + // Clean power cases, e.g. 10^2, 10^3, 1/10^1, 1/10^2 + match (exponent, mantissa_close_to_1, mantissa_close_to_10) { + // Special cases for readability, do not show 10^0, just 1 + (0, true, false) | (-1, false, true) => "1".into(), + // Special cases for readability, do not show 10^1, just 10 + (1, true, false) | (0, false, true) => "10".into(), + // Negative exponent: 1/10¹, 1/10² + (e, _, _) if e < 0 => format!("1/{}{}", BASE_INT, to_superscript((-e) as u32)), + // Positive exponent: 10², 10³, etc. + (e, _, _) => format!("{BASE_INT}{}", to_superscript(e as u32)), + } + } else if mantissa_rounded >= 2.0 && mantissa_rounded <= 9.0 && (mantissa - mantissa_rounded).abs() < 0.1 { + // It's a simple multiple of a power (2×10⁴, 3×10⁴, etc.) + let mult_int = mantissa_rounded as i32; + + if exponent >= 0 { + if exponent == 0 { + // Just show the number itself for 10⁰ + mantissa_rounded.to_string() + } else { + // Positive exponent: 2×10⁴, 3×10⁴ + format!("{mult_int}×{BASE_INT}{}", to_superscript(exponent as u32)) + } + } else { + // Negative exponent: 2/10², 3/10² + format!("{mult_int}×1/{BASE_INT}{}", to_superscript((-exponent) as u32)) + } + } else { + // For intermediate values (like 2.5×10² = 250), try to format nicely + // Check if it's a simple decimal multiple (2.5, 3.5, etc.) + let decimal_mult = (mantissa * 2.0).round() / 2.0; // Round to nearest 0.5 + + if (mantissa - decimal_mult).abs() < 0.05 && decimal_mult >= 1.5 && decimal_mult <= 9.5 && exponent >= 0 { + // It's close to a half-integer multiple + if exponent == 0 { + // Just show the decimal number + if (decimal_mult - decimal_mult.round()).abs() < 0.01 { + format!("{}", decimal_mult.round() as i32) + } else { + format!("{decimal_mult:.1}") + } + } else { + // Show as decimal multiple: 2.5×10² + if (decimal_mult - decimal_mult.round()).abs() < 0.01 { + format!( + "{}×{}{}", + decimal_mult.round() as i32, + BASE_INT, + to_superscript(exponent as u32) + ) + } else { + format!("{:.1}×{}{}", decimal_mult, BASE_INT, to_superscript(exponent as u32)) + } + } + } else { + // Not a simple multiple, fall back to default formatting + crate::label::format_number(value, 2) + } + } + } +} + +/// Returns a formatter for logarithmic axes that displays values in +/// computer-scientific notation. +/// +/// The format is easy to type, but is not the way +/// humans have learned to write numbers in scientific notation. +/// +/// Example output: `1e0`, `1e1`, `1e2`, `1e3`, etc. +/// +/// # Example +/// ```ignore +/// Plot::new("my_plot") +/// .log_y(10.0) +/// .y_axis_formatter(log_formatter_scientific()) +/// .show(ui, |plot_ui| { /* ... */ }); +/// ``` +pub fn log_formatter_computer() -> impl Fn(GridMark, &std::ops::RangeInclusive) -> String { + move |mark, _range| { + let value = mark.value; + format!("{value:e}") + } +} + +/// Returns a formatter for logarithmic axes that displays values in compact +/// form with suffixes, also known as "engineering notation", or Metric prefix. +/// +/// Example output: `1`, `10`, `100`, `1K`, `10K`, `100K`, `1M`, `10M`, etc. +/// +/// # Example +/// ```ignore +/// Plot::new("my_plot") +/// .log_y(10.0) +/// .y_axis_formatter(log_formatter_compact()) +/// .show(ui, |plot_ui| { /* ... */ }); +/// ``` +pub fn log_formatter_engineering() -> impl Fn(GridMark, &std::ops::RangeInclusive) -> String { + move |mark, _range| { + let value = mark.value; + let abs_value = value.abs(); + + let (scaled, suffix) = if abs_value >= 1e30 { + // Quetta + (value / 1e30, "Q") + } else if abs_value >= 1e27 { + // Ronna + (value / 1e27, "R") + } else if abs_value >= 1e24 { + // Yotta + (value / 1e24, "Y") + } else if abs_value >= 1e21 { + // Zetta + (value / 1e21, "Z") + } else if abs_value >= 1e18 { + // Exa + (value / 1e18, "E") + } else if abs_value >= 1e15 { + // Peta + (value / 1e15, "P") + } else if abs_value >= 1e12 { + // Tera + (value / 1e12, "T") + } else if abs_value >= 1e9 { + // Giga + (value / 1e9, "G") + } else if abs_value >= 1e6 { + // Mega + (value / 1e6, "M") + } else if abs_value >= 1e3 { + // Kilo + (value / 1e3, "k") + } else if abs_value >= 1.0 { + (value, "") + } else if abs_value >= 1e-3 { + // milli + (value * 1e3, "m") + } else if abs_value >= 1e-6 { + // micro + (value * 1e6, "µ") + } else if abs_value >= 1e-9 { + // nano + (value * 1e9, "n") + } else if abs_value >= 1e-12 { + // pico + (value * 1e12, "p") + } else if abs_value >= 1e-15 { + // femto + (value * 1e15, "f") + } else if abs_value >= 1e-18 { + // atto + (value * 1e18, "a") + } else if abs_value >= 1e-21 { + // zepto + (value * 1e21, "z") + } else if abs_value >= 1e-24 { + // yocto + (value * 1e24, "y") + } else if abs_value >= 1e-27 { + // ronto + (value * 1e27, "r") + } else if abs_value >= 1e-30 { + // quecto + (value * 1e30, "q") + } else { + (value, "") + }; + + // Format with minimal decimal places + // if scaled.fract().abs() < 0.01 { + // format!("{}{}", scaled.round() as i64, suffix) + // } else if scaled.abs() >= 10.0 { + // format!("{scaled:.0}{suffix}") + // } else { + // format!("{scaled}{suffix}") + // } + format!("{scaled}{suffix}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_float_roundtrip() { + // Test that splitting and reconstructing gives back the original value + let test_values = vec![ + // Positive values + 1.0, 10.0, 123.45, 1234.5, 0.12345, 0.0012345, 1e10, 1e-10, // Negative values + -1.0, -10.0, -123.45, -1234.5, -0.12345, -0.0012345, -1e10, -1e-10, // Edge cases + 0.0, -0.0, -9.999999, + ]; + + for value in test_values { + let (mantissa, exponent) = split_float(value); + let reconstructed = mantissa * 10.0_f64.powi(exponent as i32); + + if value == 0.0 { + // Special case: zero + assert_eq!(mantissa, 0.0, "Zero mantissa should be 0.0"); + assert_eq!(exponent, 0, "Zero exponent should be 0"); + assert_eq!(reconstructed, 0.0, "Reconstructed zero should be 0.0"); + } else { + // Check that mantissa is in range [1, 10) or (-10, -1] for negative + let abs_mantissa = mantissa.abs(); + assert!( + abs_mantissa >= 1.0 && abs_mantissa < 10.0, + "Mantissa {mantissa} should be in range [1, 10) for value {value}", + ); + + // Check sign preservation + assert_eq!( + mantissa.is_sign_positive(), + value.is_sign_positive(), + "Sign should be preserved: value={value}, mantissa={mantissa}" + ); + + // Check roundtrip (with some tolerance for floating point errors) + let relative_error = ((reconstructed - value) / value).abs(); + assert!( + relative_error < 1e-10, + "Roundtrip failed for {value}: got {reconstructed}, error={relative_error}" + ); + } + } + } + + #[test] + fn test_split_float_special_values() { + // Test NaN + let (mantissa, exponent) = split_float(f64::NAN); + assert!(mantissa.is_nan(), "NaN mantissa should be NaN"); + assert_eq!(exponent, 0, "NaN exponent should be 0"); + + // Test positive infinity + let (mantissa, exponent) = split_float(f64::INFINITY); + assert_eq!(mantissa, f64::INFINITY, "Infinity mantissa should be Infinity"); + assert_eq!(exponent, 0, "Infinity exponent should be 0"); + + // Test negative infinity + let (mantissa, exponent) = split_float(f64::NEG_INFINITY); + assert_eq!( + mantissa, + f64::NEG_INFINITY, + "Negative infinity mantissa should be -Infinity" + ); + assert_eq!(exponent, 0, "Negative infinity exponent should be 0"); + } + + #[test] + fn test_log_formatter_superscript_powers_of_10() { + let formatter = log_formatter_superscript(); + let range = 1.0..=1e10; + + // Test clean powers of 10 - these should all format appropriately + let test_cases = vec![ + (1.0, "1"), // Special case: just "1" + (10.0, "10"), // Special case: just "10" + (100.0, "10²"), + (1_000.0, "10³"), // Previously failed due to FP error + (10_000.0, "10⁴"), + (100_000.0, "10⁵"), + (1_000_000.0, "10⁶"), // Previously failed due to FP error + (10_000_000.0, "10⁷"), + (100_000_000.0, "10⁸"), + (1_000_000_000.0, "10⁹"), // Previously failed due to FP error + ]; + + for (value, expected) in test_cases { + let mark = GridMark { value, step_size: 10.0 }; + let result = formatter(mark, &range); + assert_eq!( + result, expected, + "Failed for value {value}: got '{result}', expected '{expected}'" + ); + } + } + + #[test] + fn test_log_formatter_superscript_multiples() { + let formatter = log_formatter_superscript(); + let range = 1.0..=1e10; + + // Test simple multiples like 2×10², 5×10³, etc. + let test_cases = vec![ + (2.0, "2"), + (5.0, "5"), + (20.0, "2×10¹"), + (50.0, "5×10¹"), + (200.0, "2×10²"), + (5000.0, "5×10³"), + ]; + + for (value, expected) in test_cases { + let mark = GridMark { value, step_size: 1.0 }; + let result = formatter(mark, &range); + assert_eq!( + result, expected, + "Failed for value {value}: got '{result}', expected '{expected}'" + ); + } + } + + #[test] + fn test_log_formatter_superscript_negative_exponents() { + let formatter = log_formatter_superscript(); + let range = 1e-10..=1.0; + + // Test negative exponents - should use 1/10ⁿ notation + let test_cases = vec![(0.1, "1/10¹"), (0.01, "1/10²"), (0.001, "1/10³"), (0.0001, "1/10⁴")]; + + for (value, expected) in test_cases { + let mark = GridMark { value, step_size: 10.0 }; + let result = formatter(mark, &range); + assert_eq!( + result, expected, + "Failed for value {value}: got '{result}', expected '{expected}'" + ); + } + } +} diff --git a/egui_plot/src/memory.rs b/egui_plot/src/memory.rs index b0b0aa0e..7accd617 100644 --- a/egui_plot/src/memory.rs +++ b/egui_plot/src/memory.rs @@ -6,6 +6,7 @@ use egui::Pos2; use egui::Vec2b; use crate::axis::PlotTransform; +use crate::axis_transform::AxisTransformKind; use crate::bounds::PlotBounds; /// Information about the plot that has to persist between frames. @@ -36,12 +37,19 @@ pub struct PlotMemory { /// in order to fit the labels, if necessary. pub(crate) x_axis_thickness: BTreeMap, pub(crate) y_axis_thickness: BTreeMap, + + /// The axis transform kinds from last frame. + /// Used to detect when the transform type changes and reset auto-bounds. + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) last_x_transform: Option, + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) last_y_transform: Option, } impl PlotMemory { #[inline] - pub fn transform(&self) -> PlotTransform { - self.transform + pub fn transform(&self) -> &PlotTransform { + &self.transform } #[inline] diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index 16b30c0c..c7dd9786 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -132,6 +132,9 @@ pub struct Plot<'a> { grid_strength_exponent: f32, clamp_grid: bool, + x_axis_transform_kind: crate::axis_transform::AxisTransformKind, + y_axis_transform_kind: crate::axis_transform::AxisTransformKind, + sense: Sense, } @@ -186,6 +189,9 @@ impl<'a> Plot<'a> { grid_strength_exponent: 0.5, clamp_grid: false, + x_axis_transform_kind: crate::axis_transform::AxisTransformKind::Linear, + y_axis_transform_kind: crate::axis_transform::AxisTransformKind::Linear, + sense: egui::Sense::click_and_drag(), } } @@ -771,6 +777,50 @@ impl<'a> Plot<'a> { self } + /// Use logarithmic scale on the X-axis with base 10. + /// + /// Default: linear scale. + /// + /// Note: logarithmic scales only work with positive values. + /// By default, axis labels will use superscript notation (e.g., 10², 10³). + /// You can customize this with `x_axis_formatter()`. + #[inline] + pub fn log_x(mut self) -> Self { + self.x_axis_transform_kind = crate::axis_transform::AxisTransformKind::Log; + // Update the grid spacer to use the log scale grid generation + let transform = self.x_axis_transform_kind.make_transform(); + self.grid_spacers[0] = Box::new(move |input| transform.generate_marks(input)); + + // Set default superscript formatter for log scale + if let Some(main) = self.x_axes.first_mut() { + main.formatter = std::sync::Arc::new(crate::log_formatter_superscript()); + } + + self + } + + /// Use logarithmic scale on the Y-axis with the specified base. + /// + /// Default: linear scale. + /// + /// Note: logarithmic scales only work with positive values. + /// By default, axis labels will use superscript notation (e.g., 10², 10³). + /// You can customize this with `y_axis_formatter()`. + #[inline] + pub fn log_y(mut self) -> Self { + self.y_axis_transform_kind = crate::axis_transform::AxisTransformKind::Log; + // Update the grid spacer to use the log scale grid generation + let transform = self.y_axis_transform_kind.make_transform(); + self.grid_spacers[1] = Box::new(move |input| transform.generate_marks(input)); + + // Set default superscript formatter for log scale + if let Some(main) = self.y_axes.first_mut() { + main.formatter = std::sync::Arc::new(crate::log_formatter_superscript()); + } + + self + } + /// Set the main Y-axis-width by number of digits #[inline] #[deprecated = "Use `y_axis_min_width` instead"] @@ -778,6 +828,30 @@ impl<'a> Plot<'a> { self.y_axis_min_width(12.0 * digits as f32) } + /// Set a custom axis transform for the X-axis. + /// + /// For common cases, prefer [`Self::x_axis_logarithmic`]. + #[inline] + pub fn x_axis_transform(mut self, transform: crate::axis_transform::AxisTransformKind) -> Self { + self.x_axis_transform_kind = transform; + // Update the grid spacer to use the transform's grid generation + let transform_impl = transform.make_transform(); + self.grid_spacers[0] = Box::new(move |input| transform_impl.generate_marks(input)); + self + } + + /// Set a custom axis transform for the Y-axis. + /// + /// For common cases, prefer [`Self::y_axis_logarithmic`]. + #[inline] + pub fn y_axis_transform(mut self, transform: crate::axis_transform::AxisTransformKind) -> Self { + self.y_axis_transform_kind = transform; + // Update the grid spacer to use the transform's grid generation + let transform_impl = transform.make_transform(); + self.grid_spacers[1] = Box::new(move |input| transform_impl.generate_marks(input)); + self + } + /// Set custom configuration for X-axis /// /// More than one axis may be specified. The first specified axis is @@ -897,31 +971,60 @@ impl<'a> Plot<'a> { auto_bounds: self.default_auto_bounds, hovered_legend_item: None, hidden_items: Default::default(), - transform: PlotTransform::new_with_invert_axis( + transform: PlotTransform::new_with_transforms_and_invert( plot_rect, self.min_auto_bounds, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), + self.x_axis_transform_kind.make_transform(), + self.y_axis_transform_kind.make_transform(), ), last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), + last_x_transform: Some(self.x_axis_transform_kind), + last_y_transform: Some(self.y_axis_transform_kind), } } else { - PlotMemory::load(ui.ctx(), plot_id).unwrap_or_else(|| PlotMemory { + let mut mem = PlotMemory::load(ui.ctx(), plot_id).unwrap_or_else(|| PlotMemory { auto_bounds: self.default_auto_bounds, hovered_legend_item: None, hidden_items: Default::default(), - transform: PlotTransform::new_with_invert_axis( + transform: PlotTransform::new_with_transforms_and_invert( plot_rect, self.min_auto_bounds, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), + self.x_axis_transform_kind.make_transform(), + self.y_axis_transform_kind.make_transform(), ), last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), - }) + last_x_transform: Some(self.x_axis_transform_kind), + last_y_transform: Some(self.y_axis_transform_kind), + }); + + // Detect if axis transform type changed and reset auto-bounds if so + let x_transform_changed = mem.last_x_transform.map_or(true, |last| { + std::mem::discriminant(&last) != std::mem::discriminant(&self.x_axis_transform_kind) + }); + let y_transform_changed = mem.last_y_transform.map_or(true, |last| { + std::mem::discriminant(&last) != std::mem::discriminant(&self.y_axis_transform_kind) + }); + + if x_transform_changed { + mem.auto_bounds.x = true; + } + if y_transform_changed { + mem.auto_bounds.y = true; + } + + // Update the stored transform kinds + mem.last_x_transform = Some(self.x_axis_transform_kind); + mem.last_y_transform = Some(self.y_axis_transform_kind); + + mem } } @@ -1068,20 +1171,59 @@ impl<'a> Plot<'a> { } } + // Apply margins based on the axis transform type + // For linear axes: apply margin in data space (additive) + // For log axes: apply margin in plot space (multiplicative in data + // space) + if auto_x { - bounds.add_relative_margin_x(self.margin_fraction); + match self.x_axis_transform_kind { + crate::axis_transform::AxisTransformKind::Linear => { + // Linear: apply margin in data space + bounds.add_relative_margin_x(self.margin_fraction); + } + crate::axis_transform::AxisTransformKind::Log => { + // Log: apply margin in plot space + let transform = self.x_axis_transform_kind.make_transform(); + let (mut plot_min, mut plot_max) = transform.bounds_to_plot(bounds.min[0], bounds.max[0]); + let plot_range = (plot_max - plot_min).abs(); + let margin = plot_range * self.margin_fraction.x as f64; + plot_min -= margin; + plot_max += margin; + bounds.min[0] = transform.transform_from_plot(plot_min); + bounds.max[0] = transform.transform_from_plot(plot_max); + } + } } if auto_y { - bounds.add_relative_margin_y(self.margin_fraction); + match self.y_axis_transform_kind { + crate::axis_transform::AxisTransformKind::Linear => { + // Linear: apply margin in data space + bounds.add_relative_margin_y(self.margin_fraction); + } + crate::axis_transform::AxisTransformKind::Log => { + // Log: apply margin in plot space + let transform = self.y_axis_transform_kind.make_transform(); + let (mut plot_min, mut plot_max) = transform.bounds_to_plot(bounds.min[1], bounds.max[1]); + let plot_range = (plot_max - plot_min).abs(); + let margin = plot_range * self.margin_fraction.y as f64; + plot_min -= margin; + plot_max += margin; + bounds.min[1] = transform.transform_from_plot(plot_min); + bounds.max[1] = transform.transform_from_plot(plot_max); + } + } } } - mem.transform = PlotTransform::new_with_invert_axis( + mem.transform = PlotTransform::new_with_transforms_and_invert( plot_rect, bounds, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), + self.x_axis_transform_kind.make_transform(), + self.y_axis_transform_kind.make_transform(), ); // Enforce aspect ratio @@ -1264,7 +1406,7 @@ impl<'a> Plot<'a> { // Process X-axis widgets for widget in &mut axis_widgets[0] { widget.range = x_axis_range.clone(); - widget.transform = Some(mem.transform); + widget.transform = Some(mem.transform.clone()); widget.steps = Arc::clone(&x_steps); } let x_axis_widgets = std::mem::take(&mut axis_widgets[0]); @@ -1276,7 +1418,7 @@ impl<'a> Plot<'a> { // Process Y-axis widgets for widget in &mut axis_widgets[1] { widget.range = y_axis_range.clone(); - widget.transform = Some(mem.transform); + widget.transform = Some(mem.transform.clone()); widget.steps = Arc::clone(&y_steps); } let y_axis_widgets = std::mem::take(&mut axis_widgets[1]); @@ -1629,7 +1771,7 @@ impl<'a> Plot<'a> { // Load or initialize memory let mut mem = self.load_or_init_memory(ui, plot_id, plot_rect); - let last_plot_transform = mem.transform; + let last_plot_transform = mem.transform.clone(); // Call the plot build function. let mut plot_ui = PlotUi { @@ -1667,7 +1809,7 @@ impl<'a> Plot<'a> { } let (shapes, plot_cursors, mut hovered_plot_item) = - self.collect_shapes(ui, &plot_ui, plot_id, &mem.transform, show_xy); + self.collect_shapes(ui, &plot_ui, plot_id, mem.transform(), show_xy); // Get the painter from ui and configure it with the plot's clip rect // The painter is used to render all accumulated shapes @@ -1721,7 +1863,7 @@ impl<'a> Plot<'a> { }; // Store memory for the next frame. - let transform = mem.transform(); + let transform = mem.transform().clone(); mem.store(ui.ctx(), plot_id); ui.advance_cursor_after_rect(complete_rect); diff --git a/examples/log_scale/Cargo.toml b/examples/log_scale/Cargo.toml new file mode 100644 index 00000000..bf87230a --- /dev/null +++ b/examples/log_scale/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "log_scale" +version = "0.1.0" +authors = ["J.A. de Jong eframe::Result { + env_logger::init(); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]), + ..Default::default() + }; + eframe::run_native( + "Log Scale Plot Example", + options, + Box::new(|_cc| Ok(Box::::default())), + ) +} + +#[derive(PartialEq, Default)] +enum LogFormatter { + #[default] + Superscript, + Computer, + Engineering, +} + +#[derive(Default)] +struct MyApp { + log_x: bool, + log_y: bool, + log_axis_formatter: LogFormatter, +} + +impl eframe::App for MyApp { + fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) {} + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show_inside(ui, |ui| { + ui.heading("Log Scale Plot Example"); + + ui.horizontal(|ui| { + ui.label("This example demonstrates logarithmic axis scaling."); + }); + + ui.horizontal(|ui| { + ui.checkbox(&mut self.log_x, "Logarithmic X-axis"); + ui.checkbox(&mut self.log_y, "Logarithmic Y-axis"); + }); + + ui.horizontal(|ui| { + ui.label("Log-axis formatter:"); + ui.radio_value( + &mut self.log_axis_formatter, + LogFormatter::Superscript, + "Superscript (10², 1/10²)", + ); + ui.radio_value(&mut self.log_axis_formatter, LogFormatter::Computer, "Computer (1e2)"); + ui.radio_value( + &mut self.log_axis_formatter, + LogFormatter::Engineering, + "Engineering (1K, 1M)", + ); + }); + + ui.separator(); + + // Generate exponential data (y = 10^x) + // X from 0.1 to 10 (suitable for log scale) + let exponential: PlotPoints<'_> = (1..=100) + .map(|i| { + let x = i as f64 * 0.1; + let y = 10_f64.powf(x); + [x, y] + }) + .collect(); + + // Generate power law data (y = x^3) + // X from 1 to 100 (suitable for log scale) + let power_law: PlotPoints<'_> = (1..=100) + .map(|i| { + let x = i as f64; + let y = x.powi(3); + [x, y] + }) + .collect(); + + let mut plot = Plot::new("log_plot") + .height(400.0) + .legend(egui_plot::Legend::default()) + .allow_double_click_reset(true); + + if self.log_x { + plot = plot.log_x(); + plot = match self.log_axis_formatter { + LogFormatter::Superscript => plot.x_axis_formatter(log_formatter_superscript()), + LogFormatter::Computer => plot.x_axis_formatter(log_formatter_computer()), + LogFormatter::Engineering => plot.x_axis_formatter(log_formatter_engineering()), + }; + } + if self.log_y { + plot = plot.log_y(); + // Override default formatter based on selection + plot = match self.log_axis_formatter { + LogFormatter::Superscript => plot.y_axis_formatter(log_formatter_superscript()), + LogFormatter::Computer => plot.y_axis_formatter(log_formatter_computer()), + LogFormatter::Engineering => plot.y_axis_formatter(log_formatter_engineering()), + }; + } + + plot.show(ui, |plot_ui| { + plot_ui.line(Line::new("y = 10^x", exponential).color(egui::Color32::from_rgb(200, 100, 100))); + plot_ui.line(Line::new("y = x³", power_law).color(egui::Color32::from_rgb(100, 150, 250))); + }); + + ui.separator(); + ui.label("💡 Interaction:"); + ui.label(" - Try enabling logarithmic Y-axis to see exponential data linearized"); + ui.label(" - Try enabling logarithmic X-axis to see power law relationships"); + ui.label(" - Both axes can be logarithmic simultaneously"); + ui.label(" - Double-click the plot to reset zoom to extents"); + ui.label(" - Try different formatters to see how labels are displayed"); + }); + } +} From d2a47f96898902445b03fcced0d4033bb611bb53 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong" Date: Wed, 27 May 2026 22:12:31 +0200 Subject: [PATCH 2/3] Merge AxisTransform and AxisTransformKind and renamed it to AxisTransformType. Made this type Copy to remove Boxing, boxed_clone and created an enum dispatcher for the AxisTransform Trait. With that, the feature = serde problem is fixed. --- egui_plot/src/axis.rs | 19 +++--- egui_plot/src/axis_transform.rs | 104 +++++++++++++++++++++----------- egui_plot/src/lib.rs | 3 +- egui_plot/src/memory.rs | 6 +- egui_plot/src/plot.rs | 82 ++++++++++++------------- 5 files changed, 126 insertions(+), 88 deletions(-) diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis.rs index 6a7cd45f..9bc77a30 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis.rs @@ -21,6 +21,8 @@ use emath::Vec2b; use emath::pos2; use emath::remap; +use crate::axis_transform::AxisTransform; +use crate::axis_transform::AxisTransformType; use crate::bounds::PlotBounds; use crate::bounds::PlotPoint; use crate::grid::GridMark; @@ -463,6 +465,7 @@ impl<'a> AxisWidget<'a> { /// Contains the screen rectangle and the plot bounds and provides methods to /// transform between them. #[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PlotTransform { /// The screen rectangle. frame: Rect, @@ -474,10 +477,10 @@ pub struct PlotTransform { plot_bounds: PlotBounds, /// Transform for the x-axis (data space -> plot space). - x_transform: Box, + x_transform: AxisTransformType, /// Transform for the y-axis (data space -> plot space). - y_transform: Box, + y_transform: AxisTransformType, /// Whether to always center the x-range or y-range of the bounds. centered: Vec2b, @@ -499,8 +502,8 @@ impl PlotTransform { frame, bounds, center_axis, - Box::new(crate::axis_transform::LinearAxisTransform), - Box::new(crate::axis_transform::LinearAxisTransform), + AxisTransformType::linear(), + AxisTransformType::linear(), ) } @@ -509,8 +512,8 @@ impl PlotTransform { frame: Rect, bounds: PlotBounds, center_axis: impl Into, - x_transform: Box, - y_transform: Box, + x_transform: AxisTransformType, + y_transform: AxisTransformType, ) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), @@ -590,8 +593,8 @@ impl PlotTransform { bounds: PlotBounds, center_axis: impl Into, invert_axis: impl Into, - x_transform: Box, - y_transform: Box, + x_transform: AxisTransformType, + y_transform: AxisTransformType, ) -> Self { let mut new = Self::new_with_transforms(frame, bounds, center_axis, x_transform, y_transform); new.inverted_axis = invert_axis.into(); diff --git a/egui_plot/src/axis_transform.rs b/egui_plot/src/axis_transform.rs index fd300bdf..d503f29d 100644 --- a/egui_plot/src/axis_transform.rs +++ b/egui_plot/src/axis_transform.rs @@ -34,9 +34,6 @@ pub trait AxisTransform: Send + Sync + std::fmt::Debug { None } - /// Returns a boxed clone of this transform. - fn boxed_clone(&self) -> Box; - /// Zoom the bounds by a factor around a center point. /// /// All values are in data space. @@ -56,14 +53,80 @@ pub trait AxisTransform: Send + Sync + std::fmt::Debug { fn pan_bounds(&self, min: f64, max: f64, delta_pixels: f64, dvalue_dpos: f64) -> (f64, f64); } -impl Clone for Box { - fn clone(&self) -> Self { - self.boxed_clone() +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +/// Represents the type of axis transform to use. +pub enum AxisTransformType { + Linear(LinearAxisTransform), + Log(LogAxisTransform), +} +impl Default for AxisTransformType { + fn default() -> Self { + Self::Linear(LinearAxisTransform) + } +} +impl AxisTransformType { + /// Returns a linear axis transform. + pub fn linear() -> Self { + Self::Linear(LinearAxisTransform) + } + /// Returns a logarithmic axis transform. + pub fn log() -> Self { + Self::Log(LogAxisTransform) + } +} + +impl AxisTransform for AxisTransformType { + fn transform_to_plot(&self, data_value: f64) -> f64 { + match self { + Self::Linear(transform) => transform.transform_to_plot(data_value), + Self::Log(transform) => transform.transform_to_plot(data_value), + } + } + + fn transform_from_plot(&self, plot_value: f64) -> f64 { + match self { + Self::Linear(transform) => transform.transform_from_plot(plot_value), + Self::Log(transform) => transform.transform_from_plot(plot_value), + } + } + + fn generate_marks(&self, input: GridInput) -> Vec { + match self { + Self::Linear(transform) => transform.generate_marks(input), + Self::Log(transform) => transform.generate_marks(input), + } + } + + fn zoom_bounds(&self, min: f64, max: f64, zoom_factor: f64, center: f64) -> (f64, f64) { + match self { + Self::Linear(transform) => transform.zoom_bounds(min, max, zoom_factor, center), + Self::Log(transform) => transform.zoom_bounds(min, max, zoom_factor, center), + } + } + + fn pan_bounds(&self, min: f64, max: f64, delta_pixels: f64, dvalue_dpos: f64) -> (f64, f64) { + match self { + Self::Linear(transform) => transform.pan_bounds(min, max, delta_pixels, dvalue_dpos), + Self::Log(transform) => transform.pan_bounds(min, max, delta_pixels, dvalue_dpos), + } + } + + fn bounds_to_plot(&self, data_min: f64, data_max: f64) -> (f64, f64) { + (self.transform_to_plot(data_min), self.transform_to_plot(data_max)) + } + + fn valid_range(&self) -> Option<(f64, f64)> { + match self { + Self::Linear(transform) => transform.valid_range(), + Self::Log(transform) => transform.valid_range(), + } } } /// Linear axis transform (identity transform). #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct LinearAxisTransform; impl AxisTransform for LinearAxisTransform { @@ -86,10 +149,6 @@ impl AxisTransform for LinearAxisTransform { spacer(input) } - fn boxed_clone(&self) -> Box { - Box::new(*self) - } - fn zoom_bounds(&self, min: f64, max: f64, zoom_factor: f64, center: f64) -> (f64, f64) { // Linear zoom: standard formula let new_min = center + (min - center) / zoom_factor; @@ -106,6 +165,7 @@ impl AxisTransform for LinearAxisTransform { /// Logarithmic axis transform (base 10). #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct LogAxisTransform; impl LogAxisTransform { @@ -286,10 +346,6 @@ impl AxisTransform for LogAxisTransform { Some((0f64.next_up(), f64::INFINITY)) } - fn boxed_clone(&self) -> Box { - Box::new(*self) - } - fn zoom_bounds(&self, min: f64, max: f64, zoom_factor: f64, center: f64) -> (f64, f64) { // For log scales, zoom multiplicatively in data space // This makes zoom feel natural regardless of the magnitude @@ -332,23 +388,3 @@ impl AxisTransform for LogAxisTransform { (new_min, new_max) } } - -/// Default axis transform configuration for an axis. -#[derive(Clone, Copy, Default, Debug)] -pub enum AxisTransformKind { - /// Linear scale (default). - #[default] - Linear, - /// Logarithmic scale. - Log, -} - -impl AxisTransformKind { - /// Create the actual transform implementation. - pub fn make_transform(&self) -> Box { - match self { - Self::Linear => Box::new(LinearAxisTransform), - Self::Log => Box::new(LogAxisTransform), - } - } -} diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 9d12cf87..644ce946 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -33,8 +33,7 @@ pub use crate::aesthetics::Orientation; pub use crate::axis::Axis; pub use crate::axis::AxisHints; pub use crate::axis::PlotTransform; -pub use crate::axis_transform::AxisTransform; -pub use crate::axis_transform::AxisTransformKind; +pub use crate::axis_transform::AxisTransformType; pub use crate::axis_transform::LinearAxisTransform; pub use crate::axis_transform::LogAxisTransform; pub use crate::bounds::PlotBounds; diff --git a/egui_plot/src/memory.rs b/egui_plot/src/memory.rs index 7accd617..76ad6991 100644 --- a/egui_plot/src/memory.rs +++ b/egui_plot/src/memory.rs @@ -6,7 +6,7 @@ use egui::Pos2; use egui::Vec2b; use crate::axis::PlotTransform; -use crate::axis_transform::AxisTransformKind; +use crate::axis_transform::AxisTransformType; use crate::bounds::PlotBounds; /// Information about the plot that has to persist between frames. @@ -41,9 +41,9 @@ pub struct PlotMemory { /// The axis transform kinds from last frame. /// Used to detect when the transform type changes and reset auto-bounds. #[cfg_attr(feature = "serde", serde(skip))] - pub(crate) last_x_transform: Option, + pub(crate) last_x_transform: Option, #[cfg_attr(feature = "serde", serde(skip))] - pub(crate) last_y_transform: Option, + pub(crate) last_y_transform: Option, } impl PlotMemory { diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index c7dd9786..a89e5910 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -30,6 +30,8 @@ use crate::axis::Axis; use crate::axis::AxisHints; use crate::axis::AxisWidget; use crate::axis::PlotTransform; +use crate::axis_transform::AxisTransform; +use crate::axis_transform::AxisTransformType; use crate::bounds::BoundsLinkGroups; use crate::bounds::BoundsModification; use crate::bounds::LinkedBounds; @@ -132,8 +134,8 @@ pub struct Plot<'a> { grid_strength_exponent: f32, clamp_grid: bool, - x_axis_transform_kind: crate::axis_transform::AxisTransformKind, - y_axis_transform_kind: crate::axis_transform::AxisTransformKind, + x_axis_transform_type: AxisTransformType, + y_axis_transform_type: AxisTransformType, sense: Sense, } @@ -189,8 +191,8 @@ impl<'a> Plot<'a> { grid_strength_exponent: 0.5, clamp_grid: false, - x_axis_transform_kind: crate::axis_transform::AxisTransformKind::Linear, - y_axis_transform_kind: crate::axis_transform::AxisTransformKind::Linear, + x_axis_transform_type: AxisTransformType::linear(), + y_axis_transform_type: AxisTransformType::linear(), sense: egui::Sense::click_and_drag(), } @@ -786,10 +788,9 @@ impl<'a> Plot<'a> { /// You can customize this with `x_axis_formatter()`. #[inline] pub fn log_x(mut self) -> Self { - self.x_axis_transform_kind = crate::axis_transform::AxisTransformKind::Log; + self.x_axis_transform_type = AxisTransformType::log(); // Update the grid spacer to use the log scale grid generation - let transform = self.x_axis_transform_kind.make_transform(); - self.grid_spacers[0] = Box::new(move |input| transform.generate_marks(input)); + self.grid_spacers[0] = Box::new(move |input| AxisTransformType::log().generate_marks(input)); // Set default superscript formatter for log scale if let Some(main) = self.x_axes.first_mut() { @@ -808,13 +809,13 @@ impl<'a> Plot<'a> { /// You can customize this with `y_axis_formatter()`. #[inline] pub fn log_y(mut self) -> Self { - self.y_axis_transform_kind = crate::axis_transform::AxisTransformKind::Log; // Update the grid spacer to use the log scale grid generation - let transform = self.y_axis_transform_kind.make_transform(); - self.grid_spacers[1] = Box::new(move |input| transform.generate_marks(input)); + self.grid_spacers[1] = Box::new(move |input| AxisTransformType::log().generate_marks(input)); + + self.y_axis_transform_type = AxisTransformType::log(); // Set default superscript formatter for log scale - if let Some(main) = self.y_axes.first_mut() { + if let Some(main) = (&mut self.y_axes).first_mut() { main.formatter = std::sync::Arc::new(crate::log_formatter_superscript()); } @@ -832,11 +833,11 @@ impl<'a> Plot<'a> { /// /// For common cases, prefer [`Self::x_axis_logarithmic`]. #[inline] - pub fn x_axis_transform(mut self, transform: crate::axis_transform::AxisTransformKind) -> Self { - self.x_axis_transform_kind = transform; + pub fn x_axis_transform(mut self, transform: AxisTransformType) -> Self { + self.x_axis_transform_type = transform; // Update the grid spacer to use the transform's grid generation - let transform_impl = transform.make_transform(); - self.grid_spacers[0] = Box::new(move |input| transform_impl.generate_marks(input)); + + self.grid_spacers[0] = Box::new(move |input| transform.generate_marks(input)); self } @@ -844,11 +845,10 @@ impl<'a> Plot<'a> { /// /// For common cases, prefer [`Self::y_axis_logarithmic`]. #[inline] - pub fn y_axis_transform(mut self, transform: crate::axis_transform::AxisTransformKind) -> Self { - self.y_axis_transform_kind = transform; + pub fn y_axis_transform(mut self, transform: AxisTransformType) -> Self { + self.y_axis_transform_type = transform; // Update the grid spacer to use the transform's grid generation - let transform_impl = transform.make_transform(); - self.grid_spacers[1] = Box::new(move |input| transform_impl.generate_marks(input)); + self.grid_spacers[1] = Box::new(move |input| transform.generate_marks(input)); self } @@ -976,14 +976,14 @@ impl<'a> Plot<'a> { self.min_auto_bounds, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), - self.x_axis_transform_kind.make_transform(), - self.y_axis_transform_kind.make_transform(), + self.x_axis_transform_type, + self.y_axis_transform_type, ), last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), - last_x_transform: Some(self.x_axis_transform_kind), - last_y_transform: Some(self.y_axis_transform_kind), + last_x_transform: Some(self.x_axis_transform_type), + last_y_transform: Some(self.y_axis_transform_type), } } else { let mut mem = PlotMemory::load(ui.ctx(), plot_id).unwrap_or_else(|| PlotMemory { @@ -995,22 +995,22 @@ impl<'a> Plot<'a> { self.min_auto_bounds, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), - self.x_axis_transform_kind.make_transform(), - self.y_axis_transform_kind.make_transform(), + self.x_axis_transform_type, + self.y_axis_transform_type, ), last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), - last_x_transform: Some(self.x_axis_transform_kind), - last_y_transform: Some(self.y_axis_transform_kind), + last_x_transform: Some(self.x_axis_transform_type), + last_y_transform: Some(self.y_axis_transform_type), }); // Detect if axis transform type changed and reset auto-bounds if so let x_transform_changed = mem.last_x_transform.map_or(true, |last| { - std::mem::discriminant(&last) != std::mem::discriminant(&self.x_axis_transform_kind) + std::mem::discriminant(&last) != std::mem::discriminant(&self.x_axis_transform_type) }); let y_transform_changed = mem.last_y_transform.map_or(true, |last| { - std::mem::discriminant(&last) != std::mem::discriminant(&self.y_axis_transform_kind) + std::mem::discriminant(&last) != std::mem::discriminant(&self.y_axis_transform_type) }); if x_transform_changed { @@ -1021,8 +1021,8 @@ impl<'a> Plot<'a> { } // Update the stored transform kinds - mem.last_x_transform = Some(self.x_axis_transform_kind); - mem.last_y_transform = Some(self.y_axis_transform_kind); + mem.last_x_transform = Some(self.x_axis_transform_type); + mem.last_y_transform = Some(self.y_axis_transform_type); mem } @@ -1177,14 +1177,14 @@ impl<'a> Plot<'a> { // space) if auto_x { - match self.x_axis_transform_kind { - crate::axis_transform::AxisTransformKind::Linear => { + match self.x_axis_transform_type { + AxisTransformType::Linear(_) => { // Linear: apply margin in data space bounds.add_relative_margin_x(self.margin_fraction); } - crate::axis_transform::AxisTransformKind::Log => { + AxisTransformType::Log(_) => { // Log: apply margin in plot space - let transform = self.x_axis_transform_kind.make_transform(); + let transform = self.x_axis_transform_type; let (mut plot_min, mut plot_max) = transform.bounds_to_plot(bounds.min[0], bounds.max[0]); let plot_range = (plot_max - plot_min).abs(); let margin = plot_range * self.margin_fraction.x as f64; @@ -1197,14 +1197,14 @@ impl<'a> Plot<'a> { } if auto_y { - match self.y_axis_transform_kind { - crate::axis_transform::AxisTransformKind::Linear => { + match self.y_axis_transform_type { + AxisTransformType::Linear(_) => { // Linear: apply margin in data space bounds.add_relative_margin_y(self.margin_fraction); } - crate::axis_transform::AxisTransformKind::Log => { + AxisTransformType::Log(_) => { // Log: apply margin in plot space - let transform = self.y_axis_transform_kind.make_transform(); + let transform = self.y_axis_transform_type; let (mut plot_min, mut plot_max) = transform.bounds_to_plot(bounds.min[1], bounds.max[1]); let plot_range = (plot_max - plot_min).abs(); let margin = plot_range * self.margin_fraction.y as f64; @@ -1222,8 +1222,8 @@ impl<'a> Plot<'a> { bounds, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), - self.x_axis_transform_kind.make_transform(), - self.y_axis_transform_kind.make_transform(), + self.x_axis_transform_type, + self.y_axis_transform_type, ); // Enforce aspect ratio From 8f20608ad5939ef2b866f88654c7dc333996bbf6 Mon Sep 17 00:00:00 2001 From: "J.A. de Jong" Date: Fri, 29 May 2026 17:53:38 +0200 Subject: [PATCH 3/3] Added boolean flag for log scales. Easier with making logarithmic scale optional using boolean flag. --- egui_plot/src/axis.rs | 1 + egui_plot/src/axis_transform.rs | 1 + egui_plot/src/plot.rs | 39 ++++++++++++++++++++++++--------- examples/log_scale/src/main.rs | 4 ++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis.rs index 9bc77a30..52729c6a 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis.rs @@ -21,6 +21,7 @@ use emath::Vec2b; use emath::pos2; use emath::remap; +#[expect(clippy::unused_trait_names, reason = "Clippy false positive")] use crate::axis_transform::AxisTransform; use crate::axis_transform::AxisTransformType; use crate::bounds::PlotBounds; diff --git a/egui_plot/src/axis_transform.rs b/egui_plot/src/axis_transform.rs index d503f29d..55b98247 100644 --- a/egui_plot/src/axis_transform.rs +++ b/egui_plot/src/axis_transform.rs @@ -30,6 +30,7 @@ pub trait AxisTransform: Send + Sync + std::fmt::Debug { /// /// For example, logarithmic scales are only valid for positive values. /// Returns `None` if all values are valid. + #[expect(unused)] fn valid_range(&self) -> Option<(f64, f64)> { None } diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index a89e5910..e11b7104 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -30,6 +30,7 @@ use crate::axis::Axis; use crate::axis::AxisHints; use crate::axis::AxisWidget; use crate::axis::PlotTransform; +#[expect(clippy::unused_trait_names, reason = "Clippy false positive")] use crate::axis_transform::AxisTransform; use crate::axis_transform::AxisTransformType; use crate::bounds::BoundsLinkGroups; @@ -781,13 +782,20 @@ impl<'a> Plot<'a> { /// Use logarithmic scale on the X-axis with base 10. /// - /// Default: linear scale. + /// # Arguments + /// + /// * `log_x`: whether to use logarithmic scale on the X-axis. + /// + /// The default is a linear scale. /// /// Note: logarithmic scales only work with positive values. /// By default, axis labels will use superscript notation (e.g., 10², 10³). /// You can customize this with `x_axis_formatter()`. #[inline] - pub fn log_x(mut self) -> Self { + pub fn log_x(mut self, log_x: bool) -> Self { + if !log_x { + return self; + } self.x_axis_transform_type = AxisTransformType::log(); // Update the grid spacer to use the log scale grid generation self.grid_spacers[0] = Box::new(move |input| AxisTransformType::log().generate_marks(input)); @@ -802,20 +810,27 @@ impl<'a> Plot<'a> { /// Use logarithmic scale on the Y-axis with the specified base. /// + /// # Arguments + /// + /// * `log_y`: whether to use logarithmic scale on the Y-axis. + /// /// Default: linear scale. /// /// Note: logarithmic scales only work with positive values. /// By default, axis labels will use superscript notation (e.g., 10², 10³). /// You can customize this with `y_axis_formatter()`. #[inline] - pub fn log_y(mut self) -> Self { + pub fn log_y(mut self, log_y: bool) -> Self { + if !log_y { + return self; + } // Update the grid spacer to use the log scale grid generation self.grid_spacers[1] = Box::new(move |input| AxisTransformType::log().generate_marks(input)); self.y_axis_transform_type = AxisTransformType::log(); // Set default superscript formatter for log scale - if let Some(main) = (&mut self.y_axes).first_mut() { + if let Some(main) = self.y_axes.first_mut() { main.formatter = std::sync::Arc::new(crate::log_formatter_superscript()); } @@ -1006,12 +1021,16 @@ impl<'a> Plot<'a> { }); // Detect if axis transform type changed and reset auto-bounds if so - let x_transform_changed = mem.last_x_transform.map_or(true, |last| { - std::mem::discriminant(&last) != std::mem::discriminant(&self.x_axis_transform_type) - }); - let y_transform_changed = mem.last_y_transform.map_or(true, |last| { - std::mem::discriminant(&last) != std::mem::discriminant(&self.y_axis_transform_type) - }); + let x_transform_changed = if let Some(transform) = mem.last_x_transform { + transform != self.x_axis_transform_type + } else { + true + }; + let y_transform_changed = if let Some(transform) = mem.last_y_transform { + transform != self.y_axis_transform_type + } else { + true + }; if x_transform_changed { mem.auto_bounds.x = true; diff --git a/examples/log_scale/src/main.rs b/examples/log_scale/src/main.rs index e2e9f21d..a3a5cb97 100644 --- a/examples/log_scale/src/main.rs +++ b/examples/log_scale/src/main.rs @@ -88,7 +88,7 @@ impl eframe::App for MyApp { .allow_double_click_reset(true); if self.log_x { - plot = plot.log_x(); + plot = plot.log_x(true); plot = match self.log_axis_formatter { LogFormatter::Superscript => plot.x_axis_formatter(log_formatter_superscript()), LogFormatter::Computer => plot.x_axis_formatter(log_formatter_computer()), @@ -96,7 +96,7 @@ impl eframe::App for MyApp { }; } if self.log_y { - plot = plot.log_y(); + plot = plot.log_y(true); // Override default formatter based on selection plot = match self.log_axis_formatter { LogFormatter::Superscript => plot.y_axis_formatter(log_formatter_superscript()),