diff --git a/demo/src/plot_demo.rs b/demo/src/plot_demo.rs index 498a8ff..02c42f8 100644 --- a/demo/src/plot_demo.rs +++ b/demo/src/plot_demo.rs @@ -2,13 +2,13 @@ use std::f64::consts::TAU; use std::ops::RangeInclusive; use egui::{ - remap, vec2, Color32, ComboBox, NumExt, Pos2, Response, ScrollArea, Stroke, TextWrapMode, Vec2b, + remap, vec2, Color32, ComboBox, DragValue, NumExt, Pos2, Response, ScrollArea, Stroke, + TextWrapMode, Vec2b, }; - use egui_plot::{ - Arrows, AxisHints, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, - GridInput, GridMark, HLine, Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, PlotPoint, - PlotPoints, PlotResponse, Points, Polygon, Text, VLine, + Arrows, AxisHints, AxisTransform, AxisTransforms, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, + CoordinatesFormatter, Corner, GridInput, GridMark, HLine, Legend, Line, LineStyle, MarkerShape, + Plot, PlotBounds, PlotImage, PlotPoint, PlotPoints, PlotResponse, Points, Polygon, Text, VLine, }; // ---------------------------------------------------------------------------- @@ -23,6 +23,7 @@ enum Panel { Interaction, CustomAxes, LinkedAxes, + LogAxes, } impl Default for Panel { @@ -43,6 +44,7 @@ pub struct PlotDemo { interaction_demo: InteractionDemo, custom_axes_demo: CustomAxesDemo, linked_axes_demo: LinkedAxesDemo, + log_axes_demo: LogAxesDemo, open_panel: Panel, } @@ -74,6 +76,7 @@ impl PlotDemo { ui.selectable_value(&mut self.open_panel, Panel::Interaction, "Interaction"); ui.selectable_value(&mut self.open_panel, Panel::CustomAxes, "Custom Axes"); ui.selectable_value(&mut self.open_panel, Panel::LinkedAxes, "Linked Axes"); + ui.selectable_value(&mut self.open_panel, Panel::LogAxes, "Log Axes"); }); ui.separator(); @@ -102,6 +105,9 @@ impl PlotDemo { Panel::LinkedAxes => { self.linked_axes_demo.ui(ui); } + Panel::LogAxes => { + self.log_axes_demo.ui(ui); + } } } } @@ -691,6 +697,111 @@ impl LinkedAxesDemo { } } +// ---------------------------------------------------------------------------- +#[derive(PartialEq, serde::Deserialize, serde::Serialize, Default)] +struct LogAxesDemo { + axis_transforms: AxisTransforms, +} + +/// Helper function showing how to do arbitrary transform picking +fn transform_edit(id: &str, old_transform: AxisTransform, ui: &mut egui::Ui) -> AxisTransform { + ui.horizontal(|ui| { + ui.label(format!("Transform for {id}")); + if ui + .radio(matches!(old_transform, AxisTransform::Linear), "Linear") + .clicked() + { + return AxisTransform::Linear; + } + if ui + .radio( + matches!(old_transform, AxisTransform::Logarithmic(_)), + "Logarithmic", + ) + .clicked() + { + let reuse_base = if let AxisTransform::Logarithmic(base) = old_transform { + base + } else { + 10.0 + }; + return AxisTransform::Logarithmic(reuse_base); + } + + // no change, but perhaps additional things? + match old_transform { + // Nah? + AxisTransform::Logarithmic(mut base) => { + ui.label("Base:"); + ui.add(DragValue::new(&mut base).range(2.0..=100.0)); + AxisTransform::Logarithmic(base) + } + _ => old_transform, + } + }) + .inner +} + +impl LogAxesDemo { + fn line_exp() -> Line { + Line::new(PlotPoints::from_explicit_callback( + move |x| 10.0_f64.powf(x / 200.0), + 0.1..=1000.0, + 1000, + )) + .name("y = 10^(x/200)") + .color(Color32::RED) + } + + fn line_lin() -> Line { + Line::new(PlotPoints::from_explicit_callback( + move |x| -5.0 + x, + 0.1..=1000.0, + 1000, + )) + .name("y = -5 + x") + .color(Color32::GREEN) + } + + fn line_log() -> Line { + Line::new(PlotPoints::from_explicit_callback( + move |x| x.log10(), + 0.1..=1000.0, + 1000, + )) + .name("y = log10(x)") + .color(Color32::BLUE) + } + + fn ui(&mut self, ui: &mut egui::Ui) -> Response { + let old_transforms = self.axis_transforms; + self.axis_transforms.horizontal = + transform_edit("horizontal axis", self.axis_transforms.horizontal, ui); + self.axis_transforms.vertical = + transform_edit("vertical axis", self.axis_transforms.vertical, ui); + let just_changed = old_transforms != self.axis_transforms; + Plot::new("log_demo") + .axis_transforms(self.axis_transforms) + .x_axis_label("x") + .y_axis_label("y") + .show_axes(Vec2b::new(true, true)) + .legend(Legend::default()) + .show(ui, |ui| { + if just_changed { + if let AxisTransform::Logarithmic(_) = self.axis_transforms.horizontal { + ui.set_plot_bounds(PlotBounds::from_min_max([0.1, 0.1], [1e3, 1e4])); + } else { + ui.set_plot_bounds(PlotBounds::from_min_max([0.0, 0.0], [3.0, 1000.0])); + } + } + ui.line(Self::line_exp()); + ui.line(Self::line_lin()); + ui.line(Self::line_log()); + }) + .response + } +} + // ---------------------------------------------------------------------------- #[derive(Default, PartialEq, serde::Deserialize, serde::Serialize)] diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis.rs index c1e4392..c1b5558 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis.rs @@ -339,8 +339,11 @@ 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; + let spacing_in_points = transform.points_at_pos_range( + [step.value, step.value], + [step.step_size, step.step_size], + )[usize::from(axis)] + .abs(); if spacing_in_points <= label_spacing.min { // Labels are too close together - don't paint them. diff --git a/egui_plot/src/items/bar.rs b/egui_plot/src/items/bar.rs index 9b86b52..e907dab 100644 --- a/egui_plot/src/items/bar.rs +++ b/egui_plot/src/items/bar.rs @@ -186,7 +186,7 @@ impl RectElement for Bar { } fn default_values_format(&self, transform: &PlotTransform) -> String { - let scale = transform.dvalue_dpos(); + let scale = transform.smallest_distance_per_point(); let scale = match self.orientation { Orientation::Horizontal => scale[0], Orientation::Vertical => scale[1], diff --git a/egui_plot/src/items/box_elem.rs b/egui_plot/src/items/box_elem.rs index 9075514..234bfbd 100644 --- a/egui_plot/src/items/box_elem.rs +++ b/egui_plot/src/items/box_elem.rs @@ -271,7 +271,7 @@ impl RectElement for BoxElem { } fn default_values_format(&self, transform: &PlotTransform) -> String { - let scale = transform.dvalue_dpos(); + let scale = transform.smallest_distance_per_point(); let scale = match self.orientation { Orientation::Horizontal => scale[0], Orientation::Vertical => scale[1], diff --git a/egui_plot/src/items/mod.rs b/egui_plot/src/items/mod.rs index 111f3fc..61f7936 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -41,7 +41,7 @@ pub trait PlotItem { fn shapes(&self, ui: &Ui, transform: &PlotTransform, shapes: &mut Vec); /// For plot-items which are generated based on x values (plotting functions). - fn initialize(&mut self, x_range: RangeInclusive); + fn initialize(&mut self, x_range: RangeInclusive, log_base: Option); fn name(&self) -> &str; @@ -228,7 +228,7 @@ impl PlotItem for HLine { style.style_line(points, *stroke, *highlight, shapes); } - fn initialize(&mut self, _x_range: RangeInclusive) {} + fn initialize(&mut self, _x_range: RangeInclusive, _log_base: Option) {} fn name(&self) -> &str { &self.name @@ -371,7 +371,7 @@ impl PlotItem for VLine { style.style_line(points, *stroke, *highlight, shapes); } - fn initialize(&mut self, _x_range: RangeInclusive) {} + fn initialize(&mut self, _x_range: RangeInclusive, _log_base: Option) {} fn name(&self) -> &str { &self.name @@ -581,8 +581,8 @@ impl PlotItem for Line { style.style_line(values_tf, *stroke, *highlight, shapes); } - fn initialize(&mut self, x_range: RangeInclusive) { - self.series.generate_points(x_range); + fn initialize(&mut self, x_range: RangeInclusive, log_base: Option) { + self.series.generate_points(x_range, log_base); } fn name(&self) -> &str { @@ -737,8 +737,8 @@ impl PlotItem for Polygon { style.style_line(values_tf, *stroke, *highlight, shapes); } - fn initialize(&mut self, x_range: RangeInclusive) { - self.series.generate_points(x_range); + fn initialize(&mut self, x_range: RangeInclusive, log_base: Option) { + self.series.generate_points(x_range, log_base); } fn name(&self) -> &str { @@ -879,7 +879,7 @@ impl PlotItem for Text { } } - fn initialize(&mut self, _x_range: RangeInclusive) {} + fn initialize(&mut self, _x_range: RangeInclusive, _log_base: Option) {} fn name(&self) -> &str { self.name.as_str() @@ -1157,8 +1157,8 @@ impl PlotItem for Points { }); } - fn initialize(&mut self, x_range: RangeInclusive) { - self.series.generate_points(x_range); + fn initialize(&mut self, x_range: RangeInclusive, log_base: Option) { + self.series.generate_points(x_range, log_base); } fn name(&self) -> &str { @@ -1312,10 +1312,11 @@ impl PlotItem for Arrows { }); } - fn initialize(&mut self, _x_range: RangeInclusive) { + fn initialize(&mut self, _x_range: RangeInclusive, log_base: Option) { self.origins - .generate_points(f64::NEG_INFINITY..=f64::INFINITY); - self.tips.generate_points(f64::NEG_INFINITY..=f64::INFINITY); + .generate_points(f64::NEG_INFINITY..=f64::INFINITY, log_base); + self.tips + .generate_points(f64::NEG_INFINITY..=f64::INFINITY, log_base); } fn name(&self) -> &str { @@ -1504,7 +1505,7 @@ impl PlotItem for PlotImage { } } - fn initialize(&mut self, _x_range: RangeInclusive) {} + fn initialize(&mut self, _x_range: RangeInclusive, _log_base: Option) {} fn name(&self) -> &str { self.name.as_str() @@ -1699,7 +1700,7 @@ impl PlotItem for BarChart { } } - fn initialize(&mut self, _x_range: RangeInclusive) { + fn initialize(&mut self, _x_range: RangeInclusive, _log_base: Option) { // nothing to do } @@ -1873,7 +1874,7 @@ impl PlotItem for BoxPlot { } } - fn initialize(&mut self, _x_range: RangeInclusive) { + fn initialize(&mut self, _x_range: RangeInclusive, _log_base: Option) { // nothing to do } @@ -2059,7 +2060,7 @@ pub(super) fn rulers_at_value( }; let text = { - let scale = plot.transform.dvalue_dpos(); + let scale = plot.transform.smallest_distance_per_point(); let x_decimals = ((-scale[0].abs().log10()).ceil().at_least(0.0) as usize).clamp(1, 6); let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).clamp(1, 6); if let Some(custom_label) = label_formatter { diff --git a/egui_plot/src/items/values.rs b/egui_plot/src/items/values.rs index 1251396..3b36b00 100644 --- a/egui_plot/src/items/values.rs +++ b/egui_plot/src/items/values.rs @@ -276,15 +276,28 @@ impl PlotPoints { /// If initialized with a generator function, this will generate `n` evenly spaced points in the /// given range. - pub(super) fn generate_points(&mut self, x_range: RangeInclusive) { + pub(super) fn generate_points(&mut self, x_range: RangeInclusive, log_base: Option) { if let Self::Generator(generator) = self { *self = Self::range_intersection(&x_range, &generator.x_range) .map(|intersection| { - let increment = - (intersection.end() - intersection.start()) / (generator.points - 1) as f64; + let increment = match log_base { + Some(base) => { + (intersection.end().log(base) - intersection.start().log(base)) + / (generator.points - 1) as f64 + } + None => { + (intersection.end() - intersection.start()) + / (generator.points - 1) as f64 + } + }; (0..generator.points) .map(|i| { - let x = intersection.start() + i as f64 * increment; + let x = match log_base { + Some(base) => { + base.powf(intersection.start().log(base) + i as f64 * increment) + } + None => intersection.start() + i as f64 * increment, + }; let y = (generator.function)(x); [x, y] }) diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index d5b0fe5..26b0999 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -85,6 +85,64 @@ impl Default for CoordinatesFormatter<'_> { // ---------------------------------------------------------------------------- +/// Describes how an axis is scaled/displayed +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Debug, Copy, Clone, PartialEq, Default)] +pub enum AxisTransform { + /// An axis the way you would expect + #[default] + Linear, + + /// A logarithmic transform to the given base + Logarithmic(f64), +} + +impl AxisTransform { + /// Alternative method to get an `AxisTransform::Linear` + pub fn linear() -> Self { + Self::Linear + } + + /// Alternative method to get an `AxisTransform::Log(base)` + pub fn log(base: f64) -> Self { + Self::Logarithmic(base) + } + + /// Alternative method to get an `AxisTransform::Log(10.0)` + pub fn log10() -> Self { + Self::Logarithmic(10.0) + } +} + +/// Holds the transforms for both the horizontal and the vertical axis +/// +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Debug, Copy, Clone, PartialEq, Default)] +pub struct AxisTransforms { + pub horizontal: AxisTransform, + pub vertical: AxisTransform, +} + +impl AxisTransforms { + /// Create a new axis transform + pub fn new(horizontal: AxisTransform, vertical: AxisTransform) -> Self { + Self { + horizontal, + vertical, + } + } + + /// Get the transform for a specific `egui_plot::Axis` + pub fn for_axis(&self, axis: Axis) -> AxisTransform { + match axis { + Axis::X => self.horizontal, + Axis::Y => self.vertical, + } + } +} + +// ---------------------------------------------------------------------------- + /// Indicates a vertical or horizontal cursor line in plot coordinates. #[derive(Copy, Clone, PartialEq)] pub enum Cursor { @@ -183,6 +241,7 @@ pub struct Plot<'a> { cursor_color: Option, show_background: bool, show_axes: Vec2b, + axis_transforms: AxisTransforms, show_grid: Vec2b, grid_spacing: Rangef, @@ -230,6 +289,7 @@ impl<'a> Plot<'a> { cursor_color: None, show_background: true, show_axes: true.into(), + axis_transforms: AxisTransforms::default(), show_grid: true.into(), grid_spacing: Rangef::new(8.0, 300.0), @@ -722,6 +782,15 @@ impl<'a> Plot<'a> { self } + /// Set if the axis are to be scaled logarithmically + /// + /// This will limit plot bounds to values above 0, and any plot point below 0 will be converted to NaN + #[inline] + pub fn axis_transforms(mut self, axis_transforms: AxisTransforms) -> Self { + self.axis_transforms = axis_transforms; + self + } + /// Interact with and add items to the plot and finally draw it. pub fn show( self, @@ -767,6 +836,7 @@ impl<'a> Plot<'a> { reset, show_background, show_axes, + axis_transforms, show_grid, grid_spacing, linked_axes, @@ -848,7 +918,7 @@ impl<'a> Plot<'a> { auto_bounds: default_auto_bounds, hovered_legend_item: None, hidden_items: Default::default(), - transform: PlotTransform::new(plot_rect, min_auto_bounds, center_axis), + transform: PlotTransform::new(plot_rect, min_auto_bounds, center_axis, axis_transforms), last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), @@ -1006,15 +1076,23 @@ impl<'a> Plot<'a> { } if auto_x { - bounds.add_relative_margin_x(margin_fraction); + if let AxisTransform::Logarithmic(base) = axis_transforms.horizontal { + bounds.add_relative_margin_x_log(base, margin_fraction); + } else { + bounds.add_relative_margin_x(margin_fraction); + } } if auto_y { - bounds.add_relative_margin_y(margin_fraction); + if let AxisTransform::Logarithmic(base) = axis_transforms.vertical { + bounds.add_relative_margin_y_log(base, margin_fraction); + } else { + bounds.add_relative_margin_y(margin_fraction); + } } } - mem.transform = PlotTransform::new(plot_rect, bounds, center_axis); + mem.transform = PlotTransform::new(plot_rect, bounds, center_axis, axis_transforms); // Enforce aspect ratio if let Some(data_aspect) = data_aspect { @@ -1042,8 +1120,13 @@ impl<'a> Plot<'a> { if !allow_drag.y { delta.y = 0.0; } - mem.transform - .translate_bounds((delta.x as f64, delta.y as f64)); + let mouse_cursor = ui + .ctx() + .input(|i| i.pointer.hover_pos().unwrap_or(Pos2::new(0.0, 0.0))); + mem.transform.translate_bounds( + (mouse_cursor.x as f64, mouse_cursor.y as f64), + (delta.x as f64, delta.y as f64), + ); mem.auto_bounds = mem.auto_bounds.and(!allow_drag); } @@ -1131,9 +1214,14 @@ impl<'a> Plot<'a> { if !allow_scroll.y { scroll_delta.y = 0.0; } + let mouse_cursor = ui + .ctx() + .input(|i| i.pointer.hover_pos().unwrap_or(Pos2::new(0.0, 0.0))); if scroll_delta != Vec2::ZERO { - mem.transform - .translate_bounds((-scroll_delta.x as f64, -scroll_delta.y as f64)); + mem.transform.translate_bounds( + (mouse_cursor.x as f64, mouse_cursor.y as f64), + (-scroll_delta.x as f64, -scroll_delta.y as f64), + ); mem.auto_bounds = false.into(); } } @@ -1147,7 +1235,9 @@ impl<'a> Plot<'a> { let x_steps = Arc::new({ let input = GridInput { bounds: (bounds.min[0], bounds.max[0]), - base_step_size: mem.transform.dvalue_dpos()[0].abs() * grid_spacing.min as f64, + base_step_size: mem.transform.smallest_distance_per_point()[0].abs() + * grid_spacing.min as f64, + axis_transform: axis_transforms.horizontal, }; (grid_spacers[0])(input) }); @@ -1155,7 +1245,9 @@ impl<'a> Plot<'a> { let y_steps = Arc::new({ let input = GridInput { bounds: (bounds.min[1], bounds.max[1]), - base_step_size: mem.transform.dvalue_dpos()[1].abs() * grid_spacing.min as f64, + base_step_size: mem.transform.smallest_distance_per_point()[1].abs() + * grid_spacing.min as f64, + axis_transform: axis_transforms.vertical, }; (grid_spacers[1])(input) }); @@ -1176,7 +1268,12 @@ impl<'a> Plot<'a> { // Initialize values from functions. for item in &mut items { - item.initialize(mem.transform.bounds().range_x()); + let log_base = if let AxisTransform::Logarithmic(base) = axis_transforms.horizontal { + Some(base) + } else { + None + }; + item.initialize(mem.transform.bounds().range_x(), log_base); } let prepared = PreparedPlot { @@ -1194,6 +1291,7 @@ impl<'a> Plot<'a> { cursor_color, grid_spacers, clamp_grid, + axis_transforms, }; let (plot_cursors, hovered_plot_item) = prepared.ui(ui, &response); @@ -1403,6 +1501,10 @@ pub struct GridInput { /// /// Always positive. pub base_step_size: f64, + + /// Hint if the axis are logarithmic. Can be used to emit fewer grid lines + /// Have a look at the default grid spacer function for an example how this is used. + pub axis_transform: AxisTransform, } /// One mark (horizontal or vertical line) in the background grid of a plot. @@ -1436,18 +1538,50 @@ pub fn log_grid_spacer(log_base: i64) -> GridSpacer<'static> { // to the next-bigger power of base let smallest_visible_unit = next_power(input.base_step_size, log_base); - let step_sizes = [ - smallest_visible_unit, - smallest_visible_unit * log_base, - smallest_visible_unit * log_base * log_base, - ]; - - generate_marks(step_sizes, input.bounds) + // now we should differentiate between log and non-log axes + // in non-log axes we simply subdivide + if let AxisTransform::Logarithmic(base) = input.axis_transform { + gen_log_spaced_out_marks(base, smallest_visible_unit, input.bounds) + } else { + let step_sizes = [ + smallest_visible_unit, + smallest_visible_unit * log_base, + smallest_visible_unit * log_base * log_base, + ]; + generate_marks(step_sizes, input.bounds) + } }; Box::new(step_sizes) } +fn gen_log_spaced_out_marks(base: f64, min_size: f64, clamp_range: (f64, f64)) -> Vec { + let mut marks = Vec::new(); + let ibase = base.ceil() as usize; + // We need to offset i in such a way + let mut i = 0; + loop { + i += 1; + let m = i % ibase; + let p = i / ibase; + let val = min_size * (m as f64) * base.powf(p as f64); + if m != 0 { + if val < clamp_range.0 || val < min_size { + continue; + } else if val > clamp_range.1 { + break; + } + let mark = GridMark { + value: val, + step_size: base.powf(p as f64) * min_size, + }; + marks.push(mark); + } + } + marks.shrink_to_fit(); + marks +} + /// Splits the grid into uniform-sized spacings (e.g. 100, 25, 1). /// /// This function should return 3 positive step sizes, designating where the lines in the grid are drawn. @@ -1484,6 +1618,7 @@ struct PreparedPlot<'a> { cursor_color: Option, clamp_grid: bool, + axis_transforms: AxisTransforms, } impl<'a> PreparedPlot<'a> { @@ -1593,7 +1728,9 @@ impl<'a> PreparedPlot<'a> { let input = GridInput { bounds: (bounds.min[iaxis], bounds.max[iaxis]), - base_step_size: transform.dvalue_dpos()[iaxis].abs() * fade_range.min as f64, + base_step_size: transform + .value_for_pixel_offset_from_bounds([fade_range.min, fade_range.min])[iaxis], + axis_transform: self.axis_transforms.for_axis(axis), }; let steps = (grid_spacers[iaxis])(input); @@ -1631,7 +1768,10 @@ impl<'a> PreparedPlot<'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 + .points_for_decade([step.value, step.value], [step.step_size, step.step_size]) + [iaxis] + .abs(); if spacing_in_points <= fade_range.min { continue; // Too close together diff --git a/egui_plot/src/plot_ui.rs b/egui_plot/src/plot_ui.rs index fdd38c3..5708c0a 100644 --- a/egui_plot/src/plot_ui.rs +++ b/egui_plot/src/plot_ui.rs @@ -101,9 +101,14 @@ impl PlotUi { /// The pointer drag delta in plot coordinates. pub fn pointer_coordinate_drag_delta(&self) -> Vec2 { + let cursor = self + .ctx() + .input(|i| i.pointer.latest_pos().unwrap_or(Pos2::new(0.0, 0.0))); 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 drag_start = cursor - delta; + let pos_start = self.last_plot_transform.value_from_position(drag_start); + let pos_end = self.last_plot_transform.value_from_position(cursor); + pos_end.to_vec2() - pos_start.to_vec2() } /// Read the transform between plot coordinates and screen coordinates. diff --git a/egui_plot/src/transform.rs b/egui_plot/src/transform.rs index d75829b..0448e96 100644 --- a/egui_plot/src/transform.rs +++ b/egui_plot/src/transform.rs @@ -1,8 +1,8 @@ -use std::ops::RangeInclusive; +use std::ops::{Add, RangeInclusive}; use egui::{pos2, remap, Pos2, Rect, Vec2, Vec2b}; -use crate::Axis; +use crate::{next_power, Axis, AxisTransform, AxisTransforms}; use super::PlotPoint; @@ -142,6 +142,16 @@ impl PlotBounds { } } + #[inline] + fn expand_x_log(&mut self, base: f64, log_pad: f64) { + if log_pad.is_finite() { + let log_min = self.min[0].log(base) - log_pad; + let log_max = self.max[0].log(base) + log_pad; + self.min[0] = base.powf(log_min); + self.max[0] = base.powf(log_max); + } + } + #[inline] pub fn expand_y(&mut self, pad: f64) { if pad.is_finite() { @@ -151,6 +161,15 @@ impl PlotBounds { } } + #[inline] + fn expand_y_log(&mut self, base: f64, log_pad: f64) { + if log_pad.is_finite() { + let log_min = self.min[1].log(base) - log_pad; + let log_max = self.max[1].log(base) + log_pad; + self.min[1] = base.powf(log_min); + self.max[1] = base.powf(log_max); + } + } #[inline] pub fn merge_x(&mut self, other: &Self) { self.min[0] = self.min[0].min(other.min[0]); @@ -233,12 +252,24 @@ impl PlotBounds { self.expand_x(margin_fraction.x as f64 * width); } + #[inline] + pub fn add_relative_margin_x_log(&mut self, base: f64, margin_fraction: Vec2) { + let log_width = self.range_x().end().log(base) - self.range_x().start().log(base); + self.expand_x_log(base, margin_fraction.x as f64 * log_width); + } + #[inline] pub fn add_relative_margin_y(&mut self, margin_fraction: Vec2) { let height = self.height().max(0.0); self.expand_y(margin_fraction.y as f64 * height); } + #[inline] + pub fn add_relative_margin_y_log(&mut self, base: f64, margin_fraction: Vec2) { + let log_height = self.range_y().end().log(base) - self.range_y().start().log(base); + self.expand_y_log(base, margin_fraction.y as f64 * log_height); + } + #[inline] pub fn range_x(&self) -> RangeInclusive { self.min[0]..=self.max[0] @@ -276,10 +307,18 @@ pub struct PlotTransform { /// Whether to always center the x-range or y-range of the bounds. centered: Vec2b, + + /// Whether to transform the coordinates logarithmically + axis_transforms: AxisTransforms, } impl PlotTransform { - pub fn new(frame: Rect, bounds: PlotBounds, center_axis: Vec2b) -> Self { + pub fn new( + frame: Rect, + bounds: PlotBounds, + center_axis: Vec2b, + axis_transforms: AxisTransforms, + ) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), "Bad plot frame: {frame:?}" @@ -294,39 +333,84 @@ impl PlotTransform { // 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. + // + // For log axis, we need to check that we are above 0 for both axis, and instead of defaulting to +/- 1.0, we will default to 1e-5 to 1e5. + // Thin log axis also makes less sense, so we will also default there if !bounds.is_finite_x() { - new_bounds.set_x(&PlotBounds::new_symmetrical(1.0)); + match axis_transforms.horizontal { + AxisTransform::Linear => { + new_bounds.set_x(&PlotBounds::new_symmetrical(1.0)); + } + AxisTransform::Logarithmic(_) => { + new_bounds.set_x(&PlotBounds::from_min_max([1e1, 1e1], [1e5, 1e5])); + } + } } 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 - }, - ); + match axis_transforms.horizontal { + AxisTransform::Logarithmic(_) => { + new_bounds.set_x(&PlotBounds::from_min_max([1e1, 1e1], [1e5, 1e5])); + } + AxisTransform::Linear => { + 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)); + match axis_transforms.vertical { + AxisTransform::Linear => { + new_bounds.set_y(&PlotBounds::new_symmetrical(1.0)); + } + AxisTransform::Logarithmic(_) => { + new_bounds.set_y(&PlotBounds::from_min_max([1e1, 1e1], [1e5, 1e5])); + } + } } 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 - }, - ); + match axis_transforms.vertical { + AxisTransform::Linear => { + new_bounds.set_y_center_height( + bounds.center().y, + if bounds.is_valid_x() { + bounds.width() + } else { + 1.0 + }, + ); + } + AxisTransform::Logarithmic(_) => { + new_bounds.set_y(&PlotBounds::from_min_max([1e1, 1e1], [1e5, 1e5])); + } + } }; - // Scale axes so that the origin is in the center. - if center_axis.x { + // Scale axes so that the origin is in the center if we aren't log scaled + if center_axis.x && !matches!(axis_transforms.horizontal, AxisTransform::Logarithmic(_)) { new_bounds.make_x_symmetrical(); - }; - if center_axis.y { + } + if center_axis.y && !matches!(axis_transforms.vertical, AxisTransform::Logarithmic(_)) { new_bounds.make_y_symmetrical(); - }; + } + + // Make absolutely double sure we are not <= zero on any of the axis + if let AxisTransform::Logarithmic(_) = axis_transforms.horizontal { + if new_bounds.min[0] <= 0.0 { + new_bounds.min[0] = 1e-10; + } + new_bounds.max[0] = new_bounds.min[0].max(new_bounds.max[0]); + } + if let AxisTransform::Logarithmic(_) = axis_transforms.vertical { + if new_bounds.min[1] <= 0.0 { + new_bounds.min[1] = 1e-10; + } + new_bounds.max[1] = new_bounds.min[1].max(new_bounds.max[1]); + } debug_assert!( new_bounds.is_valid(), @@ -337,6 +421,7 @@ impl PlotTransform { frame, bounds: new_bounds, centered: center_axis, + axis_transforms, } } @@ -357,44 +442,111 @@ impl PlotTransform { self.bounds = bounds; } - pub fn translate_bounds(&mut self, mut delta_pos: (f64, f64)) { - if self.centered.x { - delta_pos.0 = 0.; + pub fn translate_bounds(&mut self, translate_origin: (f64, f64), mut delta_pos: (f64, f64)) { + let movement_start = self.value_from_position(Pos2::new( + translate_origin.0 as f32, + translate_origin.1 as f32, + )); + let movement_current = self.value_from_position(Pos2::new( + (translate_origin.0 + delta_pos.0) as f32, + (translate_origin.1 + delta_pos.1) as f32, + )); + + match self.axis_transforms.horizontal { + AxisTransform::Linear => { + if self.centered.x { + delta_pos.0 = 0.; + } + delta_pos.0 *= self.dvalue_dpos()[0]; + self.bounds.translate_x(delta_pos.0); + } + AxisTransform::Logarithmic(base) => { + let log_delta = movement_current.x.log(base) - movement_start.x.log(base); + self.bounds.min[0] = base.powf(self.bounds().min[0].log(base) + log_delta); + self.bounds.max[0] = base.powf(self.bounds().max[0].log(base) + log_delta); + } } - if self.centered.y { - delta_pos.1 = 0.; + match self.axis_transforms.vertical { + AxisTransform::Linear => { + if self.centered.y { + delta_pos.1 = 0.; + } + + delta_pos.1 *= self.dvalue_dpos()[1]; + self.bounds.translate_y(delta_pos.1); + } + AxisTransform::Logarithmic(base) => { + let log_delta = movement_current.y.log(base) - movement_start.y.log(base); + self.bounds.min[1] = base.powf(self.bounds().min[1].log(base) + log_delta); + self.bounds.max[1] = base.powf(self.bounds().max[1].log(base) + log_delta); + } } - 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 center = self.value_from_position(center); let mut new_bounds = self.bounds; + if let AxisTransform::Logarithmic(base) = self.axis_transforms.horizontal { + new_bounds.min[0] = new_bounds.min[0].log(base); + new_bounds.max[0] = new_bounds.max[0].log(base); + center.x = center.x.log(base); + } + if let AxisTransform::Logarithmic(base) = self.axis_transforms.vertical { + new_bounds.min[1] = new_bounds.min[1].log(base); + new_bounds.max[1] = new_bounds.max[1].log(base); + center.y = center.y.log(base); + } + new_bounds.zoom(zoom_factor, center); + if let AxisTransform::Logarithmic(base) = self.axis_transforms.horizontal { + new_bounds.min[0] = base.powf(new_bounds.min[0]); + new_bounds.max[0] = base.powf(new_bounds.max[0]); + } + if let AxisTransform::Logarithmic(base) = self.axis_transforms.vertical { + new_bounds.min[1] = base.powf(new_bounds.min[1]); + new_bounds.max[1] = base.powf(new_bounds.max[1]); + } + 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], - (self.frame.left() as f64)..=(self.frame.right() as f64), - ) as f32 + let val = if let AxisTransform::Logarithmic(base) = self.axis_transforms.horizontal { + remap( + value.log(base), + self.bounds.min[0].log(base)..=self.bounds.max[0].log(base), + (self.frame.left() as f64)..=(self.frame.right() as f64), + ) + } else { + remap( + value, + self.bounds.min[0]..=self.bounds.max[0], + (self.frame.left() as f64)..=(self.frame.right() as f64), + ) + }; + val as f32 } pub fn position_from_point_y(&self, value: f64) -> f32 { - remap( - value, - self.bounds.min[1]..=self.bounds.max[1], - (self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis! - ) as f32 + let val = if let AxisTransform::Logarithmic(base) = self.axis_transforms.vertical { + remap( + value.log(base), + self.bounds.min[1].log(base)..=self.bounds.max[1].log(base), + (self.frame.bottom() as f64)..=(self.frame.top() as f64), + ) + } else { + remap( + value, + self.bounds.min[1]..=self.bounds.max[1], + (self.frame.bottom() as f64)..=(self.frame.top() as f64), + ) + }; + val as f32 } /// Screen/ui position from point on plot. @@ -407,16 +559,38 @@ impl PlotTransform { /// Plot point from screen/ui position. pub fn value_from_position(&self, pos: Pos2) -> PlotPoint { - let x = remap( - pos.x as f64, - (self.frame.left() as f64)..=(self.frame.right() as f64), - self.bounds.range_x(), - ); - let y = remap( - pos.y as f64, - (self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis! - self.bounds.range_y(), - ); + let x = if let AxisTransform::Logarithmic(base) = self.axis_transforms.horizontal { + let log_range = + self.bounds.range_x().start().log(base)..=self.bounds.range_x().end().log(base); + let remapped = remap( + pos.x as f64, + (self.frame.left() as f64)..=(self.frame.right() as f64), + log_range, + ); + base.powf(remapped) + } else { + remap( + pos.x as f64, + (self.frame.left() as f64)..=(self.frame.right() as f64), + self.bounds.range_x(), + ) + }; + let y = if let AxisTransform::Logarithmic(base) = self.axis_transforms.vertical { + let log_range = + self.bounds.range_y().start().log(base)..=self.bounds.range_y().end().log(base); + let remapped = remap( + pos.y as f64, + (self.frame.bottom() as f64)..=(self.frame.top() as f64), + log_range, + ); + base.powf(remapped) + } else { + remap( + pos.y as f64, + (self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis! + self.bounds.range_y(), + ) + }; PlotPoint::new(x, y) } @@ -434,26 +608,92 @@ impl PlotTransform { 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 { + /// delta position / delta value = how many ui points per step in the X axis in "plot space" for linear transformations + fn dpos_dvalue_x(&self) -> f64 { 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 { + /// delta position / delta value = how many ui points per step in the Y axis in "plot space" for linear transformations + fn dpos_dvalue_y(&self) -> f64 { -self.frame.height() as f64 / self.bounds.height() // negated y axis! } - /// 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? + /// delta value / delta position = how much ground do we cover in "plot space" per ui point for linear transformations? pub fn dvalue_dpos(&self) -> [f64; 2] { [1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()] } + /// Depending on log or linear plots, pixel spacing is not linear + /// This function, for a given distance, returns the maximum number of pixels needed if to display it + /// For linear transformations that is just `transform.dpos_dvalue()*step_size`, but for logarithmic it's a bit more bloated to calculate + pub fn points_for_decade(&self, pos: [f64; 2], offset: [f64; 2]) -> [f32; 2] { + let x = if matches!( + self.axis_transforms.horizontal, + AxisTransform::Logarithmic(_) + ) && pos[0] != 0.0 + { + let dec_start = next_power(pos[0], 10.0) / 10.0; + let dec_end = dec_start + offset[0]; + self.position_from_point_x(dec_end) - self.position_from_point_x(dec_start) + } else { + (offset[0] * self.dpos_dvalue_x()) as f32 + }; + + let y = if matches!(self.axis_transforms.vertical, AxisTransform::Logarithmic(_)) + && pos[1] != 0.0 + { + let dec_start = next_power(pos[1] + offset[1] / 10.0, 10.0) / 10.0; + let dec_end = dec_start + offset[1]; + self.position_from_point_y(dec_end) - self.position_from_point_y(dec_start) + } else { + (offset[1] * self.dpos_dvalue_y()) as f32 + }; + + [x, y] + } + + /// Same as points for decade, but does not jump down a decade + pub fn points_at_pos_range(&self, pos: [f64; 2], offset: [f64; 2]) -> [f32; 2] { + let x = if matches!( + self.axis_transforms.horizontal, + AxisTransform::Logarithmic(_) + ) && pos[0] != 0.0 + { + let dec_start = pos[0]; + let dec_end = dec_start + offset[0]; + self.position_from_point_x(dec_end) - self.position_from_point_x(dec_start) + } else { + (offset[0] * self.dpos_dvalue_x()) as f32 + }; + + let y = if matches!(self.axis_transforms.vertical, AxisTransform::Logarithmic(_)) + && pos[1] != 0.0 + { + let dec_start = pos[1]; + let dec_end = dec_start + offset[1]; + self.position_from_point_y(dec_end) - self.position_from_point_y(dec_start) + } else { + (offset[1] * self.dpos_dvalue_y()) as f32 + }; + + [x, y] + } + + /// what is the smallest distance covered by a single pixel in plot space + pub fn smallest_distance_per_point(&self) -> [f64; 2] { + let a = self.value_from_position(self.frame.left_bottom()); + let b = self.value_from_position(self.frame.left_bottom().add(Vec2::new(1.0, -1.0))); + [(b.x - a.x).abs(), (b.y - a.y).abs()] + } + + /// helper for grid and axis ticks: from the lower bound, how much does the given offset, in plot splace, cover in pixels? + pub fn value_for_pixel_offset_from_bounds(&self, offset: [f32; 2]) -> [f64; 2] { + let lower = self.value_from_position(self.frame.left_bottom()); + let upper = + self.value_from_position(self.frame.left_bottom() + Vec2::new(offset[0], -offset[1])); + [upper.x - lower.x, upper.y - lower.y] + } + /// scale.x/scale.y ratio. /// /// If 1.0, it means the scale factor is the same in both axes.