From e22f6d9a7eca02e525806183a67cd1ca656c815e Mon Sep 17 00:00:00 2001 From: Jan Haller Date: Tue, 19 Apr 2022 11:35:05 +0200 Subject: [PATCH] Customize grid spacing in plots (#1180) --- CHANGELOG.md | 3 +- egui/src/widgets/plot/mod.rs | 200 ++++++++++++++++++++--- egui_demo_lib/src/apps/demo/plot_demo.rs | 152 ++++++++++++++--- 3 files changed, 305 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 606931d0cbc..b620ebcee40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w * Added `Frame::outer_margin`. * Added `Painter::hline` and `Painter::vline`. * Added `Link` and `ui.link` ([#1506](https://github.com/emilk/egui/pull/1506)). +* Added `Plot::x_grid_spacer` and `Plot::y_grid_spacer` for custom grid spacing ([#1180](https://github.com/emilk/egui/pull/1180)). ### Changed 🔧 * `ClippedMesh` has been replaced with `ClippedPrimitive` ([#1351](https://github.com/emilk/egui/pull/1351)). @@ -30,7 +31,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w * Renamed `Painter::sub_region` to `Painter::with_clip_rect`. ### Fixed 🐛 -* Fixed `ComboBox`:es always being rendered left-aligned ([#1304](https://github.com/emilk/egui/pull/1304)). +* Fixed `ComboBox`es always being rendered left-aligned ([#1304](https://github.com/emilk/egui/pull/1304)). * Fixed ui code that could lead to a deadlock ([#1380](https://github.com/emilk/egui/pull/1380)). * Text is darker and more readable in bright mode ([#1412](https://github.com/emilk/egui/pull/1412)). * Fixed `Ui::add_visible` sometimes leaving the `Ui` in a disabled state. ([#1436](https://github.com/emilk/egui/issues/1436)). diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 7efb4b81117..803d4d52dad 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -6,6 +6,7 @@ use crate::*; use epaint::ahash::AHashSet; use epaint::color::Hsva; use epaint::util::FloatOrd; + use items::PlotItem; use legend::LegendWidget; use transform::ScreenTransform; @@ -26,6 +27,9 @@ type LabelFormatter = Option>; type AxisFormatterFn = dyn Fn(f64, &RangeInclusive) -> String; type AxisFormatter = Option>; +type GridSpacerFn = dyn Fn(GridInput) -> Vec; +type GridSpacer = Box; + /// Specifies the coordinates formatting when passed to [`Plot::coordinates_formatter`]. pub struct CoordinatesFormatter { function: Box String>, @@ -61,6 +65,8 @@ impl Default for CoordinatesFormatter { // ---------------------------------------------------------------------------- +const MIN_LINE_SPACING_IN_POINTS: f64 = 6.0; // TODO: large enough for a wide label + /// Information about the plot that has to persist between frames. #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone)] @@ -186,6 +192,7 @@ pub struct Plot { legend_config: Option, show_background: bool, show_axes: [bool; 2], + grid_spacers: [GridSpacer; 2], } impl Plot { @@ -219,6 +226,7 @@ impl Plot { legend_config: None, show_background: true, show_axes: [true; 2], + grid_spacers: [log_grid_spacer(10), log_grid_spacer(10)], } } @@ -393,6 +401,49 @@ impl Plot { self } + /// Configure how the grid in the background is spaced apart along the X axis. + /// + /// Default is a log-10 grid, i.e. every plot unit is divided into 10 other units. + /// + /// The function has this signature: + /// ```ignore + /// fn get_step_sizes(input: GridInput) -> Vec; + /// ``` + /// + /// This function should return all marks along the visible range of the X axis. + /// `step_size` also determines how thick/faint each line is drawn. + /// For example, if x = 80..=230 is visible and you want big marks at steps of + /// 100 and small ones at 25, you can return: + /// ```no_run + /// # use egui::plot::GridMark; + /// vec![ + /// // 100s + /// GridMark { value: 100.0, step_size: 100.0 }, + /// GridMark { value: 200.0, step_size: 100.0 }, + /// + /// // 25s + /// GridMark { value: 125.0, step_size: 25.0 }, + /// GridMark { value: 150.0, step_size: 25.0 }, + /// GridMark { value: 175.0, step_size: 25.0 }, + /// GridMark { value: 225.0, step_size: 25.0 }, + /// ]; + /// # () + /// ``` + /// + /// There are helpers for common cases, see [`log_grid_spacer`] and [`uniform_grid_spacer`]. + pub fn x_grid_spacer(mut self, spacer: impl Fn(GridInput) -> Vec + 'static) -> Self { + self.grid_spacers[0] = Box::new(spacer); + self + } + + /// Default is a log-10 grid, i.e. every plot unit is divided into 10 other units. + /// + /// See [`Self::x_grid_spacer`] for explanation. + pub fn y_grid_spacer(mut self, spacer: impl Fn(GridInput) -> Vec + 'static) -> Self { + self.grid_spacers[1] = Box::new(spacer); + self + } + /// Expand bounds to include the given x value. /// For instance, to always show the y axis, call `plot.include_x(0.0)`. pub fn include_x(mut self, x: impl Into) -> Self { @@ -463,6 +514,7 @@ impl Plot { show_background, show_axes, linked_axes, + grid_spacers, } = self; // Determine the size of the plot in the UI @@ -706,6 +758,7 @@ impl Plot { axis_formatters, show_axes, transform: transform.clone(), + grid_spacers, }; prepared.ui(ui, &response); @@ -922,6 +975,80 @@ impl PlotUi { } } +// ---------------------------------------------------------------------------- +// Grid + +/// Input for "grid spacer" functions. +/// +/// See [`Plot::x_grid_spacer()`] and [`Plot::y_grid_spacer()`]. +pub struct GridInput { + /// Min/max of the visible data range (the values at the two edges of the plot, + /// for the current axis). + pub bounds: (f64, f64), + + /// Recommended (but not required) lower-bound on the step size returned by custom grid spacers. + /// + /// Computed as the ratio between the diagram's bounds (in plot coordinates) and the viewport + /// (in frame/window coordinates), scaled up to represent the minimal possible step. + pub base_step_size: f64, +} + +/// One mark (horizontal or vertical line) in the background grid of a plot. +pub struct GridMark { + /// X or Y value in the plot. + pub value: f64, + + /// The (approximate) distance to the next value of same thickness. + /// + /// Determines how thick the grid line is painted. It's not important that `step_size` + /// matches the difference between two `value`s precisely, but rather that grid marks of + /// same thickness have same `step_size`. For example, months can have a different number + /// of days, but consistently using a `step_size` of 30 days is a valid approximation. + pub step_size: f64, +} + +/// Recursively splits the grid into `base` subdivisions (e.g. 100, 10, 1). +/// +/// The logarithmic base, expressing how many times each grid unit is subdivided. +/// 10 is a typical value, others are possible though. +pub fn log_grid_spacer(log_base: i64) -> GridSpacer { + let log_base = log_base as f64; + let get_step_sizes = move |input: GridInput| -> Vec { + // The distance between two of the thinnest grid lines is "rounded" up + // to the next-bigger power of base + let smallest_visible_unit = next_power(input.base_step_size, log_base); + + let step_sizes = [ + smallest_visible_unit, + smallest_visible_unit * log_base, + smallest_visible_unit * log_base * log_base, + ]; + + generate_marks(step_sizes, input.bounds) + }; + + Box::new(get_step_sizes) +} + +/// Splits the grid into uniform-sized spacings (e.g. 100, 25, 1). +/// +/// This function should return 3 positive step sizes, designating where the lines in the grid are drawn. +/// Lines are thicker for larger step sizes. Ordering of returned value is irrelevant. +/// +/// Why only 3 step sizes? Three is the number of different line thicknesses that egui typically uses in the grid. +/// Ideally, those 3 are not hardcoded values, but depend on the visible range (accessible through `GridInput`). +pub fn uniform_grid_spacer(spacer: impl Fn(GridInput) -> [f64; 3] + 'static) -> GridSpacer { + let get_marks = move |input: GridInput| -> Vec { + let bounds = input.bounds; + let step_sizes = spacer(input); + generate_marks(step_sizes, bounds) + }; + + Box::new(get_marks) +} + +// ---------------------------------------------------------------------------- + struct PreparedPlot { items: Vec>, show_x: bool, @@ -931,6 +1058,7 @@ struct PreparedPlot { axis_formatters: [AxisFormatter; 2], show_axes: [bool; 2], transform: ScreenTransform, + grid_spacers: [GridSpacer; 2], } impl PreparedPlot { @@ -979,6 +1107,7 @@ impl PreparedPlot { let Self { transform, axis_formatters, + grid_spacers, .. } = self; @@ -991,43 +1120,31 @@ impl PreparedPlot { let font_id = TextStyle::Body.resolve(ui.style()); - let base: i64 = 10; - let basef = base as f64; - - let min_line_spacing_in_points = 6.0; // TODO: large enough for a wide label - let step_size = transform.dvalue_dpos()[axis] * min_line_spacing_in_points; - let step_size = basef.powi(step_size.abs().log(basef).ceil() as i32); - - let step_size_in_points = (transform.dpos_dvalue()[axis] * step_size).abs() as f32; - // Where on the cross-dimension to show the label values + let bounds = transform.bounds(); let value_cross = 0.0_f64.clamp(bounds.min[1 - axis], bounds.max[1 - axis]); - for i in 0.. { - let value_main = step_size * (bounds.min[axis] / step_size + i as f64).floor(); - if value_main > bounds.max[axis] { - break; - } + let input = GridInput { + bounds: (bounds.min[axis], bounds.max[axis]), + base_step_size: transform.dvalue_dpos()[axis] * MIN_LINE_SPACING_IN_POINTS, + }; + let steps = (grid_spacers[axis])(input); + + for step in steps { + let value_main = step.value; let value = if axis == 0 { Value::new(value_main, value_cross) } else { Value::new(value_cross, value_main) }; - let pos_in_gui = transform.position_from_value(&value); - let n = (value_main / step_size).round() as i64; - let spacing_in_points = if n % (base * base) == 0 { - step_size_in_points * (basef * basef) as f32 // think line (multiple of 100) - } else if n % base == 0 { - step_size_in_points * basef as f32 // medium line (multiple of 10) - } else { - step_size_in_points // thin line - }; + let pos_in_gui = transform.position_from_value(&value); + let spacing_in_points = (transform.dpos_dvalue()[axis] * step.step_size).abs() as f32; let line_alpha = remap_clamp( spacing_in_points, - (min_line_spacing_in_points as f32)..=300.0, + (MIN_LINE_SPACING_IN_POINTS as f32)..=300.0, 0.0..=0.15, ); @@ -1119,3 +1236,38 @@ impl PreparedPlot { } } } + +/// Returns next bigger power in given base +/// e.g. +/// ```ignore +/// use egui::plot::next_power; +/// assert_eq!(next_power(0.01, 10.0), 0.01); +/// assert_eq!(next_power(0.02, 10.0), 0.1); +/// assert_eq!(next_power(0.2, 10.0), 1); +/// ``` +fn next_power(value: f64, base: f64) -> f64 { + assert_ne!(value, 0.0); // can be negative (typical for Y axis) + base.powi(value.abs().log(base).ceil() as i32) +} + +/// Fill in all values between [min, max] which are a multiple of `step_size` +fn generate_marks(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec { + let mut steps = vec![]; + fill_marks_between(&mut steps, step_sizes[0], bounds); + fill_marks_between(&mut steps, step_sizes[1], bounds); + fill_marks_between(&mut steps, step_sizes[2], bounds); + steps +} + +/// Fill in all values between [min, max] which are a multiple of `step_size` +fn fill_marks_between(out: &mut Vec, step_size: f64, (min, max): (f64, f64)) { + assert!(max > min); + let first = (min / step_size).ceil() as i64; + let last = (max / step_size).ceil() as i64; + + let marks_iter = (first..last).map(|i| { + let value = (i as f64) * step_size; + GridMark { value, step_size } + }); + out.extend(marks_iter); +} diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index bf845f094ec..d1c2dc6c80f 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -1,5 +1,7 @@ use std::f64::consts::TAU; +use std::ops::RangeInclusive; +use egui::plot::{GridInput, GridMark}; use egui::*; use plot::{ Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine, @@ -309,6 +311,125 @@ impl Widget for &mut LegendDemo { } } +#[derive(PartialEq, Default)] +struct CustomAxisDemo {} + +impl CustomAxisDemo { + const MINS_PER_DAY: f64 = 24.0 * 60.0; + const MINS_PER_H: f64 = 60.0; + + fn logistic_fn() -> Line { + fn days(min: f64) -> f64 { + CustomAxisDemo::MINS_PER_DAY * min + } + + let values = Values::from_explicit_callback( + move |x| 1.0 / (1.0 + (-2.5 * (x / CustomAxisDemo::MINS_PER_DAY - 2.0)).exp()), + days(0.0)..days(5.0), + 100, + ); + Line::new(values) + } + + #[allow(clippy::needless_pass_by_value)] + fn x_grid(input: GridInput) -> Vec { + // Note: this always fills all possible marks. For optimization, `input.bounds` + // could be used to decide when the low-interval grids (minutes) should be added. + + let mut marks = vec![]; + + let (min, max) = input.bounds; + let min = min.floor() as i32; + let max = max.ceil() as i32; + + for i in min..=max { + let step_size = if i % Self::MINS_PER_DAY as i32 == 0 { + // 1 day + Self::MINS_PER_DAY + } else if i % Self::MINS_PER_H as i32 == 0 { + // 1 hour + Self::MINS_PER_H + } else if i % 5 == 0 { + // 5min + 5.0 + } else { + // skip grids below 5min + continue; + }; + + marks.push(GridMark { + value: i as f64, + step_size, + }); + } + + marks + } +} + +impl Widget for &mut CustomAxisDemo { + fn ui(self, ui: &mut Ui) -> Response { + const MINS_PER_DAY: f64 = CustomAxisDemo::MINS_PER_DAY; + const MINS_PER_H: f64 = CustomAxisDemo::MINS_PER_H; + + fn get_day(x: f64) -> f64 { + (x / MINS_PER_DAY).floor() + } + fn get_hour(x: f64) -> f64 { + (x.rem_euclid(MINS_PER_DAY) / MINS_PER_H).floor() + } + fn get_minute(x: f64) -> f64 { + x.rem_euclid(MINS_PER_H).floor() + } + fn get_percent(y: f64) -> f64 { + (100.0 * y).round() + } + + let x_fmt = |x, _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 {}", get_day(x)) + } else { + // Hours and minutes + format!("{h}:{m:02}", h = get_hour(x), m = get_minute(x)) + } + }; + + let y_fmt = |y, _range: &RangeInclusive| { + // Display only integer percentages + if !is_approx_zero(y) && is_approx_integer(100.0 * y) { + format!("{}%", get_percent(y)) + } else { + String::new() + } + }; + + let label_fmt = |_s: &str, val: &Value| { + format!( + "Day {d}, {h}:{m:02}\n{p}%", + d = get_day(val.x), + h = get_hour(val.x), + m = get_minute(val.x), + p = get_percent(val.y) + ) + }; + + Plot::new("custom_axes") + .data_aspect(2.0 * MINS_PER_DAY as f32) + .x_axis_formatter(x_fmt) + .y_axis_formatter(y_fmt) + .x_grid_spacer(CustomAxisDemo::x_grid) + .label_formatter(label_fmt) + .show(ui, |plot_ui| { + plot_ui.line(CustomAxisDemo::logistic_fn()); + }) + .response + } +} + #[derive(PartialEq)] struct LinkedAxisDemo { link_x: bool, @@ -604,40 +725,15 @@ impl ChartsDemo { .name("Set 4") .stack_on(&[&chart1, &chart2, &chart3]); - let mut x_fmt: fn(f64, &std::ops::RangeInclusive) -> String = |val, _range| { - if val >= 0.0 && val <= 4.0 && is_approx_integer(val) { - // Only label full days from 0 to 4 - format!("Day {}", val) - } else { - // Otherwise return empty string (i.e. no label) - String::new() - } - }; - - let mut y_fmt: fn(f64, &std::ops::RangeInclusive) -> String = |val, _range| { - let percent = 100.0 * val; - - if is_approx_integer(percent) && !is_approx_zero(percent) { - // Only show integer percentages, - // and don't show at Y=0 (label overlaps with X axis label) - format!("{}%", percent) - } else { - String::new() - } - }; - if !self.vertical { chart1 = chart1.horizontal(); chart2 = chart2.horizontal(); chart3 = chart3.horizontal(); chart4 = chart4.horizontal(); - std::mem::swap(&mut x_fmt, &mut y_fmt); } Plot::new("Stacked Bar Chart Demo") .legend(Legend::default()) - .x_axis_formatter(x_fmt) - .y_axis_formatter(y_fmt) .data_aspect(1.0) .show(ui, |plot_ui| { plot_ui.bar_chart(chart1); @@ -720,6 +816,7 @@ enum Panel { Charts, Items, Interaction, + CustomAxes, LinkedAxes, } @@ -737,6 +834,7 @@ pub struct PlotDemo { charts_demo: ChartsDemo, items_demo: ItemsDemo, interaction_demo: InteractionDemo, + custom_axes_demo: CustomAxisDemo, linked_axes_demo: LinkedAxisDemo, open_panel: Panel, } @@ -782,6 +880,7 @@ impl super::View for PlotDemo { ui.selectable_value(&mut self.open_panel, Panel::Charts, "Charts"); ui.selectable_value(&mut self.open_panel, Panel::Items, "Items"); ui.selectable_value(&mut self.open_panel, Panel::Interaction, "Interaction"); + ui.selectable_value(&mut self.open_panel, Panel::CustomAxes, "Custom Axes"); ui.selectable_value(&mut self.open_panel, Panel::LinkedAxes, "Linked Axes"); }); ui.separator(); @@ -805,6 +904,9 @@ impl super::View for PlotDemo { Panel::Interaction => { ui.add(&mut self.interaction_demo); } + Panel::CustomAxes => { + ui.add(&mut self.custom_axes_demo); + } Panel::LinkedAxes => { ui.add(&mut self.linked_axes_demo); }