From 6cd852883d4e1201be512235d1bfd0f667657ce1 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 Especially the spacer generation is still kinda WIP; it is messy at best right now. Especially for zooming in, it currently only adds it on the lower bound due to the way the generator function works right now. I will address this in a follow up commit (or someone else will). --- demo/src/plot_demo.rs | 80 +++++++- 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 | 2 +- egui_plot/src/lib.rs | 120 ++++++++++-- egui_plot/src/plot_ui.rs | 9 +- egui_plot/src/transform.rs | 331 ++++++++++++++++++++++++++------ 8 files changed, 463 insertions(+), 90 deletions(-) diff --git a/demo/src/plot_demo.rs b/demo/src/plot_demo.rs index 498a8ff..7dc0041 100644 --- a/demo/src/plot_demo.rs +++ b/demo/src/plot_demo.rs @@ -7,8 +7,8 @@ use egui::{ 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, + 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,76 @@ impl LinkedAxesDemo { } } +// ---------------------------------------------------------------------------- +#[derive(PartialEq, serde::Deserialize, serde::Serialize)] +struct LogAxesDemo { + log_axes: Vec2b, +} + +impl Default for LogAxesDemo { + fn default() -> Self { + Self { + log_axes: Vec2b::new(false, true), + } + } +} + +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 just_changed = ui.checkbox(&mut self.log_axes.x, "Log X-Axis").clicked(); + ui.checkbox(&mut self.log_axes.y, "Log Y-Axis"); + Plot::new("log_demo") + .log_axes(self.log_axes) + .x_axis_label("x") + .y_axis_label("y") + .show_axes(Vec2b::new(true, true)) + .legend(Legend::default()) + .show(ui, |ui| { + if just_changed { + if self.log_axes.x { + 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 d121b46..83e6abe 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -2069,7 +2069,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/lib.rs b/egui_plot/src/lib.rs index df74ffa..7ea35fb 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -183,6 +183,7 @@ pub struct Plot<'a> { cursor_color: Option, show_background: bool, show_axes: Vec2b, + log_axes: Vec2b, show_grid: Vec2b, grid_spacing: Rangef, @@ -231,6 +232,7 @@ impl<'a> Plot<'a> { cursor_color: None, show_background: true, show_axes: true.into(), + log_axes: false.into(), show_grid: true.into(), grid_spacing: Rangef::new(8.0, 300.0), @@ -724,6 +726,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 log_axes(mut self, log_axes: Vec2b) -> Self { + self.log_axes = log_axes; + self + } + /// Interact with and add items to the plot and finally draw it. pub fn show( self, @@ -769,6 +780,7 @@ impl<'a> Plot<'a> { reset, show_background, show_axes, + log_axes, show_grid, grid_spacing, linked_axes, @@ -851,7 +863,12 @@ 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, + log_axes, + ), last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), @@ -1009,15 +1026,24 @@ impl<'a> Plot<'a> { } if auto_x { - bounds.add_relative_margin_x(margin_fraction); + if log_axes.x { + bounds.add_relative_margin_x_log(margin_fraction); + } else { + bounds.add_relative_margin_x(margin_fraction); + } } if auto_y { - bounds.add_relative_margin_y(margin_fraction); + if log_axes.y { + bounds.add_relative_margin_y_log(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, log_axes); // Enforce aspect ratio if let Some(data_aspect) = data_aspect { @@ -1045,8 +1071,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); } @@ -1134,9 +1165,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(); } } @@ -1150,7 +1186,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, + log_axes: log_axes.x, }; (grid_spacers[0])(input) }); @@ -1158,7 +1196,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, + log_axes: log_axes.y, }; (grid_spacers[1])(input) }); @@ -1198,6 +1238,7 @@ impl<'a> Plot<'a> { grid_spacers, sharp_grid_lines, clamp_grid, + log_axes, }; let (plot_cursors, hovered_plot_item) = prepared.ui(ui, &response); @@ -1407,6 +1448,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 log_axes: bool, } /// One mark (horizontal or vertical line) in the background grid of a plot. @@ -1440,18 +1485,51 @@ 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, - ]; + // now we should differentiate between log and non-log axes + // in non-log axes we simply subdivide + if !input.log_axes { + let step_sizes = [ + smallest_visible_unit, + smallest_visible_unit * log_base, + smallest_visible_unit * log_base * log_base, + ]; - generate_marks(step_sizes, input.bounds) + generate_marks(step_sizes, input.bounds) + } else { + gen_log_spaced_out_marks(log_base, smallest_visible_unit, input.bounds) + } }; Box::new(step_sizes) } +fn gen_log_spaced_out_marks(base: f64, min_size: f64, clamp_range: (f64, f64)) -> Vec { + let start = next_power(clamp_range.0, base) / base; + let mut marks = Vec::new(); + let ibase = base.ceil() as usize; + let mut i = 0; + loop { + i += 1; + let m = i % ibase; + let p = i / ibase; + let val = start + 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. @@ -1489,6 +1567,7 @@ struct PreparedPlot<'a> { sharp_grid_lines: bool, clamp_grid: bool, + log_axes: Vec2b, } impl<'a> PreparedPlot<'a> { @@ -1598,7 +1677,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], + log_axes: self.log_axes[iaxis], }; let steps = (grid_spacers[iaxis])(input); @@ -1636,7 +1717,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..1ccbfd6 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}; use super::PlotPoint; @@ -142,6 +142,16 @@ impl PlotBounds { } } + #[inline] + fn expand_x_log(&mut self, log_pad: f64) { + if log_pad.is_finite() { + let log_min = self.min[0].log10() - log_pad; + let log_max = self.max[0].log10() + log_pad; + self.min[0] = 10.0_f64.powf(log_min); + self.max[0] = 10.0_f64.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, log_pad: f64) { + if log_pad.is_finite() { + let log_min = self.min[1].log10() - log_pad; + let log_max = self.max[1].log10() + log_pad; + self.min[1] = 10.0_f64.powf(log_min); + self.max[1] = 10.0_f64.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, margin_fraction: Vec2) { + let log_width = self.range_x().end().log10() - self.range_x().start().log10(); + self.expand_x_log(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, margin_fraction: Vec2) { + let log_height = self.range_y().end().log10() - self.range_y().start().log10(); + self.expand_y_log(margin_fraction.y as f64 * log_height); + } + #[inline] pub fn range_x(&self) -> RangeInclusive { self.min[0]..=self.max[0] @@ -276,10 +307,13 @@ pub struct PlotTransform { /// Whether to always center the x-range or y-range of the bounds. centered: Vec2b, + + /// Whether to transform the coordinates logarithmically + log_coords: Vec2b, } impl PlotTransform { - pub fn new(frame: Rect, bounds: PlotBounds, center_axis: Vec2b) -> Self { + pub fn new(frame: Rect, bounds: PlotBounds, center_axis: Vec2b, log_coords: Vec2b) -> Self { debug_assert!( 0.0 <= frame.width() && 0.0 <= frame.height(), "Bad plot frame: {frame:?}" @@ -294,40 +328,73 @@ 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)); + if log_coords[0] { + new_bounds.set_x(&PlotBounds::from_min_max([1e-5, 1e-5], [1e5, 1e5])); + } else { + 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 log_coords[0] { + new_bounds.set_x(&PlotBounds::from_min_max([1e-5, 1e-5], [1e5, 1e5])); + } else { + 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)); + if log_coords[0] { + new_bounds.set_y(&PlotBounds::from_min_max([1e-5, 1e-5], [1e5, 1e5])); + } else { + 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 - }, - ); + if log_coords[0] { + new_bounds.set_y(&PlotBounds::from_min_max([1e-5, 1e-5], [1e5, 1e5])); + } else { + 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 { + // Scale axes so that the origin is in the center if we aren't log scaled + if center_axis.x && !log_coords[0] { new_bounds.make_x_symmetrical(); }; - if center_axis.y { + if center_axis.y && !log_coords[1] { new_bounds.make_y_symmetrical(); }; + // Make absolutely double sure we are not <= zero on any of the axis + if log_coords[0] { + 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 log_coords[1] { + 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(), "Bad final plot bounds: {new_bounds:?}" @@ -337,6 +404,7 @@ impl PlotTransform { frame, bounds: new_bounds, centered: center_axis, + log_coords, } } @@ -357,44 +425,105 @@ 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, + )); + + if self.log_coords.x { + let log_delta = movement_current.x.log10() - movement_start.x.log10(); + self.bounds.min[0] = 10.0_f64.powf(self.bounds().min[0].log10() + log_delta); + self.bounds.max[0] = 10.0_f64.powf(self.bounds().max[0].log10() + log_delta); + } else { + if self.centered.x { + delta_pos.0 = 0.; + } + delta_pos.0 *= self.dvalue_dpos()[0]; + self.bounds.translate_x(delta_pos.0); } - if self.centered.y { - delta_pos.1 = 0.; + if self.log_coords.y { + let log_delta = movement_current.y.log10() - movement_start.y.log10(); + self.bounds.min[1] = 10.0_f64.powf(self.bounds().min[1].log10() + log_delta); + self.bounds.max[1] = 10.0_f64.powf(self.bounds().max[1].log10() + log_delta); + } else { + if self.centered.y { + delta_pos.1 = 0.; + } + + delta_pos.1 *= self.dvalue_dpos()[1]; + self.bounds.translate_y(delta_pos.1); } - 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 self.log_coords.x { + new_bounds.min[0] = new_bounds.min[0].log10(); + new_bounds.max[0] = new_bounds.max[0].log10(); + center.x = center.x.log10(); + } + if self.log_coords.y { + new_bounds.min[1] = new_bounds.min[1].log10(); + new_bounds.max[1] = new_bounds.max[1].log10(); + center.y = center.y.log10(); + } + new_bounds.zoom(zoom_factor, center); + if self.log_coords.x { + new_bounds.min[0] = 10.0_f64.powf(new_bounds.min[0]); + new_bounds.max[0] = 10.0_f64.powf(new_bounds.max[0]); + } + if self.log_coords.y { + new_bounds.min[1] = 10.0_f64.powf(new_bounds.min[1]); + new_bounds.max[1] = 10.0_f64.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 self.log_coords.x { + remap( + value.log10(), + self.bounds.min[0].log10()..=self.bounds.max[0].log10(), + (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 self.log_coords.y { + remap( + value.log10(), + self.bounds.min[1].log10()..=self.bounds.max[1].log10(), + (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 +536,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 self.log_coords.x { + let log_range = + self.bounds.range_x().start().log10()..=self.bounds.range_x().end().log10(); + let remapped = remap( + pos.x as f64, + (self.frame.left() as f64)..=(self.frame.right() as f64), + log_range, + ); + 10.0_f64.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 self.log_coords.y { + let log_range = + self.bounds.range_y().start().log10()..=self.bounds.range_y().end().log10(); + let remapped = remap( + pos.y as f64, + (self.frame.bottom() as f64)..=(self.frame.top() as f64), + log_range, + ); + 10.0_f64.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 +585,80 @@ 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 self.log_coords.x && 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 self.log_coords.y && 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 self.log_coords.x && 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 self.log_coords.y && 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.