From f287a926e3388481b041e9e103cb53c161e16040 Mon Sep 17 00:00:00 2001 From: James McNally Date: Mon, 25 May 2026 09:39:35 +0100 Subject: [PATCH 1/7] refactor: Factor Out LinearAxis Separated the axis logic from the transform logic in preparation for being able to support log axis. This still has cases that won't work as an API for log axis but provides a starting point with all tests passing. Failing tests on drawing grid lines and marks though. --- egui_plot/src/axis/linear.rs | 127 +++++++ egui_plot/src/{axis.rs => axis/mod.rs} | 285 +-------------- egui_plot/src/axis/transform.rs | 487 +++++++++++++++++++++++++ egui_plot/src/items/span.rs | 2 +- egui_plot/src/memory.rs | 2 +- egui_plot/src/plot.rs | 21 +- 6 files changed, 634 insertions(+), 290 deletions(-) create mode 100644 egui_plot/src/axis/linear.rs rename egui_plot/src/{axis.rs => axis/mod.rs} (57%) create mode 100644 egui_plot/src/axis/transform.rs diff --git a/egui_plot/src/axis/linear.rs b/egui_plot/src/axis/linear.rs new file mode 100644 index 00000000..f5aee581 --- /dev/null +++ b/egui_plot/src/axis/linear.rs @@ -0,0 +1,127 @@ +use std::ops::RangeInclusive; +use emath::{remap, Rangef}; +use crate::axis::transform::AxisSpace; + +#[derive(Debug, Copy, Clone)] +pub struct LinearAxisSpace { + min: f64, + max: f64, + invert: bool, + frame_start: f32, + frame_end: f32, +} + +impl LinearAxisSpace { + pub fn new(value_range: RangeInclusive, frame_range: Rangef, invert: bool) -> Self { + Self { + min: *value_range.start(), + max: *value_range.end(), + frame_start: frame_range.min, + frame_end: frame_range.max, + invert + } + } + fn clamp_to_finite(&mut self) { + + self.min = self.min.clamp(f64::MIN, f64::MAX); + if self.min.is_nan() { + self.min = 0.0; + } + + self.max = self.max.clamp(f64::MIN, f64::MAX); + if self.max.is_nan() { + self.max = 0.0; + } + } + + /// Specifies the output range of the space, inverting it + /// if inversion is selected. + fn frame_range(&self) -> RangeInclusive { + if self.invert { + (self.frame_end)..=(self.frame_start) + } else { + (self.frame_start)..=(self.frame_end) + } + } + + /// Get the frame range as f64 format. This is not the natural + /// format for screen units but is needed for remapping to and + /// from values. + fn frame_range_f64(&self) -> RangeInclusive { + let as_f32 = self.frame_range(); + *as_f32.start() as f64..=*as_f32.end() as f64 + } + +} + +impl AxisSpace for LinearAxisSpace { + fn translate(&mut self, frame_distance: f32) { + let dvalue_per_dpos = self.dvalue_per_dpos(); + let value_translation = frame_distance as f64 * dvalue_per_dpos; + self.min += value_translation; + self.max += value_translation; + self.clamp_to_finite(); + } + + fn zoom(&mut self, zoom_factor: f32, center: f64) { + self.min = center + (self.min - center) / (zoom_factor as f64); + self.max = center + (self.max - center) / (zoom_factor as f64); + } + + fn value_min(&self) -> f64 { + self.min + } + + fn value_max(&self) -> f64 { + self.max + } + + fn frame_min(&self) -> f32 { + self.frame_start + } + + fn frame_max(&self) -> f32 { + self.frame_end + } + + + fn position_from_value(&self, value: f64) -> f32 { + remap( + value, + self.min..=self.max, + self.frame_range_f64() + ) as f32 + } + + fn value_from_position(&self, position: f32) -> f64 { + remap(position as f64, self.frame_range_f64(), self.min..=self.max) + } + + fn dvalue_per_dpos(&self) -> f64 { + let frame_range = self.frame_range_f64(); + self.value_length() / (frame_range.end() - frame_range.start()) + } + + fn dpos_per_dvalue(&self) -> f32 { + let frame_range = self.frame_range(); + (frame_range.end() - frame_range.start()) / (self.value_length() as f32) + } + + fn expand(&mut self, pad: f64) { + if pad.is_finite() { + self.min -= pad; + self.max += pad; + self.clamp_to_finite(); + } + } + + fn set_inverted(&mut self, invert: bool) { + self.invert = invert; + } + + fn set_value_range(&mut self, range: RangeInclusive) { + self.min = *range.start(); + self.max = *range.end(); + self.clamp_to_finite(); + } +} \ No newline at end of file diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis/mod.rs similarity index 57% rename from egui_plot/src/axis.rs rename to egui_plot/src/axis/mod.rs index c22acaa8..1d3b3cd1 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis/mod.rs @@ -1,3 +1,6 @@ +pub mod transform; +pub mod linear; + use std::fmt::Debug; use std::ops::RangeInclusive; use std::sync::Arc; @@ -17,12 +20,8 @@ use egui::WidgetText; use egui::emath::Rot2; use egui::emath::remap_clamp; use egui::epaint::TextShape; -use emath::Vec2b; -use emath::pos2; -use emath::remap; -use crate::bounds::PlotBounds; -use crate::bounds::PlotPoint; +pub use transform::PlotTransform; use crate::grid::GridMark; use crate::placement::HPlacement; use crate::placement::Placement; @@ -283,7 +282,8 @@ impl<'a> AxisWidget<'a> { 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; + // todo: This mimics the grid spacing logic. May want to make this common. + let spacing_in_points = (transform.dpos_dvalue()[usize::from(axis)] * step.step_size as f32).abs(); if spacing_in_points <= label_spacing.min { // Labels are too close together - don't paint them. @@ -360,276 +360,3 @@ 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)] -pub struct PlotTransform { - /// The screen rectangle. - frame: Rect, - - /// The plot bounds. - bounds: PlotBounds, - - /// Whether to always center the x-range or y-range of the bounds. - centered: Vec2b, - - /// Whether to always invert the x and/or y axis - inverted_axis: Vec2b, -} - -impl PlotTransform { - pub fn new(frame: Rect, bounds: PlotBounds, center_axis: impl Into) -> Self { - debug_assert!( - 0.0 <= frame.width() && 0.0 <= frame.height(), - "Bad plot frame: {frame:?}" - ); - let center_axis = center_axis.into(); - - // Since the current Y bounds an affect the final X bounds and vice versa, we - // need to keep the original version of the `bounds` before we start - // modifying it. - let mut new_bounds = bounds; - - // Sanitize bounds. - // - // When a given bound axis is "thin" (e.g. width or height is 0) but finite, we - // center the bounds around that value. If the other axis is "fat", we - // reuse its extent for the thin axis, and default to +/- 1.0 otherwise. - if !bounds.is_finite_x() { - new_bounds.set_x(&PlotBounds::new_symmetrical(1.0)); - } else if bounds.width() <= 0.0 { - new_bounds.set_x_center_width( - bounds.center().x, - if bounds.is_valid_y() { bounds.height() } else { 1.0 }, - ); - } - - if !bounds.is_finite_y() { - new_bounds.set_y(&PlotBounds::new_symmetrical(1.0)); - } else if bounds.height() <= 0.0 { - new_bounds.set_y_center_height( - bounds.center().y, - if bounds.is_valid_x() { bounds.width() } else { 1.0 }, - ); - } - - // Scale axes so that the origin is in the center. - if center_axis.x { - new_bounds.make_x_symmetrical(); - } - if center_axis.y { - new_bounds.make_y_symmetrical(); - } - - debug_assert!(new_bounds.is_valid(), "Bad final plot bounds: {new_bounds:?}"); - - Self { - frame, - bounds: new_bounds, - centered: center_axis, - inverted_axis: Vec2b::new(false, false), - } - } - - pub fn new_with_invert_axis( - frame: Rect, - bounds: PlotBounds, - center_axis: impl Into, - invert_axis: impl Into, - ) -> Self { - let mut new = Self::new(frame, bounds, center_axis); - new.inverted_axis = invert_axis.into(); - new - } - - /// ui-space rectangle. - #[inline] - pub fn frame(&self) -> &Rect { - &self.frame - } - - /// Plot-space bounds. - #[inline] - pub fn bounds(&self) -> &PlotBounds { - &self.bounds - } - - #[inline] - pub fn set_bounds(&mut self, bounds: PlotBounds) { - self.bounds = bounds; - } - - pub fn translate_bounds(&mut self, mut delta_pos: (f64, f64)) { - if self.centered.x { - delta_pos.0 = 0.; - } - 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)); - } - - /// 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 mut new_bounds = self.bounds; - new_bounds.zoom(zoom_factor, center); - - if new_bounds.is_valid() { - self.bounds = new_bounds; - } - } - - pub fn position_from_point_x(&self, value: f64) -> f32 { - remap( - value, - self.bounds.min[0]..=self.bounds.max[0], - 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) - }, - ) as f32 - } - - pub fn position_from_point_y(&self, value: f64) -> f32 { - remap( - value, - self.bounds.min[1]..=self.bounds.max[1], - // negated y axis by default - if self.inverted_axis[1] { - (self.frame.top() as f64)..=(self.frame.bottom() as f64) - } else { - (self.frame.bottom() as f64)..=(self.frame.top() as f64) - }, - ) as f32 - } - - /// Screen/ui position from point on plot. - pub fn position_from_point(&self, value: &PlotPoint) -> Pos2 { - pos2(self.position_from_point_x(value.x), self.position_from_point_y(value.y)) - } - - /// Plot point from screen/ui position. - pub fn value_from_position(&self, pos: Pos2) -> PlotPoint { - let 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(), - ); - let y = remap( - pos.y as f64, - // negated y axis by default - if self.inverted_axis[1] { - (self.frame.top() as f64)..=(self.frame.bottom() as f64) - } else { - (self.frame.bottom() as f64)..=(self.frame.top() as f64) - }, - self.bounds.range_y(), - ); - - PlotPoint::new(x, y) - } - - /// Transform a rectangle of plot values to a screen-coordinate rectangle. - /// - /// This typically means that the rect is mirrored vertically (top becomes - /// bottom and vice versa), since the plot's coordinate system has +Y - /// up, while egui has +Y down. - pub fn rect_from_values(&self, value1: &PlotPoint, value2: &PlotPoint) -> Rect { - let pos1 = self.position_from_point(value1); - let pos2 = self.position_from_point(value2); - - let mut rect = Rect::NOTHING; - rect.extend_with(pos1); - rect.extend_with(pos2); - rect - } - - /// delta position / delta value = how many ui points per step in the X axis - /// in "plot space" - 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() - } - - /// delta position / delta value = how many ui points per step in the Y axis - /// in "plot space" - 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() - } - - /// delta position / delta value = how many ui points per step in "plot - /// space" - pub fn dpos_dvalue(&self) -> [f64; 2] { - [self.dpos_dvalue_x(), self.dpos_dvalue_y()] - } - - /// delta value / delta position = how much ground do we cover in "plot - /// space" per ui point? - pub fn dvalue_dpos(&self) -> [f64; 2] { - [1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()] - } - - /// scale.x/scale.y ratio. - /// - /// If 1.0, it means the scale factor is the same in both axes. - fn aspect(&self) -> f64 { - let rw = self.frame.width() as f64; - let rh = self.frame.height() as f64; - (self.bounds.width() / rw) / (self.bounds.height() / rh) - } - - /// Sets the aspect ratio by expanding the x- or y-axis. - /// - /// This never contracts, so we don't miss out on any data. - pub(crate) fn set_aspect_by_expanding(&mut self, aspect: f64) { - let current_aspect = self.aspect(); - - let epsilon = 1e-5; - if (current_aspect - aspect).abs() < epsilon { - // Don't make any changes when the aspect is already almost correct. - return; - } - - if current_aspect < aspect { - self.bounds - .expand_x((aspect / current_aspect - 1.0) * self.bounds.width() * 0.5); - } else { - self.bounds - .expand_y((current_aspect / aspect - 1.0) * self.bounds.height() * 0.5); - } - } - - /// Sets the aspect ratio by changing either the X or Y axis (callers - /// choice). - pub(crate) fn set_aspect_by_changing_axis(&mut self, aspect: f64, axis: Axis) { - let current_aspect = self.aspect(); - - let epsilon = 1e-5; - if (current_aspect - aspect).abs() < epsilon { - // Don't make any changes when the aspect is already almost correct. - return; - } - - match axis { - Axis::X => { - self.bounds - .expand_x((aspect / current_aspect - 1.0) * self.bounds.width() * 0.5); - } - Axis::Y => { - self.bounds - .expand_y((current_aspect / aspect - 1.0) * self.bounds.height() * 0.5); - } - } - } -} diff --git a/egui_plot/src/axis/transform.rs b/egui_plot/src/axis/transform.rs new file mode 100644 index 00000000..112fa267 --- /dev/null +++ b/egui_plot/src/axis/transform.rs @@ -0,0 +1,487 @@ +use std::ops::RangeInclusive; +use emath::{pos2, Pos2, Rect, Vec2, Vec2b}; +use crate::{Axis, PlotBounds, PlotPoint}; +use crate::axis::linear::LinearAxisSpace; + +pub(super) trait AxisSpace { + /// The minimum value of the axis. + fn value_min(&self) -> f64; + /// The maximum value of the axis. + fn value_max(&self) -> f64; + /// The smallest edge of the frame. + fn frame_min(&self) -> f32; + /// The largest edge of the frame. + fn frame_max(&self) -> f32; + + /// Get the length of the value range. + fn value_length(&self) -> f64 { + self.value_max() - self.value_min() + } + + /// Confirm all aspects of the axis space are valid. + fn is_valid(&self) -> bool { + self.value_min().is_finite() && self.value_max().is_finite() && self.value_length() > 0.0 + } + /// Set whether the axis is inverted on the screen. + fn set_inverted(&mut self, invert: bool); + + /// Set the value range of the axis. + fn set_value_range(&mut self, range: RangeInclusive); + + /// Convert a value to a screen position. + fn position_from_value(&self, value: f64) -> f32; + + /// Convert a screen position to a value. + fn value_from_position(&self, position: f32) -> f64; + + /// Get value per screen distance. + fn dvalue_per_dpos(&self) -> f64; + + /// Get screen distance per value unit. + fn dpos_per_dvalue(&self) -> f32; + + /// Alter the value minimum and maximum to produce a translation + /// in the screen space. + fn translate(&mut self, frame_distance: f32); + /// Zoom around the center point. + fn zoom(&mut self, factor: f32, center: f64); + + /// Extend both ends of the value range by the provided `pad` value. + fn expand(&mut self, pad: f64); + + +} + +/// 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)] +pub struct PlotTransform { + + /// The X axis space + x_axis: LinearAxisSpace, + + /// The Y axis space + y_axis: LinearAxisSpace, + + /// Whether to always center the x-range or y-range of the bounds. + centered: Vec2b, + +} + +impl PlotTransform { + pub fn new(frame: Rect, bounds: PlotBounds, center_axis: impl Into) -> Self { + debug_assert!( + 0.0 <= frame.width() && 0.0 <= frame.height(), + "Bad plot frame: {frame:?}" + ); + let center_axis = center_axis.into(); + + // Since the current Y bounds an affect the final X bounds and vice versa, we + // need to keep the original version of the `bounds` before we start + // modifying it. + let mut new_bounds = bounds; + + // Sanitize bounds. + // + // When a given bound axis is "thin" (e.g. width or height is 0) but finite, we + // center the bounds around that value. If the other axis is "fat", we + // reuse its extent for the thin axis, and default to +/- 1.0 otherwise. + if !bounds.is_finite_x() { + new_bounds.set_x(&PlotBounds::new_symmetrical(1.0)); + } else if bounds.width() <= 0.0 { + new_bounds.set_x_center_width( + bounds.center().x, + if bounds.is_valid_y() { bounds.height() } else { 1.0 }, + ); + } + + if !bounds.is_finite_y() { + new_bounds.set_y(&PlotBounds::new_symmetrical(1.0)); + } else if bounds.height() <= 0.0 { + new_bounds.set_y_center_height( + bounds.center().y, + if bounds.is_valid_x() { bounds.width() } else { 1.0 }, + ); + } + + // Scale axes so that the origin is in the center. + if center_axis.x { + new_bounds.make_x_symmetrical(); + } + if center_axis.y { + new_bounds.make_y_symmetrical(); + } + + debug_assert!(new_bounds.is_valid(), "Bad final plot bounds: {new_bounds:?}"); + let x_axis = LinearAxisSpace::new(bounds.range_x(), frame.x_range(), false); + // Y is naturally inverted. + let y_axis = LinearAxisSpace::new(bounds.range_y(), frame.y_range(), true); + + Self { + centered: center_axis, + x_axis, + y_axis, + } + } + + pub fn new_with_invert_axis( + frame: Rect, + bounds: PlotBounds, + center_axis: impl Into, + invert_axis: impl Into, + ) -> Self { + let mut new = Self::new(frame, bounds, center_axis); + let inverted = invert_axis.into(); + new.x_axis.set_inverted(inverted.x); + // y is naturally inverted so !inverted.y is the required input. + new.y_axis.set_inverted(!inverted.y); + new + } + + /// ui-space rectangle. + #[inline] + pub fn frame(&self) -> Rect { + let min = Pos2::new(self.x_axis.frame_min(), self.y_axis.frame_min()); + let max = Pos2::new(self.x_axis.frame_max(), self.y_axis.frame_max()); + Rect::from_min_max(min, max) + } + + /// Plot-space bounds. + #[inline] + pub fn bounds(&self) -> PlotBounds { + PlotBounds::from_min_max([self.x_axis.value_min(), self.y_axis.value_min()], [self.x_axis.value_max(), self.y_axis.value_max()]) + } + + #[inline] + pub fn set_bounds(&mut self, bounds: PlotBounds) { + self.x_axis.set_value_range(bounds.range_x()); + self.y_axis.set_value_range(bounds.range_y()); + } + + pub fn translate_bounds(&mut self, mut delta_pos: (f64, f64)) { + if self.centered.x { + delta_pos.0 = 0.; + } + if self.centered.y { + delta_pos.1 = 0.; + } + self.x_axis.translate(delta_pos.0 as f32); + self.y_axis.translate(delta_pos.1 as f32); + } + + /// 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 mut new_x = self.x_axis; + let mut new_y = self.y_axis; + new_x.zoom(zoom_factor.x, center.x); + new_y.zoom(zoom_factor.y, center.y); + + if new_x.is_valid() && new_y.is_valid() { + self.x_axis = new_x; + self.y_axis = new_y; + } + } + + pub fn position_from_point_x(&self, value: f64) -> f32 { + self.x_axis.position_from_value(value) + } + + pub fn position_from_point_y(&self, value: f64) -> f32 { + self.y_axis.position_from_value(value) + } + + /// Screen/ui position from point on plot. + pub fn position_from_point(&self, value: &PlotPoint) -> Pos2 { + pos2(self.position_from_point_x(value.x), self.position_from_point_y(value.y)) + } + + /// Plot point from screen/ui position. + pub fn value_from_position(&self, pos: Pos2) -> PlotPoint { + let x = self.x_axis.value_from_position(pos.x); + let y = self.y_axis.value_from_position(pos.y); + PlotPoint::new(x, y) + } + + /// Transform a rectangle of plot values to a screen-coordinate rectangle. + /// + /// This typically means that the rect is mirrored vertically (top becomes + /// bottom and vice versa), since the plot's coordinate system has +Y + /// up, while egui has +Y down. + pub fn rect_from_values(&self, value1: &PlotPoint, value2: &PlotPoint) -> Rect { + let pos1 = self.position_from_point(value1); + let pos2 = self.position_from_point(value2); + + let mut rect = Rect::NOTHING; + rect.extend_with(pos1); + rect.extend_with(pos2); + rect + } + + + /// delta position / delta value = how many ui points per step in "plot + /// space" + pub fn dpos_dvalue(&self) -> [f32; 2] { + [self.x_axis.dpos_per_dvalue(), self.y_axis.dpos_per_dvalue()] + } + + /// delta value / delta position = how much ground do we cover in "plot + /// space" per ui point? + pub fn dvalue_dpos(&self) -> [f64; 2] { + [self.x_axis.dvalue_per_dpos(), self.y_axis.dvalue_per_dpos()] + } + + /// scale.x/scale.y ratio. + /// + /// If 1.0, it means the scale factor is the same in both axes. + fn aspect(&self) -> f64 { + self.x_axis.dvalue_per_dpos().abs() / self.y_axis.dvalue_per_dpos().abs() + } + + /// Sets the aspect ratio by expanding the x- or y-axis. + /// + /// This never contracts, so we don't miss out on any data. + pub(crate) fn set_aspect_by_expanding(&mut self, aspect: f64) { + let current_aspect = self.aspect(); + + let epsilon = 1e-5; + if (current_aspect - aspect).abs() < epsilon { + // Don't make any changes when the aspect is already almost correct. + return; + } + + if current_aspect < aspect { + self.x_axis.expand((aspect / current_aspect - 1.0) * self.x_axis.value_length() * 0.5); + } else { + self.y_axis.expand((current_aspect / aspect - 1.0) * self.y_axis.value_length() * 0.5); + } + } + + /// Sets the aspect ratio by changing either the X or Y axis (callers + /// choice). + pub(crate) fn set_aspect_by_changing_axis(&mut self, aspect: f64, axis: Axis) { + let current_aspect = self.aspect(); + + let epsilon = 1e-5; + if (current_aspect - aspect).abs() < epsilon { + // Don't make any changes when the aspect is already almost correct. + return; + } + + match axis { + Axis::X => { + self.x_axis.expand((aspect / current_aspect - 1.0) * self.x_axis.value_length() * 0.5); + } + Axis::Y => { + self.y_axis.expand((current_aspect / aspect - 1.0) * self.y_axis.value_length() * 0.5); + } + } + } +} + +#[cfg(test)] +mod tests { + use assertables::assert_approx_eq; + use super::*; + + #[test] + fn test_basic_linear_axis_transform() { + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + + let transform = PlotTransform::new(frame, bounds, false); + + assert_eq!(transform.position_from_point(&PlotPoint::new(0.0, 0.0)), pos2(50.0, 50.0)); + assert_eq!(transform.value_from_position(pos2(50.0, 50.0)), PlotPoint::new(0.0, 0.0)); + assert_eq!(transform.position_from_point(&PlotPoint::new(10.0, 10.0)), pos2(100.0, 0.0)); + assert_eq!(transform.value_from_position(pos2(100.0, 0.0)), PlotPoint::new(10.0, 10.0)); + assert_eq!(transform.position_from_point(&PlotPoint::new(-10.0, -10.0)), pos2(0.0, 100.0)); + assert_eq!(transform.value_from_position(pos2(0.0, 100.0)), PlotPoint::new(-10.0, -10.0)); + assert_eq!(transform.position_from_point(&PlotPoint::new(5.0, -5.0)), pos2(75.0, 75.0)); + assert_eq!(transform.value_from_position(pos2(75.0, 75.0)), PlotPoint::new(5.0, -5.0)); + + } + + #[test] + fn test_inverted_linear_axis_transform() { + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + let invert_axis = Vec2b::new(true, false); + let transform = PlotTransform::new_with_invert_axis(frame, bounds, false, invert_axis); + assert_eq!(transform.position_from_point(&PlotPoint::new(0.0, 0.0)), pos2(50.0, 50.0)); + assert_eq!(transform.value_from_position(pos2(50.0, 50.0)), PlotPoint::new(0.0, 0.0)); + assert_eq!(transform.position_from_point(&PlotPoint::new(10.0, 10.0)), pos2(0.0, 0.0)); + assert_eq!(transform.value_from_position(pos2(0.0, 0.0)), PlotPoint::new(10.0, 10.0)); + assert_eq!(transform.position_from_point(&PlotPoint::new(-10.0, -10.0)), pos2(100.0, 100.0)); + assert_eq!(transform.value_from_position(pos2(100.0, 100.0)), PlotPoint::new(-10.0, -10.0)); + assert_eq!(transform.position_from_point(&PlotPoint::new(5.0, -5.0)), pos2(25.0, 75.0)); + assert_eq!(transform.value_from_position(pos2(25.0, 75.0)), PlotPoint::new(5.0, -5.0)); + } + + #[test] + fn aspect_ratio_calculation() { + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + let transform = PlotTransform::new(frame, bounds, false); + assert_eq!(transform.aspect(), 1.0); + + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [100.0, 50.0]); + let transform = PlotTransform::new(frame, bounds, false); + assert_eq!(transform.aspect(), 2.0); + + + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + let transform = PlotTransform::new(frame, bounds, false); + assert_eq!(transform.aspect(), 0.5); + } + + /// Real values captured from a test which was failing. + #[test] + fn aspect_calculation_from_custom_axes() { + + let frame = Rect::from_min_max(Pos2::new(22.0,49.0), Pos2::new(778.0, 564.0)); + let bounds = PlotBounds::from_min_max([-360.0, -0.04294], [7560.0, 1.049]); + let transform = PlotTransform::new(frame, bounds, [false, false]); + assert_approx_eq!(transform.aspect(), 4940.965708); + } + + fn assert_bounds_eq(actual: PlotBounds, expected_min: [f64; 2], expected_max: [f64; 2]) { + let epsilon = 1e-9; + assert!( + (actual.min()[0] - expected_min[0]).abs() < epsilon + && (actual.min()[1] - expected_min[1]).abs() < epsilon + && (actual.max()[0] - expected_max[0]).abs() < epsilon + && (actual.max()[1] - expected_max[1]).abs() < epsilon, + "bounds mismatch: got min={:?} max={:?}, expected min={:?} max={:?}", + actual.min(), + actual.max(), + expected_min, + expected_max, + ); + } + + #[test] + fn set_aspect_by_expanding_noop_when_already_correct() { + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + let mut transform = PlotTransform::new(frame, bounds, false); + let original = transform.bounds(); + + transform.set_aspect_by_expanding(1.0); + + assert_bounds_eq(transform.bounds(), original.min(), original.max()); + } + + #[test] + fn set_aspect_by_expanding_grows_x_when_current_is_smaller() { + // current_aspect = (10/100) / (20/100) = 0.5, target = 1.0 -> grow x + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [10.0, 20.0]); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_expanding(1.0); + + assert_bounds_eq(transform.bounds(), [-5.0, 0.0], [15.0, 20.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_expanding_grows_y_when_current_is_larger() { + // current_aspect = (20/100) / (10/100) = 2.0, target = 1.0 -> grow y + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [20.0, 10.0]); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_expanding(1.0); + + assert_bounds_eq(transform.bounds(), [0.0, -5.0], [20.0, 15.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_expanding_accounts_for_non_square_frame() { + // frame 100x50, bounds 20x20 -> current_aspect = (20/100)/(20/50) = 0.5 + // target 1.0 -> grow x by (1/0.5 - 1) * 20 * 0.5 = 10 on each side + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_expanding(1.0); + + assert_bounds_eq(transform.bounds(), [-20.0, -10.0], [20.0, 10.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_changing_axis_x_grows() { + // current_aspect = 0.5, target = 1.0, change X -> grow x + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [10.0, 20.0]); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_changing_axis(1.0, Axis::X); + + assert_bounds_eq(transform.bounds(), [-5.0, 0.0], [15.0, 20.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_changing_axis_x_shrinks() { + // current_aspect = 2.0, target = 1.0, change X -> shrink x + // pad = (1/2 - 1) * 20 * 0.5 = -5 -> min += 5, max -= 5 + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [20.0, 10.0]); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_changing_axis(1.0, Axis::X); + + assert_bounds_eq(transform.bounds(), [5.0, 0.0], [15.0, 10.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_changing_axis_y_grows() { + // current_aspect = 2.0, target = 1.0, change Y -> grow y + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [20.0, 10.0]); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_changing_axis(1.0, Axis::Y); + + assert_bounds_eq(transform.bounds(), [0.0, -5.0], [20.0, 15.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_changing_axis_y_shrinks() { + // current_aspect = 0.5, target = 1.0, change Y -> shrink y + // pad = (0.5/1 - 1) * 20 * 0.5 = -5 -> min += 5, max -= 5 + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::from_min_max([0.0, 0.0], [10.0, 20.0]); + let mut transform = PlotTransform::new(frame, bounds, false); + + transform.set_aspect_by_changing_axis(1.0, Axis::Y); + + assert_bounds_eq(transform.bounds(), [0.0, 5.0], [10.0, 15.0]); + assert!((transform.aspect() - 1.0).abs() < 1e-9); + } + + #[test] + fn set_aspect_by_changing_axis_noop_when_already_correct() { + let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); + let bounds = PlotBounds::new_symmetrical(10.0); + let mut transform = PlotTransform::new(frame, bounds, false); + let original = transform.bounds(); + + transform.set_aspect_by_changing_axis(1.0, Axis::X); + assert_bounds_eq(transform.bounds(), original.min(), original.max()); + + transform.set_aspect_by_changing_axis(1.0, Axis::Y); + assert_bounds_eq(transform.bounds(), original.min(), original.max()); + } +} + diff --git a/egui_plot/src/items/span.rs b/egui_plot/src/items/span.rs index afc3e799..ba9a25b0 100644 --- a/egui_plot/src/items/span.rs +++ b/egui_plot/src/items/span.rs @@ -176,7 +176,7 @@ impl Span { } fn draw_name(&self, ui: &Ui, transform: &PlotTransform, shapes: &mut Vec, span_rect: &Rect) { - let frame = *transform.frame(); + let frame = transform.frame(); let visible_rect = span_rect.intersect(frame); let available_width = self.available_width_for_name(&visible_rect); diff --git a/egui_plot/src/memory.rs b/egui_plot/src/memory.rs index b0b0aa0e..44cfcad4 100644 --- a/egui_plot/src/memory.rs +++ b/egui_plot/src/memory.rs @@ -51,7 +51,7 @@ impl PlotMemory { /// Plot-space bounds. #[inline] - pub fn bounds(&self) -> &PlotBounds { + pub fn bounds(&self) -> PlotBounds { self.transform.bounds() } diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index d993f974..bd61570d 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -991,7 +991,7 @@ impl<'a> Plot<'a> { fn compute_bounds(&self, ui: &Ui, mem: &mut PlotMemory, plot_ui: &PlotUi<'a>, plot_rect: Rect) { // Find the cursors from other plots we need to draw - let mut bounds = *plot_ui.last_plot_transform.bounds(); + let mut bounds = plot_ui.last_plot_transform.bounds(); // Transfer the bounds from a link group. if let Some((id, axes)) = self.linked_axes.as_ref() { @@ -1244,9 +1244,10 @@ impl<'a> Plot<'a> { let bounds = mem.transform.bounds(); let x_axis_range = bounds.range_x(); let x_steps = Arc::new({ + let dvalue_dpos = mem.transform.dvalue_dpos()[0].abs(); let input = GridInput { bounds: (bounds.min[0], bounds.max[0]), - base_step_size: mem.transform.dvalue_dpos()[0].abs() * self.grid_spacing.min as f64, + base_step_size: dvalue_dpos * self.grid_spacing.min as f64, }; (self.grid_spacers[0])(input) }); @@ -1322,7 +1323,7 @@ impl<'a> Plot<'a> { ) -> (Vec, Vec, Option) { let mut child_ui = ui.new_child( egui::UiBuilder::new() - .max_rect(*transform.frame()) + .max_rect(transform.frame()) .layout(Layout::default()), ); child_ui.set_clip_rect(transform.frame().intersect(ui.clip_rect())); @@ -1431,7 +1432,7 @@ impl<'a> Plot<'a> { if let Some(pointer) = hover_pos { let font_id = TextStyle::Monospace.resolve(ui.style()); let coordinate = transform.value_from_position(pointer); - let text = formatter.format(&coordinate, transform.bounds()); + let text = formatter.format(&coordinate, &transform.bounds()); let padded_frame = transform.frame().shrink(4.0); let (anchor, position) = match corner { Corner::LeftTop => (Align2::LEFT_TOP, padded_frame.left_top()), @@ -1507,7 +1508,7 @@ impl<'a> Plot<'a> { }; let pos_in_gui = transform.position_from_point(&value); - let spacing_in_points = (transform.dpos_dvalue()[iaxis] * step.step_size).abs() as f32; + let spacing_in_points = (transform.dpos_dvalue()[iaxis] * step.step_size as f32).abs(); if spacing_in_points <= self.grid_spacing.min { continue; // Too close together @@ -1669,7 +1670,7 @@ impl<'a> Plot<'a> { // Get the painter from ui and configure it with the plot's clip rect // The painter is used to render all accumulated shapes - let painter = ui.painter().with_clip_rect(*mem.transform.frame()); + let painter = ui.painter().with_clip_rect(mem.transform.frame()); painter.extend(shapes); // Show coordinates in a corner of the plot @@ -1705,7 +1706,7 @@ impl<'a> Plot<'a> { link_groups.0.insert( *id, LinkedBounds { - bounds: *mem.transform().bounds(), + bounds: mem.transform().bounds(), auto_bounds: mem.auto_bounds, }, ); @@ -1904,7 +1905,7 @@ impl<'a> PlotUi<'a> { /// this will return bounds centered on the origin. The bounds do /// not change until the plot is drawn. pub fn plot_bounds(&self) -> PlotBounds { - *self.last_plot_transform.bounds() + self.last_plot_transform.bounds() } /// Set the plot bounds. Can be useful for implementing alternative plot @@ -1991,9 +1992,11 @@ impl<'a> PlotUi<'a> { /// The pointer drag delta in plot coordinates. pub fn pointer_coordinate_drag_delta(&self) -> Vec2 { + // todo: We are going to need to get a start and end here to + // compute the actual delta in the log case. let delta = self.response.drag_delta(); let dp_dv = self.last_plot_transform.dpos_dvalue(); - Vec2::new(delta.x / dp_dv[0] as f32, delta.y / dp_dv[1] as f32) + Vec2::new(delta.x / dp_dv[0], delta.y / dp_dv[1]) } /// Read the transform between plot coordinates and screen coordinates. From ad6380143c7ae535cf7c7a560c4c0c1584b1154e Mon Sep 17 00:00:00 2001 From: James McNally Date: Tue, 30 Jun 2026 17:43:28 +0100 Subject: [PATCH 2/7] wip: Started Work on Log Scale Got the basics of log scale working with an inner linear scale based on the exponent. Now need to handle the more complex transforms. --- egui_plot/src/axis/linear.rs | 8 -- egui_plot/src/axis/log.rs | 157 ++++++++++++++++++++++++++++++++ egui_plot/src/axis/mod.rs | 1 + egui_plot/src/axis/transform.rs | 8 +- 4 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 egui_plot/src/axis/log.rs diff --git a/egui_plot/src/axis/linear.rs b/egui_plot/src/axis/linear.rs index f5aee581..364c1c67 100644 --- a/egui_plot/src/axis/linear.rs +++ b/egui_plot/src/axis/linear.rs @@ -107,14 +107,6 @@ impl AxisSpace for LinearAxisSpace { (frame_range.end() - frame_range.start()) / (self.value_length() as f32) } - fn expand(&mut self, pad: f64) { - if pad.is_finite() { - self.min -= pad; - self.max += pad; - self.clamp_to_finite(); - } - } - fn set_inverted(&mut self, invert: bool) { self.invert = invert; } diff --git a/egui_plot/src/axis/log.rs b/egui_plot/src/axis/log.rs new file mode 100644 index 00000000..f733268a --- /dev/null +++ b/egui_plot/src/axis/log.rs @@ -0,0 +1,157 @@ +use std::ops::RangeInclusive; +use emath::Rangef; +use crate::axis::linear::LinearAxisSpace; +use crate::axis::transform::AxisSpace; + +enum LogBase { + Base10, + Base2, +} + +impl LogBase { + /// Returns the exponent of the given value (the logarithm of the value). + /// + /// Returns `None` if the value is negative or zero. + pub fn exponent(&self, value: f64) -> Option { + if value <= 0.0 { + return None; + } + match self { + LogBase::Base10 => Some(value.log10()), + LogBase::Base2 => Some(value.log2()), + } + } + + pub fn power(&self, value: f64) -> f64 { + match self { + LogBase::Base10 => f64::powf(10.0, value), + LogBase::Base2 => value.exp2(), + } + } +} + +struct LogAxis { + /// A linear scale for the exponent of the logarithm. + /// + /// This allows us to reuse much of the linear logic. + exponent_scale: LinearAxisSpace, + /// The base of the logarithm. + base: LogBase +} + +impl LogAxis { + pub fn new(value_range: RangeInclusive, frame_range: Rangef, invert: bool, base: LogBase) -> Self { + let exponent_min = base.exponent(*value_range.start()).unwrap(); + let exponent_max = base.exponent(*value_range.end()).unwrap(); + Self { + exponent_scale: LinearAxisSpace::new(exponent_min..=exponent_max, frame_range, invert), + base, + } + } +} + +impl AxisSpace for LogAxis { + fn value_min(&self) -> f64 { + self.base.power(self.exponent_scale.value_min()) + } + + fn value_max(&self) -> f64 { + self.base.power(self.exponent_scale.value_max()) + } + + fn frame_min(&self) -> f32 { + self.exponent_scale.frame_min() + } + + fn frame_max(&self) -> f32 { + self.exponent_scale.frame_max() + } + + fn set_inverted(&mut self, invert: bool) { + self.exponent_scale.set_inverted(invert); + } + + fn set_value_range(&mut self, range: RangeInclusive) { + let exponent_min = self.base.exponent(*range.start()).unwrap(); + let exponent_max = self.base.exponent(*range.end()).unwrap(); + self.exponent_scale.set_value_range(exponent_min..=exponent_max); + } + + fn position_from_value(&self, value: f64) -> f32 { + let exponent = self.base.exponent(value).unwrap(); + self.exponent_scale.position_from_value(exponent) + } + + fn value_from_position(&self, position: f32) -> f64 { + let exponent = self.exponent_scale.value_from_position(position); + self.base.power(exponent) + } + + fn dvalue_per_dpos(&self) -> f64 { + todo!() + } + + fn dpos_per_dvalue(&self) -> f32 { + todo!() + } + + fn translate(&mut self, frame_distance: f32) { + todo!() + } + + fn zoom(&mut self, factor: f32, center: f64) { + if let Some(exponent) = self.base.exponent(center) { + self.exponent_scale.zoom(factor, exponent); + } + } + +} + +#[cfg(test)] +mod tests { + use assertables::{assert_approx_eq, assert_in_delta}; + use super::*; + + #[test] + fn test_value_changes_map_to_linear_and_back() { + let mut log_axis = LogAxis::new(1.0..=1000.0, Rangef::new(0.0, 1.0), false, LogBase::Base10); + + let value = 100.0; + let position = log_axis.position_from_value(value); + let value_back = log_axis.value_from_position(position); + assert_in_delta!(value, value_back, 1e-4); + assert_approx_eq!(position, 0.666666666); + + log_axis.set_value_range(10.0..=10000.0); + let position = log_axis.position_from_value(value); + let value_back = log_axis.value_from_position(position); + assert_in_delta!(value, value_back, 1e-4); + assert_approx_eq!(position, 0.333333333); + assert_approx_eq!(log_axis.value_min(), 10.0); + assert_approx_eq!(log_axis.value_max(), 10000.0); + assert_approx_eq!(log_axis.frame_min(), 0.0); + assert_approx_eq!(log_axis.frame_max(), 1.0); + + } + #[test] + fn test_value_changes_map_to_linear_and_back_base2() { + let mut log_axis = LogAxis::new(1.0..=8.0, Rangef::new(0.0, 1.0), false, LogBase::Base2); + + let value = 4.0; + let position = log_axis.position_from_value(value); + let value_back = log_axis.value_from_position(position); + assert_in_delta!(value, value_back, 1e-4); + assert_approx_eq!(position, 0.666666666); + + log_axis.set_value_range(2.0..=16.0); + let position = log_axis.position_from_value(value); + let value_back = log_axis.value_from_position(position); + assert_in_delta!(value, value_back, 1e-4); + assert_in_delta!(position, 0.33333333, 1e-4); + assert_approx_eq!(log_axis.value_min(), 2.0); + assert_approx_eq!(log_axis.value_max(), 16.0); + assert_approx_eq!(log_axis.frame_min(), 0.0); + assert_approx_eq!(log_axis.frame_max(), 1.0); + + } +} diff --git a/egui_plot/src/axis/mod.rs b/egui_plot/src/axis/mod.rs index 1d3b3cd1..674f9d65 100644 --- a/egui_plot/src/axis/mod.rs +++ b/egui_plot/src/axis/mod.rs @@ -1,5 +1,6 @@ pub mod transform; pub mod linear; +pub mod log; use std::fmt::Debug; use std::ops::RangeInclusive; diff --git a/egui_plot/src/axis/transform.rs b/egui_plot/src/axis/transform.rs index 112fa267..6670ef2e 100644 --- a/egui_plot/src/axis/transform.rs +++ b/egui_plot/src/axis/transform.rs @@ -47,7 +47,13 @@ pub(super) trait AxisSpace { fn zoom(&mut self, factor: f32, center: f64); /// Extend both ends of the value range by the provided `pad` value. - fn expand(&mut self, pad: f64); + fn expand(&mut self, pad: f64) { + if pad.is_finite() { + let new_min = self.value_min() - pad; + let new_max = self.value_max() + pad; + self.set_value_range(new_min..=new_max); + } + } } From 53302bfa7579a1832000200f600105adbbdd8749 Mon Sep 17 00:00:00 2001 From: James McNally Date: Tue, 30 Jun 2026 22:06:19 +0100 Subject: [PATCH 3/7] wip: First Attempt to Remove dv/dv from API Looked to remove it - in most cases was just used to define a sensible minimum - but some tests have broken so still need to review --- egui_plot/src/axis/linear.rs | 16 ++++++------ egui_plot/src/axis/log.rs | 6 +---- egui_plot/src/axis/mod.rs | 4 +-- egui_plot/src/axis/transform.rs | 43 ++++++++++++++++++-------------- egui_plot/src/items/bar_chart.rs | 7 +++--- egui_plot/src/items/box_plot.rs | 7 +++--- egui_plot/src/plot.rs | 35 +++++++++++++------------- 7 files changed, 61 insertions(+), 57 deletions(-) diff --git a/egui_plot/src/axis/linear.rs b/egui_plot/src/axis/linear.rs index 364c1c67..97a168f4 100644 --- a/egui_plot/src/axis/linear.rs +++ b/egui_plot/src/axis/linear.rs @@ -52,6 +52,11 @@ impl LinearAxisSpace { *as_f32.start() as f64..=*as_f32.end() as f64 } + fn dvalue_per_dpos(&self) -> f64 { + let frame_range = self.frame_range_f64(); + self.value_length() / (frame_range.end() - frame_range.start()) + } + } impl AxisSpace for LinearAxisSpace { @@ -97,14 +102,9 @@ impl AxisSpace for LinearAxisSpace { remap(position as f64, self.frame_range_f64(), self.min..=self.max) } - fn dvalue_per_dpos(&self) -> f64 { - let frame_range = self.frame_range_f64(); - self.value_length() / (frame_range.end() - frame_range.start()) - } - - fn dpos_per_dvalue(&self) -> f32 { - let frame_range = self.frame_range(); - (frame_range.end() - frame_range.start()) / (self.value_length() as f32) + /// Provides the minimum value step for the screen step size provided. + fn minimum_value_step(&self, spacing: f32) -> f64 { + self.dvalue_per_dpos() * (spacing as f64) } fn set_inverted(&mut self, invert: bool) { diff --git a/egui_plot/src/axis/log.rs b/egui_plot/src/axis/log.rs index f733268a..293bfd39 100644 --- a/egui_plot/src/axis/log.rs +++ b/egui_plot/src/axis/log.rs @@ -87,11 +87,7 @@ impl AxisSpace for LogAxis { self.base.power(exponent) } - fn dvalue_per_dpos(&self) -> f64 { - todo!() - } - - fn dpos_per_dvalue(&self) -> f32 { + fn minimum_value_step(&self, spacing: f32) -> f64 { todo!() } diff --git a/egui_plot/src/axis/mod.rs b/egui_plot/src/axis/mod.rs index 674f9d65..6907d9fe 100644 --- a/egui_plot/src/axis/mod.rs +++ b/egui_plot/src/axis/mod.rs @@ -23,6 +23,7 @@ use egui::emath::remap_clamp; use egui::epaint::TextShape; pub use transform::PlotTransform; +use crate::axis::transform::AxisSpace; use crate::grid::GridMark; use crate::placement::HPlacement; use crate::placement::Placement; @@ -283,8 +284,7 @@ impl<'a> AxisWidget<'a> { for step in self.steps.iter() { let text = (self.hints.formatter)(*step, &self.range); if !text.is_empty() { - // todo: This mimics the grid spacing logic. May want to make this common. - let spacing_in_points = (transform.dpos_dvalue()[usize::from(axis)] * step.step_size as f32).abs(); + let spacing_in_points = transform.minimum_value_step(axis, step.step_size as f32) as f32; if spacing_in_points <= label_spacing.min { // Labels are too close together - don't paint them. diff --git a/egui_plot/src/axis/transform.rs b/egui_plot/src/axis/transform.rs index 6670ef2e..b3a7d925 100644 --- a/egui_plot/src/axis/transform.rs +++ b/egui_plot/src/axis/transform.rs @@ -3,7 +3,7 @@ use emath::{pos2, Pos2, Rect, Vec2, Vec2b}; use crate::{Axis, PlotBounds, PlotPoint}; use crate::axis::linear::LinearAxisSpace; -pub(super) trait AxisSpace { +pub trait AxisSpace { /// The minimum value of the axis. fn value_min(&self) -> f64; /// The maximum value of the axis. @@ -34,11 +34,8 @@ pub(super) trait AxisSpace { /// Convert a screen position to a value. fn value_from_position(&self, position: f32) -> f64; - /// Get value per screen distance. - fn dvalue_per_dpos(&self) -> f64; - - /// Get screen distance per value unit. - fn dpos_per_dvalue(&self) -> f32; + /// Provides the minimum value step for the screen step size provided. + fn minimum_value_step(&self, spacing: f32) -> f64; /// Alter the value minimum and maximum to produce a translation /// in the screen space. @@ -190,6 +187,22 @@ impl PlotTransform { self.y_axis = new_y; } } + + pub(crate) fn axis_space(&self, axis: Axis) -> &impl AxisSpace { + match axis { + Axis::X => &self.x_axis, + Axis::Y => &self.y_axis, + } + } + + /// Returns the smallest possible value step for the given input step size + /// in screen units. Often used to confirm if lines should be visibl. + pub(crate) fn minimum_value_step(&self, axis: Axis, screen_step_size: f32) -> f64 { + match axis { + Axis::X => self.x_axis.minimum_value_step(screen_step_size), + Axis::Y => self.y_axis.minimum_value_step(screen_step_size), + } + } pub fn position_from_point_x(&self, value: f64) -> f32 { self.x_axis.position_from_value(value) @@ -227,23 +240,15 @@ impl PlotTransform { } - /// delta position / delta value = how many ui points per step in "plot - /// space" - pub fn dpos_dvalue(&self) -> [f32; 2] { - [self.x_axis.dpos_per_dvalue(), self.y_axis.dpos_per_dvalue()] - } - - /// delta value / delta position = how much ground do we cover in "plot - /// space" per ui point? - pub fn dvalue_dpos(&self) -> [f64; 2] { - [self.x_axis.dvalue_per_dpos(), self.y_axis.dvalue_per_dpos()] - } - /// scale.x/scale.y ratio. /// /// If 1.0, it means the scale factor is the same in both axes. fn aspect(&self) -> f64 { - self.x_axis.dvalue_per_dpos().abs() / self.y_axis.dvalue_per_dpos().abs() + let x_value_range = self.x_axis.value_length().abs(); + let y_value_range = self.y_axis.value_length().abs(); + let x_frame_range = self.x_axis.frame_max() - self.x_axis.frame_min(); + let y_frame_range = self.y_axis.frame_max() - self.y_axis.frame_min(); + (x_value_range / x_frame_range as f64) / (y_value_range / y_frame_range as f64) } /// Sets the aspect ratio by expanding the x- or y-axis. diff --git a/egui_plot/src/items/bar_chart.rs b/egui_plot/src/items/bar_chart.rs index 16685233..f05b38e9 100644 --- a/egui_plot/src/items/bar_chart.rs +++ b/egui_plot/src/items/bar_chart.rs @@ -12,7 +12,9 @@ use emath::NumExt as _; use emath::Pos2; use crate::aesthetics::Orientation; +use crate::Axis; use crate::axis::PlotTransform; +use crate::axis::transform::AxisSpace; use crate::bounds::PlotBounds; use crate::bounds::PlotPoint; use crate::colors::highlighted_color; @@ -408,10 +410,9 @@ impl RectElement for Bar { } fn default_values_format(&self, transform: &PlotTransform) -> String { - let scale = transform.dvalue_dpos(); let scale = match self.orientation { - Orientation::Horizontal => scale[0], - Orientation::Vertical => scale[1], + Orientation::Horizontal => transform.axis_space(Axis::X).minimum_value_step(1.0), + Orientation::Vertical => transform.axis_space(Axis::Y).minimum_value_step(1.0), }; let decimals = ((-scale.abs().log10()).ceil().at_least(0.0) as usize).at_most(6); crate::label::format_number(self.value, decimals) diff --git a/egui_plot/src/items/box_plot.rs b/egui_plot/src/items/box_plot.rs index 74c3238d..66f2f791 100644 --- a/egui_plot/src/items/box_plot.rs +++ b/egui_plot/src/items/box_plot.rs @@ -11,7 +11,9 @@ use emath::NumExt as _; use emath::Pos2; use crate::aesthetics::Orientation; +use crate::Axis; use crate::axis::PlotTransform; +use crate::axis::transform::AxisSpace; use crate::bounds::PlotBounds; use crate::bounds::PlotPoint; use crate::colors::highlighted_color; @@ -440,10 +442,9 @@ impl RectElement for BoxElem { } fn default_values_format(&self, transform: &PlotTransform) -> String { - let scale = transform.dvalue_dpos(); let scale = match self.orientation { - Orientation::Horizontal => scale[0], - Orientation::Vertical => scale[1], + Orientation::Horizontal => transform.axis_space(Axis::X).minimum_value_step(1.0), + Orientation::Vertical => transform.axis_space(Axis::Y).minimum_value_step(1.0), }; let y_decimals = ((-scale.abs().log10()).ceil().at_least(0.0) as usize) .at_most(6) diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index bd61570d..d81657f4 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; +use crate::axis::transform::AxisSpace; use crate::bounds::BoundsLinkGroups; use crate::bounds::BoundsModification; use crate::bounds::LinkedBounds; @@ -1244,19 +1245,12 @@ impl<'a> Plot<'a> { let bounds = mem.transform.bounds(); let x_axis_range = bounds.range_x(); let x_steps = Arc::new({ - let dvalue_dpos = mem.transform.dvalue_dpos()[0].abs(); - let input = GridInput { - bounds: (bounds.min[0], bounds.max[0]), - base_step_size: dvalue_dpos * self.grid_spacing.min as f64, - }; + let input = grid_input_for_axis(mem.transform.axis_space(Axis::X), self.grid_spacing.min); (self.grid_spacers[0])(input) }); let y_axis_range = bounds.range_y(); let y_steps = Arc::new({ - let input = GridInput { - bounds: (bounds.min[1], bounds.max[1]), - base_step_size: mem.transform.dvalue_dpos()[1].abs() * self.grid_spacing.min as f64, - }; + let input = grid_input_for_axis(mem.transform.axis_space(Axis::Y), self.grid_spacing.min); (self.grid_spacers[1])(input) }); @@ -1467,11 +1461,9 @@ impl<'a> Plot<'a> { // Where on the cross-dimension to show the label values let bounds = transform.bounds(); let value_cross = 0.0_f64.clamp(bounds.min[1 - iaxis], bounds.max[1 - iaxis]); - - let input = GridInput { - bounds: (bounds.min[iaxis], bounds.max[iaxis]), - base_step_size: transform.dvalue_dpos()[iaxis].abs() * self.grid_spacing.min as f64, - }; + + let axis_space = transform.axis_space(axis); + let input = grid_input_for_axis(axis_space, self.grid_spacing.min); let steps = (self.grid_spacers[iaxis])(input); let clamp_range = self.clamp_grid.then(|| { @@ -1508,7 +1500,7 @@ impl<'a> Plot<'a> { }; let pos_in_gui = transform.position_from_point(&value); - let spacing_in_points = (transform.dpos_dvalue()[iaxis] * step.step_size as f32).abs(); + let spacing_in_points = transform.minimum_value_step(axis, step.step_size as f32) as f32; if spacing_in_points <= self.grid_spacing.min { continue; // Too close together @@ -1734,6 +1726,14 @@ impl<'a> Plot<'a> { } } +fn grid_input_for_axis(axis: &T, spacing: f32) -> GridInput { + GridInput { + bounds: (axis.value_min(), axis.value_max()), + base_step_size: axis.minimum_value_step(spacing) + } + +} + /// Returns the rect left after adding axes. fn axis_widgets<'a>( mem: Option<&PlotMemory>, @@ -1995,8 +1995,9 @@ impl<'a> PlotUi<'a> { // todo: We are going to need to get a start and end here to // compute the actual delta in the log case. let delta = self.response.drag_delta(); - let dp_dv = self.last_plot_transform.dpos_dvalue(); - Vec2::new(delta.x / dp_dv[0], delta.y / dp_dv[1]) + let x_dp_dv = self.last_plot_transform.axis_space(Axis::X).minimum_value_step(1.0); + let y_dp_dv = self.last_plot_transform.axis_space(Axis::Y).minimum_value_step(1.0); + Vec2::new(delta.x / x_dp_dv as f32, delta.y / y_dp_dv as f32) } /// Read the transform between plot coordinates and screen coordinates. From 989cac4c8cd0990959e046838509f7b83ec5aeb2 Mon Sep 17 00:00:00 2001 From: James McNally Date: Wed, 1 Jul 2026 07:30:27 +0100 Subject: [PATCH 4/7] wip: Improve Abstraction of Axis * Looked at the usage of the linear scales and attempted to abstract better domain methods. --- egui_plot/src/axis/linear.rs | 68 ++++++++------- egui_plot/src/axis/log.rs | 28 +++--- egui_plot/src/axis/mod.rs | 12 +-- egui_plot/src/axis/transform.rs | 143 +++++++++++++++++++++---------- egui_plot/src/items/bar_chart.rs | 4 +- egui_plot/src/items/box_plot.rs | 4 +- egui_plot/src/plot.rs | 23 ++--- 7 files changed, 171 insertions(+), 111 deletions(-) diff --git a/egui_plot/src/axis/linear.rs b/egui_plot/src/axis/linear.rs index 97a168f4..fbed620e 100644 --- a/egui_plot/src/axis/linear.rs +++ b/egui_plot/src/axis/linear.rs @@ -1,6 +1,7 @@ -use std::ops::RangeInclusive; -use emath::{remap, Rangef}; +use crate::GridInput; use crate::axis::transform::AxisSpace; +use emath::{Rangef, remap}; +use std::ops::RangeInclusive; #[derive(Debug, Copy, Clone)] pub struct LinearAxisSpace { @@ -18,11 +19,10 @@ impl LinearAxisSpace { max: *value_range.end(), frame_start: frame_range.min, frame_end: frame_range.max, - invert + invert, } } fn clamp_to_finite(&mut self) { - self.min = self.min.clamp(f64::MIN, f64::MAX); if self.min.is_nan() { self.min = 0.0; @@ -43,7 +43,7 @@ impl LinearAxisSpace { (self.frame_start)..=(self.frame_end) } } - + /// Get the frame range as f64 format. This is not the natural /// format for screen units but is needed for remapping to and /// from values. @@ -56,23 +56,9 @@ impl LinearAxisSpace { let frame_range = self.frame_range_f64(); self.value_length() / (frame_range.end() - frame_range.start()) } - } impl AxisSpace for LinearAxisSpace { - fn translate(&mut self, frame_distance: f32) { - let dvalue_per_dpos = self.dvalue_per_dpos(); - let value_translation = frame_distance as f64 * dvalue_per_dpos; - self.min += value_translation; - self.max += value_translation; - self.clamp_to_finite(); - } - - fn zoom(&mut self, zoom_factor: f32, center: f64) { - self.min = center + (self.min - center) / (zoom_factor as f64); - self.max = center + (self.max - center) / (zoom_factor as f64); - } - fn value_min(&self) -> f64 { self.min } @@ -89,13 +75,18 @@ impl AxisSpace for LinearAxisSpace { self.frame_end } + fn set_inverted(&mut self, invert: bool) { + self.invert = invert; + } + + fn set_value_range(&mut self, range: RangeInclusive) { + self.min = *range.start(); + self.max = *range.end(); + self.clamp_to_finite(); + } fn position_from_value(&self, value: f64) -> f32 { - remap( - value, - self.min..=self.max, - self.frame_range_f64() - ) as f32 + remap(value, self.min..=self.max, self.frame_range_f64()) as f32 } fn value_from_position(&self, position: f32) -> f64 { @@ -104,16 +95,31 @@ impl AxisSpace for LinearAxisSpace { /// Provides the minimum value step for the screen step size provided. fn minimum_value_step(&self, spacing: f32) -> f64 { - self.dvalue_per_dpos() * (spacing as f64) + self.dvalue_per_dpos().abs() * (spacing as f64) } - fn set_inverted(&mut self, invert: bool) { - self.invert = invert; + fn grid_input(&self, spacing: f32) -> GridInput { + GridInput { + bounds: (self.min, self.max), + base_step_size: self.minimum_value_step(spacing), + } } - fn set_value_range(&mut self, range: RangeInclusive) { - self.min = *range.start(); - self.max = *range.end(); + fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32 { + let delta = value2 - value1; + (delta / self.dvalue_per_dpos()) as f32 + } + + fn translate(&mut self, frame_distance: f32) { + let dvalue_per_dpos = self.dvalue_per_dpos(); + let value_translation = frame_distance as f64 * dvalue_per_dpos; + self.min += value_translation; + self.max += value_translation; self.clamp_to_finite(); } -} \ No newline at end of file + + fn zoom(&mut self, zoom_factor: f32, center: f64) { + self.min = center + (self.min - center) / (zoom_factor as f64); + self.max = center + (self.max - center) / (zoom_factor as f64); + } +} diff --git a/egui_plot/src/axis/log.rs b/egui_plot/src/axis/log.rs index 293bfd39..b5bc4c14 100644 --- a/egui_plot/src/axis/log.rs +++ b/egui_plot/src/axis/log.rs @@ -1,7 +1,8 @@ -use std::ops::RangeInclusive; -use emath::Rangef; +use crate::GridInput; use crate::axis::linear::LinearAxisSpace; use crate::axis::transform::AxisSpace; +use emath::Rangef; +use std::ops::RangeInclusive; enum LogBase { Base10, @@ -17,15 +18,15 @@ impl LogBase { return None; } match self { - LogBase::Base10 => Some(value.log10()), - LogBase::Base2 => Some(value.log2()), + Self::Base10 => Some(value.log10()), + Self::Base2 => Some(value.log2()), } } pub fn power(&self, value: f64) -> f64 { match self { - LogBase::Base10 => f64::powf(10.0, value), - LogBase::Base2 => value.exp2(), + Self::Base10 => f64::powf(10.0, value), + Self::Base2 => value.exp2(), } } } @@ -36,7 +37,7 @@ struct LogAxis { /// This allows us to reuse much of the linear logic. exponent_scale: LinearAxisSpace, /// The base of the logarithm. - base: LogBase + base: LogBase, } impl LogAxis { @@ -91,6 +92,14 @@ impl AxisSpace for LogAxis { todo!() } + fn grid_input(&self, spacing: f32) -> GridInput { + todo!() + } + + fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32 { + todo!() + } + fn translate(&mut self, frame_distance: f32) { todo!() } @@ -100,13 +109,12 @@ impl AxisSpace for LogAxis { self.exponent_scale.zoom(factor, exponent); } } - } #[cfg(test)] mod tests { - use assertables::{assert_approx_eq, assert_in_delta}; use super::*; + use assertables::{assert_approx_eq, assert_in_delta}; #[test] fn test_value_changes_map_to_linear_and_back() { @@ -127,7 +135,6 @@ mod tests { assert_approx_eq!(log_axis.value_max(), 10000.0); assert_approx_eq!(log_axis.frame_min(), 0.0); assert_approx_eq!(log_axis.frame_max(), 1.0); - } #[test] fn test_value_changes_map_to_linear_and_back_base2() { @@ -148,6 +155,5 @@ mod tests { assert_approx_eq!(log_axis.value_max(), 16.0); assert_approx_eq!(log_axis.frame_min(), 0.0); assert_approx_eq!(log_axis.frame_max(), 1.0); - } } diff --git a/egui_plot/src/axis/mod.rs b/egui_plot/src/axis/mod.rs index 6907d9fe..2ac16faf 100644 --- a/egui_plot/src/axis/mod.rs +++ b/egui_plot/src/axis/mod.rs @@ -1,6 +1,6 @@ -pub mod transform; pub mod linear; pub mod log; +pub mod transform; use std::fmt::Debug; use std::ops::RangeInclusive; @@ -22,12 +22,12 @@ use egui::emath::Rot2; use egui::emath::remap_clamp; use egui::epaint::TextShape; -pub use transform::PlotTransform; -use crate::axis::transform::AxisSpace; +use crate::axis::transform::AxisSpace as _; use crate::grid::GridMark; use crate::placement::HPlacement; use crate::placement::Placement; use crate::placement::VPlacement; +pub use transform::PlotTransform; // Gap between tick labels and axis label in units of the axis label height const AXIS_LABEL_GAP: f32 = 0.25; @@ -279,12 +279,15 @@ impl<'a> AxisWidget<'a> { const SIDE_MARGIN: f32 = 4.0; // Add some margin to both sides of the text on the Y axis. let painter = ui.painter(); + let axis_space = transform.axis_space(axis); // 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.minimum_value_step(axis, step.step_size as f32) as f32; + let spacing_in_points = axis_space + .screen_distance_between_values(step.value, step.value + step.step_size) + .abs(); if spacing_in_points <= label_spacing.min { // Labels are too close together - don't paint them. @@ -360,4 +363,3 @@ impl<'a> AxisWidget<'a> { thickness } } - diff --git a/egui_plot/src/axis/transform.rs b/egui_plot/src/axis/transform.rs index b3a7d925..0f815adc 100644 --- a/egui_plot/src/axis/transform.rs +++ b/egui_plot/src/axis/transform.rs @@ -1,7 +1,14 @@ -use std::ops::RangeInclusive; -use emath::{pos2, Pos2, Rect, Vec2, Vec2b}; -use crate::{Axis, PlotBounds, PlotPoint}; +//! Handles the transformations between the data space +//! and the screen space. +//! +// Broadly speaking f32 values are screen units and f64 are +// value units. We could consider a unit type in the future to +// make this explicit. + use crate::axis::linear::LinearAxisSpace; +use crate::{Axis, GridInput, PlotBounds, PlotPoint}; +use emath::{Pos2, Rect, Vec2, Vec2b, pos2}; +use std::ops::RangeInclusive; pub trait AxisSpace { /// The minimum value of the axis. @@ -37,6 +44,13 @@ pub trait AxisSpace { /// Provides the minimum value step for the screen step size provided. fn minimum_value_step(&self, spacing: f32) -> f64; + /// Get the grid input configuration for the axis given the provided + /// minimum gap in screen units. + fn grid_input(&self, spacing: f32) -> GridInput; + + /// Screen distance between two points. + fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32; + /// Alter the value minimum and maximum to produce a translation /// in the screen space. fn translate(&mut self, frame_distance: f32); @@ -51,8 +65,6 @@ pub trait AxisSpace { self.set_value_range(new_min..=new_max); } } - - } /// Contains the screen rectangle and the plot bounds and provides methods to @@ -60,16 +72,14 @@ pub trait AxisSpace { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone, Copy, Debug)] pub struct PlotTransform { - /// The X axis space x_axis: LinearAxisSpace, - + /// The Y axis space y_axis: LinearAxisSpace, /// Whether to always center the x-range or y-range of the bounds. centered: Vec2b, - } impl PlotTransform { @@ -153,7 +163,10 @@ impl PlotTransform { /// Plot-space bounds. #[inline] pub fn bounds(&self) -> PlotBounds { - PlotBounds::from_min_max([self.x_axis.value_min(), self.y_axis.value_min()], [self.x_axis.value_max(), self.y_axis.value_max()]) + PlotBounds::from_min_max( + [self.x_axis.value_min(), self.y_axis.value_min()], + [self.x_axis.value_max(), self.y_axis.value_max()], + ) } #[inline] @@ -187,22 +200,13 @@ impl PlotTransform { self.y_axis = new_y; } } - + pub(crate) fn axis_space(&self, axis: Axis) -> &impl AxisSpace { match axis { Axis::X => &self.x_axis, Axis::Y => &self.y_axis, } } - - /// Returns the smallest possible value step for the given input step size - /// in screen units. Often used to confirm if lines should be visibl. - pub(crate) fn minimum_value_step(&self, axis: Axis, screen_step_size: f32) -> f64 { - match axis { - Axis::X => self.x_axis.minimum_value_step(screen_step_size), - Axis::Y => self.y_axis.minimum_value_step(screen_step_size), - } - } pub fn position_from_point_x(&self, value: f64) -> f32 { self.x_axis.position_from_value(value) @@ -239,7 +243,6 @@ impl PlotTransform { rect } - /// scale.x/scale.y ratio. /// /// If 1.0, it means the scale factor is the same in both axes. @@ -264,9 +267,11 @@ impl PlotTransform { } if current_aspect < aspect { - self.x_axis.expand((aspect / current_aspect - 1.0) * self.x_axis.value_length() * 0.5); + self.x_axis + .expand((aspect / current_aspect - 1.0) * self.x_axis.value_length() * 0.5); } else { - self.y_axis.expand((current_aspect / aspect - 1.0) * self.y_axis.value_length() * 0.5); + self.y_axis + .expand((current_aspect / aspect - 1.0) * self.y_axis.value_length() * 0.5); } } @@ -283,10 +288,12 @@ impl PlotTransform { match axis { Axis::X => { - self.x_axis.expand((aspect / current_aspect - 1.0) * self.x_axis.value_length() * 0.5); + self.x_axis + .expand((aspect / current_aspect - 1.0) * self.x_axis.value_length() * 0.5); } Axis::Y => { - self.y_axis.expand((current_aspect / aspect - 1.0) * self.y_axis.value_length() * 0.5); + self.y_axis + .expand((current_aspect / aspect - 1.0) * self.y_axis.value_length() * 0.5); } } } @@ -294,8 +301,8 @@ impl PlotTransform { #[cfg(test)] mod tests { - use assertables::assert_approx_eq; use super::*; + use assertables::assert_approx_eq; #[test] fn test_basic_linear_axis_transform() { @@ -304,15 +311,38 @@ mod tests { let transform = PlotTransform::new(frame, bounds, false); - assert_eq!(transform.position_from_point(&PlotPoint::new(0.0, 0.0)), pos2(50.0, 50.0)); - assert_eq!(transform.value_from_position(pos2(50.0, 50.0)), PlotPoint::new(0.0, 0.0)); - assert_eq!(transform.position_from_point(&PlotPoint::new(10.0, 10.0)), pos2(100.0, 0.0)); - assert_eq!(transform.value_from_position(pos2(100.0, 0.0)), PlotPoint::new(10.0, 10.0)); - assert_eq!(transform.position_from_point(&PlotPoint::new(-10.0, -10.0)), pos2(0.0, 100.0)); - assert_eq!(transform.value_from_position(pos2(0.0, 100.0)), PlotPoint::new(-10.0, -10.0)); - assert_eq!(transform.position_from_point(&PlotPoint::new(5.0, -5.0)), pos2(75.0, 75.0)); - assert_eq!(transform.value_from_position(pos2(75.0, 75.0)), PlotPoint::new(5.0, -5.0)); - + assert_eq!( + transform.position_from_point(&PlotPoint::new(0.0, 0.0)), + pos2(50.0, 50.0) + ); + assert_eq!( + transform.value_from_position(pos2(50.0, 50.0)), + PlotPoint::new(0.0, 0.0) + ); + assert_eq!( + transform.position_from_point(&PlotPoint::new(10.0, 10.0)), + pos2(100.0, 0.0) + ); + assert_eq!( + transform.value_from_position(pos2(100.0, 0.0)), + PlotPoint::new(10.0, 10.0) + ); + assert_eq!( + transform.position_from_point(&PlotPoint::new(-10.0, -10.0)), + pos2(0.0, 100.0) + ); + assert_eq!( + transform.value_from_position(pos2(0.0, 100.0)), + PlotPoint::new(-10.0, -10.0) + ); + assert_eq!( + transform.position_from_point(&PlotPoint::new(5.0, -5.0)), + pos2(75.0, 75.0) + ); + assert_eq!( + transform.value_from_position(pos2(75.0, 75.0)), + PlotPoint::new(5.0, -5.0) + ); } #[test] @@ -321,14 +351,38 @@ mod tests { let bounds = PlotBounds::new_symmetrical(10.0); let invert_axis = Vec2b::new(true, false); let transform = PlotTransform::new_with_invert_axis(frame, bounds, false, invert_axis); - assert_eq!(transform.position_from_point(&PlotPoint::new(0.0, 0.0)), pos2(50.0, 50.0)); - assert_eq!(transform.value_from_position(pos2(50.0, 50.0)), PlotPoint::new(0.0, 0.0)); - assert_eq!(transform.position_from_point(&PlotPoint::new(10.0, 10.0)), pos2(0.0, 0.0)); - assert_eq!(transform.value_from_position(pos2(0.0, 0.0)), PlotPoint::new(10.0, 10.0)); - assert_eq!(transform.position_from_point(&PlotPoint::new(-10.0, -10.0)), pos2(100.0, 100.0)); - assert_eq!(transform.value_from_position(pos2(100.0, 100.0)), PlotPoint::new(-10.0, -10.0)); - assert_eq!(transform.position_from_point(&PlotPoint::new(5.0, -5.0)), pos2(25.0, 75.0)); - assert_eq!(transform.value_from_position(pos2(25.0, 75.0)), PlotPoint::new(5.0, -5.0)); + assert_eq!( + transform.position_from_point(&PlotPoint::new(0.0, 0.0)), + pos2(50.0, 50.0) + ); + assert_eq!( + transform.value_from_position(pos2(50.0, 50.0)), + PlotPoint::new(0.0, 0.0) + ); + assert_eq!( + transform.position_from_point(&PlotPoint::new(10.0, 10.0)), + pos2(0.0, 0.0) + ); + assert_eq!( + transform.value_from_position(pos2(0.0, 0.0)), + PlotPoint::new(10.0, 10.0) + ); + assert_eq!( + transform.position_from_point(&PlotPoint::new(-10.0, -10.0)), + pos2(100.0, 100.0) + ); + assert_eq!( + transform.value_from_position(pos2(100.0, 100.0)), + PlotPoint::new(-10.0, -10.0) + ); + assert_eq!( + transform.position_from_point(&PlotPoint::new(5.0, -5.0)), + pos2(25.0, 75.0) + ); + assert_eq!( + transform.value_from_position(pos2(25.0, 75.0)), + PlotPoint::new(5.0, -5.0) + ); } #[test] @@ -343,7 +397,6 @@ mod tests { let transform = PlotTransform::new(frame, bounds, false); assert_eq!(transform.aspect(), 2.0); - let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); let bounds = PlotBounds::new_symmetrical(10.0); let transform = PlotTransform::new(frame, bounds, false); @@ -353,8 +406,7 @@ mod tests { /// Real values captured from a test which was failing. #[test] fn aspect_calculation_from_custom_axes() { - - let frame = Rect::from_min_max(Pos2::new(22.0,49.0), Pos2::new(778.0, 564.0)); + let frame = Rect::from_min_max(Pos2::new(22.0, 49.0), Pos2::new(778.0, 564.0)); let bounds = PlotBounds::from_min_max([-360.0, -0.04294], [7560.0, 1.049]); let transform = PlotTransform::new(frame, bounds, [false, false]); assert_approx_eq!(transform.aspect(), 4940.965708); @@ -495,4 +547,3 @@ mod tests { assert_bounds_eq(transform.bounds(), original.min(), original.max()); } } - diff --git a/egui_plot/src/items/bar_chart.rs b/egui_plot/src/items/bar_chart.rs index f05b38e9..f040a62c 100644 --- a/egui_plot/src/items/bar_chart.rs +++ b/egui_plot/src/items/bar_chart.rs @@ -11,10 +11,10 @@ use emath::Float as _; use emath::NumExt as _; use emath::Pos2; -use crate::aesthetics::Orientation; use crate::Axis; +use crate::aesthetics::Orientation; use crate::axis::PlotTransform; -use crate::axis::transform::AxisSpace; +use crate::axis::transform::AxisSpace as _; use crate::bounds::PlotBounds; use crate::bounds::PlotPoint; use crate::colors::highlighted_color; diff --git a/egui_plot/src/items/box_plot.rs b/egui_plot/src/items/box_plot.rs index 66f2f791..1a1d8b4f 100644 --- a/egui_plot/src/items/box_plot.rs +++ b/egui_plot/src/items/box_plot.rs @@ -10,10 +10,10 @@ use egui::epaint::RectShape; use emath::NumExt as _; use emath::Pos2; -use crate::aesthetics::Orientation; use crate::Axis; +use crate::aesthetics::Orientation; use crate::axis::PlotTransform; -use crate::axis::transform::AxisSpace; +use crate::axis::transform::AxisSpace as _; use crate::bounds::PlotBounds; use crate::bounds::PlotPoint; use crate::colors::highlighted_color; diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index d81657f4..6c67400c 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -30,7 +30,7 @@ use crate::axis::Axis; use crate::axis::AxisHints; use crate::axis::AxisWidget; use crate::axis::PlotTransform; -use crate::axis::transform::AxisSpace; +use crate::axis::transform::AxisSpace as _; use crate::bounds::BoundsLinkGroups; use crate::bounds::BoundsModification; use crate::bounds::LinkedBounds; @@ -1245,12 +1245,12 @@ impl<'a> Plot<'a> { let bounds = mem.transform.bounds(); let x_axis_range = bounds.range_x(); let x_steps = Arc::new({ - let input = grid_input_for_axis(mem.transform.axis_space(Axis::X), self.grid_spacing.min); + let input = mem.transform.axis_space(Axis::X).grid_input(self.grid_spacing.min); (self.grid_spacers[0])(input) }); let y_axis_range = bounds.range_y(); let y_steps = Arc::new({ - let input = grid_input_for_axis(mem.transform.axis_space(Axis::Y), self.grid_spacing.min); + let input = mem.transform.axis_space(Axis::Y).grid_input(self.grid_spacing.min); (self.grid_spacers[1])(input) }); @@ -1461,9 +1461,9 @@ impl<'a> Plot<'a> { // Where on the cross-dimension to show the label values let bounds = transform.bounds(); let value_cross = 0.0_f64.clamp(bounds.min[1 - iaxis], bounds.max[1 - iaxis]); - + let axis_space = transform.axis_space(axis); - let input = grid_input_for_axis(axis_space, self.grid_spacing.min); + let input = axis_space.grid_input(self.grid_spacing.min); let steps = (self.grid_spacers[iaxis])(input); let clamp_range = self.clamp_grid.then(|| { @@ -1476,6 +1476,7 @@ impl<'a> Plot<'a> { tight_bounds }); + let axis_space = transform.axis_space(axis); for step in steps { let value_main = step.value; @@ -1500,7 +1501,9 @@ impl<'a> Plot<'a> { }; let pos_in_gui = transform.position_from_point(&value); - let spacing_in_points = transform.minimum_value_step(axis, step.step_size as f32) as f32; + let spacing_in_points = axis_space + .screen_distance_between_values(step.value, step.value + step.step_size) + .abs(); if spacing_in_points <= self.grid_spacing.min { continue; // Too close together @@ -1726,14 +1729,6 @@ impl<'a> Plot<'a> { } } -fn grid_input_for_axis(axis: &T, spacing: f32) -> GridInput { - GridInput { - bounds: (axis.value_min(), axis.value_max()), - base_step_size: axis.minimum_value_step(spacing) - } - -} - /// Returns the rect left after adding axes. fn axis_widgets<'a>( mem: Option<&PlotMemory>, From ab3809b2ef7d4ff1b7c92223d896c093182f6fe8 Mon Sep 17 00:00:00 2001 From: James McNally Date: Fri, 3 Jul 2026 16:40:15 +0100 Subject: [PATCH 5/7] wip: Basic Working Demo Still some issues to work out related to scaling, the demo shows a memory issue which I need to understand. Something related to line generation I think --- Cargo.lock | 10 ++ egui_plot/src/axis/log.rs | 43 +++++-- egui_plot/src/axis/mod.rs | 156 ++++++++++++++++++++++++- egui_plot/src/axis/transform.rs | 51 ++++---- egui_plot/src/lib.rs | 1 + egui_plot/src/plot.rs | 28 ++++- examples/log_axes/Cargo.toml | 23 ++++ examples/log_axes/README.md | 18 +++ examples/log_axes/screenshot.png | 3 + examples/log_axes/screenshot_thumb.png | 3 + examples/log_axes/src/app.rs | 62 ++++++++++ examples/log_axes/src/lib.rs | 41 +++++++ examples/log_axes/src/main.rs | 4 + 13 files changed, 405 insertions(+), 38 deletions(-) create mode 100644 examples/log_axes/Cargo.toml create mode 100644 examples/log_axes/README.md create mode 100644 examples/log_axes/screenshot.png create mode 100644 examples/log_axes/screenshot_thumb.png create mode 100644 examples/log_axes/src/app.rs create mode 100644 examples/log_axes/src/lib.rs create mode 100644 examples/log_axes/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 45ba4cd5..4394e280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,6 +2295,16 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "log_axes" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_plot", + "env_logger", + "examples_utils", +] + [[package]] name = "markers" version = "0.1.0" diff --git a/egui_plot/src/axis/log.rs b/egui_plot/src/axis/log.rs index b5bc4c14..84fde3a5 100644 --- a/egui_plot/src/axis/log.rs +++ b/egui_plot/src/axis/log.rs @@ -4,7 +4,8 @@ use crate::axis::transform::AxisSpace; use emath::Rangef; use std::ops::RangeInclusive; -enum LogBase { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogBase { Base10, Base2, } @@ -31,7 +32,8 @@ impl LogBase { } } -struct LogAxis { +#[derive(Debug, Clone, Copy)] +pub struct LogAxis { /// A linear scale for the exponent of the logarithm. /// /// This allows us to reuse much of the linear logic. @@ -42,8 +44,8 @@ struct LogAxis { impl LogAxis { pub fn new(value_range: RangeInclusive, frame_range: Rangef, invert: bool, base: LogBase) -> Self { - let exponent_min = base.exponent(*value_range.start()).unwrap(); - let exponent_max = base.exponent(*value_range.end()).unwrap(); + let exponent_min = base.exponent(*value_range.start()).unwrap_or(0.0); + let exponent_max = base.exponent(*value_range.end()).unwrap_or(0.0); Self { exponent_scale: LinearAxisSpace::new(exponent_min..=exponent_max, frame_range, invert), base, @@ -73,13 +75,13 @@ impl AxisSpace for LogAxis { } fn set_value_range(&mut self, range: RangeInclusive) { - let exponent_min = self.base.exponent(*range.start()).unwrap(); - let exponent_max = self.base.exponent(*range.end()).unwrap(); + let exponent_min = self.base.exponent(*range.start()).unwrap_or(0.0); + let exponent_max = self.base.exponent(*range.end()).unwrap_or(0.0); self.exponent_scale.set_value_range(exponent_min..=exponent_max); } fn position_from_value(&self, value: f64) -> f32 { - let exponent = self.base.exponent(value).unwrap(); + let exponent = self.base.exponent(value).unwrap_or(0.0); self.exponent_scale.position_from_value(exponent) } @@ -89,19 +91,27 @@ impl AxisSpace for LogAxis { } fn minimum_value_step(&self, spacing: f32) -> f64 { - todo!() + let linear_step = self.exponent_scale.minimum_value_step(spacing); + let linear_min = self.exponent_scale.value_min(); + let log_min_step = self.base.power(linear_step + linear_min); + log_min_step - self.value_min() } fn grid_input(&self, spacing: f32) -> GridInput { - todo!() + GridInput { + bounds: (self.value_min(), self.value_max()), + base_step_size: self.minimum_value_step(spacing), + } } fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32 { - todo!() + let exponent1 = self.base.exponent(value1).unwrap_or(0.0); + let exponent2 = self.base.exponent(value2).unwrap_or(0.0); + self.exponent_scale.screen_distance_between_values(exponent1, exponent2) } fn translate(&mut self, frame_distance: f32) { - todo!() + self.exponent_scale.translate(frame_distance); } fn zoom(&mut self, factor: f32, center: f64) { @@ -116,6 +126,17 @@ mod tests { use super::*; use assertables::{assert_approx_eq, assert_in_delta}; + + #[test] + fn basic_log10_conversions() { + let pairings = [[100.0, 2.0], [1000.0, 3.0], [0.1, -1.0]]; + for [value, exponent] in pairings { + assert_approx_eq!(exponent, LogBase::Base10.exponent(value).unwrap()); + assert_approx_eq!(value, LogBase::Base10.power(exponent)); + } + assert!(LogBase::Base10.exponent(0.0).is_none()); + assert!(LogBase::Base10.exponent(-1.0).is_none()); + } #[test] fn test_value_changes_map_to_linear_and_back() { let mut log_axis = LogAxis::new(1.0..=1000.0, Rangef::new(0.0, 1.0), false, LogBase::Base10); diff --git a/egui_plot/src/axis/mod.rs b/egui_plot/src/axis/mod.rs index 2ac16faf..a21e0212 100644 --- a/egui_plot/src/axis/mod.rs +++ b/egui_plot/src/axis/mod.rs @@ -22,12 +22,15 @@ use egui::emath::Rot2; use egui::emath::remap_clamp; use egui::epaint::TextShape; -use crate::axis::transform::AxisSpace as _; +use crate::axis::transform::AxisSpace; use crate::grid::GridMark; use crate::placement::HPlacement; use crate::placement::Placement; use crate::placement::VPlacement; pub use transform::PlotTransform; +use crate::axis::linear::LinearAxisSpace; +use crate::axis::log::{LogAxis, LogBase}; +use crate::GridInput; // Gap between tick labels and axis label in units of the axis label height const AXIS_LABEL_GAP: f32 = 0.25; @@ -54,6 +57,157 @@ impl From for usize { } } +/// The scaling method for the axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AxisScale { + Linear, + Log10, + Log2, +} + +impl AxisScale { + pub(crate) fn new_axis(&self, bounds: RangeInclusive, frame: Rangef, invert: bool) -> AxisSpaceImpl { + match self { + AxisScale::Linear => LinearAxisSpace::new(bounds, frame, invert).into(), + AxisScale::Log10 => LogAxis::new(bounds, frame, invert, LogBase::Base10).into(), + AxisScale::Log2 => LogAxis::new(bounds, frame, invert, LogBase::Base2).into(), + } + } +} + +/// An enum style dispatch for the axis space. +#[derive(Debug, Clone, Copy)] +pub enum AxisSpaceImpl { + Linear(LinearAxisSpace), + Log(LogAxis), +} + +impl From for AxisSpaceImpl { + fn from(value: LinearAxisSpace) -> Self { + Self::Linear(value) + } +} + +impl From for AxisSpaceImpl { + fn from(value: LogAxis) -> Self { + Self::Log(value) + } +} + +impl AxisSpace for AxisSpaceImpl { + fn value_min(&self) -> f64 { + match self { + AxisSpaceImpl::Linear(axis) => axis.value_min(), + AxisSpaceImpl::Log(axis) => axis.value_min(), + } + } + + fn value_max(&self) -> f64 { + match self { + AxisSpaceImpl::Linear(axis) => axis.value_max(), + AxisSpaceImpl::Log(axis) => axis.value_max(), + } + } + + fn frame_min(&self) -> f32 { + match self { + AxisSpaceImpl::Linear(axis) => axis.frame_min(), + AxisSpaceImpl::Log(axis) => axis.frame_min(), + } + } + + fn frame_max(&self) -> f32 { + match self { + AxisSpaceImpl::Linear(axis) => axis.frame_max(), + AxisSpaceImpl::Log(axis) => axis.frame_max(), + } + } + + fn value_length(&self) -> f64 { + match self { + AxisSpaceImpl::Linear(axis) => axis.value_length(), + AxisSpaceImpl::Log(axis) => axis.value_length(), + } + } + + fn is_valid(&self) -> bool { + match self { + AxisSpaceImpl::Linear(axis) => axis.is_valid(), + AxisSpaceImpl::Log(axis) => axis.is_valid(), + } + } + + fn set_inverted(&mut self, invert: bool) { + match self { + AxisSpaceImpl::Linear(axis) => axis.set_inverted(invert), + AxisSpaceImpl::Log(axis) => axis.set_inverted(invert), + } + } + + fn set_value_range(&mut self, range: RangeInclusive) { + match self { + AxisSpaceImpl::Linear(axis) => axis.set_value_range(range), + AxisSpaceImpl::Log(axis) => axis.set_value_range(range), + } + } + + fn position_from_value(&self, value: f64) -> f32 { + match self { + AxisSpaceImpl::Linear(axis) => axis.position_from_value(value), + AxisSpaceImpl::Log(axis) => axis.position_from_value(value), + } + } + + fn value_from_position(&self, position: f32) -> f64 { + match self { + AxisSpaceImpl::Linear(axis) => axis.value_from_position(position), + AxisSpaceImpl::Log(axis) => axis.value_from_position(position), + } + } + + fn minimum_value_step(&self, spacing: f32) -> f64 { + match self { + AxisSpaceImpl::Linear(axis) => axis.minimum_value_step(spacing), + AxisSpaceImpl::Log(axis) => axis.minimum_value_step(spacing), + } + } + + fn grid_input(&self, spacing: f32) -> GridInput { + match self { + AxisSpaceImpl::Linear(axis) => axis.grid_input(spacing), + AxisSpaceImpl::Log(axis) => axis.grid_input(spacing), + } + } + + fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32 { + match self { + AxisSpaceImpl::Linear(axis) => axis.screen_distance_between_values(value1, value2), + AxisSpaceImpl::Log(axis) => axis.screen_distance_between_values(value1, value2), + } + } + + fn translate(&mut self, frame_distance: f32) { + match self { + AxisSpaceImpl::Linear(axis) => axis.translate(frame_distance), + AxisSpaceImpl::Log(axis) => axis.translate(frame_distance), + } + } + + fn zoom(&mut self, factor: f32, center: f64) { + match self { + AxisSpaceImpl::Linear(axis) => axis.zoom(factor, center), + AxisSpaceImpl::Log(axis) => axis.zoom(factor, center), + } + } + + fn expand(&mut self, pad: f64) { + match self { + AxisSpaceImpl::Linear(axis) => axis.expand(pad), + AxisSpaceImpl::Log(axis) => axis.expand(pad), + } + } +} + /// Axis configuration. /// /// Used to configure axis label and ticks. diff --git a/egui_plot/src/axis/transform.rs b/egui_plot/src/axis/transform.rs index 0f815adc..02183e19 100644 --- a/egui_plot/src/axis/transform.rs +++ b/egui_plot/src/axis/transform.rs @@ -5,12 +5,13 @@ // value units. We could consider a unit type in the future to // make this explicit. -use crate::axis::linear::LinearAxisSpace; -use crate::{Axis, GridInput, PlotBounds, PlotPoint}; +use std::fmt::Debug; +use crate::{Axis, AxisScale, GridInput, PlotBounds, PlotPoint}; use emath::{Pos2, Rect, Vec2, Vec2b, pos2}; use std::ops::RangeInclusive; +use crate::axis::AxisSpaceImpl; -pub trait AxisSpace { +pub trait AxisSpace: Debug + Clone { /// The minimum value of the axis. fn value_min(&self) -> f64; /// The maximum value of the axis. @@ -73,17 +74,17 @@ pub trait AxisSpace { #[derive(Clone, Copy, Debug)] pub struct PlotTransform { /// The X axis space - x_axis: LinearAxisSpace, + x_axis: AxisSpaceImpl, /// The Y axis space - y_axis: LinearAxisSpace, + y_axis: AxisSpaceImpl, /// Whether to always center the x-range or y-range of the bounds. centered: Vec2b, } impl PlotTransform { - pub fn new(frame: Rect, bounds: PlotBounds, center_axis: impl Into) -> Self { + pub fn new(frame: Rect, bounds: PlotBounds, x_scale: AxisScale, y_scale: AxisScale, center_axis: impl Into) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), "Bad plot frame: {frame:?}" @@ -127,9 +128,9 @@ impl PlotTransform { } debug_assert!(new_bounds.is_valid(), "Bad final plot bounds: {new_bounds:?}"); - let x_axis = LinearAxisSpace::new(bounds.range_x(), frame.x_range(), false); + let x_axis = x_scale.new_axis(bounds.range_x(), frame.x_range(), false); // Y is naturally inverted. - let y_axis = LinearAxisSpace::new(bounds.range_y(), frame.y_range(), true); + let y_axis = y_scale.new_axis(bounds.range_y(), frame.y_range(), true); Self { centered: center_axis, @@ -141,10 +142,12 @@ impl PlotTransform { pub fn new_with_invert_axis( frame: Rect, bounds: PlotBounds, + x_scale: AxisScale, + y_scale: AxisScale, center_axis: impl Into, invert_axis: impl Into, ) -> Self { - let mut new = Self::new(frame, bounds, center_axis); + let mut new = Self::new(frame, bounds, x_scale, y_scale, center_axis); let inverted = invert_axis.into(); new.x_axis.set_inverted(inverted.x); // y is naturally inverted so !inverted.y is the required input. @@ -309,7 +312,7 @@ mod tests { let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::new_symmetrical(10.0); - let transform = PlotTransform::new(frame, bounds, false); + let transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); assert_eq!( transform.position_from_point(&PlotPoint::new(0.0, 0.0)), @@ -350,7 +353,7 @@ mod tests { let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::new_symmetrical(10.0); let invert_axis = Vec2b::new(true, false); - let transform = PlotTransform::new_with_invert_axis(frame, bounds, false, invert_axis); + let transform = PlotTransform::new_with_invert_axis(frame, bounds, AxisScale::Linear, AxisScale::Linear,false, invert_axis); assert_eq!( transform.position_from_point(&PlotPoint::new(0.0, 0.0)), pos2(50.0, 50.0) @@ -389,17 +392,17 @@ mod tests { fn aspect_ratio_calculation() { let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::new_symmetrical(10.0); - let transform = PlotTransform::new(frame, bounds, false); + let transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); assert_eq!(transform.aspect(), 1.0); let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [100.0, 50.0]); - let transform = PlotTransform::new(frame, bounds, false); + let transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); assert_eq!(transform.aspect(), 2.0); let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); let bounds = PlotBounds::new_symmetrical(10.0); - let transform = PlotTransform::new(frame, bounds, false); + let transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); assert_eq!(transform.aspect(), 0.5); } @@ -408,7 +411,7 @@ mod tests { fn aspect_calculation_from_custom_axes() { let frame = Rect::from_min_max(Pos2::new(22.0, 49.0), Pos2::new(778.0, 564.0)); let bounds = PlotBounds::from_min_max([-360.0, -0.04294], [7560.0, 1.049]); - let transform = PlotTransform::new(frame, bounds, [false, false]); + let transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, [false, false]); assert_approx_eq!(transform.aspect(), 4940.965708); } @@ -431,7 +434,7 @@ mod tests { fn set_aspect_by_expanding_noop_when_already_correct() { let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::new_symmetrical(10.0); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); let original = transform.bounds(); transform.set_aspect_by_expanding(1.0); @@ -444,7 +447,7 @@ mod tests { // current_aspect = (10/100) / (20/100) = 0.5, target = 1.0 -> grow x let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [10.0, 20.0]); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_expanding(1.0); @@ -457,7 +460,7 @@ mod tests { // current_aspect = (20/100) / (10/100) = 2.0, target = 1.0 -> grow y let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [20.0, 10.0]); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_expanding(1.0); @@ -471,7 +474,7 @@ mod tests { // target 1.0 -> grow x by (1/0.5 - 1) * 20 * 0.5 = 10 on each side let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); let bounds = PlotBounds::new_symmetrical(10.0); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_expanding(1.0); @@ -484,7 +487,7 @@ mod tests { // current_aspect = 0.5, target = 1.0, change X -> grow x let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [10.0, 20.0]); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_changing_axis(1.0, Axis::X); @@ -498,7 +501,7 @@ mod tests { // pad = (1/2 - 1) * 20 * 0.5 = -5 -> min += 5, max -= 5 let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [20.0, 10.0]); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_changing_axis(1.0, Axis::X); @@ -511,7 +514,7 @@ mod tests { // current_aspect = 2.0, target = 1.0, change Y -> grow y let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [20.0, 10.0]); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_changing_axis(1.0, Axis::Y); @@ -525,7 +528,7 @@ mod tests { // pad = (0.5/1 - 1) * 20 * 0.5 = -5 -> min += 5, max -= 5 let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::from_min_max([0.0, 0.0], [10.0, 20.0]); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); transform.set_aspect_by_changing_axis(1.0, Axis::Y); @@ -537,7 +540,7 @@ mod tests { fn set_aspect_by_changing_axis_noop_when_already_correct() { let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::new_symmetrical(10.0); - let mut transform = PlotTransform::new(frame, bounds, false); + let mut transform = PlotTransform::new(frame, bounds, AxisScale::Linear, AxisScale::Linear, false); let original = transform.bounds(); transform.set_aspect_by_changing_axis(1.0, Axis::X); diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 91bbae06..9597be03 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -77,3 +77,4 @@ pub use crate::placement::VPlacement; pub use crate::plot::Plot; pub use crate::plot::PlotResponse; pub use crate::plot::PlotUi; +pub use crate::axis::AxisScale; diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index 6c67400c..93844ecf 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -26,7 +26,7 @@ use emath::Vec2b; use emath::remap_clamp; use emath::vec2; -use crate::axis::Axis; +use crate::axis::{Axis, AxisScale}; use crate::axis::AxisHints; use crate::axis::AxisWidget; use crate::axis::PlotTransform; @@ -112,7 +112,9 @@ pub struct Plot<'a> { view_aspect: Option, invert_x: bool, invert_y: bool, - + x_scale: AxisScale, + y_scale: AxisScale, + reset: bool, show_x: bool, @@ -166,6 +168,8 @@ impl<'a> Plot<'a> { view_aspect: None, invert_x: false, invert_y: false, + x_scale: AxisScale::Linear, + y_scale: AxisScale::Linear, reset: false, @@ -238,6 +242,20 @@ impl<'a> Plot<'a> { self.invert_y = invert; self } + + /// Set the scaling mode for the x-axis. + #[inline] + pub fn x_scale(mut self, scale: AxisScale) -> Self { + self.x_scale = scale; + self + } + + /// Set the scaling mode for the y-axis. + #[inline] + pub fn y_scale(mut self, scale: AxisScale) -> Self { + self.y_scale = scale; + self + } /// Width of plot. By default a plot will fill the ui it is in. /// If you set [`Self::view_aspect`], the width can be calculated from the @@ -899,6 +917,8 @@ impl<'a> Plot<'a> { transform: PlotTransform::new_with_invert_axis( plot_rect, self.min_auto_bounds, + self.x_scale, + self.y_scale, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), ), @@ -914,6 +934,8 @@ impl<'a> Plot<'a> { transform: PlotTransform::new_with_invert_axis( plot_rect, self.min_auto_bounds, + self.x_scale, + self.y_scale, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), ), @@ -1079,6 +1101,8 @@ impl<'a> Plot<'a> { mem.transform = PlotTransform::new_with_invert_axis( plot_rect, bounds, + self.x_scale, + self.y_scale, self.center_axis, Vec2b::new(self.invert_x, self.invert_y), ); diff --git a/examples/log_axes/Cargo.toml b/examples/log_axes/Cargo.toml new file mode 100644 index 00000000..9d262db3 --- /dev/null +++ b/examples/log_axes/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "log_axes" +version = "0.1.0" +authors = ["Nicolas "] +license.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +eframe = { workspace = true, features = ["default"] } +egui_plot.workspace = true +env_logger = { workspace = true, default-features = false, features = [ + "auto-color", + "humantime", +] } +examples_utils.workspace = true + +[package.metadata.cargo-shear] +ignored = ["env_logger"] # used by make_main! macro diff --git a/examples/log_axes/README.md b/examples/log_axes/README.md new file mode 100644 index 00000000..6ee3b09d --- /dev/null +++ b/examples/log_axes/README.md @@ -0,0 +1,18 @@ +# Line Demo + +This example demonstrates various line plotting features including animated lines, different line styles (solid, dashed, dotted), gradients, fills, axis inversion, and coordinate display. It shows a comprehensive set of line customization options. + +## Running + +Native +```sh +cargo run -p lines +``` + +Web (WASM) +```sh +cd examples/lines +trunk serve +``` + +![](screenshot.png) diff --git a/examples/log_axes/screenshot.png b/examples/log_axes/screenshot.png new file mode 100644 index 00000000..4d890083 --- /dev/null +++ b/examples/log_axes/screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50406ef578fcffe28cf06a08a1f68fd779b8e5c58877347172c9c088d08a2cba +size 117339 diff --git a/examples/log_axes/screenshot_thumb.png b/examples/log_axes/screenshot_thumb.png new file mode 100644 index 00000000..272770a2 --- /dev/null +++ b/examples/log_axes/screenshot_thumb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:124fb2c13677557aab9d8238faaec56c94d8c9d9d08c8e700658796c4abd40c1 +size 20023 diff --git a/examples/log_axes/src/app.rs b/examples/log_axes/src/app.rs new file mode 100644 index 00000000..81d65375 --- /dev/null +++ b/examples/log_axes/src/app.rs @@ -0,0 +1,62 @@ + +use eframe::egui; +use eframe::egui::Response; +use egui::NumExt as _; +use egui_plot::AxisScale; +use egui_plot::Legend; +use egui_plot::Line; +use egui_plot::LineStyle; +use egui_plot::Plot; +use egui_plot::PlotPoints; + +#[derive(Clone, Copy, PartialEq)] +pub struct LogScaleExample { + invert_x: bool, + invert_y: bool, + log_x: bool, + log_y: bool, +} + +impl Default for LogScaleExample { + fn default() -> Self { + Self { + invert_x: false, + invert_y: false, + log_x: false, + log_y: false, + } + } +} + +impl LogScaleExample { + pub fn show_controls(&mut self, ui: &mut egui::Ui) -> Response { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.checkbox(&mut self.invert_x, "Invert X axis"); + ui.checkbox(&mut self.invert_y, "Invert Y axis"); + ui.checkbox(&mut self.log_x, "Log X axis"); + ui.checkbox(&mut self.log_y, "Log Y axis"); + }); + }) + .response + } + + fn squared_line(&self) -> Line<'_> { + Line::new("exponential", PlotPoints::from_explicit_callback(|x| 10.0_f64.powf(x), 0.0..10.0, 512)) + .color(egui::Color32::from_rgb(100, 150, 250)) + .style(LineStyle::Solid) + } + + pub fn show_plot(&mut self, ui: &mut egui::Ui) -> Response { + let mut plot = Plot::new("lines_demo") + .legend(Legend::default().title("Lines")) + .invert_x(self.invert_x) + .invert_y(self.invert_y) + .x_scale(if self.log_x { AxisScale::Log10 } else { AxisScale::Linear }) + .y_scale(if self.log_y { AxisScale::Log10 } else { AxisScale::Linear }); + plot.show(ui, |plot_ui| { + plot_ui.line(self.squared_line()); + }) + .response + } +} diff --git a/examples/log_axes/src/lib.rs b/examples/log_axes/src/lib.rs new file mode 100644 index 00000000..f049d783 --- /dev/null +++ b/examples/log_axes/src/lib.rs @@ -0,0 +1,41 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] + +use eframe::egui; +use examples_utils::PlotExample; + +mod app; +pub use app::LogScaleExample; + +impl PlotExample for LogScaleExample { + fn name(&self) -> &'static str { + "lines" + } + + fn title(&self) -> &'static str { + "Line Demo" + } + + fn description(&self) -> &'static str { + "This example demonstrates various line plotting features including animated lines, different line styles (solid, dashed, dotted), gradients, fills, axis inversion, and coordinate display. It shows a comprehensive set of line customization options." + } + + fn tags(&self) -> &'static [&'static str] { + &["lines", "animation", "styling"] + } + + fn thumbnail_bytes(&self) -> &'static [u8] { + include_bytes!("../screenshot_thumb.png") + } + + fn code_bytes(&self) -> &'static [u8] { + include_bytes!("./app.rs") + } + + fn show_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { + self.show_plot(ui) + } + + fn show_controls(&mut self, ui: &mut egui::Ui) -> egui::Response { + self.show_controls(ui) + } +} diff --git a/examples/log_axes/src/main.rs b/examples/log_axes/src/main.rs new file mode 100644 index 00000000..5ca70aec --- /dev/null +++ b/examples/log_axes/src/main.rs @@ -0,0 +1,4 @@ +use examples_utils::make_main; +use log_axes::LogScaleExample; + +make_main!(LogScaleExample); From 1741749fe5ed07082bd5b6f0544b8b3b3fc11618 Mon Sep 17 00:00:00 2001 From: James McNally Date: Sat, 4 Jul 2026 08:35:52 +0100 Subject: [PATCH 6/7] wip: Complete Delta and Offsets Nearly working but we get an error due to having to handle the option of a pointer co-oridnate on a drag. Also need to work out the base spacing --- egui_plot/src/axis/linear.rs | 7 ++- egui_plot/src/axis/log.rs | 57 +++++++++++++++++------ egui_plot/src/axis/mod.rs | 78 ++++++++++++++++---------------- egui_plot/src/axis/transform.rs | 25 +++++++--- egui_plot/src/items/bar_chart.rs | 4 +- egui_plot/src/items/box_plot.rs | 4 +- egui_plot/src/lib.rs | 2 +- egui_plot/src/plot.rs | 23 ++++++---- examples/log_axes/src/app.rs | 35 ++++++++------ 9 files changed, 146 insertions(+), 89 deletions(-) diff --git a/egui_plot/src/axis/linear.rs b/egui_plot/src/axis/linear.rs index fbed620e..871368d1 100644 --- a/egui_plot/src/axis/linear.rs +++ b/egui_plot/src/axis/linear.rs @@ -93,15 +93,14 @@ impl AxisSpace for LinearAxisSpace { remap(position as f64, self.frame_range_f64(), self.min..=self.max) } - /// Provides the minimum value step for the screen step size provided. - fn minimum_value_step(&self, spacing: f32) -> f64 { - self.dvalue_per_dpos().abs() * (spacing as f64) + fn position_delta_from_screen_delta(&self, _start_position: f64, drag_delta: f32) -> f64 { + drag_delta as f64 * self.dvalue_per_dpos() } fn grid_input(&self, spacing: f32) -> GridInput { GridInput { bounds: (self.min, self.max), - base_step_size: self.minimum_value_step(spacing), + base_step_size: self.dvalue_per_dpos().abs() * (spacing as f64), } } diff --git a/egui_plot/src/axis/log.rs b/egui_plot/src/axis/log.rs index 84fde3a5..ed027c19 100644 --- a/egui_plot/src/axis/log.rs +++ b/egui_plot/src/axis/log.rs @@ -90,18 +90,25 @@ impl AxisSpace for LogAxis { self.base.power(exponent) } - fn minimum_value_step(&self, spacing: f32) -> f64 { - let linear_step = self.exponent_scale.minimum_value_step(spacing); - let linear_min = self.exponent_scale.value_min(); - let log_min_step = self.base.power(linear_step + linear_min); - log_min_step - self.value_min() - } - - fn grid_input(&self, spacing: f32) -> GridInput { - GridInput { - bounds: (self.value_min(), self.value_max()), - base_step_size: self.minimum_value_step(spacing), - } + fn position_delta_from_screen_delta(&self, start_position_value: f64, delta: f32) -> f64 { + let start_exponent = self.base.exponent(start_position_value).unwrap_or(0.0); + let exponent_delta = self + .exponent_scale + .position_delta_from_screen_delta(start_position_value, delta); + let end_exponent = start_exponent + exponent_delta; + let end_value = self.base.power(end_exponent); + end_value - start_position_value + } + + fn grid_input(&self, _spacing: f32) -> GridInput { + //todo: This still isn't right + let max_exponent = self.exponent_scale.value_max(); + let min_step_exponent = max_exponent - 3.0; + + GridInput { + bounds: (self.value_min(), self.value_max()), + base_step_size: self.base.power(min_step_exponent), + } } fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32 { @@ -126,7 +133,6 @@ mod tests { use super::*; use assertables::{assert_approx_eq, assert_in_delta}; - #[test] fn basic_log10_conversions() { let pairings = [[100.0, 2.0], [1000.0, 3.0], [0.1, -1.0]]; @@ -177,4 +183,29 @@ mod tests { assert_approx_eq!(log_axis.frame_min(), 0.0); assert_approx_eq!(log_axis.frame_max(), 1.0); } + + #[test] + fn value_delta_from_screen_delta() { + let log_axis = LogAxis::new(1.0..=1000.0, Rangef::new(0.0, 1.0), false, LogBase::Base10); + + let screen_start = 0.1; + let screen_end = 0.2; + + let value_start = log_axis.value_from_position(screen_start); + let value_end = log_axis.value_from_position(screen_end); + let delta = value_end - value_start; + + let calculated_delta = log_axis.position_delta_from_screen_delta(value_start, 0.1); + assert_approx_eq!(calculated_delta, delta); + + //non linear means that offsetting should still compensate. + let screen_start = 0.3; + let screen_end = 0.4; + let value_start = log_axis.value_from_position(screen_start); + let value_end = log_axis.value_from_position(screen_end); + let delta = value_end - value_start; + + let calculated_delta = log_axis.position_delta_from_screen_delta(value_start, 0.1); + assert_approx_eq!(calculated_delta, delta); + } } diff --git a/egui_plot/src/axis/mod.rs b/egui_plot/src/axis/mod.rs index a21e0212..1c6ef582 100644 --- a/egui_plot/src/axis/mod.rs +++ b/egui_plot/src/axis/mod.rs @@ -22,15 +22,15 @@ use egui::emath::Rot2; use egui::emath::remap_clamp; use egui::epaint::TextShape; +use crate::GridInput; +use crate::axis::linear::LinearAxisSpace; +use crate::axis::log::{LogAxis, LogBase}; use crate::axis::transform::AxisSpace; use crate::grid::GridMark; use crate::placement::HPlacement; use crate::placement::Placement; use crate::placement::VPlacement; pub use transform::PlotTransform; -use crate::axis::linear::LinearAxisSpace; -use crate::axis::log::{LogAxis, LogBase}; -use crate::GridInput; // Gap between tick labels and axis label in units of the axis label height const AXIS_LABEL_GAP: f32 = 0.25; @@ -68,9 +68,9 @@ pub enum AxisScale { impl AxisScale { pub(crate) fn new_axis(&self, bounds: RangeInclusive, frame: Rangef, invert: bool) -> AxisSpaceImpl { match self { - AxisScale::Linear => LinearAxisSpace::new(bounds, frame, invert).into(), - AxisScale::Log10 => LogAxis::new(bounds, frame, invert, LogBase::Base10).into(), - AxisScale::Log2 => LogAxis::new(bounds, frame, invert, LogBase::Base2).into(), + Self::Linear => LinearAxisSpace::new(bounds, frame, invert).into(), + Self::Log10 => LogAxis::new(bounds, frame, invert, LogBase::Base10).into(), + Self::Log2 => LogAxis::new(bounds, frame, invert, LogBase::Base2).into(), } } } @@ -97,113 +97,113 @@ impl From for AxisSpaceImpl { impl AxisSpace for AxisSpaceImpl { fn value_min(&self) -> f64 { match self { - AxisSpaceImpl::Linear(axis) => axis.value_min(), - AxisSpaceImpl::Log(axis) => axis.value_min(), + Self::Linear(axis) => axis.value_min(), + Self::Log(axis) => axis.value_min(), } } fn value_max(&self) -> f64 { match self { - AxisSpaceImpl::Linear(axis) => axis.value_max(), - AxisSpaceImpl::Log(axis) => axis.value_max(), + Self::Linear(axis) => axis.value_max(), + Self::Log(axis) => axis.value_max(), } } fn frame_min(&self) -> f32 { match self { - AxisSpaceImpl::Linear(axis) => axis.frame_min(), - AxisSpaceImpl::Log(axis) => axis.frame_min(), + Self::Linear(axis) => axis.frame_min(), + Self::Log(axis) => axis.frame_min(), } } fn frame_max(&self) -> f32 { match self { - AxisSpaceImpl::Linear(axis) => axis.frame_max(), - AxisSpaceImpl::Log(axis) => axis.frame_max(), + Self::Linear(axis) => axis.frame_max(), + Self::Log(axis) => axis.frame_max(), } } fn value_length(&self) -> f64 { match self { - AxisSpaceImpl::Linear(axis) => axis.value_length(), - AxisSpaceImpl::Log(axis) => axis.value_length(), + Self::Linear(axis) => axis.value_length(), + Self::Log(axis) => axis.value_length(), } } fn is_valid(&self) -> bool { match self { - AxisSpaceImpl::Linear(axis) => axis.is_valid(), - AxisSpaceImpl::Log(axis) => axis.is_valid(), + Self::Linear(axis) => axis.is_valid(), + Self::Log(axis) => axis.is_valid(), } } fn set_inverted(&mut self, invert: bool) { match self { - AxisSpaceImpl::Linear(axis) => axis.set_inverted(invert), - AxisSpaceImpl::Log(axis) => axis.set_inverted(invert), + Self::Linear(axis) => axis.set_inverted(invert), + Self::Log(axis) => axis.set_inverted(invert), } } fn set_value_range(&mut self, range: RangeInclusive) { match self { - AxisSpaceImpl::Linear(axis) => axis.set_value_range(range), - AxisSpaceImpl::Log(axis) => axis.set_value_range(range), + Self::Linear(axis) => axis.set_value_range(range), + Self::Log(axis) => axis.set_value_range(range), } } fn position_from_value(&self, value: f64) -> f32 { match self { - AxisSpaceImpl::Linear(axis) => axis.position_from_value(value), - AxisSpaceImpl::Log(axis) => axis.position_from_value(value), + Self::Linear(axis) => axis.position_from_value(value), + Self::Log(axis) => axis.position_from_value(value), } } fn value_from_position(&self, position: f32) -> f64 { match self { - AxisSpaceImpl::Linear(axis) => axis.value_from_position(position), - AxisSpaceImpl::Log(axis) => axis.value_from_position(position), + Self::Linear(axis) => axis.value_from_position(position), + Self::Log(axis) => axis.value_from_position(position), } } - fn minimum_value_step(&self, spacing: f32) -> f64 { + fn position_delta_from_screen_delta(&self, start_position_value: f64, delta: f32) -> f64 { match self { - AxisSpaceImpl::Linear(axis) => axis.minimum_value_step(spacing), - AxisSpaceImpl::Log(axis) => axis.minimum_value_step(spacing), + Self::Linear(axis) => axis.position_delta_from_screen_delta(start_position_value, delta), + Self::Log(axis) => axis.position_delta_from_screen_delta(start_position_value, delta), } } fn grid_input(&self, spacing: f32) -> GridInput { match self { - AxisSpaceImpl::Linear(axis) => axis.grid_input(spacing), - AxisSpaceImpl::Log(axis) => axis.grid_input(spacing), + Self::Linear(axis) => axis.grid_input(spacing), + Self::Log(axis) => axis.grid_input(spacing), } } fn screen_distance_between_values(&self, value1: f64, value2: f64) -> f32 { match self { - AxisSpaceImpl::Linear(axis) => axis.screen_distance_between_values(value1, value2), - AxisSpaceImpl::Log(axis) => axis.screen_distance_between_values(value1, value2), + Self::Linear(axis) => axis.screen_distance_between_values(value1, value2), + Self::Log(axis) => axis.screen_distance_between_values(value1, value2), } } fn translate(&mut self, frame_distance: f32) { match self { - AxisSpaceImpl::Linear(axis) => axis.translate(frame_distance), - AxisSpaceImpl::Log(axis) => axis.translate(frame_distance), + Self::Linear(axis) => axis.translate(frame_distance), + Self::Log(axis) => axis.translate(frame_distance), } } fn zoom(&mut self, factor: f32, center: f64) { match self { - AxisSpaceImpl::Linear(axis) => axis.zoom(factor, center), - AxisSpaceImpl::Log(axis) => axis.zoom(factor, center), + Self::Linear(axis) => axis.zoom(factor, center), + Self::Log(axis) => axis.zoom(factor, center), } } fn expand(&mut self, pad: f64) { match self { - AxisSpaceImpl::Linear(axis) => axis.expand(pad), - AxisSpaceImpl::Log(axis) => axis.expand(pad), + Self::Linear(axis) => axis.expand(pad), + Self::Log(axis) => axis.expand(pad), } } } diff --git a/egui_plot/src/axis/transform.rs b/egui_plot/src/axis/transform.rs index 02183e19..036ecd1e 100644 --- a/egui_plot/src/axis/transform.rs +++ b/egui_plot/src/axis/transform.rs @@ -5,11 +5,11 @@ // value units. We could consider a unit type in the future to // make this explicit. -use std::fmt::Debug; +use crate::axis::AxisSpaceImpl; use crate::{Axis, AxisScale, GridInput, PlotBounds, PlotPoint}; use emath::{Pos2, Rect, Vec2, Vec2b, pos2}; +use std::fmt::Debug; use std::ops::RangeInclusive; -use crate::axis::AxisSpaceImpl; pub trait AxisSpace: Debug + Clone { /// The minimum value of the axis. @@ -42,8 +42,8 @@ pub trait AxisSpace: Debug + Clone { /// Convert a screen position to a value. fn value_from_position(&self, position: f32) -> f64; - /// Provides the minimum value step for the screen step size provided. - fn minimum_value_step(&self, spacing: f32) -> f64; + /// Convert a screen drag vector to the distance of the vector in plot space. + fn position_delta_from_screen_delta(&self, start_position_value: f64, delta: f32) -> f64; /// Get the grid input configuration for the axis given the provided /// minimum gap in screen units. @@ -84,7 +84,13 @@ pub struct PlotTransform { } impl PlotTransform { - pub fn new(frame: Rect, bounds: PlotBounds, x_scale: AxisScale, y_scale: AxisScale, center_axis: impl Into) -> Self { + pub fn new( + frame: Rect, + bounds: PlotBounds, + x_scale: AxisScale, + y_scale: AxisScale, + center_axis: impl Into, + ) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), "Bad plot frame: {frame:?}" @@ -353,7 +359,14 @@ mod tests { let frame = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)); let bounds = PlotBounds::new_symmetrical(10.0); let invert_axis = Vec2b::new(true, false); - let transform = PlotTransform::new_with_invert_axis(frame, bounds, AxisScale::Linear, AxisScale::Linear,false, invert_axis); + let transform = PlotTransform::new_with_invert_axis( + frame, + bounds, + AxisScale::Linear, + AxisScale::Linear, + false, + invert_axis, + ); assert_eq!( transform.position_from_point(&PlotPoint::new(0.0, 0.0)), pos2(50.0, 50.0) diff --git a/egui_plot/src/items/bar_chart.rs b/egui_plot/src/items/bar_chart.rs index f040a62c..0ffb54a8 100644 --- a/egui_plot/src/items/bar_chart.rs +++ b/egui_plot/src/items/bar_chart.rs @@ -411,8 +411,8 @@ impl RectElement for Bar { fn default_values_format(&self, transform: &PlotTransform) -> String { let scale = match self.orientation { - Orientation::Horizontal => transform.axis_space(Axis::X).minimum_value_step(1.0), - Orientation::Vertical => transform.axis_space(Axis::Y).minimum_value_step(1.0), + Orientation::Horizontal => transform.axis_space(Axis::X).grid_input(1.0).base_step_size, + Orientation::Vertical => transform.axis_space(Axis::Y).grid_input(1.0).base_step_size, }; let decimals = ((-scale.abs().log10()).ceil().at_least(0.0) as usize).at_most(6); crate::label::format_number(self.value, decimals) diff --git a/egui_plot/src/items/box_plot.rs b/egui_plot/src/items/box_plot.rs index 1a1d8b4f..1fca35d1 100644 --- a/egui_plot/src/items/box_plot.rs +++ b/egui_plot/src/items/box_plot.rs @@ -443,8 +443,8 @@ impl RectElement for BoxElem { fn default_values_format(&self, transform: &PlotTransform) -> String { let scale = match self.orientation { - Orientation::Horizontal => transform.axis_space(Axis::X).minimum_value_step(1.0), - Orientation::Vertical => transform.axis_space(Axis::Y).minimum_value_step(1.0), + Orientation::Horizontal => transform.axis_space(Axis::X).grid_input(1.0).base_step_size, + Orientation::Vertical => transform.axis_space(Axis::Y).grid_input(1.0).base_step_size, }; let y_decimals = ((-scale.abs().log10()).ceil().at_least(0.0) as usize) .at_most(6) diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 9597be03..4616b953 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -30,6 +30,7 @@ pub use crate::aesthetics::MarkerShape; pub use crate::aesthetics::Orientation; pub use crate::axis::Axis; pub use crate::axis::AxisHints; +pub use crate::axis::AxisScale; pub use crate::axis::PlotTransform; pub use crate::bounds::PlotBounds; pub use crate::bounds::PlotPoint; @@ -77,4 +78,3 @@ pub use crate::placement::VPlacement; pub use crate::plot::Plot; pub use crate::plot::PlotResponse; pub use crate::plot::PlotUi; -pub use crate::axis::AxisScale; diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index 93844ecf..c62aeea6 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -26,11 +26,11 @@ use emath::Vec2b; use emath::remap_clamp; use emath::vec2; -use crate::axis::{Axis, AxisScale}; use crate::axis::AxisHints; use crate::axis::AxisWidget; use crate::axis::PlotTransform; use crate::axis::transform::AxisSpace as _; +use crate::axis::{Axis, AxisScale}; use crate::bounds::BoundsLinkGroups; use crate::bounds::BoundsModification; use crate::bounds::LinkedBounds; @@ -114,7 +114,7 @@ pub struct Plot<'a> { invert_y: bool, x_scale: AxisScale, y_scale: AxisScale, - + reset: bool, show_x: bool, @@ -242,14 +242,14 @@ impl<'a> Plot<'a> { self.invert_y = invert; self } - + /// Set the scaling mode for the x-axis. #[inline] pub fn x_scale(mut self, scale: AxisScale) -> Self { self.x_scale = scale; self } - + /// Set the scaling mode for the y-axis. #[inline] pub fn y_scale(mut self, scale: AxisScale) -> Self { @@ -2011,12 +2011,17 @@ impl<'a> PlotUi<'a> { /// The pointer drag delta in plot coordinates. pub fn pointer_coordinate_drag_delta(&self) -> Vec2 { - // todo: We are going to need to get a start and end here to - // compute the actual delta in the log case. let delta = self.response.drag_delta(); - let x_dp_dv = self.last_plot_transform.axis_space(Axis::X).minimum_value_step(1.0); - let y_dp_dv = self.last_plot_transform.axis_space(Axis::Y).minimum_value_step(1.0); - Vec2::new(delta.x / x_dp_dv as f32, delta.y / y_dp_dv as f32) + let position = self.pointer_coordinate().expect("Needed for drag action"); + let x = self + .transform() + .axis_space(Axis::X) + .position_delta_from_screen_delta(position.x, delta.x); + let y = self + .transform() + .axis_space(Axis::Y) + .position_delta_from_screen_delta(position.y, delta.y); + Vec2::new(x as f32, y as f32) } /// Read the transform between plot coordinates and screen coordinates. diff --git a/examples/log_axes/src/app.rs b/examples/log_axes/src/app.rs index 81d65375..b828e9d3 100644 --- a/examples/log_axes/src/app.rs +++ b/examples/log_axes/src/app.rs @@ -1,7 +1,5 @@ - use eframe::egui; use eframe::egui::Response; -use egui::NumExt as _; use egui_plot::AxisScale; use egui_plot::Legend; use egui_plot::Line; @@ -9,7 +7,7 @@ use egui_plot::LineStyle; use egui_plot::Plot; use egui_plot::PlotPoints; -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct LogScaleExample { invert_x: bool, invert_y: bool, @@ -23,7 +21,7 @@ impl Default for LogScaleExample { invert_x: false, invert_y: false, log_x: false, - log_y: false, + log_y: true, } } } @@ -41,21 +39,32 @@ impl LogScaleExample { .response } - fn squared_line(&self) -> Line<'_> { - Line::new("exponential", PlotPoints::from_explicit_callback(|x| 10.0_f64.powf(x), 0.0..10.0, 512)) - .color(egui::Color32::from_rgb(100, 150, 250)) - .style(LineStyle::Solid) + fn squared_line() -> Line<'static> { + Line::new( + "exponential", + PlotPoints::from_explicit_callback(|x| 10.0_f64.powf(x), 0.0..10.0, 512), + ) + .color(egui::Color32::from_rgb(100, 150, 250)) + .style(LineStyle::Solid) } - pub fn show_plot(&mut self, ui: &mut egui::Ui) -> Response { - let mut plot = Plot::new("lines_demo") + pub fn show_plot(&self, ui: &mut egui::Ui) -> Response { + let plot = Plot::new("log_demo") .legend(Legend::default().title("Lines")) .invert_x(self.invert_x) .invert_y(self.invert_y) - .x_scale(if self.log_x { AxisScale::Log10 } else { AxisScale::Linear }) - .y_scale(if self.log_y { AxisScale::Log10 } else { AxisScale::Linear }); + .x_scale(if self.log_x { + AxisScale::Log10 + } else { + AxisScale::Linear + }) + .y_scale(if self.log_y { + AxisScale::Log10 + } else { + AxisScale::Linear + }); plot.show(ui, |plot_ui| { - plot_ui.line(self.squared_line()); + plot_ui.line(Self::squared_line()); }) .response } From 84221a6953b7bbe016b1de9c5790a3c5e5a76c03 Mon Sep 17 00:00:00 2001 From: James McNally Date: Sat, 4 Jul 2026 15:07:30 +0100 Subject: [PATCH 7/7] wip: Fix bad defaults - still needs work --- egui_plot/src/axis/log.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/egui_plot/src/axis/log.rs b/egui_plot/src/axis/log.rs index ed027c19..4f2f6aa1 100644 --- a/egui_plot/src/axis/log.rs +++ b/egui_plot/src/axis/log.rs @@ -44,8 +44,8 @@ pub struct LogAxis { impl LogAxis { pub fn new(value_range: RangeInclusive, frame_range: Rangef, invert: bool, base: LogBase) -> Self { - let exponent_min = base.exponent(*value_range.start()).unwrap_or(0.0); - let exponent_max = base.exponent(*value_range.end()).unwrap_or(0.0); + let exponent_min = base.exponent(*value_range.start()).unwrap_or(-9.0); + let exponent_max = base.exponent(*value_range.end()).unwrap_or(-9.0); Self { exponent_scale: LinearAxisSpace::new(exponent_min..=exponent_max, frame_range, invert), base, @@ -75,12 +75,13 @@ impl AxisSpace for LogAxis { } fn set_value_range(&mut self, range: RangeInclusive) { - let exponent_min = self.base.exponent(*range.start()).unwrap_or(0.0); - let exponent_max = self.base.exponent(*range.end()).unwrap_or(0.0); + let exponent_min = self.base.exponent(*range.start()).unwrap_or(-9.0); + let exponent_max = self.base.exponent(*range.end()).unwrap_or(-9.0); self.exponent_scale.set_value_range(exponent_min..=exponent_max); } fn position_from_value(&self, value: f64) -> f32 { + //todo: appropriate response, NaN? let exponent = self.base.exponent(value).unwrap_or(0.0); self.exponent_scale.position_from_value(exponent) }