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. 71
      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",
"document-features",
"egui",
"emath",
"serde",
]

11
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<f64>| {
let time_formatter = |mark: GridMark, _range: &RangeInclusive<f64>| {
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<f64>| {
let percentage_formatter = |mark: GridMark, _range: &RangeInclusive<f64>| {
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)

1
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

71
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<f64>) -> String + 'a;
pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, &RangeInclusive<f64>) -> String + 'a;
/// X or Y axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -101,7 +101,7 @@ impl From<Placement> for VPlacement {
pub struct AxisHints<'a> {
pub(super) label: WidgetText,
pub(super) formatter: Arc<AxisFormatterFn<'a>>,
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<f64>) -> String + 'a,
fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> String + 'a,
) -> Self {
self.formatter = Arc::new(fmt);
self
}
fn default_formatter(
mark: GridMark,
max_digits: usize,
_range: &RangeInclusive<f64>,
) -> String {
let tick = mark.value;
fn default_formatter(mark: GridMark, _range: &RangeInclusive<f64>) -> 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;

99
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<f64>) -> String + 'a,
fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> 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<f64>) -> String + 'a,
fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> 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<GridMark> {
// 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<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);
}
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 {

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)
}
/// 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 {
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<u
let max_decimals = max_decimals.min(16);
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.
for decimals in min_decimals..max_decimals {
let text = format!("{value:.decimals$}");

Loading…
Cancel
Save