From 1f4220a579f5c2cfd95d389733d5d8a798c58c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20Kie=C3=9Fling?= Date: Thu, 18 Jul 2024 11:12:48 +0200 Subject: [PATCH] Add logarithmic plot axes This commit is an initial implementation for adding logarithmic plotting axis. This very much needs more testing! The basic idea is, that everything stays the same, but PlotTransform does the much needed coordinate transformation for us. That is, unfortunatley not all of the story. * In a lot of places, we need estimates of "how many pixels does 1 plot space unit take" and the likes, either for overdraw reduction, or generally to size things. PlotTransform has been modifed for that for now, so this should work. * While the normal grid spacer renders just fine, it will also casually try to generate 100s of thousands of lines for a bigger range log plot. So GridInput has been made aware if there is a log axis present. The default spacer has also been modified to work initially. * All of the PlotBound transformations within PlotTransform need to be aware and handle the log scaling properly. This is done and works well, but its a bit.. icky, for lack of a better word. If someone has a better idea how to handle this, be my guest :D * PlotPoint generation from generator functions has to become aware of logarithmic plotting, otherwise the resolution of the plotted points will suffer. Especially the spacer generation is still kinda WIP; it is messy at best right now. Especially for zooming in, it currently only adds lines on the lower bound due to the way the generator function works right now. I will address this in a follow up commit/--amend (or someone else will). --- demo/src/plot_demo.rs | 122 ++++++- demo/tests/snapshots/demos/Charts.png | 4 +- demo/tests/snapshots/demos/Custom Axes.png | 4 +- demo/tests/snapshots/demos/Interaction.png | 4 +- demo/tests/snapshots/demos/Items.png | 4 +- demo/tests/snapshots/demos/Legend.png | 4 +- demo/tests/snapshots/demos/Lines.png | 4 +- demo/tests/snapshots/demos/Linked Axes.png | 4 +- demo/tests/snapshots/demos/Log Axes.png | 3 + demo/tests/snapshots/demos/Markers.png | 4 +- demo/tests/snapshots/light_mode.png | 4 +- demo/tests/snapshots/scale_0.50.png | 4 +- demo/tests/snapshots/scale_1.00.png | 4 +- demo/tests/snapshots/scale_1.39.png | 4 +- demo/tests/snapshots/scale_2.00.png | 4 +- egui_plot/src/axis.rs | 7 +- egui_plot/src/items/bar.rs | 2 +- egui_plot/src/items/box_elem.rs | 2 +- egui_plot/src/items/mod.rs | 35 +- egui_plot/src/items/values.rs | 21 +- egui_plot/src/lib.rs | 180 ++++++++-- egui_plot/src/plot_ui.rs | 9 +- egui_plot/src/transform.rs | 370 +++++++++++++++++---- 23 files changed, 659 insertions(+), 144 deletions(-) create mode 100644 demo/tests/snapshots/demos/Log Axes.png diff --git a/demo/src/plot_demo.rs b/demo/src/plot_demo.rs index 7acbc54..853899c 100644 --- a/demo/src/plot_demo.rs +++ b/demo/src/plot_demo.rs @@ -2,14 +2,13 @@ use std::f64::consts::TAU; use std::ops::RangeInclusive; use egui::{ - remap, vec2, Color32, ComboBox, NumExt, Pos2, Response, ScrollArea, Stroke, TextWrapMode, - Vec2b, WidgetInfo, WidgetType, + remap, vec2, Color32, ComboBox, DragValue, NumExt, Pos2, Response, ScrollArea, Stroke, + TextWrapMode, Vec2b, WidgetInfo, WidgetType, }; - 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, }; // ---------------------------------------------------------------------------- @@ -24,6 +23,7 @@ enum Panel { Interaction, CustomAxes, LinkedAxes, + LogAxes, } impl Default for Panel { @@ -44,6 +44,7 @@ pub struct PlotDemo { interaction_demo: InteractionDemo, custom_axes_demo: CustomAxesDemo, linked_axes_demo: LinkedAxesDemo, + log_axes_demo: LogAxesDemo, open_panel: Panel, } @@ -88,6 +89,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(); @@ -117,6 +119,9 @@ impl PlotDemo { Panel::LinkedAxes => { self.linked_axes_demo.ui(ui); } + Panel::LogAxes => { + self.log_axes_demo.ui(ui); + } } } } @@ -706,6 +711,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) + } + AxisTransform::Linear => old_transform, + } + }) + .inner +} + +impl LogAxesDemo { + fn line_exp<'a>() -> Line<'a> { + 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<'a>() -> Line<'a> { + 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<'a>() -> Line<'a> { + 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/demo/tests/snapshots/demos/Charts.png b/demo/tests/snapshots/demos/Charts.png index 625d7d7..007f34d 100644 --- a/demo/tests/snapshots/demos/Charts.png +++ b/demo/tests/snapshots/demos/Charts.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f2e6e4b9e9aec7c2f02faff3071c6d20c70b5122d02cf2549b3aa9d937b33f9 -size 81432 +oid sha256:57ce7233dad057e46658712b54532a17a71dcb3a7116db133bb005c3091b5d52 +size 82590 diff --git a/demo/tests/snapshots/demos/Custom Axes.png b/demo/tests/snapshots/demos/Custom Axes.png index 26fa30d..47cdb54 100644 --- a/demo/tests/snapshots/demos/Custom Axes.png +++ b/demo/tests/snapshots/demos/Custom Axes.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:491553ab1c2628d4f28af53d1b242db787e2c4a662aa44ed45ee17a190aae598 -size 69851 +oid sha256:1057f68ccb796a8e1eb42431c5210cffdca7396fb01914db8061e93d941e18b1 +size 70940 diff --git a/demo/tests/snapshots/demos/Interaction.png b/demo/tests/snapshots/demos/Interaction.png index 763cf1a..f3e182e 100644 --- a/demo/tests/snapshots/demos/Interaction.png +++ b/demo/tests/snapshots/demos/Interaction.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d731cdff796638a19f9123e78be4bc0194bc29b74470b52fca8de65da189eb6b -size 74274 +oid sha256:9f42027b91855af33c3b1b4a489da57c25247f6bd7c5615feb1febe166d3128a +size 75395 diff --git a/demo/tests/snapshots/demos/Items.png b/demo/tests/snapshots/demos/Items.png index 5fd96f7..4c8cc7c 100644 --- a/demo/tests/snapshots/demos/Items.png +++ b/demo/tests/snapshots/demos/Items.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdac172ced399b2ee7baf4ddd6cead4c629452440f8f47991eaf486851b153fd -size 103878 +oid sha256:9a7a9e46c546b21d77ea4bd5da63552291650f49c9f86683959684fcc75e13e4 +size 104928 diff --git a/demo/tests/snapshots/demos/Legend.png b/demo/tests/snapshots/demos/Legend.png index 6f3b5be..d762148 100644 --- a/demo/tests/snapshots/demos/Legend.png +++ b/demo/tests/snapshots/demos/Legend.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2ea3e5de71aac86e3e96a46e8898f33c97658973bea821c353ff4f816fba13d -size 131204 +oid sha256:87b7a3f745f459eba63dd4a350df5dd17f6778893458385fce6275081a4fd57e +size 132377 diff --git a/demo/tests/snapshots/demos/Lines.png b/demo/tests/snapshots/demos/Lines.png index 3376a19..853df74 100644 --- a/demo/tests/snapshots/demos/Lines.png +++ b/demo/tests/snapshots/demos/Lines.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79a91cc56eac36bbf51c378eaa002d97dadb20ebd7d98ef1bcb81ffb338059d1 -size 114835 +oid sha256:c9cb2836a256bd612ae767122c2de780d3023e76d61741b873c00ece9bd5140b +size 116007 diff --git a/demo/tests/snapshots/demos/Linked Axes.png b/demo/tests/snapshots/demos/Linked Axes.png index 5b80c3e..d73867a 100644 --- a/demo/tests/snapshots/demos/Linked Axes.png +++ b/demo/tests/snapshots/demos/Linked Axes.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a9eca857d38b30c0ecc02727c5ff6c4b962f6648c25426d3a9d52040d195048 -size 81395 +oid sha256:5dd092f6ddb8757a5aa4216185f348d825726277293aedf26e3322922cad73e7 +size 82605 diff --git a/demo/tests/snapshots/demos/Log Axes.png b/demo/tests/snapshots/demos/Log Axes.png new file mode 100644 index 0000000..5edf20d --- /dev/null +++ b/demo/tests/snapshots/demos/Log Axes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c210ffdad95676c6ca7c9bca25014ab3f91fb1620a81a2e249a69af79f92024 +size 69302 diff --git a/demo/tests/snapshots/demos/Markers.png b/demo/tests/snapshots/demos/Markers.png index 4e30ca1..09fe552 100644 --- a/demo/tests/snapshots/demos/Markers.png +++ b/demo/tests/snapshots/demos/Markers.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29e0326610430e74ef775053df03edd1f114b3865070f5f6039a91389e6753fe -size 99436 +oid sha256:aac62980f86277a4f75d10bcec766f78c6ec995eb83fd3520fd4ff9a400b27ba +size 100700 diff --git a/demo/tests/snapshots/light_mode.png b/demo/tests/snapshots/light_mode.png index 4db2d18..86fdb00 100644 --- a/demo/tests/snapshots/light_mode.png +++ b/demo/tests/snapshots/light_mode.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3c3102927c0e40bae43f06087029cf54cbcc454f50584444d71bf4f288ce46b -size 111838 +oid sha256:240e236e15177332f9661d9e943027658a1c0768859b756c7fbb34aebcf25cd9 +size 113015 diff --git a/demo/tests/snapshots/scale_0.50.png b/demo/tests/snapshots/scale_0.50.png index cc26637..7bb075c 100644 --- a/demo/tests/snapshots/scale_0.50.png +++ b/demo/tests/snapshots/scale_0.50.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c78270b0cff295116a26bb8353b1d8eec745a6f1b2d59d5fa0282b90d40185f -size 58419 +oid sha256:e4a826aeba5d5b1845d9c7d2a55b0475adf195cf8d395b1186dbcd265b350695 +size 58786 diff --git a/demo/tests/snapshots/scale_1.00.png b/demo/tests/snapshots/scale_1.00.png index 3376a19..853df74 100644 --- a/demo/tests/snapshots/scale_1.00.png +++ b/demo/tests/snapshots/scale_1.00.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79a91cc56eac36bbf51c378eaa002d97dadb20ebd7d98ef1bcb81ffb338059d1 -size 114835 +oid sha256:c9cb2836a256bd612ae767122c2de780d3023e76d61741b873c00ece9bd5140b +size 116007 diff --git a/demo/tests/snapshots/scale_1.39.png b/demo/tests/snapshots/scale_1.39.png index 7b1e51a..fe15457 100644 --- a/demo/tests/snapshots/scale_1.39.png +++ b/demo/tests/snapshots/scale_1.39.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5ff6c81b29149f9de2306a9a8ec44424eec2af1d0ba6e94c559cf847fcb9fa6 -size 218520 +oid sha256:32f07a8ba3ea8281f994f6763866e6c477aa850c321b82929b796a2881b05392 +size 219909 diff --git a/demo/tests/snapshots/scale_2.00.png b/demo/tests/snapshots/scale_2.00.png index db28f49..58257b2 100644 --- a/demo/tests/snapshots/scale_2.00.png +++ b/demo/tests/snapshots/scale_2.00.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:21f5f0ec6bc97698d1d36ce2ceba8d53c5be76cd03d4a8dbb25b7ae8c813196a -size 244000 +oid sha256:e6685537306f92182227d041d90547c1fda799bcfa5a2b0921c37ba485c98b6e +size 247121 diff --git a/egui_plot/src/axis.rs b/egui_plot/src/axis.rs index d1a47f9..08d82fe 100644 --- a/egui_plot/src/axis.rs +++ b/egui_plot/src/axis.rs @@ -321,8 +321,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 2a2b5fe..792fc4e 100644 --- a/egui_plot/src/items/bar.rs +++ b/egui_plot/src/items/bar.rs @@ -192,7 +192,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 010d8f9..a56efc2 100644 --- a/egui_plot/src/items/box_elem.rs +++ b/egui_plot/src/items/box_elem.rs @@ -277,7 +277,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 68d83a7..b424ddc 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<'a> PlotItem for Line<'a> { 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<'a> PlotItem for Polygon<'a> { 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 { @@ -880,7 +880,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() @@ -1158,8 +1158,8 @@ impl<'a> PlotItem for Points<'a> { }); } - 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 { @@ -1313,10 +1313,11 @@ impl<'a> PlotItem for Arrows<'a> { }); } - 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 { @@ -1505,7 +1506,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() @@ -1700,7 +1701,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 } @@ -1874,7 +1875,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 } @@ -2060,7 +2061,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 51f66da..a2d63ad 100644 --- a/egui_plot/src/items/values.rs +++ b/egui_plot/src/items/values.rs @@ -288,15 +288,28 @@ impl<'a> PlotPoints<'a> { /// 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 c8bf683..01d0169 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -84,6 +84,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 { @@ -182,6 +240,7 @@ pub struct Plot<'a> { cursor_color: Option, show_background: bool, show_axes: Vec2b, + axis_transforms: AxisTransforms, show_grid: Vec2b, grid_spacing: Rangef, @@ -229,6 +288,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), @@ -721,6 +781,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<'b, R>( self, @@ -766,6 +835,7 @@ impl<'a> Plot<'a> { reset, show_background, show_axes, + axis_transforms, show_grid, grid_spacing, linked_axes, @@ -847,7 +917,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); } @@ -1133,9 +1216,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(); } } @@ -1149,7 +1237,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) }); @@ -1157,7 +1247,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) }); @@ -1178,7 +1270,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 { @@ -1196,6 +1293,7 @@ impl<'a> Plot<'a> { cursor_color, grid_spacers, clamp_grid, + axis_transforms, }; let (plot_cursors, hovered_plot_item) = prepared.ui(ui, &response); @@ -1406,6 +1504,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. @@ -1439,18 +1541,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. @@ -1487,6 +1621,7 @@ struct PreparedPlot<'a> { cursor_color: Option, clamp_grid: bool, + axis_transforms: AxisTransforms, } impl<'a> PreparedPlot<'a> { @@ -1596,7 +1731,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); @@ -1634,7 +1771,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 dfa05d7..670d735 100644 --- a/egui_plot/src/plot_ui.rs +++ b/egui_plot/src/plot_ui.rs @@ -101,9 +101,14 @@ impl<'a> PlotUi<'a> { /// 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 7f2b2dd..91775a4 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: impl Into) -> Self { + pub fn new( + frame: Rect, + bounds: PlotBounds, + center_axis: impl Into, + axis_transforms: AxisTransforms, + ) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), "Bad plot frame: {frame:?}" @@ -295,39 +334,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(), @@ -338,6 +422,7 @@ impl PlotTransform { frame, bounds: new_bounds, centered: center_axis, + axis_transforms, } } @@ -358,44 +443,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. @@ -408,16 +560,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) } @@ -435,26 +609,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.