Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

egui_plot: Improve default formatter of tick-marks #4738

Merged
merged 7 commits into from
Jun 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,7 @@ dependencies = [
"ahash",
"document-features",
"egui",
"emath",
"serde",
]

Expand Down
11 changes: 3 additions & 8 deletions crates/egui_demo_lib/src/demo/plot_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions crates/egui_plot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 31 additions & 40 deletions crates/egui_plot/src/axis.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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,
}
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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`].
Expand All @@ -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
}
}
}
}
Expand Down Expand Up @@ -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;
Expand Down
99 changes: 81 additions & 18 deletions crates/egui_plot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -676,31 +675,35 @@ 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);
}
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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion crates/emath/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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$}");
Expand Down
Loading