From dbe55ba46a7987bb4caa94856112ed48d1f9407b Mon Sep 17 00:00:00 2001 From: JohannesProgrammiert <80203331+JohannesProgrammiert@users.noreply.github.com> Date: Mon, 14 Aug 2023 17:51:17 +0200 Subject: [PATCH] Draw axis labels and ticks outside of plotting window (#2284) * Always draw axis labels at plot borders * Revert "Always draw axis labels at plot borders" This reverts commit 9235e6603366d3b8a8189e2a5fc28c9780b7f54f. * Add axis labels for plots * First Draft of axis labels outside of plotting window * plot: Tick placement of opposite axes and digit constraints * plot: Axis label API * plot: Update demo lib * plot: resolve clippy warning * Update changelog * Remove default axis * Fix clippy * plot: Remove unused comments * plot-axis: Rebase label opacity calculation on master * plot: Resolve check.sh warnings * plot-axis: Use 'into impl' as axis label formatter * plot-axis: Expose more conveniece functions to public API. Add axis labels to demo app * plot-axes: Resolve ./scripts/check.sh warnings * typo in comment * Use `TAU` instead of the legacy `PI` * Simpler generic syntax * Use `Arc` to avoid some expensive clones * Use `Margin` instead of a,b,c,d * Add some vertical spacing * De-duplicate color_from_contrast * better naming * Fix typos * cnt -> num * Axis are present by default, with empty names * Add HPlacement and VPlacement * Don't catch clicks and drags on axes * Remove generics to minimize monomorphization code bloat * Create helper function * Remove changelog entry * Simplify more --------- Co-authored-by: Emil Ernerfeldt --- crates/egui/src/style.rs | 7 + crates/egui/src/widgets/mod.rs | 2 + crates/egui/src/widgets/plot/axis.rs | 318 ++++++++++ crates/egui/src/widgets/plot/memory.rs | 33 ++ crates/egui/src/widgets/plot/mod.rs | 549 ++++++++++++------ crates/egui_demo_lib/src/demo/plot_demo.rs | 99 ++-- .../egui_demo_lib/src/demo/widget_gallery.rs | 1 + 7 files changed, 807 insertions(+), 202 deletions(-) create mode 100644 crates/egui/src/widgets/plot/axis.rs create mode 100644 crates/egui/src/widgets/plot/memory.rs diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 4a8dba7c..d8517fc9 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -338,6 +338,13 @@ pub struct Margin { } impl Margin { + pub const ZERO: Self = Self { + left: 0.0, + right: 0.0, + top: 0.0, + bottom: 0.0, + }; + #[inline] pub fn same(margin: f32) -> Self { Self { diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index f782056b..5cfe1816 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -38,6 +38,8 @@ pub use text_edit::{TextBuffer, TextEdit}; /// /// [`Button`], [`Label`], [`Slider`], etc all implement the [`Widget`] trait. /// +/// You only need to implement `Widget` if you care about being able to do `ui.add(your_widget);`. +/// /// Note that the widgets ([`Button`], [`TextEdit`] etc) are /// [builders](https://doc.rust-lang.org/1.0.0/style/ownership/builders.html), /// and not objects that hold state. diff --git a/crates/egui/src/widgets/plot/axis.rs b/crates/egui/src/widgets/plot/axis.rs new file mode 100644 index 00000000..6430378b --- /dev/null +++ b/crates/egui/src/widgets/plot/axis.rs @@ -0,0 +1,318 @@ +use std::{fmt::Debug, ops::RangeInclusive, sync::Arc}; + +use epaint::{ + emath::{remap_clamp, round_to_decimals}, + Pos2, Rect, Shape, Stroke, TextShape, +}; + +use crate::{Response, Sense, TextStyle, Ui, WidgetText}; + +use super::{transform::PlotTransform, GridMark}; + +pub(super) type AxisFormatterFn = fn(f64, usize, &RangeInclusive) -> String; + +/// X or Y axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + /// Horizontal X-Axis + X, + + /// Vertical Y-axis + Y, +} + +impl From for usize { + #[inline] + fn from(value: Axis) -> Self { + match value { + Axis::X => 0, + Axis::Y => 1, + } + } +} + +/// Placement of the horizontal X-Axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VPlacement { + Top, + Bottom, +} + +/// Placement of the vertical Y-Axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HPlacement { + Left, + Right, +} + +/// Placement of an axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Placement { + /// Bottom for X-axis, or left for Y-axis. + LeftBottom, + + /// Top for x-axis and right for y-axis. + RightTop, +} + +impl From for Placement { + #[inline] + fn from(placement: HPlacement) -> Self { + match placement { + HPlacement::Left => Placement::LeftBottom, + HPlacement::Right => Placement::RightTop, + } + } +} + +impl From for Placement { + #[inline] + fn from(placement: VPlacement) -> Self { + match placement { + VPlacement::Top => Placement::RightTop, + VPlacement::Bottom => Placement::LeftBottom, + } + } +} + +/// Axis configuration. +/// +/// Used to configure axis label and ticks. +#[derive(Clone)] +pub struct AxisHints { + pub(super) label: WidgetText, + pub(super) formatter: AxisFormatterFn, + pub(super) digits: usize, + pub(super) placement: Placement, +} + +// 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 { + /// 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 { + Self { + label: Default::default(), + formatter: Self::default_formatter, + digits: 5, + placement: Placement::LeftBottom, + } + } +} + +impl AxisHints { + /// 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: fn(f64, usize, &RangeInclusive) -> String) -> Self { + self.formatter = fmt; + self + } + + 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!("{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() + } + + /// Specify axis label. + /// + /// The default is 'x' for x-axes and 'y' for y-axes. + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + + /// Specify maximum number of digits for ticks. + /// + /// This is considered by the default tick formatter and affects the width of the y-axis + pub fn max_digits(mut self, digits: usize) -> Self { + self.digits = digits; + self + } + + /// Specify the placement of the axis. + /// + /// For X-axis, use [`VPlacement`]. + /// For Y-axis, use [`HPlacement`]. + pub fn placement(mut self, placement: impl Into) -> Self { + self.placement = placement.into(); + self + } + + 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::Y => { + if self.label.is_empty() { + (self.digits as f32) * LINE_HEIGHT + } else { + (self.digits as f32 + 1.0) * LINE_HEIGHT + } + } + } + } +} + +#[derive(Clone)] +pub(super) struct AxisWidget { + pub(super) range: RangeInclusive, + pub(super) hints: AxisHints, + pub(super) rect: Rect, + pub(super) transform: Option, + pub(super) steps: Arc>, +} + +impl AxisWidget { + /// if `rect` as width or height == 0, is will be automatically calculated from ticks and text. + pub(super) fn new(hints: AxisHints, rect: Rect) -> Self { + Self { + range: (0.0..=0.0), + hints, + rect, + transform: None, + steps: Default::default(), + } + } + + 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, + } + } + 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, + } + } + }, + }; + let shape = TextShape { + pos: text_pos, + galley: galley.galley, + underline: Stroke::NONE, + override_text_color: Some(text_color), + angle, + }; + ui.painter().add(shape); + + // --- add ticks --- + let font_id = TextStyle::Body.resolve(ui.style()); + let transform = match self.transform { + Some(t) => t, + None => 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)); + } + } + } + + response + } +} diff --git a/crates/egui/src/widgets/plot/memory.rs b/crates/egui/src/widgets/plot/memory.rs new file mode 100644 index 00000000..3e47a508 --- /dev/null +++ b/crates/egui/src/widgets/plot/memory.rs @@ -0,0 +1,33 @@ +use epaint::Pos2; + +use crate::{Context, Id}; + +use super::{transform::ScreenTransform, AxisBools}; + +/// Information about the plot that has to persist between frames. +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Clone)] +pub(super) struct PlotMemory { + /// Indicates if the user has modified the bounds, for example by moving or zooming, + /// or if the bounds should be calculated based by included point or auto bounds. + pub(super) bounds_modified: AxisBools, + + pub(super) hovered_entry: Option, + + pub(super) hidden_items: ahash::HashSet, + + pub(super) last_screen_transform: ScreenTransform, + + /// Allows to remember the first click position when performing a boxed zoom + pub(super) last_click_pos_for_zoom: Option, +} + +impl PlotMemory { + pub fn load(ctx: &Context, id: Id) -> Option { + ctx.data().get_persisted(id) + } + + pub fn store(self, ctx: &Context, id: Id) { + ctx.data().insert_persisted(id, self); + } +} diff --git a/crates/egui/src/widgets/plot/mod.rs b/crates/egui/src/widgets/plot/mod.rs index ebc73d72..456f3dc1 100644 --- a/crates/egui/src/widgets/plot/mod.rs +++ b/crates/egui/src/widgets/plot/mod.rs @@ -1,15 +1,17 @@ //! Simple plotting library. -use ahash::HashMap; -use std::ops::RangeInclusive; +use std::{ops::RangeInclusive, sync::Arc}; -use crate::*; +use ahash::HashMap; use epaint::util::FloatOrd; use epaint::Hsva; +use axis::AxisWidget; use items::PlotItem; use legend::LegendWidget; +use crate::*; + pub use items::{ Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, HLine, Line, LineStyle, MarkerShape, Orientation, PlotImage, PlotPoint, PlotPoints, Points, Polygon, Text, VLine, @@ -17,16 +19,17 @@ pub use items::{ pub use legend::{Corner, Legend}; pub use transform::{PlotBounds, PlotTransform}; -use self::items::{horizontal_line, rulers_color, vertical_line}; +use items::{horizontal_line, rulers_color, vertical_line}; +pub use axis::{Axis, AxisHints, HPlacement, Placement, VPlacement}; + +mod axis; mod items; mod legend; mod transform; type LabelFormatterFn = dyn Fn(&str, &PlotPoint) -> String; type LabelFormatter = Option>; -type AxisFormatterFn = dyn Fn(f64, &RangeInclusive) -> String; -type AxisFormatter = Option>; type GridSpacerFn = dyn Fn(GridInput) -> Vec; type GridSpacer = Box; @@ -78,6 +81,7 @@ pub struct AxisBools { } impl AxisBools { + #[inline] pub fn new(x: bool, y: bool) -> Self { Self { x, y } } @@ -89,11 +93,19 @@ impl AxisBools { } impl From for AxisBools { + #[inline] fn from(val: bool) -> Self { AxisBools { x: val, y: val } } } +impl From<[bool; 2]> for AxisBools { + #[inline] + fn from([x, y]: [bool; 2]) -> Self { + AxisBools { x, y } + } +} + /// Information about the plot that has to persist between frames. #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Clone)] @@ -182,8 +194,7 @@ pub struct PlotResponse { pub struct Plot { id_source: Id, - center_x_axis: bool, - center_y_axis: bool, + center_axis: AxisBools, allow_zoom: AxisBools, allow_drag: AxisBools, allow_scroll: bool, @@ -208,11 +219,12 @@ pub struct Plot { show_y: bool, label_formatter: LabelFormatter, coordinates_formatter: Option<(Corner, CoordinatesFormatter)>, - axis_formatters: [AxisFormatter; 2], + x_axes: Vec, // default x axes + y_axes: Vec, // default y axes legend_config: Option, show_background: bool, - show_axes: [bool; 2], - + show_axes: AxisBools, + show_grid: AxisBools, grid_spacers: [GridSpacer; 2], sharp_grid_lines: bool, clamp_grid: bool, @@ -224,8 +236,7 @@ impl Plot { Self { id_source: Id::new(id_source), - center_x_axis: false, - center_y_axis: false, + center_axis: false.into(), allow_zoom: true.into(), allow_drag: true.into(), allow_scroll: true, @@ -250,11 +261,12 @@ impl Plot { show_y: true, label_formatter: None, coordinates_formatter: None, - axis_formatters: [None, None], // [None; 2] requires Copy + x_axes: vec![Default::default()], + y_axes: vec![Default::default()], legend_config: None, show_background: true, - show_axes: [true; 2], - + show_axes: true.into(), + show_grid: true.into(), grid_spacers: [log_grid_spacer(10), log_grid_spacer(10)], sharp_grid_lines: true, clamp_grid: false, @@ -311,15 +323,15 @@ impl Plot { self } - /// Always keep the x-axis centered. Default: `false`. + /// Always keep the X-axis centered. Default: `false`. pub fn center_x_axis(mut self, on: bool) -> Self { - self.center_x_axis = on; + self.center_axis.x = on; self } - /// Always keep the y-axis centered. Default: `false`. + /// Always keep the Y-axis centered. Default: `false`. pub fn center_y_axis(mut self, on: bool) -> Self { - self.center_y_axis = on; + self.center_axis.y = on; self } @@ -417,36 +429,6 @@ impl Plot { self } - /// Provide a function to customize the labels for the X axis based on the current visible value range. - /// - /// This is useful for custom input domains, e.g. date/time. - /// - /// If axis labels should not appear for certain values or beyond a certain zoom/resolution, - /// the formatter function can return empty strings. This is also useful if your domain is - /// discrete (e.g. only full days in a calendar). - pub fn x_axis_formatter( - mut self, - func: impl Fn(f64, &RangeInclusive) -> String + 'static, - ) -> Self { - self.axis_formatters[0] = Some(Box::new(func)); - self - } - - /// Provide a function to customize the labels for the Y axis based on the current value range. - /// - /// This is useful for custom value representation, e.g. percentage or units. - /// - /// If axis labels should not appear for certain values or beyond a certain zoom/resolution, - /// the formatter function can return empty strings. This is also useful if your Y values are - /// discrete (e.g. only integers). - pub fn y_axis_formatter( - mut self, - func: impl Fn(f64, &RangeInclusive) -> String + 'static, - ) -> Self { - self.axis_formatters[1] = Some(Box::new(func)); - 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. @@ -538,11 +520,19 @@ impl Plot { self } - /// Show the axes. - /// Can be useful to disable if the plot is overlaid over an existing grid or content. + /// Show axis labels and grid tick values on the side of the plot. + /// /// Default: `[true; 2]`. - pub fn show_axes(mut self, show: [bool; 2]) -> Self { - self.show_axes = show; + pub fn show_axes(mut self, show: impl Into) -> Self { + self.show_axes = show.into(); + self + } + + /// Show a grid overlay on the plot. + /// + /// Default: `[true; 2]`. + pub fn show_grid(mut self, show: impl Into) -> Self { + self.show_grid = show.into(); self } @@ -585,6 +575,94 @@ impl Plot { self } + /// Set the x axis label of the main X-axis. + /// + /// Default: no label. + pub fn x_axis_label(mut self, label: impl Into) -> Self { + if let Some(main) = self.x_axes.first_mut() { + main.label = label.into(); + } + self + } + + /// Set the y axis label of the main Y-axis. + /// + /// Default: no label. + pub fn y_axis_label(mut self, label: impl Into) -> Self { + if let Some(main) = self.y_axes.first_mut() { + main.label = label.into(); + } + self + } + + /// Set the position of the main X-axis. + pub fn x_axis_position(mut self, placement: axis::VPlacement) -> Self { + if let Some(main) = self.x_axes.first_mut() { + main.placement = placement.into(); + } + self + } + + /// Set the position of the main Y-axis. + pub fn y_axis_position(mut self, placement: axis::HPlacement) -> Self { + if let Some(main) = self.y_axes.first_mut() { + main.placement = placement.into(); + } + self + } + + /// Specify custom formatter for ticks on the main X-axis. + /// + /// The first parameter of `fmt` is the raw tick value as `f64`. + /// The second parameter is the maximum requested number of characters per tick label. + /// The second parameter of `fmt` is the currently shown range on this axis. + pub fn x_axis_formatter(mut self, fmt: fn(f64, usize, &RangeInclusive) -> String) -> Self { + if let Some(main) = self.x_axes.first_mut() { + main.formatter = fmt; + } + self + } + + /// Specify custom formatter for ticks on the main Y-axis. + /// + /// The first parameter of `formatter` is the raw tick value as `f64`. + /// The second parameter is the maximum requested number of characters per tick label. + /// The second parameter of `formatter` is the currently shown range on this axis. + pub fn y_axis_formatter(mut self, fmt: fn(f64, usize, &RangeInclusive) -> String) -> Self { + if let Some(main) = self.y_axes.first_mut() { + main.formatter = fmt; + } + self + } + + /// Set the main Y-axis-width by number of digits + /// + /// The default is 5 digits. + /// + /// > Todo: This is experimental. Changing the font size might break this. + pub fn y_axis_width(mut self, digits: usize) -> Self { + if let Some(main) = self.y_axes.first_mut() { + main.digits = digits; + } + self + } + + /// Set custom configuration for X-axis + /// + /// More than one axis may be specified. The first specified axis is considered the main axis. + pub fn custom_x_axes(mut self, hints: Vec) -> Self { + self.x_axes = hints; + self + } + + /// Set custom configuration for left Y-axis + /// + /// More than one axis may be specified. The first specified axis is considered the main axis. + pub fn custom_y_axes(mut self, hints: Vec) -> Self { + self.y_axes = hints; + self + } + /// Interact with and add items to the plot and finally draw it. pub fn show(self, ui: &mut Ui, build_fn: impl FnOnce(&mut PlotUi) -> R) -> PlotResponse { self.show_dyn(ui, Box::new(build_fn)) @@ -597,8 +675,7 @@ impl Plot { ) -> PlotResponse { let Self { id_source, - center_x_axis, - center_y_axis, + center_axis, allow_zoom, allow_drag, allow_scroll, @@ -617,11 +694,13 @@ impl Plot { mut show_y, label_formatter, coordinates_formatter, - axis_formatters, + x_axes, + y_axes, legend_config, reset, show_background, show_axes, + show_grid, linked_axes, linked_cursors, @@ -630,7 +709,9 @@ impl Plot { sharp_grid_lines, } = self; - // Determine the size of the plot in the UI + // Determine position of widget. + let pos = ui.available_rect_before_wrap().min; + // Determine size of widget. let size = { let width = width .unwrap_or_else(|| { @@ -653,9 +734,79 @@ impl Plot { .at_least(min_size.y); vec2(width, height) }; + // Determine complete rect of widget. + let complete_rect = Rect { + min: pos, + max: pos + size, + }; + // Next we want to create this layout. + // Incides are only examples. + // + // left right + // +---+---------x----------+ + + // | | X-axis 3 | + // | +--------------------+ top + // | | X-axis 2 | + // +-+-+--------------------+-+-+ + // |y|y| |y|y| + // |-|-| |-|-| + // |A|A| |A|A| + // y|x|x| Plot Window |x|x| + // |i|i| |i|i| + // |s|s| |s|s| + // |1|0| |2|3| + // +-+-+--------------------+-+-+ + // | X-axis 0 | | + // +--------------------+ | bottom + // | X-axis 1 | | + // + +--------------------+---+ + // - // Allocate the space. - let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); + let mut plot_rect: Rect = { + // Calcuclate the space needed for each axis labels. + let mut margin = Margin::ZERO; + if show_axes.x { + for cfg in &x_axes { + match cfg.placement { + axis::Placement::LeftBottom => { + margin.bottom += cfg.thickness(Axis::X); + } + axis::Placement::RightTop => { + margin.top += cfg.thickness(Axis::X); + } + } + } + } + if show_axes.y { + for cfg in &y_axes { + match cfg.placement { + axis::Placement::LeftBottom => { + margin.left += cfg.thickness(Axis::Y); + } + axis::Placement::RightTop => { + margin.right += cfg.thickness(Axis::Y); + } + } + } + } + + // determine plot rectangle + margin.shrink_rect(complete_rect) + }; + + let [mut x_axis_widgets, mut y_axis_widgets] = + axis_widgets(show_axes, plot_rect, [&x_axes, &y_axes]); + + // If too little space, remove axis widgets + if plot_rect.width() <= 0.0 || plot_rect.height() <= 0.0 { + y_axis_widgets.clear(); + x_axis_widgets.clear(); + plot_rect = complete_rect; + } + + // Allocate the plot window. + let response = ui.allocate_rect(plot_rect, Sense::drag()); + let rect = plot_rect; // Load or initialize the memory. let plot_id = ui.make_persistent_id(id_source); @@ -679,8 +830,8 @@ impl Plot { last_plot_transform: PlotTransform::new( rect, min_auto_bounds, - center_x_axis, - center_y_axis, + center_axis.x, + center_axis.y, ), last_click_pos_for_zoom: None, }); @@ -841,7 +992,7 @@ impl Plot { } } - let mut transform = PlotTransform::new(rect, bounds, center_x_axis, center_y_axis); + let mut transform = PlotTransform::new(rect, bounds, center_axis.x, center_axis.y); // Enforce aspect ratio if let Some(data_aspect) = data_aspect { @@ -949,6 +1100,39 @@ impl Plot { } } + // --- transform initialized + + // Add legend widgets to plot + let bounds = transform.bounds(); + let x_axis_range = bounds.range_x(); + 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, + }; + (grid_spacers[0])(input) + }); + let y_axis_range = bounds.range_y(); + 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, + }; + (grid_spacers[1])(input) + }); + for mut widget in x_axis_widgets { + widget.range = x_axis_range.clone(); + widget.transform = Some(transform); + widget.steps = x_steps.clone(); + widget.ui(ui, Axis::X); + } + for mut widget in y_axis_widgets { + widget.range = y_axis_range.clone(); + widget.transform = Some(transform); + widget.steps = y_steps.clone(); + widget.ui(ui, Axis::Y); + } + // Initialize values from functions. for item in &mut items { item.initialize(transform.bounds().range_x()); @@ -960,16 +1144,16 @@ impl Plot { show_y, label_formatter, coordinates_formatter, - axis_formatters, - show_axes, + show_grid, transform, - draw_cursor_x: linked_cursors.as_ref().map_or(false, |(_, group)| group.x), - draw_cursor_y: linked_cursors.as_ref().map_or(false, |(_, group)| group.y), + 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), draw_cursors, grid_spacers, sharp_grid_lines, clamp_grid, }; + let plot_cursors = prepared.ui(ui, &response); if let Some(boxed_zoom_rect) = boxed_zoom_rect { @@ -1024,7 +1208,7 @@ impl Plot { } else { response }; - + ui.advance_cursor_after_rect(complete_rect); PlotResponse { inner, response, @@ -1033,6 +1217,79 @@ impl Plot { } } +fn axis_widgets( + show_axes: AxisBools, + plot_rect: Rect, + [x_axes, y_axes]: [&[AxisHints]; 2], +) -> [Vec; 2] { + let mut x_axis_widgets = Vec::::new(); + let mut y_axis_widgets = Vec::::new(); + + // Widget count per border of plot in order left, top, right, bottom + struct NumWidgets { + left: usize, + top: usize, + right: usize, + bottom: usize, + } + let mut num_widgets = NumWidgets { + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + if show_axes.x { + for cfg in x_axes { + let size_y = Vec2::new(0.0, cfg.thickness(Axis::X)); + let rect = match cfg.placement { + axis::Placement::LeftBottom => { + let off = num_widgets.bottom as f32; + num_widgets.bottom += 1; + Rect { + min: plot_rect.left_bottom() + size_y * off, + max: plot_rect.right_bottom() + size_y * (off + 1.0), + } + } + axis::Placement::RightTop => { + let off = num_widgets.top as f32; + num_widgets.top += 1; + Rect { + min: plot_rect.left_top() - size_y * (off + 1.0), + max: plot_rect.right_top() - size_y * off, + } + } + }; + x_axis_widgets.push(AxisWidget::new(cfg.clone(), rect)); + } + } + if show_axes.y { + for cfg in y_axes { + let size_x = Vec2::new(cfg.thickness(Axis::Y), 0.0); + let rect = match cfg.placement { + axis::Placement::LeftBottom => { + let off = num_widgets.left as f32; + num_widgets.left += 1; + Rect { + min: plot_rect.left_top() - size_x * (off + 1.0), + max: plot_rect.left_bottom() - size_x * off, + } + } + axis::Placement::RightTop => { + let off = num_widgets.right as f32; + num_widgets.right += 1; + Rect { + min: plot_rect.right_top() + size_x * off, + max: plot_rect.right_bottom() + size_x * (off + 1.0), + } + } + }; + y_axis_widgets.push(AxisWidget::new(cfg.clone(), rect)); + } + } + + [x_axis_widgets, y_axis_widgets] +} + /// User-requested modifications to the plot bounds. We collect them in the plot build function to later apply /// them at the right time, as other modifications need to happen first. enum BoundsModification { @@ -1268,6 +1525,7 @@ pub struct GridInput { } /// One mark (horizontal or vertical line) in the background grid of a plot. +#[derive(Debug, Clone, Copy)] pub struct GridMark { /// X or Y value in the plot. pub value: f64, @@ -1329,14 +1587,14 @@ struct PreparedPlot { show_y: bool, label_formatter: LabelFormatter, coordinates_formatter: Option<(Corner, CoordinatesFormatter)>, - axis_formatters: [AxisFormatter; 2], - show_axes: [bool; 2], + // axis_formatters: [AxisFormatter; 2], transform: PlotTransform, + show_grid: AxisBools, + grid_spacers: [GridSpacer; 2], draw_cursor_x: bool, draw_cursor_y: bool, draw_cursors: Vec, - grid_spacers: [GridSpacer; 2], sharp_grid_lines: bool, clamp_grid: bool, } @@ -1345,16 +1603,11 @@ impl PreparedPlot { fn ui(self, ui: &mut Ui, response: &Response) -> Vec { let mut axes_shapes = Vec::new(); - for d in 0..2 { - if self.show_axes[d] { - self.paint_axis( - ui, - d, - self.show_axes[1 - d], - &mut axes_shapes, - self.sharp_grid_lines, - ); - } + if self.show_grid.x { + self.paint_grid(ui, &mut axes_shapes, Axis::X); + } + if self.show_grid.y { + self.paint_grid(ui, &mut axes_shapes, Axis::Y); } // Sort the axes by strength so that those with higher strength are drawn in front. @@ -1431,41 +1684,27 @@ impl PreparedPlot { cursors } - fn paint_axis( - &self, - ui: &Ui, - axis: usize, - other_axis_shown: bool, - shapes: &mut Vec<(Shape, f32)>, - sharp_grid_lines: bool, - ) { + fn paint_grid(&self, ui: &Ui, shapes: &mut Vec<(Shape, f32)>, axis: Axis) { #![allow(clippy::collapsible_else_if)] - let Self { transform, - axis_formatters, + // axis_formatters, grid_spacers, clamp_grid, .. } = self; - let bounds = transform.bounds(); - let axis_range = match axis { - 0 => bounds.range_x(), - 1 => bounds.range_y(), - _ => panic!("Axis {axis} does not exist."), - }; - - let font_id = TextStyle::Body.resolve(ui.style()); + let iaxis = usize::from(axis); // Where on the cross-dimension to show the label values - let value_cross = 0.0_f64.clamp(bounds.min[1 - axis], bounds.max[1 - axis]); + let bounds = transform.bounds(); + let value_cross = 0.0_f64.clamp(bounds.min[1 - iaxis], bounds.max[1 - iaxis]); let input = GridInput { - bounds: (bounds.min[axis], bounds.max[axis]), - base_step_size: transform.dvalue_dpos()[axis] * MIN_LINE_SPACING_IN_POINTS, + bounds: (bounds.min[iaxis], bounds.max[iaxis]), + base_step_size: transform.dvalue_dpos()[iaxis] * MIN_LINE_SPACING_IN_POINTS, }; - let steps = (grid_spacers[axis])(input); + let steps = (grid_spacers[iaxis])(input); let clamp_range = clamp_grid.then(|| { let mut tight_bounds = PlotBounds::NOTHING; @@ -1481,25 +1720,27 @@ impl PreparedPlot { let value_main = step.value; if let Some(clamp_range) = clamp_range { - if axis == 0 { - if !clamp_range.range_x().contains(&value_main) { - continue; - }; - } else { - if !clamp_range.range_y().contains(&value_main) { - continue; - }; + match axis { + Axis::X => { + if !clamp_range.range_x().contains(&value_main) { + continue; + }; + } + Axis::Y => { + if !clamp_range.range_y().contains(&value_main) { + continue; + }; + } } } - let value = if axis == 0 { - PlotPoint::new(value_main, value_cross) - } else { - PlotPoint::new(value_cross, value_main) + let value = match axis { + Axis::X => PlotPoint::new(value_main, value_cross), + Axis::Y => PlotPoint::new(value_cross, value_main), }; let pos_in_gui = transform.position_from_point(&value); - let spacing_in_points = (transform.dpos_dvalue()[axis] * step.step_size).abs() as f32; + 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( @@ -1508,24 +1749,27 @@ impl PreparedPlot { 0.0..=1.0, ); - let line_color = color_from_contrast(ui, line_strength); + let line_color = color_from_strength(ui, line_strength); let mut p0 = pos_in_gui; let mut p1 = pos_in_gui; - p0[1 - axis] = transform.frame().min[1 - axis]; - p1[1 - axis] = transform.frame().max[1 - axis]; + p0[1 - iaxis] = transform.frame().min[1 - iaxis]; + p1[1 - iaxis] = transform.frame().max[1 - iaxis]; if let Some(clamp_range) = clamp_range { - if axis == 0 { - p0.y = transform.position_from_point_y(clamp_range.min[1]); - p1.y = transform.position_from_point_y(clamp_range.max[1]); - } else { - p0.x = transform.position_from_point_x(clamp_range.min[0]); - p1.x = transform.position_from_point_x(clamp_range.max[0]); + 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]); + } } } - if sharp_grid_lines { + if self.sharp_grid_lines { // Round to avoid aliasing p0 = ui.ctx().round_pos_to_pixels(p0); p1 = ui.ctx().round_pos_to_pixels(p1); @@ -1536,47 +1780,6 @@ impl PreparedPlot { line_strength, )); } - - const MIN_TEXT_SPACING: f32 = 40.0; - if spacing_in_points > MIN_TEXT_SPACING { - let text_strength = - remap_clamp(spacing_in_points, MIN_TEXT_SPACING..=150.0, 0.0..=1.0); - let color = color_from_contrast(ui, text_strength); - - let text: String = if let Some(formatter) = axis_formatters[axis].as_deref() { - formatter(value_main, &axis_range) - } else { - emath::round_to_decimals(value_main, 5).to_string() // hack - }; - - // Skip origin label for y-axis if x-axis is already showing it (otherwise displayed twice) - let skip_origin_y = axis == 1 && other_axis_shown && value_main == 0.0; - - // Custom formatters can return empty string to signal "no label at this resolution" - if !text.is_empty() && !skip_origin_y { - let galley = ui.painter().layout_no_wrap(text, font_id.clone(), color); - - let mut text_pos = pos_in_gui + vec2(1.0, -galley.size().y); - - // Make sure we see the labels, even if the axis is off-screen: - text_pos[1 - axis] = text_pos[1 - axis] - .at_most(transform.frame().max[1 - axis] - galley.size()[1 - axis] - 2.0) - .at_least(transform.frame().min[1 - axis] + 1.0); - - shapes.push((Shape::galley(text_pos, galley), text_strength)); - } - } - } - - fn color_from_contrast(ui: &Ui, contrast: f32) -> Color32 { - let bg = ui.visuals().extreme_bg_color; - let fg = ui.visuals().widgets.open.fg_stroke.color; - let mix = 0.5 * contrast.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, - ) } } @@ -1682,3 +1885,15 @@ pub fn format_number(number: f64, num_decimals: usize) -> String { format!("{:.*}", num_decimals.at_least(1), number) } } + +/// 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, + ) +} diff --git a/crates/egui_demo_lib/src/demo/plot_demo.rs b/crates/egui_demo_lib/src/demo/plot_demo.rs index 8daf3c37..c2711792 100644 --- a/crates/egui_demo_lib/src/demo/plot_demo.rs +++ b/crates/egui_demo_lib/src/demo/plot_demo.rs @@ -1,12 +1,12 @@ use std::f64::consts::TAU; use std::ops::RangeInclusive; -use egui::plot::{AxisBools, GridInput, GridMark, PlotResponse}; use egui::*; -use plot::{ - Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine, - Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, PlotPoint, PlotPoints, Points, Polygon, - Text, VLine, + +use egui::plot::{ + Arrows, AxisBools, AxisHints, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, + Corner, GridInput, GridMark, HLine, Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, + PlotPoint, PlotPoints, PlotResponse, Points, Polygon, Text, VLine, }; // ---------------------------------------------------------------------------- @@ -119,17 +119,9 @@ impl super::View for PlotDemo { } } -fn is_approx_zero(val: f64) -> bool { - val.abs() < 1e-6 -} - -fn is_approx_integer(val: f64) -> bool { - val.fract().abs() < 1e-6 -} - // ---------------------------------------------------------------------------- -#[derive(PartialEq)] +#[derive(Copy, Clone, PartialEq)] struct LineDemo { animate: bool, time: f64, @@ -138,6 +130,8 @@ struct LineDemo { square: bool, proportional: bool, coordinates: bool, + show_axes: bool, + show_grid: bool, line_style: LineStyle, } @@ -151,6 +145,8 @@ impl Default for LineDemo { square: false, proportional: true, coordinates: true, + show_axes: true, + show_grid: true, line_style: LineStyle::Solid, } } @@ -165,9 +161,10 @@ impl LineDemo { circle_center, square, proportional, - line_style, coordinates, - .. + show_axes, + show_grid, + line_style, } = self; ui.horizontal(|ui| { @@ -195,6 +192,13 @@ impl LineDemo { }); }); + ui.vertical(|ui| { + ui.checkbox(show_axes, "Show axes"); + ui.checkbox(show_grid, "Show grid"); + ui.checkbox(coordinates, "Show coordinates on hover") + .on_hover_text("Can take a custom formatting function."); + }); + ui.vertical(|ui| { ui.style_mut().wrap = Some(false); ui.checkbox(animate, "Animate"); @@ -202,8 +206,6 @@ impl LineDemo { .on_hover_text("Always keep the viewport square."); ui.checkbox(proportional, "Proportional data axes") .on_hover_text("Tick are the same size on both axes."); - ui.checkbox(coordinates, "Show coordinates") - .on_hover_text("Can take a custom formatting function."); ComboBox::from_label("Line style") .selected_text(line_style.to_string()) @@ -268,11 +270,16 @@ impl LineDemo { impl LineDemo { fn ui(&mut self, ui: &mut Ui) -> Response { self.options_ui(ui); + if self.animate { 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") + .legend(Legend::default()) + .y_axis_width(4) + .show_axes(self.show_axes) + .show_grid(self.show_grid); if self.square { plot = plot.view_aspect(1.0); } @@ -429,8 +436,8 @@ 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 @@ -523,7 +530,7 @@ impl CustomAxesDemo { 100.0 * y } - let x_fmt = |x, _range: &RangeInclusive| { + 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() @@ -536,7 +543,7 @@ impl CustomAxesDemo { } }; - let y_fmt = |y, _range: &RangeInclusive| { + 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)) @@ -557,10 +564,23 @@ 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"), + ]; + let y_axes = vec![ + AxisHints::default() + .label("Percent") + .formatter(y_fmt) + .max_digits(4), + AxisHints::default() + .label("Absolute") + .placement(plot::HPlacement::Right), + ]; Plot::new("custom_axes") .data_aspect(2.0 * MINS_PER_DAY as f32) - .x_axis_formatter(x_fmt) - .y_axis_formatter(y_fmt) + .custom_x_axes(x_axes) + .custom_y_axes(y_axes) .x_grid_spacer(CustomAxesDemo::x_grid) .label_formatter(label_fmt) .show(ui, |plot_ui| { @@ -582,15 +602,11 @@ struct LinkedAxesDemo { impl Default for LinkedAxesDemo { fn default() -> Self { - let link_x = true; - let link_y = false; - let link_cursor_x = true; - let link_cursor_y = false; Self { - link_x, - link_y, - link_cursor_x, - link_cursor_y, + link_x: true, + link_y: true, + link_cursor_x: true, + link_cursor_y: true, } } } @@ -642,25 +658,29 @@ impl LinkedAxesDemo { let link_group_id = ui.id().with("linked_demo"); ui.horizontal(|ui| { - Plot::new("linked_axis_1") + Plot::new("left-top") .data_aspect(1.0) .width(250.0) .height(250.0) .link_axis(link_group_id, self.link_x, self.link_y) .link_cursor(link_group_id, self.link_cursor_x, self.link_cursor_y) .show(ui, LinkedAxesDemo::configure_plot); - Plot::new("linked_axis_2") + Plot::new("right-top") .data_aspect(2.0) .width(150.0) .height(250.0) + .y_axis_width(3) + .y_axis_label("y") + .y_axis_position(plot::HPlacement::Right) .link_axis(link_group_id, self.link_x, self.link_y) .link_cursor(link_group_id, self.link_cursor_x, self.link_cursor_y) .show(ui, LinkedAxesDemo::configure_plot); }); - Plot::new("linked_axis_3") + Plot::new("left-bottom") .data_aspect(0.5) .width(250.0) .height(150.0) + .x_axis_label("x") .link_axis(link_group_id, self.link_x, self.link_y) .link_cursor(link_group_id, self.link_cursor_x, self.link_cursor_y) .show(ui, LinkedAxesDemo::configure_plot) @@ -889,6 +909,7 @@ impl ChartsDemo { Plot::new("Normal Distribution Demo") .legend(Legend::default()) .clamp_grid(true) + .y_axis_width(3) .allow_zoom(self.allow_zoom) .allow_drag(self.allow_drag) .show(ui, |plot_ui| plot_ui.bar_chart(chart)) @@ -1003,3 +1024,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 +} diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index eb424e9e..27dc4026 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -271,6 +271,7 @@ fn example_plot(ui: &mut egui::Ui) -> egui::Response { let line = Line::new(line_points); egui::plot::Plot::new("example_plot") .height(32.0) + .show_axes(false) .data_aspect(1.0) .show(ui, |plot_ui| plot_ui.line(line)) .response