From b6fd1cfc99879f5a224c7c6c83626e1f1ff4ced3 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 30 Jun 2024 14:20:41 +0200 Subject: [PATCH] egui_plot: Improve default formatter of tick-marks (#4738) The default `Plot` formatter now picks precision intelligently based on zoom level. The width of the Y axis are is now much smaller by default, and expands as needed. Also deprecates `Plot::y_axis_with`; replaced with `y_axis_min_width`. --- Cargo.lock | 1 + crates/egui_demo_lib/src/demo/plot_demo.rs | 11 +-- crates/egui_plot/Cargo.toml | 1 + crates/egui_plot/src/axis.rs | 71 +++++++--------- crates/egui_plot/src/lib.rs | 99 ++++++++++++++++++---- crates/emath/src/lib.rs | 5 +- 6 files changed, 121 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dad7a2383..217d7bda8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1355,6 +1355,7 @@ dependencies = [ "ahash", "document-features", "egui", + "emath", "serde", ] diff --git a/crates/egui_demo_lib/src/demo/plot_demo.rs b/crates/egui_demo_lib/src/demo/plot_demo.rs index 25e942925..d9046a0b8 100644 --- a/crates/egui_demo_lib/src/demo/plot_demo.rs +++ b/crates/egui_demo_lib/src/demo/plot_demo.rs @@ -277,7 +277,6 @@ impl LineDemo { }; let mut plot = Plot::new("lines_demo") .legend(Legend::default()) - .y_axis_width(2) .show_axes(self.show_axes) .show_grid(self.show_grid); if self.square { @@ -437,7 +436,6 @@ impl LegendDemo { ui.end_row(); }); let legend_plot = Plot::new("legend_demo") - .y_axis_width(2) .legend(config.clone()) .data_aspect(1.0); legend_plot @@ -530,7 +528,7 @@ impl CustomAxesDemo { 100.0 * y } - let time_formatter = |mark: GridMark, _digits, _range: &RangeInclusive| { + let time_formatter = |mark: GridMark, _range: &RangeInclusive| { let minutes = mark.value; if minutes < 0.0 || 5.0 * MINS_PER_DAY <= minutes { // No labels outside value bounds @@ -544,7 +542,7 @@ impl CustomAxesDemo { } }; - let percentage_formatter = |mark: GridMark, _digits, _range: &RangeInclusive| { + let percentage_formatter = |mark: GridMark, _range: &RangeInclusive| { let percent = 100.0 * mark.value; if is_approx_zero(percent) { String::new() // skip zero @@ -575,8 +573,7 @@ impl CustomAxesDemo { let y_axes = vec![ AxisHints::new_y() .label("Percent") - .formatter(percentage_formatter) - .max_digits(4), + .formatter(percentage_formatter), AxisHints::new_y() .label("Absolute") .placement(egui_plot::HPlacement::Right), @@ -673,7 +670,6 @@ impl LinkedAxesDemo { .data_aspect(2.0) .width(150.0) .height(250.0) - .y_axis_width(2) .y_axis_label("y") .y_axis_position(egui_plot::HPlacement::Right) .link_axis(link_group_id, self.link_x, self.link_y) @@ -962,7 +958,6 @@ impl ChartsDemo { Plot::new("Normal Distribution Demo") .legend(Legend::default()) .clamp_grid(true) - .y_axis_width(2) .allow_zoom(self.allow_zoom) .allow_drag(self.allow_drag) .allow_scroll(self.allow_scroll) diff --git a/crates/egui_plot/Cargo.toml b/crates/egui_plot/Cargo.toml index 17e458522..9717fd052 100644 --- a/crates/egui_plot/Cargo.toml +++ b/crates/egui_plot/Cargo.toml @@ -36,6 +36,7 @@ serde = ["dep:serde", "egui/serde"] [dependencies] egui = { workspace = true, default-features = false } +emath = { workspace = true, default-features = false } ahash.workspace = true diff --git a/crates/egui_plot/src/axis.rs b/crates/egui_plot/src/axis.rs index 3827307a2..244691f38 100644 --- a/crates/egui_plot/src/axis.rs +++ b/crates/egui_plot/src/axis.rs @@ -1,14 +1,14 @@ use std::{fmt::Debug, ops::RangeInclusive, sync::Arc}; use egui::{ - emath::{remap_clamp, round_to_decimals, Rot2}, + emath::{remap_clamp, Rot2}, epaint::TextShape, Pos2, Rangef, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, WidgetText, }; use super::{transform::PlotTransform, GridMark}; -pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, usize, &RangeInclusive) -> String + 'a; +pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, &RangeInclusive) -> String + 'a; /// X or Y axis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -101,7 +101,7 @@ impl From for VPlacement { pub struct AxisHints<'a> { pub(super) label: WidgetText, pub(super) formatter: Arc>, - pub(super) digits: usize, + pub(super) min_thickness: f32, pub(super) placement: Placement, pub(super) label_spacing: Rangef, } @@ -124,12 +124,11 @@ impl<'a> AxisHints<'a> { /// /// `label` is empty. /// `formatter` is default float to string formatter. - /// maximum `digits` on tick label is 5. pub fn new(axis: Axis) -> Self { Self { label: Default::default(), formatter: Arc::new(Self::default_formatter), - digits: 5, + min_thickness: 14.0, placement: Placement::LeftBottom, label_spacing: match axis { Axis::X => Rangef::new(60.0, 80.0), // labels can get pretty wide @@ -141,32 +140,20 @@ impl<'a> AxisHints<'a> { /// Specify custom formatter for ticks. /// /// The first parameter of `formatter` is the raw tick value as `f64`. - /// The second parameter is the maximum number of characters that fit into y-labels. /// The second parameter of `formatter` is the currently shown range on this axis. pub fn formatter( mut self, - fmt: impl Fn(GridMark, usize, &RangeInclusive) -> String + 'a, + fmt: impl Fn(GridMark, &RangeInclusive) -> String + 'a, ) -> Self { self.formatter = Arc::new(fmt); self } - fn default_formatter( - mark: GridMark, - max_digits: usize, - _range: &RangeInclusive, - ) -> String { - let tick = mark.value; + fn default_formatter(mark: GridMark, _range: &RangeInclusive) -> String { + // Example: If the step to the next tick is `0.01`, we should use 2 decimals of precision: + let num_decimals = -mark.step_size.log10().round() as usize; - if tick.abs() > 10.0_f64.powf(max_digits as f64) { - let tick_rounded = tick as isize; - return format!("{tick_rounded:+e}"); - } - let tick_rounded = round_to_decimals(tick, max_digits); - if tick.abs() < 10.0_f64.powf(-(max_digits as f64)) && tick != 0.0 { - return format!("{tick_rounded:+e}"); - } - tick_rounded.to_string() + emath::format_with_decimals_in_range(mark.value, num_decimals..=num_decimals) } /// Specify axis label. @@ -178,15 +165,20 @@ impl<'a> AxisHints<'a> { self } - /// Specify maximum number of digits for ticks. - /// - /// This is considered by the default tick formatter and affects the width of the y-axis + /// Specify minimum thickness of the axis #[inline] - pub fn max_digits(mut self, digits: usize) -> Self { - self.digits = digits; + pub fn min_thickness(mut self, min_thickness: f32) -> Self { + self.min_thickness = min_thickness; self } + /// Specify maximum number of digits for ticks. + #[inline] + #[deprecated = "Use `min_thickness` instead"] + pub fn max_digits(self, digits: usize) -> Self { + self.min_thickness(12.0 * digits as f32) + } + /// Specify the placement of the axis. /// /// For X-axis, use [`VPlacement`]. @@ -211,19 +203,18 @@ impl<'a> AxisHints<'a> { pub(super) fn thickness(&self, axis: Axis) -> f32 { match axis { - Axis::X => { - if self.label.is_empty() { - 1.0 * LINE_HEIGHT - } else { - 3.0 * LINE_HEIGHT - } - } + Axis::X => self.min_thickness.max(if self.label.is_empty() { + 1.0 * LINE_HEIGHT + } else { + 3.0 * LINE_HEIGHT + }), Axis::Y => { - if self.label.is_empty() { - (self.digits as f32) * LINE_HEIGHT - } else { - (self.digits as f32 + 1.0) * LINE_HEIGHT - } + self.min_thickness + + if self.label.is_empty() { + 0.0 + } else { + LINE_HEIGHT + } } } } @@ -328,7 +319,7 @@ impl<'a> AxisWidget<'a> { // Add tick labels: for step in self.steps.iter() { - let text = (self.hints.formatter)(*step, self.hints.digits, &self.range); + 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; diff --git a/crates/egui_plot/src/lib.rs b/crates/egui_plot/src/lib.rs index 7f1e59e26..ea78c95d3 100644 --- a/crates/egui_plot/src/lib.rs +++ b/crates/egui_plot/src/lib.rs @@ -660,11 +660,10 @@ impl<'a> Plot<'a> { /// /// Arguments of `fmt`: /// * the grid mark to format - /// * maximum requested number of characters per tick label. /// * currently shown range on this axis. pub fn x_axis_formatter( mut self, - fmt: impl Fn(GridMark, usize, &RangeInclusive) -> String + 'a, + fmt: impl Fn(GridMark, &RangeInclusive) -> String + 'a, ) -> Self { if let Some(main) = self.x_axes.first_mut() { main.formatter = Arc::new(fmt); @@ -676,11 +675,10 @@ impl<'a> Plot<'a> { /// /// Arguments of `fmt`: /// * the grid mark to format - /// * maximum requested number of characters per tick label. /// * currently shown range on this axis. pub fn y_axis_formatter( mut self, - fmt: impl Fn(GridMark, usize, &RangeInclusive) -> String + 'a, + fmt: impl Fn(GridMark, &RangeInclusive) -> String + 'a, ) -> Self { if let Some(main) = self.y_axes.first_mut() { main.formatter = Arc::new(fmt); @@ -688,19 +686,24 @@ impl<'a> Plot<'a> { self } - /// Set the main Y-axis-width by number of digits - /// - /// The default is 5 digits. + /// Set the minimum width of the main y-axis, in ui points. /// - /// > Todo: This is experimental. Changing the font size might break this. + /// The width will automatically expand if any tickmark text is wider than this. #[inline] - pub fn y_axis_width(mut self, digits: usize) -> Self { + pub fn y_axis_min_width(mut self, min_width: f32) -> Self { if let Some(main) = self.y_axes.first_mut() { - main.digits = digits; + main.min_thickness = min_width; } self } + /// Set the main Y-axis-width by number of digits + #[inline] + #[deprecated = "Use `y_axis_min_width` instead"] + pub fn y_axis_width(self, digits: usize) -> Self { + self.y_axis_min_width(12.0 * digits as f32) + } + /// Set custom configuration for X-axis /// /// More than one axis may be specified. The first specified axis is considered the main axis. @@ -1395,7 +1398,7 @@ pub struct GridInput { } /// One mark (horizontal or vertical line) in the background grid of a plot. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct GridMark { /// X or Y value in the plot. pub value: f64, @@ -1743,15 +1746,75 @@ fn generate_marks(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec { // step_size[1] = 100 => [ 0, 100 ] // step_size[2] = 1000 => [ 0 ] - steps.sort_by(|a, b| match cmp_f64(a.value, b.value) { - // Keep the largest step size when we dedup later - Ordering::Equal => cmp_f64(b.step_size, a.step_size), + steps.sort_by(|a, b| cmp_f64(a.value, b.value)); - ord => ord, - }); - steps.dedup_by(|a, b| a.value == b.value); + let min_step = step_sizes.iter().fold(f64::INFINITY, |a, &b| a.min(b)); + let eps = 0.1 * min_step; // avoid putting two ticks too closely together + + let mut deduplicated: Vec = Vec::with_capacity(steps.len()); + for step in steps { + if let Some(last) = deduplicated.last_mut() { + if (last.value - step.value).abs() < eps { + // Keep the one with the largest step size + if last.step_size < step.step_size { + *last = step; + } + continue; + } + } + deduplicated.push(step); + } - steps + deduplicated +} + +#[test] +fn test_generate_marks() { + fn approx_eq(a: &GridMark, b: &GridMark) -> bool { + (a.value - b.value).abs() < 1e-10 && a.step_size == b.step_size + } + + let gm = |value, step_size| GridMark { value, step_size }; + + let marks = generate_marks([0.01, 0.1, 1.0], (2.855, 3.015)); + let expected = vec![ + gm(2.86, 0.01), + gm(2.87, 0.01), + gm(2.88, 0.01), + gm(2.89, 0.01), + gm(2.90, 0.1), + gm(2.91, 0.01), + gm(2.92, 0.01), + gm(2.93, 0.01), + gm(2.94, 0.01), + gm(2.95, 0.01), + gm(2.96, 0.01), + gm(2.97, 0.01), + gm(2.98, 0.01), + gm(2.99, 0.01), + gm(3.00, 1.), + gm(3.01, 0.01), + ]; + + let mut problem = None; + if marks.len() != expected.len() { + problem = Some(format!( + "Different lengths: got {}, expected {}", + marks.len(), + expected.len() + )); + } + + for (i, (a, b)) in marks.iter().zip(&expected).enumerate() { + if !approx_eq(a, b) { + problem = Some(format!("Mismatch at index {i}: {a:?} != {b:?}")); + break; + } + } + + if let Some(problem) = problem { + panic!("Test failed: {problem}. Got: {marks:#?}, expected: {expected:#?}"); + } } fn cmp_f64(a: f64, b: f64) -> Ordering { diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs index 5abf59e22..2f30a55ca 100644 --- a/crates/emath/src/lib.rs +++ b/crates/emath/src/lib.rs @@ -190,6 +190,9 @@ pub fn format_with_minimum_decimals(value: f64, decimals: usize) -> String { format_with_decimals_in_range(value, decimals..=6) } +/// Use as few decimals as possible to show the value accurately, but within the given range. +/// +/// Decimals are counted after the decimal point. pub fn format_with_decimals_in_range(value: f64, decimal_range: RangeInclusive) -> String { let min_decimals = *decimal_range.start(); let max_decimals = *decimal_range.end(); @@ -198,7 +201,7 @@ pub fn format_with_decimals_in_range(value: f64, decimal_range: RangeInclusive