Browse Source

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`.
pull/4740/head
Emil Ernerfeldt 4 months ago
committed by GitHub
parent
commit
b6fd1cfc99
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      Cargo.lock
  2. 11
      crates/egui_demo_lib/src/demo/plot_demo.rs
  3. 1
      crates/egui_plot/Cargo.toml
  4. 61
      crates/egui_plot/src/axis.rs
  5. 99
      crates/egui_plot/src/lib.rs
  6. 5
      crates/emath/src/lib.rs

1
Cargo.lock

@ -1355,6 +1355,7 @@ dependencies = [
"ahash", "ahash",
"document-features", "document-features",
"egui", "egui",
"emath",
"serde", "serde",
] ]

11
crates/egui_demo_lib/src/demo/plot_demo.rs

@ -277,7 +277,6 @@ impl LineDemo {
}; };
let mut plot = Plot::new("lines_demo") let mut plot = Plot::new("lines_demo")
.legend(Legend::default()) .legend(Legend::default())
.y_axis_width(2)
.show_axes(self.show_axes) .show_axes(self.show_axes)
.show_grid(self.show_grid); .show_grid(self.show_grid);
if self.square { if self.square {
@ -437,7 +436,6 @@ impl LegendDemo {
ui.end_row(); ui.end_row();
}); });
let legend_plot = Plot::new("legend_demo") let legend_plot = Plot::new("legend_demo")
.y_axis_width(2)
.legend(config.clone()) .legend(config.clone())
.data_aspect(1.0); .data_aspect(1.0);
legend_plot legend_plot
@ -530,7 +528,7 @@ impl CustomAxesDemo {
100.0 * y 100.0 * y
} }
let time_formatter = |mark: GridMark, _digits, _range: &RangeInclusive<f64>| { let time_formatter = |mark: GridMark, _range: &RangeInclusive<f64>| {
let minutes = mark.value; let minutes = mark.value;
if minutes < 0.0 || 5.0 * MINS_PER_DAY <= minutes { if minutes < 0.0 || 5.0 * MINS_PER_DAY <= minutes {
// No labels outside value bounds // No labels outside value bounds
@ -544,7 +542,7 @@ impl CustomAxesDemo {
} }
}; };
let percentage_formatter = |mark: GridMark, _digits, _range: &RangeInclusive<f64>| { let percentage_formatter = |mark: GridMark, _range: &RangeInclusive<f64>| {
let percent = 100.0 * mark.value; let percent = 100.0 * mark.value;
if is_approx_zero(percent) { if is_approx_zero(percent) {
String::new() // skip zero String::new() // skip zero
@ -575,8 +573,7 @@ impl CustomAxesDemo {
let y_axes = vec![ let y_axes = vec![
AxisHints::new_y() AxisHints::new_y()
.label("Percent") .label("Percent")
.formatter(percentage_formatter) .formatter(percentage_formatter),
.max_digits(4),
AxisHints::new_y() AxisHints::new_y()
.label("Absolute") .label("Absolute")
.placement(egui_plot::HPlacement::Right), .placement(egui_plot::HPlacement::Right),
@ -673,7 +670,6 @@ impl LinkedAxesDemo {
.data_aspect(2.0) .data_aspect(2.0)
.width(150.0) .width(150.0)
.height(250.0) .height(250.0)
.y_axis_width(2)
.y_axis_label("y") .y_axis_label("y")
.y_axis_position(egui_plot::HPlacement::Right) .y_axis_position(egui_plot::HPlacement::Right)
.link_axis(link_group_id, self.link_x, self.link_y) .link_axis(link_group_id, self.link_x, self.link_y)
@ -962,7 +958,6 @@ impl ChartsDemo {
Plot::new("Normal Distribution Demo") Plot::new("Normal Distribution Demo")
.legend(Legend::default()) .legend(Legend::default())
.clamp_grid(true) .clamp_grid(true)
.y_axis_width(2)
.allow_zoom(self.allow_zoom) .allow_zoom(self.allow_zoom)
.allow_drag(self.allow_drag) .allow_drag(self.allow_drag)
.allow_scroll(self.allow_scroll) .allow_scroll(self.allow_scroll)

1
crates/egui_plot/Cargo.toml

@ -36,6 +36,7 @@ serde = ["dep:serde", "egui/serde"]
[dependencies] [dependencies]
egui = { workspace = true, default-features = false } egui = { workspace = true, default-features = false }
emath = { workspace = true, default-features = false }
ahash.workspace = true ahash.workspace = true

61
crates/egui_plot/src/axis.rs

@ -1,14 +1,14 @@
use std::{fmt::Debug, ops::RangeInclusive, sync::Arc}; use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};
use egui::{ use egui::{
emath::{remap_clamp, round_to_decimals, Rot2}, emath::{remap_clamp, Rot2},
epaint::TextShape, epaint::TextShape,
Pos2, Rangef, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, WidgetText, Pos2, Rangef, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, WidgetText,
}; };
use super::{transform::PlotTransform, GridMark}; use super::{transform::PlotTransform, GridMark};
pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'a; pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, &RangeInclusive<f64>) -> String + 'a;
/// X or Y axis. /// X or Y axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -101,7 +101,7 @@ impl From<Placement> for VPlacement {
pub struct AxisHints<'a> { pub struct AxisHints<'a> {
pub(super) label: WidgetText, pub(super) label: WidgetText,
pub(super) formatter: Arc<AxisFormatterFn<'a>>, pub(super) formatter: Arc<AxisFormatterFn<'a>>,
pub(super) digits: usize, pub(super) min_thickness: f32,
pub(super) placement: Placement, pub(super) placement: Placement,
pub(super) label_spacing: Rangef, pub(super) label_spacing: Rangef,
} }
@ -124,12 +124,11 @@ impl<'a> AxisHints<'a> {
/// ///
/// `label` is empty. /// `label` is empty.
/// `formatter` is default float to string formatter. /// `formatter` is default float to string formatter.
/// maximum `digits` on tick label is 5.
pub fn new(axis: Axis) -> Self { pub fn new(axis: Axis) -> Self {
Self { Self {
label: Default::default(), label: Default::default(),
formatter: Arc::new(Self::default_formatter), formatter: Arc::new(Self::default_formatter),
digits: 5, min_thickness: 14.0,
placement: Placement::LeftBottom, placement: Placement::LeftBottom,
label_spacing: match axis { label_spacing: match axis {
Axis::X => Rangef::new(60.0, 80.0), // labels can get pretty wide 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. /// Specify custom formatter for ticks.
/// ///
/// The first parameter of `formatter` is the raw tick value as `f64`. /// 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. /// The second parameter of `formatter` is the currently shown range on this axis.
pub fn formatter( pub fn formatter(
mut self, mut self,
fmt: impl Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'a, fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> String + 'a,
) -> Self { ) -> Self {
self.formatter = Arc::new(fmt); self.formatter = Arc::new(fmt);
self self
} }
fn default_formatter( fn default_formatter(mark: GridMark, _range: &RangeInclusive<f64>) -> String {
mark: GridMark, // Example: If the step to the next tick is `0.01`, we should use 2 decimals of precision:
max_digits: usize, let num_decimals = -mark.step_size.log10().round() as usize;
_range: &RangeInclusive<f64>,
) -> String {
let tick = mark.value;
if tick.abs() > 10.0_f64.powf(max_digits as f64) { emath::format_with_decimals_in_range(mark.value, num_decimals..=num_decimals)
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()
} }
/// Specify axis label. /// Specify axis label.
@ -178,15 +165,20 @@ impl<'a> AxisHints<'a> {
self self
} }
/// Specify maximum number of digits for ticks. /// Specify minimum thickness of the axis
///
/// This is considered by the default tick formatter and affects the width of the y-axis
#[inline] #[inline]
pub fn max_digits(mut self, digits: usize) -> Self { pub fn min_thickness(mut self, min_thickness: f32) -> Self {
self.digits = digits; self.min_thickness = min_thickness;
self 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. /// Specify the placement of the axis.
/// ///
/// For X-axis, use [`VPlacement`]. /// For X-axis, use [`VPlacement`].
@ -211,18 +203,17 @@ impl<'a> AxisHints<'a> {
pub(super) fn thickness(&self, axis: Axis) -> f32 { pub(super) fn thickness(&self, axis: Axis) -> f32 {
match axis { match axis {
Axis::X => { Axis::X => self.min_thickness.max(if self.label.is_empty() {
if self.label.is_empty() {
1.0 * LINE_HEIGHT 1.0 * LINE_HEIGHT
} else { } else {
3.0 * LINE_HEIGHT 3.0 * LINE_HEIGHT
} }),
}
Axis::Y => { Axis::Y => {
if self.label.is_empty() { self.min_thickness
(self.digits as f32) * LINE_HEIGHT + if self.label.is_empty() {
0.0
} else { } else {
(self.digits as f32 + 1.0) * LINE_HEIGHT LINE_HEIGHT
} }
} }
} }
@ -328,7 +319,7 @@ impl<'a> AxisWidget<'a> {
// Add tick labels: // Add tick labels:
for step in self.steps.iter() { 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() { if !text.is_empty() {
let spacing_in_points = let spacing_in_points =
(transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32; (transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32;

99
crates/egui_plot/src/lib.rs

@ -660,11 +660,10 @@ impl<'a> Plot<'a> {
/// ///
/// Arguments of `fmt`: /// Arguments of `fmt`:
/// * the grid mark to format /// * the grid mark to format
/// * maximum requested number of characters per tick label.
/// * currently shown range on this axis. /// * currently shown range on this axis.
pub fn x_axis_formatter( pub fn x_axis_formatter(
mut self, mut self,
fmt: impl Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'a, fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> String + 'a,
) -> Self { ) -> Self {
if let Some(main) = self.x_axes.first_mut() { if let Some(main) = self.x_axes.first_mut() {
main.formatter = Arc::new(fmt); main.formatter = Arc::new(fmt);
@ -676,11 +675,10 @@ impl<'a> Plot<'a> {
/// ///
/// Arguments of `fmt`: /// Arguments of `fmt`:
/// * the grid mark to format /// * the grid mark to format
/// * maximum requested number of characters per tick label.
/// * currently shown range on this axis. /// * currently shown range on this axis.
pub fn y_axis_formatter( pub fn y_axis_formatter(
mut self, mut self,
fmt: impl Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'a, fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> String + 'a,
) -> Self { ) -> Self {
if let Some(main) = self.y_axes.first_mut() { if let Some(main) = self.y_axes.first_mut() {
main.formatter = Arc::new(fmt); main.formatter = Arc::new(fmt);
@ -688,19 +686,24 @@ impl<'a> Plot<'a> {
self self
} }
/// Set the main Y-axis-width by number of digits /// Set the minimum width of the main y-axis, in ui points.
///
/// The default is 5 digits.
/// ///
/// > 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] #[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() { if let Some(main) = self.y_axes.first_mut() {
main.digits = digits; main.min_thickness = min_width;
} }
self 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 /// Set custom configuration for X-axis
/// ///
/// More than one axis may be specified. The first specified axis is considered the main 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. /// 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 { pub struct GridMark {
/// X or Y value in the plot. /// X or Y value in the plot.
pub value: f64, pub value: f64,
@ -1743,15 +1746,75 @@ fn generate_marks(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec<GridMark> {
// step_size[1] = 100 => [ 0, 100 ] // step_size[1] = 100 => [ 0, 100 ]
// step_size[2] = 1000 => [ 0 ] // step_size[2] = 1000 => [ 0 ]
steps.sort_by(|a, b| match cmp_f64(a.value, b.value) { steps.sort_by(|a, b| 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),
ord => ord, 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
steps.dedup_by(|a, b| a.value == b.value);
steps let mut deduplicated: Vec<GridMark> = 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);
}
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 { fn cmp_f64(a: f64, b: f64) -> Ordering {

5
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) 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<usize>) -> String { pub fn format_with_decimals_in_range(value: f64, decimal_range: RangeInclusive<usize>) -> String {
let min_decimals = *decimal_range.start(); let min_decimals = *decimal_range.start();
let max_decimals = *decimal_range.end(); let max_decimals = *decimal_range.end();
@ -198,7 +201,7 @@ pub fn format_with_decimals_in_range(value: f64, decimal_range: RangeInclusive<u
let max_decimals = max_decimals.min(16); let max_decimals = max_decimals.min(16);
let min_decimals = min_decimals.min(max_decimals); let min_decimals = min_decimals.min(max_decimals);
if min_decimals != max_decimals { if min_decimals < max_decimals {
// Ugly/slow way of doing this. TODO(emilk): clean up precision. // Ugly/slow way of doing this. TODO(emilk): clean up precision.
for decimals in min_decimals..max_decimals { for decimals in min_decimals..max_decimals {
let text = format!("{value:.decimals$}"); let text = format!("{value:.decimals$}");

Loading…
Cancel
Save