summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEmil Ernerfeldt <emil.ernerfeldt@gmail.com>2024-06-30 14:20:41 +0200
committerGitHub <noreply@github.com>2024-06-30 14:20:41 +0200
commitb6fd1cfc99879f5a224c7c6c83626e1f1ff4ced3 (patch)
tree31b733f33d7f5c098f80d1e473a1858175093fa2
parent17fd305967fbeed3b04d9b36e856983fe982bbfb (diff)
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`.
-rw-r--r--Cargo.lock1
-rw-r--r--crates/egui_demo_lib/src/demo/plot_demo.rs11
-rw-r--r--crates/egui_plot/Cargo.toml1
-rw-r--r--crates/egui_plot/src/axis.rs71
-rw-r--r--crates/egui_plot/src/lib.rs99
-rw-r--r--crates/emath/src/lib.rs5
6 files changed, 121 insertions, 67 deletions
diff --git a/Cargo.lock b/Cargo.lock
index dad7a238..217d7bda 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 25e94292..d9046a0b 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<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)
diff --git a/crates/egui_plot/Cargo.toml b/crates/egui_plot/Cargo.toml
index 17e45852..9717fd05 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 3827307a..244691f3 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<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;
diff --git a/crates/egui_plot/src/lib.rs b/crates/egui_plot/src/lib.rs
index 7f1e59e2..ea78c95d 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<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 {
diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs
index 5abf59e2..2f30a55c 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<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$}");