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/linear.rs b/egui_plot/src/axis/linear.rs new file mode 100644 index 00000000..871368d1 --- /dev/null +++ b/egui_plot/src/axis/linear.rs @@ -0,0 +1,124 @@ +use crate::GridInput; +use crate::axis::transform::AxisSpace; +use emath::{Rangef, remap}; +use std::ops::RangeInclusive; + +#[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 + } + + 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 { + 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 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 + } + + fn value_from_position(&self, position: f32) -> f64 { + remap(position as f64, self.frame_range_f64(), self.min..=self.max) + } + + 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.dvalue_per_dpos().abs() * (spacing as f64), + } + } + + 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(); + } + + 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 new file mode 100644 index 00000000..4f2f6aa1 --- /dev/null +++ b/egui_plot/src/axis/log.rs @@ -0,0 +1,212 @@ +use crate::GridInput; +use crate::axis::linear::LinearAxisSpace; +use crate::axis::transform::AxisSpace; +use emath::Rangef; +use std::ops::RangeInclusive; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub 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 { + Self::Base10 => Some(value.log10()), + Self::Base2 => Some(value.log2()), + } + } + + pub fn power(&self, value: f64) -> f64 { + match self { + Self::Base10 => f64::powf(10.0, value), + Self::Base2 => value.exp2(), + } + } +} + +#[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. + 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_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, + } + } +} + +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_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) + } + + fn value_from_position(&self, position: f32) -> f64 { + let exponent = self.exponent_scale.value_from_position(position); + self.base.power(exponent) + } + + 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 { + 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) { + self.exponent_scale.translate(frame_distance); + } + + 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 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); + + 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); + } + + #[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.rs b/egui_plot/src/axis/mod.rs similarity index 58% rename from egui_plot/src/axis.rs rename to egui_plot/src/axis/mod.rs index c22acaa8..1c6ef582 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis/mod.rs @@ -1,3 +1,7 @@ +pub mod linear; +pub mod log; +pub mod transform; + use std::fmt::Debug; use std::ops::RangeInclusive; use std::sync::Arc; @@ -17,16 +21,16 @@ 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; +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; // Gap between tick labels and axis label in units of the axis label height const AXIS_LABEL_GAP: f32 = 0.25; @@ -53,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 { + 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(), + } + } +} + +/// 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 { + Self::Linear(axis) => axis.value_min(), + Self::Log(axis) => axis.value_min(), + } + } + + fn value_max(&self) -> f64 { + match self { + Self::Linear(axis) => axis.value_max(), + Self::Log(axis) => axis.value_max(), + } + } + + fn frame_min(&self) -> f32 { + match self { + Self::Linear(axis) => axis.frame_min(), + Self::Log(axis) => axis.frame_min(), + } + } + + fn frame_max(&self) -> f32 { + match self { + Self::Linear(axis) => axis.frame_max(), + Self::Log(axis) => axis.frame_max(), + } + } + + fn value_length(&self) -> f64 { + match self { + Self::Linear(axis) => axis.value_length(), + Self::Log(axis) => axis.value_length(), + } + } + + fn is_valid(&self) -> bool { + match self { + Self::Linear(axis) => axis.is_valid(), + Self::Log(axis) => axis.is_valid(), + } + } + + fn set_inverted(&mut self, invert: bool) { + match self { + Self::Linear(axis) => axis.set_inverted(invert), + Self::Log(axis) => axis.set_inverted(invert), + } + } + + fn set_value_range(&mut self, range: RangeInclusive) { + match self { + 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 { + 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 { + Self::Linear(axis) => axis.value_from_position(position), + Self::Log(axis) => axis.value_from_position(position), + } + } + + fn position_delta_from_screen_delta(&self, start_position_value: f64, delta: f32) -> f64 { + match self { + 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 { + 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 { + 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 { + Self::Linear(axis) => axis.translate(frame_distance), + Self::Log(axis) => axis.translate(frame_distance), + } + } + + fn zoom(&mut self, factor: f32, center: f64) { + match self { + Self::Linear(axis) => axis.zoom(factor, center), + Self::Log(axis) => axis.zoom(factor, center), + } + } + + fn expand(&mut self, pad: f64) { + match self { + Self::Linear(axis) => axis.expand(pad), + Self::Log(axis) => axis.expand(pad), + } + } +} + /// Axis configuration. /// /// Used to configure axis label and ticks. @@ -278,12 +433,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.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() 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. @@ -359,277 +517,3 @@ impl<'a> AxisWidget<'a> { thickness } } - -/// 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..036ecd1e --- /dev/null +++ b/egui_plot/src/axis/transform.rs @@ -0,0 +1,565 @@ +//! 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::AxisSpaceImpl; +use crate::{Axis, AxisScale, GridInput, PlotBounds, PlotPoint}; +use emath::{Pos2, Rect, Vec2, Vec2b, pos2}; +use std::fmt::Debug; +use std::ops::RangeInclusive; + +pub trait AxisSpace: Debug + Clone { + /// 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; + + /// 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. + 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); + /// 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) { + 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); + } + } +} + +/// 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: AxisSpaceImpl, + + /// The Y axis space + 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, + 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:?}" + ); + 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 = x_scale.new_axis(bounds.range_x(), frame.x_range(), false); + // Y is naturally inverted. + let y_axis = y_scale.new_axis(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, + x_scale: AxisScale, + y_scale: AxisScale, + center_axis: impl Into, + invert_axis: impl Into, + ) -> Self { + 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. + 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(crate) fn axis_space(&self, axis: Axis) -> &impl AxisSpace { + match axis { + Axis::X => &self.x_axis, + Axis::Y => &self.y_axis, + } + } + + 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 + } + + /// scale.x/scale.y ratio. + /// + /// If 1.0, it means the scale factor is the same in both axes. + fn aspect(&self) -> f64 { + 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. + /// + /// 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 super::*; + use assertables::assert_approx_eq; + + #[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, AxisScale::Linear, AxisScale::Linear, 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, + AxisScale::Linear, + AxisScale::Linear, + 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, 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, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, [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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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, AxisScale::Linear, AxisScale::Linear, 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/bar_chart.rs b/egui_plot/src/items/bar_chart.rs index 16685233..0ffb54a8 100644 --- a/egui_plot/src/items/bar_chart.rs +++ b/egui_plot/src/items/bar_chart.rs @@ -11,8 +11,10 @@ use emath::Float as _; use emath::NumExt as _; use emath::Pos2; +use crate::Axis; use crate::aesthetics::Orientation; use crate::axis::PlotTransform; +use crate::axis::transform::AxisSpace as _; 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).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 74c3238d..1fca35d1 100644 --- a/egui_plot/src/items/box_plot.rs +++ b/egui_plot/src/items/box_plot.rs @@ -10,8 +10,10 @@ use egui::epaint::RectShape; use emath::NumExt as _; use emath::Pos2; +use crate::Axis; use crate::aesthetics::Orientation; use crate::axis::PlotTransform; +use crate::axis::transform::AxisSpace as _; 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).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/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/lib.rs b/egui_plot/src/lib.rs index 91bbae06..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; 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..c62aeea6 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -26,10 +26,11 @@ use emath::Vec2b; use emath::remap_clamp; use emath::vec2; -use crate::axis::Axis; 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; @@ -111,6 +112,8 @@ pub struct Plot<'a> { view_aspect: Option, invert_x: bool, invert_y: bool, + x_scale: AxisScale, + y_scale: AxisScale, reset: bool, @@ -165,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 +243,20 @@ impl<'a> Plot<'a> { 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 /// height. @@ -898,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), ), @@ -913,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), ), @@ -991,7 +1014,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() { @@ -1078,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), ); @@ -1244,18 +1269,12 @@ impl<'a> Plot<'a> { let bounds = mem.transform.bounds(); let x_axis_range = bounds.range_x(); let x_steps = Arc::new({ - 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, - }; + 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 = 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 = mem.transform.axis_space(Axis::Y).grid_input(self.grid_spacing.min); (self.grid_spacers[1])(input) }); @@ -1322,7 +1341,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 +1450,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()), @@ -1467,10 +1486,8 @@ impl<'a> Plot<'a> { 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 = axis_space.grid_input(self.grid_spacing.min); let steps = (self.grid_spacers[iaxis])(input); let clamp_range = self.clamp_grid.then(|| { @@ -1483,6 +1500,7 @@ impl<'a> Plot<'a> { tight_bounds }); + let axis_space = transform.axis_space(axis); for step in steps { let value_main = step.value; @@ -1507,7 +1525,9 @@ 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 = 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 @@ -1669,7 +1689,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 +1725,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 +1924,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 @@ -1992,8 +2012,16 @@ impl<'a> PlotUi<'a> { /// The pointer drag delta in plot coordinates. pub fn pointer_coordinate_drag_delta(&self) -> Vec2 { 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) + 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/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..b828e9d3 --- /dev/null +++ b/examples/log_axes/src/app.rs @@ -0,0 +1,71 @@ +use eframe::egui; +use eframe::egui::Response; +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, Eq)] +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: true, + } + } +} + +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() -> 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(&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 + }); + 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);