From 9aba7a1dac8c51ffa60a80df3defa789610d7ca9 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 26 Jan 2024 13:36:49 +0100 Subject: [PATCH] egui_plot: customizable spacing of grid and axis label spacing (#3896) This lets users specify the spacing of the grid lines and the axis labels, as well as when these start to fade out. New: * `AxisHints::new_x/new_y` (replaces `::default()`) * `AxisHints::label_spacing` * `Plot::grid_spacing` --- crates/egui_demo_lib/src/demo/plot_demo.rs | 32 +-- crates/egui_plot/src/axis.rs | 257 ++++++++++++--------- crates/egui_plot/src/lib.rs | 120 +++++----- 3 files changed, 229 insertions(+), 180 deletions(-) diff --git a/crates/egui_demo_lib/src/demo/plot_demo.rs b/crates/egui_demo_lib/src/demo/plot_demo.rs index fef3b4fc6dd..edd5215bfb5 100644 --- a/crates/egui_demo_lib/src/demo/plot_demo.rs +++ b/crates/egui_demo_lib/src/demo/plot_demo.rs @@ -530,23 +530,27 @@ impl CustomAxesDemo { 100.0 * y } - let x_fmt = |x, _digits, _range: &RangeInclusive| { - if x < 0.0 * MINS_PER_DAY || x >= 5.0 * MINS_PER_DAY { + let time_formatter = |mark: GridMark, _digits, _range: &RangeInclusive| { + let minutes = mark.value; + if minutes < 0.0 || 5.0 * MINS_PER_DAY <= minutes { // No labels outside value bounds String::new() - } else if is_approx_integer(x / MINS_PER_DAY) { + } else if is_approx_integer(minutes / MINS_PER_DAY) { // Days - format!("Day {}", day(x)) + format!("Day {}", day(minutes)) } else { // Hours and minutes - format!("{h}:{m:02}", h = hour(x), m = minute(x)) + format!("{h}:{m:02}", h = hour(minutes), m = minute(minutes)) } }; - 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)) + let percentage_formatter = |mark: GridMark, _digits, _range: &RangeInclusive| { + let percent = 100.0 * mark.value; + if is_approx_zero(percent) { + String::new() // skip zero + } else if is_approx_integer(percent) { + // Display only integer percentages + format!("{percent:.0}%") } else { String::new() } @@ -565,15 +569,15 @@ impl CustomAxesDemo { ui.label("Zoom in on the X-axis to see hours and minutes"); let x_axes = vec![ - AxisHints::default().label("Time").formatter(x_fmt), - AxisHints::default().label("Value"), + AxisHints::new_x().label("Time").formatter(time_formatter), + AxisHints::new_x().label("Value"), ]; let y_axes = vec![ - AxisHints::default() + AxisHints::new_y() .label("Percent") - .formatter(y_fmt) + .formatter(percentage_formatter) .max_digits(4), - AxisHints::default() + AxisHints::new_y() .label("Absolute") .placement(egui_plot::HPlacement::Right), ]; diff --git a/crates/egui_plot/src/axis.rs b/crates/egui_plot/src/axis.rs index cf704c7ae16..d81debed322 100644 --- a/crates/egui_plot/src/axis.rs +++ b/crates/egui_plot/src/axis.rs @@ -1,22 +1,23 @@ use std::{fmt::Debug, ops::RangeInclusive, sync::Arc}; -use egui::emath::{remap_clamp, round_to_decimals, Pos2, Rect}; -use egui::epaint::{Shape, TextShape}; - -use crate::{Response, Sense, TextStyle, Ui, WidgetText}; +use egui::{ + emath::{remap_clamp, round_to_decimals}, + epaint::TextShape, + Pos2, Rangef, Rect, Response, Sense, Shape, TextStyle, Ui, WidgetText, +}; use super::{transform::PlotTransform, GridMark}; -pub(super) type AxisFormatterFn = dyn Fn(f64, usize, &RangeInclusive) -> String; +pub(super) type AxisFormatterFn = dyn Fn(GridMark, usize, &RangeInclusive) -> String; /// X or Y axis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Axis { /// Horizontal X-Axis - X, + X = 0, /// Vertical Y-axis - Y, + Y = 1, } impl From for usize { @@ -82,28 +83,41 @@ pub struct AxisHints { pub(super) formatter: Arc, pub(super) digits: usize, pub(super) placement: Placement, + pub(super) label_spacing: Rangef, } // TODO: this just a guess. It might cease to work if a user changes font size. const LINE_HEIGHT: f32 = 12.0; -impl Default for AxisHints { +impl AxisHints { + /// Initializes a default axis configuration for the X axis. + pub fn new_x() -> Self { + Self::new(Axis::X) + } + + /// Initializes a default axis configuration for the X axis. + pub fn new_y() -> Self { + Self::new(Axis::Y) + } + /// Initializes a default axis configuration for the specified axis. /// /// `label` is empty. /// `formatter` is default float to string formatter. /// maximum `digits` on tick label is 5. - fn default() -> Self { + pub fn new(axis: Axis) -> Self { Self { label: Default::default(), formatter: Arc::new(Self::default_formatter), digits: 5, placement: Placement::LeftBottom, + label_spacing: match axis { + Axis::X => Rangef::new(60.0, 80.0), // labels can get pretty wide + Axis::Y => Rangef::new(20.0, 30.0), // text isn't very high + }, } } -} -impl AxisHints { /// Specify custom formatter for ticks. /// /// The first parameter of `formatter` is the raw tick value as `f64`. @@ -111,13 +125,19 @@ impl AxisHints { /// The second parameter of `formatter` is the currently shown range on this axis. pub fn formatter( mut self, - fmt: impl Fn(f64, usize, &RangeInclusive) -> String + 'static, + fmt: impl Fn(GridMark, usize, &RangeInclusive) -> String + 'static, ) -> Self { self.formatter = Arc::new(fmt); self } - fn default_formatter(tick: f64, max_digits: usize, _range: &RangeInclusive) -> String { + fn default_formatter( + mark: GridMark, + max_digits: usize, + _range: &RangeInclusive, + ) -> String { + let tick = mark.value; + if tick.abs() > 10.0_f64.powf(max_digits as f64) { let tick_rounded = tick as isize; return format!("{tick_rounded:+e}"); @@ -157,6 +177,18 @@ impl AxisHints { self } + /// Set the minimum spacing between labels + /// + /// When labels get closer together than the given minimum, then they become invisible. + /// When they get further apart than the max, they are at full opacity. + /// + /// Labels can never be closer together than the [`crate::Plot::grid_spacing`] setting. + #[inline] + pub fn label_spacing(mut self, range: impl Into) -> Self { + self.label_spacing = range.into(); + self + } + pub(super) fn thickness(&self, axis: Axis) -> f32 { match axis { Axis::X => { @@ -201,114 +233,119 @@ impl AxisWidget { pub fn ui(self, ui: &mut Ui, axis: Axis) -> Response { let response = ui.allocate_rect(self.rect, Sense::hover()); - if ui.is_rect_visible(response.rect) { - let visuals = ui.style().visuals.clone(); - let text = self.hints.label; - let galley = text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Body); - let text_color = visuals - .override_text_color - .unwrap_or_else(|| ui.visuals().text_color()); - let angle: f32 = match axis { - Axis::X => 0.0, - Axis::Y => -std::f32::consts::TAU * 0.25, - }; - // select text_pos and angle depending on placement and orientation of widget - let text_pos = match self.hints.placement { - Placement::LeftBottom => match axis { - Axis::X => { - let pos = response.rect.center_bottom(); - Pos2 { - x: pos.x - galley.size().x / 2.0, - y: pos.y - galley.size().y * 1.25, - } + if !ui.is_rect_visible(response.rect) { + return response; + } + + let visuals = ui.style().visuals.clone(); + let text = self.hints.label; + let galley = text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Body); + let text_color = visuals + .override_text_color + .unwrap_or_else(|| ui.visuals().text_color()); + let angle: f32 = match axis { + Axis::X => 0.0, + Axis::Y => -std::f32::consts::TAU * 0.25, + }; + // select text_pos and angle depending on placement and orientation of widget + let text_pos = match self.hints.placement { + Placement::LeftBottom => match axis { + Axis::X => { + let pos = response.rect.center_bottom(); + Pos2 { + x: pos.x - galley.size().x / 2.0, + y: pos.y - galley.size().y * 1.25, } - Axis::Y => { - let pos = response.rect.left_center(); - Pos2 { - x: pos.x, - y: pos.y + galley.size().x / 2.0, - } + } + Axis::Y => { + let pos = response.rect.left_center(); + Pos2 { + x: pos.x, + y: pos.y + galley.size().x / 2.0, + } + } + }, + Placement::RightTop => match axis { + Axis::X => { + let pos = response.rect.center_top(); + Pos2 { + x: pos.x - galley.size().x / 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 * 1.5, + y: pos.y + galley.size().x / 2.0, } - }, - Placement::RightTop => match axis { + } + }, + }; + + ui.painter() + .add(TextShape::new(text_pos, galley, text_color).with_angle(angle)); + + // --- add ticks --- + let font_id = TextStyle::Body.resolve(ui.style()); + let Some(transform) = self.transform else { + return response; + }; + + let label_spacing = self.hints.label_spacing; + + for step in self.steps.iter() { + let text = (self.hints.formatter)(*step, self.hints.digits, &self.range); + if !text.is_empty() { + let spacing_in_points = + (transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32; + + if spacing_in_points <= label_spacing.min { + // Labels are too close together - don't paint them. + continue; + } + + // Fade in labels as they get further apart: + let strength = remap_clamp(spacing_in_points, label_spacing, 0.0..=1.0); + + let text_color = super::color_from_strength(ui, strength); + let galley = ui + .painter() + .layout_no_wrap(text, font_id.clone(), text_color); + + if spacing_in_points < galley.size()[axis as usize] { + continue; // the galley won't fit + } + + let text_pos = match axis { Axis::X => { - let pos = response.rect.center_top(); + let y = match self.hints.placement { + Placement::LeftBottom => self.rect.min.y, + Placement::RightTop => self.rect.max.y - galley.size().y, + }; + let projected_point = super::PlotPoint::new(step.value, 0.0); Pos2 { - x: pos.x - galley.size().x / 2.0, - y: pos.y + galley.size().y * 0.25, + x: transform.position_from_point(&projected_point).x + - galley.size().x / 2.0, + y, } } Axis::Y => { - let pos = response.rect.right_center(); + let x = match self.hints.placement { + Placement::LeftBottom => self.rect.max.x - galley.size().x, + Placement::RightTop => self.rect.min.x, + }; + let projected_point = super::PlotPoint::new(0.0, step.value); Pos2 { - x: pos.x - galley.size().y * 1.5, - y: pos.y + galley.size().x / 2.0, + x, + y: transform.position_from_point(&projected_point).y + - galley.size().y / 2.0, } } - }, - }; - - ui.painter() - .add(TextShape::new(text_pos, galley, text_color).with_angle(angle)); - - // --- add ticks --- - let font_id = TextStyle::Body.resolve(ui.style()); - let Some(transform) = self.transform else { - return response; - }; - - for step in self.steps.iter() { - let text = (self.hints.formatter)(step.value, self.hints.digits, &self.range); - if !text.is_empty() { - const MIN_TEXT_SPACING: f32 = 20.0; - const FULL_CONTRAST_SPACING: f32 = 40.0; - let spacing_in_points = - (transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32; - - if spacing_in_points <= MIN_TEXT_SPACING { - continue; - } - let line_strength = remap_clamp( - spacing_in_points, - MIN_TEXT_SPACING..=FULL_CONTRAST_SPACING, - 0.0..=1.0, - ); - - let line_color = super::color_from_strength(ui, line_strength); - let galley = ui - .painter() - .layout_no_wrap(text, font_id.clone(), line_color); - - let text_pos = match axis { - Axis::X => { - let y = match self.hints.placement { - Placement::LeftBottom => self.rect.min.y, - Placement::RightTop => 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, - } - } - Axis::Y => { - let x = match self.hints.placement { - Placement::LeftBottom => self.rect.max.x - galley.size().x, - Placement::RightTop => self.rect.min.x, - }; - let projected_point = super::PlotPoint::new(0.0, step.value); - Pos2 { - x, - y: transform.position_from_point(&projected_point).y - - galley.size().y / 2.0, - } - } - }; + }; - ui.painter() - .add(Shape::galley(text_pos, galley, text_color)); - } + ui.painter() + .add(Shape::galley(text_pos, galley, text_color)); } } diff --git a/crates/egui_plot/src/lib.rs b/crates/egui_plot/src/lib.rs index 5dc159f288f..4fe89ff367d 100644 --- a/crates/egui_plot/src/lib.rs +++ b/crates/egui_plot/src/lib.rs @@ -79,10 +79,6 @@ impl Default for CoordinatesFormatter { // ---------------------------------------------------------------------------- -const MIN_LINE_SPACING_IN_POINTS: f64 = 6.0; // TODO(emilk): large enough for a wide label - -// ---------------------------------------------------------------------------- - /// Indicates a vertical or horizontal cursor line in plot coordinates. #[derive(Copy, Clone, PartialEq)] enum Cursor { @@ -175,7 +171,9 @@ pub struct Plot { legend_config: Option, show_background: bool, show_axes: Vec2b, + show_grid: Vec2b, + grid_spacing: Rangef, grid_spacers: [GridSpacer; 2], sharp_grid_lines: bool, clamp_grid: bool, @@ -213,12 +211,14 @@ impl Plot { show_y: true, label_formatter: None, coordinates_formatter: None, - x_axes: vec![Default::default()], - y_axes: vec![Default::default()], + x_axes: vec![AxisHints::new(Axis::X)], + y_axes: vec![AxisHints::new(Axis::Y)], legend_config: None, show_background: true, show_axes: true.into(), + show_grid: true.into(), + grid_spacing: Rangef::new(8.0, 300.0), grid_spacers: [log_grid_spacer(10), log_grid_spacer(10)], sharp_grid_lines: true, clamp_grid: false, @@ -453,6 +453,17 @@ impl Plot { self } + /// Set when the grid starts showing. + /// + /// When grid lines are closer than the given minimum, they will be hidden. + /// When they get further apart they will fade in, until the reaches the given maximum, + /// at which point they are fully opaque. + #[inline] + pub fn grid_spacing(mut self, grid_spacing: impl Into) -> Self { + self.grid_spacing = grid_spacing.into(); + self + } + /// Clamp the grid to only be visible at the range of data where we have values. /// /// Default: `false`. @@ -624,12 +635,12 @@ impl Plot { /// Specify custom formatter for ticks on the main X-axis. /// /// Arguments of `fmt`: - /// * raw tick value as `f64`. + /// * 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(f64, usize, &RangeInclusive) -> String + 'static, + fmt: impl Fn(GridMark, usize, &RangeInclusive) -> String + 'static, ) -> Self { if let Some(main) = self.x_axes.first_mut() { main.formatter = Arc::new(fmt); @@ -640,12 +651,12 @@ impl Plot { /// Specify custom formatter for ticks on the main Y-axis. /// /// Arguments of `fmt`: - /// * raw tick value as `f64`. + /// * 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(f64, usize, &RangeInclusive) -> String + 'static, + fmt: impl Fn(GridMark, usize, &RangeInclusive) -> String + 'static, ) -> Self { if let Some(main) = self.y_axes.first_mut() { main.formatter = Arc::new(fmt); @@ -723,6 +734,7 @@ impl Plot { show_background, show_axes, show_grid, + grid_spacing, linked_axes, linked_cursors, @@ -827,7 +839,7 @@ impl Plot { } // Allocate the plot window. - let response = ui.allocate_rect(plot_rect, Sense::drag()); + let response = ui.allocate_rect(plot_rect, Sense::click_and_drag()); let rect = plot_rect; // Load or initialize the memory. @@ -1131,7 +1143,7 @@ impl Plot { let x_steps = Arc::new({ let input = GridInput { bounds: (bounds.min[0], bounds.max[0]), - base_step_size: transform.dvalue_dpos()[0] * MIN_LINE_SPACING_IN_POINTS * 2.0, + base_step_size: transform.dvalue_dpos()[0].abs() * grid_spacing.min as f64, }; (grid_spacers[0])(input) }); @@ -1139,7 +1151,7 @@ impl Plot { let y_steps = Arc::new({ let input = GridInput { bounds: (bounds.min[1], bounds.max[1]), - base_step_size: transform.dvalue_dpos()[1] * MIN_LINE_SPACING_IN_POINTS * 2.0, + base_step_size: transform.dvalue_dpos()[1].abs() * grid_spacing.min as f64, }; (grid_spacers[1])(input) }); @@ -1168,6 +1180,7 @@ impl Plot { label_formatter, coordinates_formatter, show_grid, + grid_spacing, transform, draw_cursor_x: linked_cursors.as_ref().map_or(false, |group| group.1.x), draw_cursor_y: linked_cursors.as_ref().map_or(false, |group| group.1.y), @@ -1565,6 +1578,8 @@ pub struct GridInput { /// /// 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. + /// + /// Always positive. pub base_step_size: f64, } @@ -1634,6 +1649,7 @@ struct PreparedPlot { // axis_formatters: [AxisFormatter; 2], transform: PlotTransform, show_grid: Vec2b, + grid_spacing: Rangef, grid_spacers: [GridSpacer; 2], draw_cursor_x: bool, draw_cursor_y: bool, @@ -1648,10 +1664,10 @@ impl PreparedPlot { let mut axes_shapes = Vec::new(); if self.show_grid.x { - self.paint_grid(ui, &mut axes_shapes, Axis::X); + self.paint_grid(ui, &mut axes_shapes, Axis::X, self.grid_spacing); } if self.show_grid.y { - self.paint_grid(ui, &mut axes_shapes, Axis::Y); + self.paint_grid(ui, &mut axes_shapes, Axis::Y, self.grid_spacing); } // Sort the axes by strength so that those with higher strength are drawn in front. @@ -1728,7 +1744,7 @@ impl PreparedPlot { cursors } - fn paint_grid(&self, ui: &Ui, shapes: &mut Vec<(Shape, f32)>, axis: Axis) { + fn paint_grid(&self, ui: &Ui, shapes: &mut Vec<(Shape, f32)>, axis: Axis, fade_range: Rangef) { #![allow(clippy::collapsible_else_if)] let Self { transform, @@ -1746,7 +1762,7 @@ impl PreparedPlot { let input = GridInput { bounds: (bounds.min[iaxis], bounds.max[iaxis]), - base_step_size: transform.dvalue_dpos()[iaxis] * MIN_LINE_SPACING_IN_POINTS, + base_step_size: transform.dvalue_dpos()[iaxis].abs() * fade_range.min as f64, }; let steps = (grid_spacers[iaxis])(input); @@ -1786,44 +1802,42 @@ impl PreparedPlot { let pos_in_gui = transform.position_from_point(&value); let spacing_in_points = (transform.dpos_dvalue()[iaxis] * step.step_size).abs() as f32; - if spacing_in_points > MIN_LINE_SPACING_IN_POINTS as f32 { - let line_strength = remap_clamp( - spacing_in_points, - MIN_LINE_SPACING_IN_POINTS as f32..=300.0, - 0.0..=1.0, - ); + if spacing_in_points <= fade_range.min { + continue; // Too close together + } - let line_color = color_from_strength(ui, line_strength); + let line_strength = remap_clamp(spacing_in_points, fade_range, 0.0..=1.0); - let mut p0 = pos_in_gui; - let mut p1 = pos_in_gui; - p0[1 - iaxis] = transform.frame().min[1 - iaxis]; - p1[1 - iaxis] = transform.frame().max[1 - iaxis]; + let line_color = color_from_strength(ui, line_strength); - if let Some(clamp_range) = clamp_range { - match axis { - Axis::X => { - p0.y = transform.position_from_point_y(clamp_range.min[1]); - p1.y = transform.position_from_point_y(clamp_range.max[1]); - } - Axis::Y => { - p0.x = transform.position_from_point_x(clamp_range.min[0]); - p1.x = transform.position_from_point_x(clamp_range.max[0]); - } - } - } + let mut p0 = pos_in_gui; + let mut p1 = pos_in_gui; + p0[1 - iaxis] = transform.frame().min[1 - iaxis]; + p1[1 - iaxis] = transform.frame().max[1 - iaxis]; - if self.sharp_grid_lines { - // Round to avoid aliasing - p0 = ui.painter().round_pos_to_pixels(p0); - p1 = ui.painter().round_pos_to_pixels(p1); + if let Some(clamp_range) = clamp_range { + match axis { + Axis::X => { + p0.y = transform.position_from_point_y(clamp_range.min[1]); + p1.y = transform.position_from_point_y(clamp_range.max[1]); + } + Axis::Y => { + p0.x = transform.position_from_point_x(clamp_range.min[0]); + p1.x = transform.position_from_point_x(clamp_range.max[0]); + } } + } - shapes.push(( - Shape::line_segment([p0, p1], Stroke::new(1.0, line_color)), - line_strength, - )); + if self.sharp_grid_lines { + // Round to avoid aliasing + p0 = ui.painter().round_pos_to_pixels(p0); + p1 = ui.painter().round_pos_to_pixels(p1); } + + shapes.push(( + Shape::line_segment([p0, p1], Stroke::new(1.0, line_color)), + line_strength, + )); } } @@ -1932,12 +1946,6 @@ pub fn format_number(number: f64, num_decimals: usize) -> String { /// Determine a color from a 0-1 strength value. pub fn color_from_strength(ui: &Ui, strength: f32) -> Color32 { - let bg = ui.visuals().extreme_bg_color; - let fg = ui.visuals().widgets.open.fg_stroke.color; - let mix = 0.5 * strength.sqrt(); - Color32::from_rgb( - lerp((bg.r() as f32)..=(fg.r() as f32), mix) as u8, - lerp((bg.g() as f32)..=(fg.g() as f32), mix) as u8, - lerp((bg.b() as f32)..=(fg.b() as f32), mix) as u8, - ) + let base_color = ui.visuals().text_color(); + base_color.gamma_multiply(strength.sqrt()) }