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:
parent
17fd305967
commit
b6fd1cfc99
|
|
@ -1355,6 +1355,7 @@ dependencies = [
|
|||
"ahash",
|
||||
"document-features",
|
||||
"egui",
|
||||
"emath",
|
||||
"serde",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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…
Reference in New Issue