From 9d76be1131152d4cca8f91ac2525c1bb0a9ed8a4 Mon Sep 17 00:00:00 2001 From: Johannes Schiffer Date: Mon, 14 Nov 2022 23:16:18 +0100 Subject: [PATCH] plot: Tick placement of opposite axes and digit constraints --- crates/egui/src/widgets/plot/axis.rs | 87 +++++++++++++++------- crates/egui/src/widgets/plot/mod.rs | 15 ++-- crates/egui_demo_lib/src/demo/plot_demo.rs | 51 ++++++++++++- 3 files changed, 114 insertions(+), 39 deletions(-) diff --git a/crates/egui/src/widgets/plot/axis.rs b/crates/egui/src/widgets/plot/axis.rs index c8ecdbf05..fdf5f2514 100644 --- a/crates/egui/src/widgets/plot/axis.rs +++ b/crates/egui/src/widgets/plot/axis.rs @@ -12,7 +12,7 @@ use crate::{Response, Sense, TextStyle, Ui, Widget, WidgetText}; use super::{transform::PlotTransform, GridMark, MIN_LINE_SPACING_IN_POINTS}; -pub(super) type AxisFormatterFn = fn(f64, &RangeInclusive) -> String; +pub(super) type AxisFormatterFn = fn(f64, usize, &RangeInclusive) -> String; /// Axis specifier. /// @@ -28,7 +28,7 @@ pub enum Axis { /// `Default` means bottom for x, left for y. /// `Opposite` means top for x, right for y. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Placement { +pub enum AxisPlacement { Default, Opposite, } @@ -38,9 +38,10 @@ pub enum Placement { /// Used to configure axis label and ticks. #[derive(Clone)] pub struct AxisConfig { - pub(super) placement: Placement, + pub(super) placement: AxisPlacement, label: String, pub(super) formatter: AxisFormatterFn, + digits: usize, pub(super) axis: Axis, } @@ -53,6 +54,8 @@ impl Debug for AxisConfig { ) } } +// TODO: this just a guess. It might cease to work if a user changes font size. + const LINE_HEIGHT: f32 = 12.0; impl AxisConfig { @@ -63,9 +66,10 @@ impl AxisConfig { /// `formatter` is default float to string formatter pub const fn default(axis: Axis) -> Self { Self { - placement: Placement::Default, + placement: AxisPlacement::Default, label: String::new(), formatter: Self::default_formatter, + digits: 5, axis, } } @@ -79,36 +83,55 @@ impl AxisConfig { /// Specify custom formatter for ticks. /// /// The first parameter of `formatter` is the raw tick value as `f64`. + /// The second paramter is the maximum number of characters that fit into y-labels. /// The second paramter of `formatter` is the currently shown range on this axis. - pub fn tick_formatter(mut self, formatter: fn(f64, &RangeInclusive) -> String) -> Self { + pub fn tick_formatter( + mut self, + formatter: fn(f64, usize, &RangeInclusive) -> String, + ) -> Self { self.formatter = formatter; self } /// Specify the placement for this axis. - pub fn placement(mut self, placement: Placement) -> Self { + pub fn placement(mut self, placement: AxisPlacement) -> Self { self.placement = placement; self } - fn default_formatter(tick: f64, _range: &RangeInclusive) -> String { - const MAX_DECIMALS: usize = 5; - if tick.abs() > 1e6 { - let tick_rounded = round_to_decimals(tick, MAX_DECIMALS); - format!("{:+e}", tick_rounded) - } else if tick.abs() < 1e-6 && tick != 0.0 { - format!("{:+e}", tick) - } else { - let tick_rounded = round_to_decimals(tick, MAX_DECIMALS); - format!("{}", tick_rounded) + fn default_formatter(tick: f64, max_digits: usize, _range: &RangeInclusive) -> String { + if tick.abs() > 10.0_f64.powf(max_digits as f64) { + let tick_rounded = tick as isize; + return format!("{:+e}", tick_rounded); } + 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!("{:+e}", tick_rounded); + } + format!("{}", tick_rounded) + } + + pub fn max_digits(mut self, digits: usize) -> Self { + self.digits = digits; + self } pub(super) fn thickness(&self) -> f32 { - if self.label.is_empty() { - LINE_HEIGHT - } else { - 2.0 * LINE_HEIGHT + match self.axis { + Axis::X => { + 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 + } + } } } } @@ -152,12 +175,12 @@ impl Widget for AxisWidget { }; // select text_pos and angle depending on placement and orientation of widget let text_pos = match self.config.placement { - Placement::Default => match self.config.axis { + AxisPlacement::Default => match self.config.axis { Axis::X => { let pos = response.rect.center_bottom(); Pos2 { x: pos.x - galley.size().x / 2.0, - y: pos.y - galley.size().y, + y: pos.y - galley.size().y * 1.25, } } Axis::Y => { @@ -168,18 +191,18 @@ impl Widget for AxisWidget { } } }, - Placement::Opposite => match self.config.axis { + AxisPlacement::Opposite => match self.config.axis { Axis::X => { let pos = response.rect.center_top(); Pos2 { x: pos.x - galley.size().x / 2.0, - y: pos.y + galley.size().y / 2.0, + y: pos.y + galley.size().y * 0.25, } } Axis::Y => { let pos = response.rect.right_center(); Pos2 { - x: pos.x - galley.size().y, + x: pos.x - galley.size().y * 1.5, y: pos.y + galley.size().x / 2.0, } } @@ -202,7 +225,7 @@ impl Widget for AxisWidget { }; for step in self.steps { - let text = (self.config.formatter)(step.value, &self.range); + let text = (self.config.formatter)(step.value, self.config.digits, &self.range); if !text.is_empty() { let spacing_in_points = (transform.dpos_dvalue()[self.config.axis as usize] * step.step_size) @@ -221,17 +244,25 @@ impl Widget for AxisWidget { .layout_no_wrap(text, font_id.clone(), line_color); let text_pos = match self.config.axis { Axis::X => { + let y = match self.config.placement { + AxisPlacement::Default => self.rect.min.y, + AxisPlacement::Opposite => self.rect.max.y - galley.size().y, + }; let projected_point = super::PlotPoint::new(step.value, 0.0); Pos2 { x: transform.position_from_point(&projected_point).x - galley.size().x / 2.0, - y: self.rect.min.y, + y, } } Axis::Y => { + let x = match self.config.placement { + AxisPlacement::Default => self.rect.max.x - galley.size().x, + AxisPlacement::Opposite => self.rect.min.x, + }; let projected_point = super::PlotPoint::new(0.0, step.value); Pos2 { - x: self.rect.max.x - galley.size().x, + x, y: transform.position_from_point(&projected_point).y - galley.size().y / 2.0, } diff --git a/crates/egui/src/widgets/plot/mod.rs b/crates/egui/src/widgets/plot/mod.rs index df9935bb1..ef482b3c5 100644 --- a/crates/egui/src/widgets/plot/mod.rs +++ b/crates/egui/src/widgets/plot/mod.rs @@ -6,6 +6,7 @@ use crate::*; use epaint::util::FloatOrd; use epaint::Hsva; +pub use axis::{AxisPlacement, Axis, AxisConfig}; use axis::AxisWidget; use items::PlotItem; use legend::LegendWidget; @@ -17,11 +18,7 @@ pub use items::{ pub use legend::{Corner, Legend}; pub use transform::{PlotBounds, PlotTransform}; -use self::{ - axis::Axis, - axis::AxisConfig, - items::{horizontal_line, rulers_color, vertical_line}, -}; +use items::{horizontal_line, rulers_color, vertical_line}; pub mod axis; mod items; @@ -674,7 +671,7 @@ impl Plot { let mut d = 0.0; for cfg in &axis_config { match cfg.placement { - axis::Placement::Default => match cfg.axis { + AxisPlacement::Default => match cfg.axis { Axis::X => { a += cfg.thickness(); } @@ -682,7 +679,7 @@ impl Plot { b += cfg.thickness(); } }, - axis::Placement::Opposite => match cfg.axis { + AxisPlacement::Opposite => match cfg.axis { Axis::X => { c += cfg.thickness(); } @@ -722,7 +719,7 @@ impl Plot { y: cfg.thickness(), }; let rect: Rect = match cfg.placement { - axis::Placement::Default => match cfg.axis { + AxisPlacement::Default => match cfg.axis { Axis::X => { let off = widget_cnt.bottom as f32; widget_cnt.bottom += 1; @@ -740,7 +737,7 @@ impl Plot { } } }, - axis::Placement::Opposite => match cfg.axis { + AxisPlacement::Opposite => match cfg.axis { Axis::X => { let off = widget_cnt.top as f32; widget_cnt.top += 1; diff --git a/crates/egui_demo_lib/src/demo/plot_demo.rs b/crates/egui_demo_lib/src/demo/plot_demo.rs index b5f515f7e..eeb93389d 100644 --- a/crates/egui_demo_lib/src/demo/plot_demo.rs +++ b/crates/egui_demo_lib/src/demo/plot_demo.rs @@ -1,6 +1,7 @@ use std::f64::consts::TAU; +use std::ops::RangeInclusive; -use egui::plot::{AxisBools, GridInput, GridMark, PlotResponse}; +use egui::plot::{AxisBools, AxisConfig, GridInput, GridMark, PlotResponse}; use egui::*; use plot::{ Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine, @@ -263,7 +264,12 @@ impl LineDemo { ui.ctx().request_repaint(); self.time += ui.input(|i| i.unstable_dt).at_most(1.0 / 30.0) as f64; }; - let mut plot = Plot::new("lines_demo").legend(Legend::default()); + let mut plot = Plot::new("lines_demo") + .axes(vec![ + AxisConfig::default(plot::Axis::X).label("x".to_string()), + AxisConfig::default(plot::Axis::Y).label("y".to_string()), + ]) + .legend(Legend::default()); if self.square { plot = plot.view_aspect(1.0); } @@ -514,6 +520,28 @@ impl CustomAxisDemo { 100.0 * y } + let x_fmt = |x, _digits, _range: &RangeInclusive| { + if x < 0.0 * MINS_PER_DAY || x >= 5.0 * MINS_PER_DAY { + // No labels outside value bounds + String::new() + } else if is_approx_integer(x / MINS_PER_DAY) { + // Days + format!("Day {}", day(x)) + } else { + // Hours and minutes + format!("{h}:{m:02}", h = hour(x), m = minute(x)) + } + }; + + let y_fmt = |y, _digits, _range: &RangeInclusive| { + // Display only integer percentages + if !is_approx_zero(y) && is_approx_integer(100.0 * y) { + format!("{:.0}%", percent(y)) + } else { + String::new() + } + }; + let label_fmt = |_s: &str, val: &PlotPoint| { format!( "Day {d}, {h}:{m:02}\n{p:.2}%", @@ -526,8 +554,19 @@ impl CustomAxisDemo { ui.label("Zoom in on the X-axis to see hours and minutes"); + let axes = vec![ + AxisConfig::default(plot::Axis::X) + .tick_formatter(x_fmt) + .label("Percent".to_string()), + AxisConfig::default(plot::Axis::Y) + .tick_formatter(y_fmt) + .max_digits(4) + .label("Time".to_string()), + ]; + Plot::new("custom_axes") .data_aspect(2.0 * MINS_PER_DAY as f32) + .axes(axes) .x_grid_spacer(CustomAxisDemo::x_grid) .label_formatter(label_fmt) .show(ui, |plot_ui| { @@ -973,3 +1012,11 @@ impl ChartsDemo { .response } } + +fn is_approx_zero(val: f64) -> bool { + val.abs() < 1e-6 +} + +fn is_approx_integer(val: f64) -> bool { + val.fract().abs() < 1e-6 +}