1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

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`.
This commit is contained in:
Emil Ernerfeldt
2024-06-30 14:20:41 +02:00
committed by GitHub
parent 17fd305967
commit b6fd1cfc99
6 changed files with 121 additions and 67 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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
/// 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]
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
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 {