From 1088d950e933646098c20d43d214ba5f0ddae51a Mon Sep 17 00:00:00 2001 From: Jan Haller Date: Mon, 29 Nov 2021 18:39:58 +0100 Subject: [PATCH] Add bar charts and box plots (#863) Changes: * New `BarChart` and `BoxPlot` diagrams * New `FloatOrd` trait for total ordering of float types * Refactoring of existing plot items Co-authored-by: niladic --- CHANGELOG.md | 1 + egui/src/util/float_ord.rs | 64 ++ egui/src/util/mod.rs | 1 + egui/src/widgets/plot/items/bar.rs | 190 ++++ egui/src/widgets/plot/items/box_elem.rs | 286 ++++++ .../widgets/plot/{items.rs => items/mod.rs} | 879 +++++++++++------- egui/src/widgets/plot/items/rect_elem.rs | 61 ++ egui/src/widgets/plot/items/values.rs | 352 +++++++ egui/src/widgets/plot/mod.rs | 147 ++- egui/src/widgets/plot/transform.rs | 22 + egui_demo_lib/src/apps/demo/plot_demo.rs | 199 +++- emath/src/rect.rs | 21 + 12 files changed, 1808 insertions(+), 415 deletions(-) create mode 100644 egui/src/util/float_ord.rs create mode 100644 egui/src/widgets/plot/items/bar.rs create mode 100644 egui/src/widgets/plot/items/box_elem.rs rename egui/src/widgets/plot/{items.rs => items/mod.rs} (66%) create mode 100644 egui/src/widgets/plot/items/rect_elem.rs create mode 100644 egui/src/widgets/plot/items/values.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0360bcbedb1..45ea9e5cca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w ## Unreleased ### Added ⭐ +* Add bar charts and box plots ([#863](https://github.com/emilk/egui/pull/863)). * Add context menus: See `Ui::menu_button` and `Response::context_menu` ([#543](https://github.com/emilk/egui/pull/543)). * You can now read and write the cursor of a `TextEdit` ([#848](https://github.com/emilk/egui/pull/848)). * Most widgets containing text (`Label`, `Button` etc) now supports rich text ([#855](https://github.com/emilk/egui/pull/855)). diff --git a/egui/src/util/float_ord.rs b/egui/src/util/float_ord.rs new file mode 100644 index 00000000000..d4fb1b2d5d5 --- /dev/null +++ b/egui/src/util/float_ord.rs @@ -0,0 +1,64 @@ +//! Total order on floating point types, assuming absence of NaN. +//! Can be used for sorting, min/max computation, and other collection algorithms. + +use std::cmp::Ordering; + +/// Totally orderable floating-point value +/// For not `f32` is supported; could be made generic if necessary. +pub(crate) struct OrderedFloat(f32); + +impl Eq for OrderedFloat {} + +impl PartialEq for OrderedFloat { + #[inline] + fn eq(&self, other: &Self) -> bool { + // NaNs are considered equal (equivalent when it comes to ordering + if self.0.is_nan() { + other.0.is_nan() + } else { + self.0 == other.0 + } + } +} + +impl PartialOrd for OrderedFloat { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + match self.0.partial_cmp(&other.0) { + Some(ord) => Some(ord), + None => Some(self.0.is_nan().cmp(&other.0.is_nan())), + } + } +} + +impl Ord for OrderedFloat { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + match self.partial_cmp(other) { + Some(ord) => ord, + None => unreachable!(), + } + } +} + +/// Extension trait to provide `ord` method +pub(crate) trait FloatOrd { + /// Type to provide total order, useful as key in sorted contexts. + fn ord(self) -> OrderedFloat; +} + +impl FloatOrd for f32 { + #[inline] + fn ord(self) -> OrderedFloat { + OrderedFloat(self) + } +} + +// TODO ordering may break down at least significant digits due to f64 -> f32 conversion +// Possible solutions: generic OrderedFloat, always OrderedFloat(f64) +impl FloatOrd for f64 { + #[inline] + fn ord(self) -> OrderedFloat { + OrderedFloat(self as f32) + } +} diff --git a/egui/src/util/mod.rs b/egui/src/util/mod.rs index ea83ea618f6..0a327f0baf9 100644 --- a/egui/src/util/mod.rs +++ b/egui/src/util/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub(crate) mod fixed_cache; +pub(crate) mod float_ord; mod history; pub mod id_type_map; pub mod undoer; diff --git a/egui/src/widgets/plot/items/bar.rs b/egui/src/widgets/plot/items/bar.rs new file mode 100644 index 00000000000..4453e9aeccf --- /dev/null +++ b/egui/src/widgets/plot/items/bar.rs @@ -0,0 +1,190 @@ +use crate::emath::NumExt; +use crate::epaint::{Color32, RectShape, Shape, Stroke}; + +use super::{add_rulers_and_text, highlighted_color, Orientation, PlotConfig, RectElement}; +use crate::plot::{BarChart, ScreenTransform, Value}; + +/// One bar in a [`BarChart`]. Potentially floating, allowing stacked bar charts. +/// Width can be changed to allow variable-width histograms. +#[derive(Clone, Debug, PartialEq)] +pub struct Bar { + /// Name of plot element in the diagram (annotated by default formatter) + pub name: String, + + /// Which direction the bar faces in the diagram + pub orientation: Orientation, + + /// Position on the argument (input) axis -- X if vertical, Y if horizontal + pub argument: f64, + + /// Position on the value (output) axis -- Y if vertical, X if horizontal + pub value: f64, + + /// For stacked bars, this denotes where the bar starts. None if base axis + pub base_offset: Option, + + /// Thickness of the bar + pub bar_width: f64, + + /// Line width and color + pub stroke: Stroke, + + /// Fill color + pub fill: Color32, +} + +impl Bar { + /// Create a bar. Its `orientation` is set by its [`BarChart`] parent. + /// + /// - `argument`: Position on the argument axis (X if vertical, Y if horizontal). + /// - `value`: Height of the bar (if vertical). + /// + /// By default the bar is vertical and its base is at zero. + pub fn new(argument: f64, height: f64) -> Bar { + Bar { + argument, + value: height, + orientation: Orientation::default(), + name: Default::default(), + base_offset: None, + bar_width: 0.5, + stroke: Stroke::new(1.0, Color32::TRANSPARENT), + fill: Color32::TRANSPARENT, + } + } + + /// Name of this bar chart element. + #[allow(clippy::needless_pass_by_value)] + pub fn name(mut self, name: impl ToString) -> Self { + self.name = name.to_string(); + self + } + + /// Add a custom stroke. + pub fn stroke(mut self, stroke: impl Into) -> Self { + self.stroke = stroke.into(); + self + } + + /// Add a custom fill color. + pub fn fill(mut self, color: impl Into) -> Self { + self.fill = color.into(); + self + } + + /// Offset the base of the bar. + /// This offset is on the Y axis for a vertical bar + /// and on the X axis for a horizontal bar. + pub fn base_offset(mut self, offset: f64) -> Self { + self.base_offset = Some(offset); + self + } + + /// Set the bar width. + pub fn width(mut self, width: f64) -> Self { + self.bar_width = width; + self + } + + /// Set orientation of the element as vertical. Argument axis is X. + pub fn vertical(mut self) -> Self { + self.orientation = Orientation::Vertical; + self + } + + /// Set orientation of the element as horizontal. Argument axis is Y. + pub fn horizontal(mut self) -> Self { + self.orientation = Orientation::Horizontal; + self + } + + pub(super) fn lower(&self) -> f64 { + if self.value.is_sign_positive() { + self.base_offset.unwrap_or(0.0) + } else { + self.base_offset.map_or(self.value, |o| o + self.value) + } + } + + pub(super) fn upper(&self) -> f64 { + if self.value.is_sign_positive() { + self.base_offset.map_or(self.value, |o| o + self.value) + } else { + self.base_offset.unwrap_or(0.0) + } + } + + pub(super) fn add_shapes( + &self, + transform: &ScreenTransform, + highlighted: bool, + shapes: &mut Vec, + ) { + let (stroke, fill) = if highlighted { + highlighted_color(self.stroke, self.fill) + } else { + (self.stroke, self.fill) + }; + + let rect = transform.rect_from_values(&self.bounds_min(), &self.bounds_max()); + let rect = Shape::Rect(RectShape { + rect, + corner_radius: 0.0, + fill, + stroke, + }); + + shapes.push(rect); + } + + pub(super) fn add_rulers_and_text( + &self, + parent: &BarChart, + plot: &PlotConfig<'_>, + shapes: &mut Vec, + ) { + let text: Option = parent + .element_formatter + .as_ref() + .map(|fmt| fmt(self, parent)); + + add_rulers_and_text(self, plot, text, shapes); + } +} + +impl RectElement for Bar { + fn name(&self) -> &str { + self.name.as_str() + } + + fn bounds_min(&self) -> Value { + self.point_at(self.argument - self.bar_width / 2.0, self.lower()) + } + + fn bounds_max(&self) -> Value { + self.point_at(self.argument + self.bar_width / 2.0, self.upper()) + } + + fn values_with_ruler(&self) -> Vec { + let base = self.base_offset.unwrap_or(0.0); + let value_center = self.point_at(self.argument, base + self.value); + + let mut ruler_positions = vec![value_center]; + + if let Some(offset) = self.base_offset { + ruler_positions.push(self.point_at(self.argument, offset)); + } + + ruler_positions + } + + fn orientation(&self) -> Orientation { + self.orientation + } + + fn default_values_format(&self, transform: &ScreenTransform) -> String { + let scale = transform.dvalue_dpos(); + let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6); + format!("\n{:.*}", y_decimals, self.value) + } +} diff --git a/egui/src/widgets/plot/items/box_elem.rs b/egui/src/widgets/plot/items/box_elem.rs new file mode 100644 index 00000000000..451cba48aba --- /dev/null +++ b/egui/src/widgets/plot/items/box_elem.rs @@ -0,0 +1,286 @@ +use crate::emath::NumExt; +use crate::epaint::{Color32, RectShape, Shape, Stroke}; + +use super::{add_rulers_and_text, highlighted_color, Orientation, PlotConfig, RectElement}; +use crate::plot::{BoxPlot, ScreenTransform, Value}; + +/// Contains the values of a single box in a box plot. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxSpread { + /// Value of lower whisker (typically minimum). + /// + /// The whisker is not drawn if `lower_whisker >= quartile1`. + pub lower_whisker: f64, + + /// Value of lower box threshold (typically 25% quartile) + pub quartile1: f64, + + /// Value of middle line in box (typically median) + pub median: f64, + + /// Value of upper box threshold (typically 75% quartile) + pub quartile3: f64, + + /// Value of upper whisker (typically maximum) + /// + /// The whisker is not drawn if `upper_whisker <= quartile3`. + pub upper_whisker: f64, +} + +impl BoxSpread { + pub fn new( + lower_whisker: f64, + quartile1: f64, + median: f64, + quartile3: f64, + upper_whisker: f64, + ) -> Self { + Self { + lower_whisker, + quartile1, + median, + quartile3, + upper_whisker, + } + } +} + +/// A box in a [`BoxPlot`] diagram. This is a low level graphical element; it will not compute quartiles and whiskers, +/// letting one use their preferred formula. Use [`Points`][`super::Points`] to draw the outliers. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxElem { + /// Name of plot element in the diagram (annotated by default formatter). + pub name: String, + + /// Which direction the box faces in the diagram. + pub orientation: Orientation, + + /// Position on the argument (input) axis -- X if vertical, Y if horizontal. + pub argument: f64, + + /// Values of the box + pub spread: BoxSpread, + + /// Thickness of the box + pub box_width: f64, + + /// Width of the whisker at minimum/maximum + pub whisker_width: f64, + + /// Line width and color + pub stroke: Stroke, + + /// Fill color + pub fill: Color32, +} + +impl BoxElem { + /// Create a box element. Its `orientation` is set by its [`BoxPlot`] parent. + /// + /// Check [`BoxElem`] fields for detailed description. + pub fn new(argument: f64, spread: BoxSpread) -> Self { + Self { + argument, + orientation: Orientation::default(), + name: String::default(), + spread, + box_width: 0.25, + whisker_width: 0.15, + stroke: Stroke::new(1.0, Color32::TRANSPARENT), + fill: Color32::TRANSPARENT, + } + } + + /// Name of this box element. + #[allow(clippy::needless_pass_by_value)] + pub fn name(mut self, name: impl ToString) -> Self { + self.name = name.to_string(); + self + } + + /// Add a custom stroke. + pub fn stroke(mut self, stroke: impl Into) -> Self { + self.stroke = stroke.into(); + self + } + + /// Add a custom fill color. + pub fn fill(mut self, color: impl Into) -> Self { + self.fill = color.into(); + self + } + + /// Set the box width. + pub fn box_width(mut self, width: f64) -> Self { + self.box_width = width; + self + } + + /// Set the whisker width. + pub fn whisker_width(mut self, width: f64) -> Self { + self.whisker_width = width; + self + } + + /// Set orientation of the element as vertical. Argument axis is X. + pub fn vertical(mut self) -> Self { + self.orientation = Orientation::Vertical; + self + } + + /// Set orientation of the element as horizontal. Argument axis is Y. + pub fn horizontal(mut self) -> Self { + self.orientation = Orientation::Horizontal; + self + } + + pub(super) fn add_shapes( + &self, + transform: &ScreenTransform, + highlighted: bool, + shapes: &mut Vec, + ) { + let (stroke, fill) = if highlighted { + highlighted_color(self.stroke, self.fill) + } else { + (self.stroke, self.fill) + }; + + let rect = transform.rect_from_values( + &self.point_at(self.argument - self.box_width / 2.0, self.spread.quartile1), + &self.point_at(self.argument + self.box_width / 2.0, self.spread.quartile3), + ); + let rect = Shape::Rect(RectShape { + rect, + corner_radius: 0.0, + fill, + stroke, + }); + shapes.push(rect); + + let line_between = |v1, v2| { + Shape::line_segment( + [ + transform.position_from_value(&v1), + transform.position_from_value(&v2), + ], + stroke, + ) + }; + let median = line_between( + self.point_at(self.argument - self.box_width / 2.0, self.spread.median), + self.point_at(self.argument + self.box_width / 2.0, self.spread.median), + ); + shapes.push(median); + + if self.spread.upper_whisker > self.spread.quartile3 { + let high_whisker = line_between( + self.point_at(self.argument, self.spread.quartile3), + self.point_at(self.argument, self.spread.upper_whisker), + ); + shapes.push(high_whisker); + if self.box_width > 0.0 { + let high_whisker_end = line_between( + self.point_at( + self.argument - self.whisker_width / 2.0, + self.spread.upper_whisker, + ), + self.point_at( + self.argument + self.whisker_width / 2.0, + self.spread.upper_whisker, + ), + ); + shapes.push(high_whisker_end); + } + } + + if self.spread.lower_whisker < self.spread.quartile1 { + let low_whisker = line_between( + self.point_at(self.argument, self.spread.quartile1), + self.point_at(self.argument, self.spread.lower_whisker), + ); + shapes.push(low_whisker); + if self.box_width > 0.0 { + let low_whisker_end = line_between( + self.point_at( + self.argument - self.whisker_width / 2.0, + self.spread.lower_whisker, + ), + self.point_at( + self.argument + self.whisker_width / 2.0, + self.spread.lower_whisker, + ), + ); + shapes.push(low_whisker_end); + } + } + } + + pub(super) fn add_rulers_and_text( + &self, + parent: &BoxPlot, + plot: &PlotConfig<'_>, + shapes: &mut Vec, + ) { + let text: Option = parent + .element_formatter + .as_ref() + .map(|fmt| fmt(self, parent)); + + add_rulers_and_text(self, plot, text, shapes); + } +} + +impl RectElement for BoxElem { + fn name(&self) -> &str { + self.name.as_str() + } + + fn bounds_min(&self) -> Value { + let argument = self.argument - self.box_width.max(self.whisker_width) / 2.0; + let value = self.spread.lower_whisker; + self.point_at(argument, value) + } + + fn bounds_max(&self) -> Value { + let argument = self.argument + self.box_width.max(self.whisker_width) / 2.0; + let value = self.spread.upper_whisker; + self.point_at(argument, value) + } + + fn values_with_ruler(&self) -> Vec { + let median = self.point_at(self.argument, self.spread.median); + let q1 = self.point_at(self.argument, self.spread.quartile1); + let q3 = self.point_at(self.argument, self.spread.quartile3); + let upper = self.point_at(self.argument, self.spread.upper_whisker); + let lower = self.point_at(self.argument, self.spread.lower_whisker); + + vec![median, q1, q3, upper, lower] + } + + fn orientation(&self) -> Orientation { + self.orientation + } + + fn corner_value(&self) -> Value { + self.point_at(self.argument, self.spread.upper_whisker) + } + + fn default_values_format(&self, transform: &ScreenTransform) -> String { + let scale = transform.dvalue_dpos(); + let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6); + format!( + "\nMax = {max:.decimals$}\ + \nQuartile 3 = {q3:.decimals$}\ + \nMedian = {med:.decimals$}\ + \nQuartile 1 = {q1:.decimals$}\ + \nMin = {min:.decimals$}", + max = self.spread.upper_whisker, + q3 = self.spread.quartile3, + med = self.spread.median, + q1 = self.spread.quartile1, + min = self.spread.lower_whisker, + decimals = y_decimals + ) + } +} diff --git a/egui/src/widgets/plot/items.rs b/egui/src/widgets/plot/items/mod.rs similarity index 66% rename from egui/src/widgets/plot/items.rs rename to egui/src/widgets/plot/items/mod.rs index 65b60e02237..9f935ca7ed8 100644 --- a/egui/src/widgets/plot/items.rs +++ b/egui/src/widgets/plot/items/mod.rs @@ -1,131 +1,89 @@ //! Contains items that can be added to a plot. -use std::ops::{Bound, RangeBounds, RangeInclusive}; +use std::ops::RangeInclusive; use epaint::Mesh; -use super::transform::{PlotBounds, ScreenTransform}; +use crate::util::float_ord::FloatOrd; use crate::*; -const DEFAULT_FILL_ALPHA: f32 = 0.05; - -/// A value in the value-space of the plot. -/// -/// Uses f64 for improved accuracy to enable plotting -/// large values (e.g. unix time on x axis). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Value { - /// This is often something monotonically increasing, such as time, but doesn't have to be. - /// Goes from left to right. - pub x: f64, - /// Goes from bottom to top (inverse of everything else in egui!). - pub y: f64, -} - -impl Value { - #[inline(always)] - pub fn new(x: impl Into, y: impl Into) -> Self { - Self { - x: x.into(), - y: y.into(), - } - } +use super::{PlotBounds, ScreenTransform}; +use rect_elem::*; +use values::*; - #[inline(always)] - pub fn to_pos2(self) -> Pos2 { - Pos2::new(self.x as f32, self.y as f32) - } +pub use bar::Bar; +pub use box_elem::{BoxElem, BoxSpread}; +pub use values::{LineStyle, MarkerShape, Value, Values}; - #[inline(always)] - pub fn to_vec2(self) -> Vec2 { - Vec2::new(self.x as f32, self.y as f32) - } -} +mod bar; +mod box_elem; +mod rect_elem; +mod values; -// ---------------------------------------------------------------------------- +const DEFAULT_FILL_ALPHA: f32 = 0.05; -#[derive(Debug, PartialEq, Clone, Copy)] -pub enum LineStyle { - Solid, - Dotted { spacing: f32 }, - Dashed { length: f32 }, +/// Container to pass-through several parameters related to plot visualization +pub(super) struct PlotConfig<'a> { + pub ui: &'a Ui, + pub transform: &'a ScreenTransform, + pub show_x: bool, + pub show_y: bool, } -impl LineStyle { - pub fn dashed_loose() -> Self { - Self::Dashed { length: 10.0 } - } - - pub fn dashed_dense() -> Self { - Self::Dashed { length: 5.0 } - } - - pub fn dotted_loose() -> Self { - Self::Dotted { spacing: 10.0 } - } +/// Trait shared by things that can be drawn in the plot. +pub(super) trait PlotItem { + fn get_shapes(&self, ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec); + fn initialize(&mut self, x_range: RangeInclusive); + fn name(&self) -> &str; + fn color(&self) -> Color32; + fn highlight(&mut self); + fn highlighted(&self) -> bool; + fn geometry(&self) -> PlotGeometry<'_>; + fn get_bounds(&self) -> PlotBounds; - pub fn dotted_dense() -> Self { - Self::Dotted { spacing: 5.0 } + fn find_closest(&self, point: Pos2, transform: &ScreenTransform) -> Option { + match self.geometry() { + PlotGeometry::None => None, + + PlotGeometry::Points(points) => points + .iter() + .enumerate() + .map(|(index, value)| { + let pos = transform.position_from_value(value); + let dist_sq = point.distance_sq(pos); + ClosestElem { index, dist_sq } + }) + .min_by_key(|e| e.dist_sq.ord()), + + PlotGeometry::Rects => { + panic!("If the PlotItem is made of rects, it should implement find_closest()") + } + } } - fn style_line( - &self, - line: Vec, - mut stroke: Stroke, - highlight: bool, - shapes: &mut Vec, - ) { - match line.len() { - 0 => {} - 1 => { - let mut radius = stroke.width / 2.0; - if highlight { - radius *= 2f32.sqrt(); - } - shapes.push(Shape::circle_filled(line[0], radius, stroke.color)); + fn on_hover(&self, elem: ClosestElem, shapes: &mut Vec, plot: &PlotConfig<'_>) { + let points = match self.geometry() { + PlotGeometry::Points(points) => points, + PlotGeometry::None => { + panic!("If the PlotItem has no geometry, on_hover() must not be called") } - _ => { - match self { - LineStyle::Solid => { - if highlight { - stroke.width *= 2.0; - } - shapes.push(Shape::line(line, stroke)); - } - LineStyle::Dotted { spacing } => { - // Take the stroke width for the radius even though it's not "correct", otherwise - // the dots would become too small. - let mut radius = stroke.width; - if highlight { - radius *= 2f32.sqrt(); - } - shapes.extend(Shape::dotted_line(&line, stroke.color, *spacing, radius)); - } - LineStyle::Dashed { length } => { - if highlight { - stroke.width *= 2.0; - } - let golden_ratio = (5.0_f32.sqrt() - 1.0) / 2.0; // 0.61803398875 - shapes.extend(Shape::dashed_line( - &line, - stroke, - *length, - length * golden_ratio, - )); - } - } + PlotGeometry::Rects => { + panic!("If the PlotItem is made of rects, it should implement on_hover()") } - } - } -} + }; -impl ToString for LineStyle { - fn to_string(&self) -> String { - match self { - LineStyle::Solid => "Solid".into(), - LineStyle::Dotted { spacing } => format!("Dotted{}Px", spacing), - LineStyle::Dashed { length } => format!("Dashed{}Px", length), - } + let line_color = if plot.ui.visuals().dark_mode { + Color32::from_gray(100).additive() + } else { + Color32::from_black_alpha(180) + }; + + // this method is only called, if the value is in the result set of find_closest() + let value = points[elem.index]; + let pointer = plot.transform.position_from_value(&value); + shapes.push(Shape::circle_filled(pointer, 3.0, line_color)); + + rulers_at_value(pointer, value, self.name(), plot, shapes); } } @@ -229,8 +187,8 @@ impl PlotItem for HLine { self.highlight } - fn values(&self) -> Option<&Values> { - None + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::None } fn get_bounds(&self) -> PlotBounds { @@ -339,8 +297,8 @@ impl PlotItem for VLine { self.highlight } - fn values(&self) -> Option<&Values> { - None + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::None } fn get_bounds(&self) -> PlotBounds { @@ -351,203 +309,6 @@ impl PlotItem for VLine { } } -/// Trait shared by things that can be drawn in the plot. -pub(super) trait PlotItem { - fn get_shapes(&self, ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec); - fn initialize(&mut self, x_range: RangeInclusive); - fn name(&self) -> &str; - fn color(&self) -> Color32; - fn highlight(&mut self); - fn highlighted(&self) -> bool; - fn values(&self) -> Option<&Values>; - fn get_bounds(&self) -> PlotBounds; -} - -// ---------------------------------------------------------------------------- - -/// Describes a function y = f(x) with an optional range for x and a number of points. -struct ExplicitGenerator { - function: Box f64>, - x_range: RangeInclusive, - points: usize, -} - -pub struct Values { - pub(super) values: Vec, - generator: Option, -} - -impl Default for Values { - fn default() -> Self { - Self { - values: Vec::new(), - generator: None, - } - } -} - -impl Values { - pub fn from_values(values: Vec) -> Self { - Self { - values, - generator: None, - } - } - - pub fn from_values_iter(iter: impl Iterator) -> Self { - Self::from_values(iter.collect()) - } - - /// Draw a line based on a function `y=f(x)`, a range (which can be infinite) for x and the number of points. - pub fn from_explicit_callback( - function: impl Fn(f64) -> f64 + 'static, - x_range: impl RangeBounds, - points: usize, - ) -> Self { - let start = match x_range.start_bound() { - Bound::Included(x) | Bound::Excluded(x) => *x, - Bound::Unbounded => f64::NEG_INFINITY, - }; - let end = match x_range.end_bound() { - Bound::Included(x) | Bound::Excluded(x) => *x, - Bound::Unbounded => f64::INFINITY, - }; - let x_range = start..=end; - - let generator = ExplicitGenerator { - function: Box::new(function), - x_range, - points, - }; - - Self { - values: Vec::new(), - generator: Some(generator), - } - } - - /// Draw a line based on a function `(x,y)=f(t)`, a range for t and the number of points. - /// The range may be specified as start..end or as start..=end. - pub fn from_parametric_callback( - function: impl Fn(f64) -> (f64, f64), - t_range: impl RangeBounds, - points: usize, - ) -> Self { - let start = match t_range.start_bound() { - Bound::Included(x) => x, - Bound::Excluded(_) => unreachable!(), - Bound::Unbounded => panic!("The range for parametric functions must be bounded!"), - }; - let end = match t_range.end_bound() { - Bound::Included(x) | Bound::Excluded(x) => x, - Bound::Unbounded => panic!("The range for parametric functions must be bounded!"), - }; - let last_point_included = matches!(t_range.end_bound(), Bound::Included(_)); - let increment = if last_point_included { - (end - start) / (points - 1) as f64 - } else { - (end - start) / points as f64 - }; - let values = (0..points).map(|i| { - let t = start + i as f64 * increment; - let (x, y) = function(t); - Value { x, y } - }); - Self::from_values_iter(values) - } - - /// From a series of y-values. - /// The x-values will be the indices of these values - pub fn from_ys_f32(ys: &[f32]) -> Self { - let values: Vec = ys - .iter() - .enumerate() - .map(|(i, &y)| Value { - x: i as f64, - y: y as f64, - }) - .collect(); - Self::from_values(values) - } - - /// Returns true if there are no data points available and there is no function to generate any. - pub(super) fn is_empty(&self) -> bool { - self.generator.is_none() && self.values.is_empty() - } - - /// If initialized with a generator function, this will generate `n` evenly spaced points in the - /// given range. - pub(super) fn generate_points(&mut self, x_range: RangeInclusive) { - if let Some(generator) = self.generator.take() { - if let Some(intersection) = Self::range_intersection(&x_range, &generator.x_range) { - let increment = - (intersection.end() - intersection.start()) / (generator.points - 1) as f64; - self.values = (0..generator.points) - .map(|i| { - let x = intersection.start() + i as f64 * increment; - let y = (generator.function)(x); - Value { x, y } - }) - .collect(); - } - } - } - - /// Returns the intersection of two ranges if they intersect. - fn range_intersection( - range1: &RangeInclusive, - range2: &RangeInclusive, - ) -> Option> { - let start = range1.start().max(*range2.start()); - let end = range1.end().min(*range2.end()); - (start < end).then(|| start..=end) - } - - pub(super) fn get_bounds(&self) -> PlotBounds { - let mut bounds = PlotBounds::NOTHING; - self.values - .iter() - .for_each(|value| bounds.extend_with(value)); - bounds - } -} - -// ---------------------------------------------------------------------------- - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum MarkerShape { - Circle, - Diamond, - Square, - Cross, - Plus, - Up, - Down, - Left, - Right, - Asterisk, -} - -impl MarkerShape { - /// Get a vector containing all marker shapes. - pub fn all() -> impl Iterator { - [ - Self::Circle, - Self::Diamond, - Self::Square, - Self::Cross, - Self::Plus, - Self::Up, - Self::Down, - Self::Left, - Self::Right, - Self::Asterisk, - ] - .iter() - .copied() - } -} - /// A series of values forming a path. pub struct Line { pub(super) series: Values, @@ -706,8 +467,8 @@ impl PlotItem for Line { self.highlight } - fn values(&self) -> Option<&Values> { - Some(&self.series) + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::Points(&self.series.values) } fn get_bounds(&self) -> PlotBounds { @@ -836,8 +597,8 @@ impl PlotItem for Polygon { self.highlight } - fn values(&self) -> Option<&Values> { - Some(&self.series) + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::Points(&self.series.values) } fn get_bounds(&self) -> PlotBounds { @@ -949,8 +710,8 @@ impl PlotItem for Text { self.highlight } - fn values(&self) -> Option<&Values> { - None + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::None } fn get_bounds(&self) -> PlotBounds { @@ -1182,8 +943,8 @@ impl PlotItem for Points { self.highlight } - fn values(&self) -> Option<&Values> { - Some(&self.series) + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::Points(&self.series.values) } fn get_bounds(&self) -> PlotBounds { @@ -1297,8 +1058,8 @@ impl PlotItem for Arrows { self.highlight } - fn values(&self) -> Option<&Values> { - Some(&self.origins) + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::Points(&self.origins.values) } fn get_bounds(&self) -> PlotBounds { @@ -1427,8 +1188,8 @@ impl PlotItem for PlotImage { self.highlight } - fn values(&self) -> Option<&Values> { - None + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::None } fn get_bounds(&self) -> PlotBounds { @@ -1446,3 +1207,477 @@ impl PlotItem for PlotImage { bounds } } + +// ---------------------------------------------------------------------------- + +/// A bar chart. +pub struct BarChart { + pub(super) bars: Vec, + pub(super) default_color: Color32, + pub(super) name: String, + /// A custom element formatter + pub(super) element_formatter: Option String>>, + highlight: bool, +} + +impl BarChart { + /// Create a bar chart. It defaults to vertically oriented elements. + pub fn new(bars: Vec) -> BarChart { + BarChart { + bars, + default_color: Color32::TRANSPARENT, + name: String::new(), + element_formatter: None, + highlight: false, + } + } + + /// Set the default color. It is set on all elements that do not already have a specific color. + /// This is the color that shows up in the legend. + /// It can be overridden at the bar level (see [[`Bar`]]). + /// Default is `Color32::TRANSPARENT` which means a color will be auto-assigned. + pub fn color(mut self, color: impl Into) -> Self { + let plot_color = color.into(); + self.default_color = plot_color; + self.bars.iter_mut().for_each(|b| { + if b.fill == Color32::TRANSPARENT && b.stroke.color == Color32::TRANSPARENT { + b.fill = plot_color.linear_multiply(0.2); + b.stroke.color = plot_color; + } + }); + self + } + + /// Name of this chart. + /// + /// This name will show up in the plot legend, if legends are turned on. Multiple charts may + /// share the same name, in which case they will also share an entry in the legend. + #[allow(clippy::needless_pass_by_value)] + pub fn name(mut self, name: impl ToString) -> Self { + self.name = name.to_string(); + self + } + + /// Set all elements to be in a vertical orientation. + /// Argument axis will be X and bar values will be on the Y axis. + pub fn vertical(mut self) -> Self { + self.bars.iter_mut().for_each(|b| { + b.orientation = Orientation::Vertical; + }); + self + } + + /// Set all elements to be in a horizontal orientation. + /// Argument axis will be Y and bar values will be on the X axis. + pub fn horizontal(mut self) -> Self { + self.bars.iter_mut().for_each(|b| { + b.orientation = Orientation::Horizontal; + }); + self + } + + /// Set the width (thickness) of all its elements. + pub fn width(mut self, width: f64) -> Self { + self.bars.iter_mut().for_each(|b| { + b.bar_width = width; + }); + self + } + + /// Highlight all plot elements. + pub fn highlight(mut self) -> Self { + self.highlight = true; + self + } + + /// Add a custom way to format an element. + /// Can be used to display a set number of decimals or custom labels. + pub fn element_formatter(mut self, formatter: Box String>) -> Self { + self.element_formatter = Some(formatter); + self + } + + /// Stacks the bars on top of another chart. + /// Positive values are stacked on top of other positive values. + /// Negative values are stacked below other negative values. + pub fn stack_on(mut self, others: &[&BarChart]) -> Self { + for (index, bar) in self.bars.iter_mut().enumerate() { + let new_base_offset = if bar.value.is_sign_positive() { + others + .iter() + .filter_map(|other_chart| other_chart.bars.get(index).map(|bar| bar.upper())) + .max_by_key(|value| value.ord()) + } else { + others + .iter() + .filter_map(|other_chart| other_chart.bars.get(index).map(|bar| bar.lower())) + .min_by_key(|value| value.ord()) + }; + + if let Some(value) = new_base_offset { + bar.base_offset = Some(value); + } + } + self + } +} + +impl PlotItem for BarChart { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + self.bars.iter().for_each(|b| { + b.add_shapes(transform, self.highlight, shapes); + }); + } + + fn initialize(&mut self, _x_range: RangeInclusive) { + // nothing to do + } + + fn name(&self) -> &str { + self.name.as_str() + } + + fn color(&self) -> Color32 { + self.default_color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::Rects + } + + fn get_bounds(&self) -> PlotBounds { + let mut bounds = PlotBounds::NOTHING; + self.bars.iter().for_each(|b| { + bounds.merge(&b.bounds()); + }); + bounds + } + + fn find_closest(&self, point: Pos2, transform: &ScreenTransform) -> Option { + find_closest_rect(&self.bars, point, transform) + } + + fn on_hover(&self, elem: ClosestElem, shapes: &mut Vec, plot: &PlotConfig<'_>) { + let bar = &self.bars[elem.index]; + + bar.add_shapes(plot.transform, true, shapes); + bar.add_rulers_and_text(self, plot, shapes); + } +} + +/// A diagram containing a series of [`BoxElem`] elements. +pub struct BoxPlot { + pub(super) boxes: Vec, + pub(super) default_color: Color32, + pub(super) name: String, + /// A custom element formatter + pub(super) element_formatter: Option String>>, + highlight: bool, +} + +impl BoxPlot { + /// Create a plot containing multiple `boxes`. It defaults to vertically oriented elements. + pub fn new(boxes: Vec) -> Self { + Self { + boxes, + default_color: Color32::TRANSPARENT, + name: String::new(), + element_formatter: None, + highlight: false, + } + } + + /// Set the default color. It is set on all elements that do not already have a specific color. + /// This is the color that shows up in the legend. + /// It can be overridden at the element level (see [`BoxElem`]). + /// Default is `Color32::TRANSPARENT` which means a color will be auto-assigned. + pub fn color(mut self, color: impl Into) -> Self { + let plot_color = color.into(); + self.default_color = plot_color; + self.boxes.iter_mut().for_each(|box_elem| { + if box_elem.fill == Color32::TRANSPARENT + && box_elem.stroke.color == Color32::TRANSPARENT + { + box_elem.fill = plot_color.linear_multiply(0.2); + box_elem.stroke.color = plot_color; + } + }); + self + } + + /// Name of this box plot diagram. + /// + /// This name will show up in the plot legend, if legends are turned on. Multiple series may + /// share the same name, in which case they will also share an entry in the legend. + #[allow(clippy::needless_pass_by_value)] + pub fn name(mut self, name: impl ToString) -> Self { + self.name = name.to_string(); + self + } + + /// Set all elements to be in a vertical orientation. + /// Argument axis will be X and values will be on the Y axis. + pub fn vertical(mut self) -> Self { + self.boxes.iter_mut().for_each(|box_elem| { + box_elem.orientation = Orientation::Vertical; + }); + self + } + + /// Set all elements to be in a horizontal orientation. + /// Argument axis will be Y and values will be on the X axis. + pub fn horizontal(mut self) -> Self { + self.boxes.iter_mut().for_each(|box_elem| { + box_elem.orientation = Orientation::Horizontal; + }); + self + } + + /// Highlight all plot elements. + pub fn highlight(mut self) -> Self { + self.highlight = true; + self + } + + /// Add a custom way to format an element. + /// Can be used to display a set number of decimals or custom labels. + pub fn element_formatter( + mut self, + formatter: Box String>, + ) -> Self { + self.element_formatter = Some(formatter); + self + } +} + +impl PlotItem for BoxPlot { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + self.boxes.iter().for_each(|b| { + b.add_shapes(transform, self.highlight, shapes); + }); + } + + fn initialize(&mut self, _x_range: RangeInclusive) { + // nothing to do + } + + fn name(&self) -> &str { + self.name.as_str() + } + + fn color(&self) -> Color32 { + self.default_color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::Rects + } + + fn get_bounds(&self) -> PlotBounds { + let mut bounds = PlotBounds::NOTHING; + self.boxes.iter().for_each(|b| { + bounds.merge(&b.bounds()); + }); + bounds + } + + fn find_closest(&self, point: Pos2, transform: &ScreenTransform) -> Option { + find_closest_rect(&self.boxes, point, transform) + } + + fn on_hover(&self, elem: ClosestElem, shapes: &mut Vec, plot: &PlotConfig<'_>) { + let box_plot = &self.boxes[elem.index]; + + box_plot.add_shapes(plot.transform, true, shapes); + box_plot.add_rulers_and_text(self, plot, shapes); + } +} + +// ---------------------------------------------------------------------------- +// Helper functions + +fn rulers_color(ui: &Ui) -> Color32 { + if ui.visuals().dark_mode { + Color32::from_gray(100).additive() + } else { + Color32::from_black_alpha(180) + } +} + +fn vertical_line(pointer: Pos2, transform: &ScreenTransform, line_color: Color32) -> Shape { + let frame = transform.frame(); + Shape::line_segment( + [ + pos2(pointer.x, frame.top()), + pos2(pointer.x, frame.bottom()), + ], + (1.0, line_color), + ) +} + +fn horizontal_line(pointer: Pos2, transform: &ScreenTransform, line_color: Color32) -> Shape { + let frame = transform.frame(); + Shape::line_segment( + [ + pos2(frame.left(), pointer.y), + pos2(frame.right(), pointer.y), + ], + (1.0, line_color), + ) +} + +fn add_rulers_and_text( + elem: &dyn RectElement, + plot: &PlotConfig<'_>, + text: Option, + shapes: &mut Vec, +) { + let orientation = elem.orientation(); + let show_argument = plot.show_x && orientation == Orientation::Vertical + || plot.show_y && orientation == Orientation::Horizontal; + let show_values = plot.show_y && orientation == Orientation::Vertical + || plot.show_x && orientation == Orientation::Horizontal; + + let line_color = rulers_color(plot.ui); + + // Rulers for argument (usually vertical) + if show_argument { + let push_argument_ruler = |argument: Value, shapes: &mut Vec| { + let position = plot.transform.position_from_value(&argument); + let line = match orientation { + Orientation::Horizontal => horizontal_line(position, plot.transform, line_color), + Orientation::Vertical => vertical_line(position, plot.transform, line_color), + }; + shapes.push(line); + }; + + for pos in elem.arguments_with_ruler() { + push_argument_ruler(pos, shapes); + } + } + + // Rulers for values (usually horizontal) + if show_values { + let push_value_ruler = |value: Value, shapes: &mut Vec| { + let position = plot.transform.position_from_value(&value); + let line = match orientation { + Orientation::Horizontal => vertical_line(position, plot.transform, line_color), + Orientation::Vertical => horizontal_line(position, plot.transform, line_color), + }; + shapes.push(line); + }; + + for pos in elem.values_with_ruler() { + push_value_ruler(pos, shapes); + } + } + + // Text + let text = text.unwrap_or({ + let mut text = elem.name().to_string(); // could be empty + + if show_values { + text.push_str(&elem.default_values_format(plot.transform)); + } + + text + }); + + let corner_value = elem.corner_value(); + shapes.push(Shape::text( + plot.ui.fonts(), + plot.transform.position_from_value(&corner_value) + vec2(3.0, -2.0), + Align2::LEFT_BOTTOM, + text, + TextStyle::Body, + plot.ui.visuals().text_color(), + )); +} + +/// Draws a cross of horizontal and vertical ruler at the `pointer` position. +/// `value` is used to for text displaying X/Y coordinates. +#[allow(clippy::too_many_arguments)] +pub(super) fn rulers_at_value( + pointer: Pos2, + value: Value, + name: &str, + plot: &PlotConfig<'_>, + shapes: &mut Vec, +) { + let line_color = rulers_color(plot.ui); + if plot.show_x { + shapes.push(vertical_line(pointer, plot.transform, line_color)); + } + if plot.show_y { + shapes.push(horizontal_line(pointer, plot.transform, line_color)); + } + + let mut prefix = String::new(); + + if !name.is_empty() { + prefix = format!("{}\n", name); + } + + let text = { + let scale = plot.transform.dvalue_dpos(); + let x_decimals = ((-scale[0].abs().log10()).ceil().at_least(0.0) as usize).at_most(6); + let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6); + if plot.show_x && plot.show_y { + format!( + "{}x = {:.*}\ny = {:.*}", + prefix, x_decimals, value.x, y_decimals, value.y + ) + } else if plot.show_x { + format!("{}x = {:.*}", prefix, x_decimals, value.x) + } else if plot.show_y { + format!("{}y = {:.*}", prefix, y_decimals, value.y) + } else { + unreachable!() + } + }; + + shapes.push(Shape::text( + plot.ui.fonts(), + pointer + vec2(3.0, -2.0), + Align2::LEFT_BOTTOM, + text, + TextStyle::Body, + plot.ui.visuals().text_color(), + )); +} + +fn find_closest_rect<'a, T>( + rects: impl IntoIterator, + point: Pos2, + transform: &ScreenTransform, +) -> Option +where + T: 'a + RectElement, +{ + rects + .into_iter() + .enumerate() + .map(|(index, bar)| { + let bar_rect: Rect = transform.rect_from_values(&bar.bounds_min(), &bar.bounds_max()); + let dist_sq = bar_rect.distance_sq_to_pos(point); + + ClosestElem { index, dist_sq } + }) + .min_by_key(|e| e.dist_sq.ord()) +} diff --git a/egui/src/widgets/plot/items/rect_elem.rs b/egui/src/widgets/plot/items/rect_elem.rs new file mode 100644 index 00000000000..395b2ca48f2 --- /dev/null +++ b/egui/src/widgets/plot/items/rect_elem.rs @@ -0,0 +1,61 @@ +use super::{Orientation, Value}; +use crate::plot::transform::{PlotBounds, ScreenTransform}; +use epaint::emath::NumExt; +use epaint::{Color32, Rgba, Stroke}; + +/// Trait that abstracts from rectangular 'Value'-like elements, such as bars or boxes +pub(super) trait RectElement { + fn name(&self) -> &str; + fn bounds_min(&self) -> Value; + fn bounds_max(&self) -> Value; + + fn bounds(&self) -> PlotBounds { + let mut bounds = PlotBounds::NOTHING; + bounds.extend_with(&self.bounds_min()); + bounds.extend_with(&self.bounds_max()); + bounds + } + + /// At which argument (input; usually X) there is a ruler (usually vertical) + fn arguments_with_ruler(&self) -> Vec { + // Default: one at center + vec![self.bounds().center()] + } + + /// At which value (output; usually Y) there is a ruler (usually horizontal) + fn values_with_ruler(&self) -> Vec; + + /// The diagram's orientation (vertical/horizontal) + fn orientation(&self) -> Orientation; + + /// Get X/Y-value for (argument, value) pair, taking into account orientation + fn point_at(&self, argument: f64, value: f64) -> Value { + match self.orientation() { + Orientation::Horizontal => Value::new(value, argument), + Orientation::Vertical => Value::new(argument, value), + } + } + + /// Right top of the rectangle (position of text) + fn corner_value(&self) -> Value { + //self.point_at(self.position + self.width / 2.0, value) + Value { + x: self.bounds_max().x, + y: self.bounds_max().y, + } + } + + /// Debug formatting for hovered-over value, if none is specified by the user + fn default_values_format(&self, transform: &ScreenTransform) -> String; +} + +// ---------------------------------------------------------------------------- +// Helper functions + +pub(super) fn highlighted_color(mut stroke: Stroke, fill: Color32) -> (Stroke, Color32) { + stroke.width *= 2.0; + let fill = Rgba::from(fill); + let fill_alpha = (2.0 * fill.a()).at_most(1.0); + let fill = fill.to_opaque().multiply(fill_alpha); + (stroke, fill.into()) +} diff --git a/egui/src/widgets/plot/items/values.rs b/egui/src/widgets/plot/items/values.rs new file mode 100644 index 00000000000..735f3e030ae --- /dev/null +++ b/egui/src/widgets/plot/items/values.rs @@ -0,0 +1,352 @@ +use epaint::{Pos2, Shape, Stroke, Vec2}; +use std::ops::{Bound, RangeBounds, RangeInclusive}; + +use crate::plot::transform::PlotBounds; + +/// A value in the value-space of the plot. +/// +/// Uses f64 for improved accuracy to enable plotting +/// large values (e.g. unix time on x axis). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Value { + /// This is often something monotonically increasing, such as time, but doesn't have to be. + /// Goes from left to right. + pub x: f64, + /// Goes from bottom to top (inverse of everything else in egui!). + pub y: f64, +} + +impl Value { + #[inline(always)] + pub fn new(x: impl Into, y: impl Into) -> Self { + Self { + x: x.into(), + y: y.into(), + } + } + + #[inline(always)] + pub fn to_pos2(self) -> Pos2 { + Pos2::new(self.x as f32, self.y as f32) + } + + #[inline(always)] + pub fn to_vec2(self) -> Vec2 { + Vec2::new(self.x as f32, self.y as f32) + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum LineStyle { + Solid, + Dotted { spacing: f32 }, + Dashed { length: f32 }, +} + +impl LineStyle { + pub fn dashed_loose() -> Self { + Self::Dashed { length: 10.0 } + } + + pub fn dashed_dense() -> Self { + Self::Dashed { length: 5.0 } + } + + pub fn dotted_loose() -> Self { + Self::Dotted { spacing: 10.0 } + } + + pub fn dotted_dense() -> Self { + Self::Dotted { spacing: 5.0 } + } + + pub(super) fn style_line( + &self, + line: Vec, + mut stroke: Stroke, + highlight: bool, + shapes: &mut Vec, + ) { + match line.len() { + 0 => {} + 1 => { + let mut radius = stroke.width / 2.0; + if highlight { + radius *= 2f32.sqrt(); + } + shapes.push(Shape::circle_filled(line[0], radius, stroke.color)); + } + _ => { + match self { + LineStyle::Solid => { + if highlight { + stroke.width *= 2.0; + } + shapes.push(Shape::line(line, stroke)); + } + LineStyle::Dotted { spacing } => { + // Take the stroke width for the radius even though it's not "correct", otherwise + // the dots would become too small. + let mut radius = stroke.width; + if highlight { + radius *= 2f32.sqrt(); + } + shapes.extend(Shape::dotted_line(&line, stroke.color, *spacing, radius)); + } + LineStyle::Dashed { length } => { + if highlight { + stroke.width *= 2.0; + } + let golden_ratio = (5.0_f32.sqrt() - 1.0) / 2.0; // 0.61803398875 + shapes.extend(Shape::dashed_line( + &line, + stroke, + *length, + length * golden_ratio, + )); + } + } + } + } + } +} + +impl ToString for LineStyle { + fn to_string(&self) -> String { + match self { + LineStyle::Solid => "Solid".into(), + LineStyle::Dotted { spacing } => format!("Dotted{}Px", spacing), + LineStyle::Dashed { length } => format!("Dashed{}Px", length), + } + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Orientation { + Horizontal, + Vertical, +} + +impl Default for Orientation { + fn default() -> Self { + Self::Vertical + } +} + +// ---------------------------------------------------------------------------- + +pub struct Values { + pub(super) values: Vec, + generator: Option, +} + +impl Default for Values { + fn default() -> Self { + Self { + values: Vec::new(), + generator: None, + } + } +} + +impl Values { + pub fn from_values(values: Vec) -> Self { + Self { + values, + generator: None, + } + } + + pub fn from_values_iter(iter: impl Iterator) -> Self { + Self::from_values(iter.collect()) + } + + /// Draw a line based on a function `y=f(x)`, a range (which can be infinite) for x and the number of points. + pub fn from_explicit_callback( + function: impl Fn(f64) -> f64 + 'static, + x_range: impl RangeBounds, + points: usize, + ) -> Self { + let start = match x_range.start_bound() { + Bound::Included(x) | Bound::Excluded(x) => *x, + Bound::Unbounded => f64::NEG_INFINITY, + }; + let end = match x_range.end_bound() { + Bound::Included(x) | Bound::Excluded(x) => *x, + Bound::Unbounded => f64::INFINITY, + }; + let x_range = start..=end; + + let generator = ExplicitGenerator { + function: Box::new(function), + x_range, + points, + }; + + Self { + values: Vec::new(), + generator: Some(generator), + } + } + + /// Draw a line based on a function `(x,y)=f(t)`, a range for t and the number of points. + /// The range may be specified as start..end or as start..=end. + pub fn from_parametric_callback( + function: impl Fn(f64) -> (f64, f64), + t_range: impl RangeBounds, + points: usize, + ) -> Self { + let start = match t_range.start_bound() { + Bound::Included(x) => x, + Bound::Excluded(_) => unreachable!(), + Bound::Unbounded => panic!("The range for parametric functions must be bounded!"), + }; + let end = match t_range.end_bound() { + Bound::Included(x) | Bound::Excluded(x) => x, + Bound::Unbounded => panic!("The range for parametric functions must be bounded!"), + }; + let last_point_included = matches!(t_range.end_bound(), Bound::Included(_)); + let increment = if last_point_included { + (end - start) / (points - 1) as f64 + } else { + (end - start) / points as f64 + }; + let values = (0..points).map(|i| { + let t = start + i as f64 * increment; + let (x, y) = function(t); + Value { x, y } + }); + Self::from_values_iter(values) + } + + /// From a series of y-values. + /// The x-values will be the indices of these values + pub fn from_ys_f32(ys: &[f32]) -> Self { + let values: Vec = ys + .iter() + .enumerate() + .map(|(i, &y)| Value { + x: i as f64, + y: y as f64, + }) + .collect(); + Self::from_values(values) + } + + /// Returns true if there are no data points available and there is no function to generate any. + pub(crate) fn is_empty(&self) -> bool { + self.generator.is_none() && self.values.is_empty() + } + + /// If initialized with a generator function, this will generate `n` evenly spaced points in the + /// given range. + pub(super) fn generate_points(&mut self, x_range: RangeInclusive) { + if let Some(generator) = self.generator.take() { + if let Some(intersection) = Self::range_intersection(&x_range, &generator.x_range) { + let increment = + (intersection.end() - intersection.start()) / (generator.points - 1) as f64; + self.values = (0..generator.points) + .map(|i| { + let x = intersection.start() + i as f64 * increment; + let y = (generator.function)(x); + Value { x, y } + }) + .collect(); + } + } + } + + /// Returns the intersection of two ranges if they intersect. + fn range_intersection( + range1: &RangeInclusive, + range2: &RangeInclusive, + ) -> Option> { + let start = range1.start().max(*range2.start()); + let end = range1.end().min(*range2.end()); + (start < end).then(|| start..=end) + } + + pub(super) fn get_bounds(&self) -> PlotBounds { + let mut bounds = PlotBounds::NOTHING; + self.values + .iter() + .for_each(|value| bounds.extend_with(value)); + bounds + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum MarkerShape { + Circle, + Diamond, + Square, + Cross, + Plus, + Up, + Down, + Left, + Right, + Asterisk, +} + +impl MarkerShape { + /// Get a vector containing all marker shapes. + pub fn all() -> impl Iterator { + [ + Self::Circle, + Self::Diamond, + Self::Square, + Self::Cross, + Self::Plus, + Self::Up, + Self::Down, + Self::Left, + Self::Right, + Self::Asterisk, + ] + .iter() + .copied() + } +} + +// ---------------------------------------------------------------------------- + +/// Query the values of the plot, for geometric relations like closest checks +pub(crate) enum PlotGeometry<'a> { + /// No geometry based on single elements (examples: text, image, horizontal/vertical line) + None, + + /// Point values (X-Y graphs) + Points(&'a [Value]), + + /// Rectangles (examples: boxes or bars) + // Has currently no data, as it would require copying rects or iterating a list of pointers. + // Instead, geometry-based functions are directly implemented in the respective PlotItem impl. + Rects, +} + +// ---------------------------------------------------------------------------- + +/// Describes a function y = f(x) with an optional range for x and a number of points. +struct ExplicitGenerator { + function: Box f64>, + x_range: RangeInclusive, + points: usize, +} + +// ---------------------------------------------------------------------------- + +/// Result of [`PlotItem::find_closest()`] search, identifies an element inside the item for immediate use +pub(crate) struct ClosestElem { + /// Position of hovered-over value (or bar/box-plot/...) in PlotItem + pub index: usize, + + /// Squared distance from the mouse cursor (needed to compare against other PlotItems, which might be nearer) + pub dist_sq: f32, +} diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 933ffed08fd..768c9f18b8f 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -1,22 +1,22 @@ //! Simple plotting library. -mod items; -mod legend; -mod transform; - +use crate::util::float_ord::FloatOrd; +use crate::*; +use color::Hsva; +use epaint::ahash::AHashSet; use items::PlotItem; +use legend::LegendWidget; +use transform::{PlotBounds, ScreenTransform}; + pub use items::{ - Arrows, HLine, Line, LineStyle, MarkerShape, PlotImage, Points, Polygon, Text, VLine, Value, - Values, + Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, HLine, Line, LineStyle, MarkerShape, + PlotImage, Points, Polygon, Text, VLine, Value, Values, }; -use legend::LegendWidget; pub use legend::{Corner, Legend}; -pub use transform::PlotBounds; -use transform::ScreenTransform; -use crate::*; -use color::Hsva; -use epaint::ahash::AHashSet; +mod items; +mod legend; +mod transform; // ---------------------------------------------------------------------------- @@ -581,6 +581,32 @@ impl PlotUi { } self.items.push(Box::new(vline)); } + + /// Add a box plot diagram. + pub fn box_plot(&mut self, mut box_plot: BoxPlot) { + if box_plot.boxes.is_empty() { + return; + } + + // Give the elements an automatic color if no color has been assigned. + if box_plot.default_color == Color32::TRANSPARENT { + box_plot = box_plot.color(self.auto_color()); + } + self.items.push(Box::new(box_plot)); + } + + /// Add a bar chart. + pub fn bar_chart(&mut self, mut chart: BarChart) { + if chart.bars.is_empty() { + return; + } + + // Give the elements an automatic color if no color has been assigned. + if chart.default_color == Color32::TRANSPARENT { + chart = chart.color(self.auto_color()); + } + self.items.push(Box::new(chart)); + } } struct PreparedPlot { @@ -713,88 +739,31 @@ impl PreparedPlot { return; } - let interact_radius: f32 = 16.0; - let mut closest_value = None; - let mut closest_item = None; - let mut closest_dist_sq = interact_radius.powi(2); - for item in items { - if let Some(values) = item.values() { - for value in &values.values { - let pos = transform.position_from_value(value); - let dist_sq = pointer.distance_sq(pos); - if dist_sq < closest_dist_sq { - closest_dist_sq = dist_sq; - closest_value = Some(value); - closest_item = Some(item.name()); - } - } - } - } - - let mut prefix = String::new(); - if let Some(name) = closest_item { - if !name.is_empty() { - prefix = format!("{}\n", name); - } - } - - let line_color = if ui.visuals().dark_mode { - Color32::from_gray(100).additive() - } else { - Color32::from_black_alpha(180) - }; + let interact_radius_sq: f32 = (16.0f32).powi(2); - let value = if let Some(value) = closest_value { - let position = transform.position_from_value(value); - shapes.push(Shape::circle_filled(position, 3.0, line_color)); - *value - } else { - transform.value_from_position(pointer) - }; - let pointer = transform.position_from_value(&value); + let candidates = items.iter().filter_map(|item| { + let item = &**item; + let closest = item.find_closest(pointer, transform); - let rect = transform.frame(); + Some(item).zip(closest) + }); - if *show_x { - // vertical line - shapes.push(Shape::line_segment( - [pos2(pointer.x, rect.top()), pos2(pointer.x, rect.bottom())], - (1.0, line_color), - )); - } - if *show_y { - // horizontal line - shapes.push(Shape::line_segment( - [pos2(rect.left(), pointer.y), pos2(rect.right(), pointer.y)], - (1.0, line_color), - )); - } + let closest = candidates + .min_by_key(|(_, elem)| elem.dist_sq.ord()) + .filter(|(_, elem)| elem.dist_sq <= interact_radius_sq); - let text = { - let scale = transform.dvalue_dpos(); - let x_decimals = ((-scale[0].abs().log10()).ceil().at_least(0.0) as usize).at_most(6); - let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6); - if *show_x && *show_y { - format!( - "{}x = {:.*}\ny = {:.*}", - prefix, x_decimals, value.x, y_decimals, value.y - ) - } else if *show_x { - format!("{}x = {:.*}", prefix, x_decimals, value.x) - } else if *show_y { - format!("{}y = {:.*}", prefix, y_decimals, value.y) - } else { - unreachable!() - } + let plot = items::PlotConfig { + ui, + transform, + show_x: *show_x, + show_y: *show_y, }; - shapes.push(Shape::text( - ui.fonts(), - pointer + vec2(3.0, -2.0), - Align2::LEFT_BOTTOM, - text, - TextStyle::Body, - ui.visuals().text_color(), - )); + if let Some((item, elem)) = closest { + item.on_hover(elem, shapes, &plot); + } else { + let value = transform.value_from_position(pointer); + items::rulers_at_value(pointer, value, "", &plot, shapes); + } } } diff --git a/egui/src/widgets/plot/transform.rs b/egui/src/widgets/plot/transform.rs index fb54fcd88d2..53007950338 100644 --- a/egui/src/widgets/plot/transform.rs +++ b/egui/src/widgets/plot/transform.rs @@ -52,6 +52,14 @@ impl PlotBounds { self.max[1] - self.min[1] } + pub fn center(&self) -> Value { + Value { + x: (self.min[0] + self.max[0]) / 2.0, + y: (self.min[1] + self.max[1]) / 2.0, + } + } + + /// Expand to include the given (x,y) value pub(crate) fn extend_with(&mut self, value: &Value) { self.extend_with_x(value.x); self.extend_with_y(value.y); @@ -225,6 +233,20 @@ impl ScreenTransform { Value::new(x, y) } + /// Transform a rectangle of plot values to a screen-coordinate rectangle. + /// + /// This typically means that the rect is mirrored vertically (top becomes bottom and vice versa), + /// since the plot's coordinate system has +Y up, while egui has +Y down. + pub fn rect_from_values(&self, value1: &Value, value2: &Value) -> Rect { + let pos1 = self.position_from_value(value1); + let pos2 = self.position_from_value(value2); + + let mut rect = Rect::NOTHING; + rect.extend_with(pos1); + rect.extend_with(pos2); + rect + } + /// delta position / delta value pub fn dpos_dvalue_x(&self) -> f64 { self.frame.width() as f64 / self.bounds.width() diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index e9d754a33b3..4bea5469c18 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -1,9 +1,10 @@ +use std::f64::consts::TAU; + use egui::*; use plot::{ - Arrows, Corner, HLine, Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, Points, Polygon, - Text, VLine, Value, Values, + Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, Corner, HLine, Legend, Line, LineStyle, + MarkerShape, Plot, PlotImage, Points, Polygon, Text, VLine, Value, Values, }; -use std::f64::consts::TAU; #[derive(PartialEq)] struct LineDemo { @@ -430,18 +431,203 @@ impl Widget for &mut InteractionDemo { } } +#[derive(PartialEq, Eq)] +enum Chart { + GaussBars, + StackedBars, + BoxPlot, +} + +impl Default for Chart { + fn default() -> Self { + Self::GaussBars + } +} + +#[derive(PartialEq)] +struct ChartsDemo { + chart: Chart, + vertical: bool, +} + +impl Default for ChartsDemo { + fn default() -> Self { + Self { + vertical: true, + chart: Chart::default(), + } + } +} + +impl ChartsDemo { + fn bar_gauss(&self, ui: &mut Ui) -> Response { + let mut chart = BarChart::new( + (-395..=395) + .step_by(10) + .map(|x| x as f64 * 0.01) + .map(|x| { + ( + x, + (-x * x / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt(), + ) + }) + // The 10 factor here is purely for a nice 1:1 aspect ratio + .map(|(x, f)| Bar::new(x, f * 10.0).width(0.095)) + .collect(), + ) + .color(Color32::LIGHT_BLUE) + .name("Normal Distribution"); + if !self.vertical { + chart = chart.horizontal(); + } + + Plot::new("Normal Distribution Demo") + .legend(Legend::default()) + .data_aspect(1.0) + .show(ui, |plot_ui| plot_ui.bar_chart(chart)) + .response + } + + fn bar_stacked(&self, ui: &mut Ui) -> Response { + let mut chart1 = BarChart::new(vec![ + Bar::new(0.5, 1.0).name("Day 1"), + Bar::new(1.5, 3.0).name("Day 2"), + Bar::new(2.5, 1.0).name("Day 3"), + Bar::new(3.5, 2.0).name("Day 4"), + Bar::new(4.5, 4.0).name("Day 5"), + ]) + .width(0.7) + .name("Set 1"); + + let mut chart2 = BarChart::new(vec![ + Bar::new(0.5, 1.0), + Bar::new(1.5, 1.5), + Bar::new(2.5, 0.1), + Bar::new(3.5, 0.7), + Bar::new(4.5, 0.8), + ]) + .width(0.7) + .name("Set 2") + .stack_on(&[&chart1]); + + let mut chart3 = BarChart::new(vec![ + Bar::new(0.5, -0.5), + Bar::new(1.5, 1.0), + Bar::new(2.5, 0.5), + Bar::new(3.5, -1.0), + Bar::new(4.5, 0.3), + ]) + .width(0.7) + .name("Set 3") + .stack_on(&[&chart1, &chart2]); + + let mut chart4 = BarChart::new(vec![ + Bar::new(0.5, 0.5), + Bar::new(1.5, 1.0), + Bar::new(2.5, 0.5), + Bar::new(3.5, -0.5), + Bar::new(4.5, -0.5), + ]) + .width(0.7) + .name("Set 4") + .stack_on(&[&chart1, &chart2, &chart3]); + + if !self.vertical { + chart1 = chart1.horizontal(); + chart2 = chart2.horizontal(); + chart3 = chart3.horizontal(); + chart4 = chart4.horizontal(); + } + + Plot::new("Stacked Bar Chart Demo") + .legend(Legend::default()) + .data_aspect(1.0) + .show(ui, |plot_ui| { + plot_ui.bar_chart(chart1); + plot_ui.bar_chart(chart2); + plot_ui.bar_chart(chart3); + plot_ui.bar_chart(chart4); + }) + .response + } + + fn box_plot(&self, ui: &mut Ui) -> Response { + let yellow = Color32::from_rgb(248, 252, 168); + let mut box1 = BoxPlot::new(vec![ + BoxElem::new(0.5, BoxSpread::new(1.5, 2.2, 2.5, 2.6, 3.1)).name("Day 1"), + BoxElem::new(2.5, BoxSpread::new(0.4, 1.0, 1.1, 1.4, 2.1)).name("Day 2"), + BoxElem::new(4.5, BoxSpread::new(1.7, 2.0, 2.2, 2.5, 2.9)).name("Day 3"), + ]) + .name("Experiment A"); + + let mut box2 = BoxPlot::new(vec![ + BoxElem::new(1.0, BoxSpread::new(0.2, 0.5, 1.0, 2.0, 2.7)).name("Day 1"), + BoxElem::new(3.0, BoxSpread::new(1.5, 1.7, 2.1, 2.9, 3.3)) + .name("Day 2: interesting") + .stroke(Stroke::new(1.5, yellow)) + .fill(yellow.linear_multiply(0.2)), + BoxElem::new(5.0, BoxSpread::new(1.3, 2.0, 2.3, 2.9, 4.0)).name("Day 3"), + ]) + .name("Experiment B"); + + let mut box3 = BoxPlot::new(vec![ + BoxElem::new(1.5, BoxSpread::new(2.1, 2.2, 2.6, 2.8, 3.0)).name("Day 1"), + BoxElem::new(3.5, BoxSpread::new(1.3, 1.5, 1.9, 2.2, 2.4)).name("Day 2"), + BoxElem::new(5.5, BoxSpread::new(0.2, 0.4, 1.0, 1.3, 1.5)).name("Day 3"), + ]) + .name("Experiment C"); + + if !self.vertical { + box1 = box1.horizontal(); + box2 = box2.horizontal(); + box3 = box3.horizontal(); + } + + Plot::new("Box Plot Demo") + .legend(Legend::default()) + .show(ui, |plot_ui| { + plot_ui.box_plot(box1); + plot_ui.box_plot(box2); + plot_ui.box_plot(box3); + }) + .response + } +} + +impl Widget for &mut ChartsDemo { + fn ui(self, ui: &mut Ui) -> Response { + ui.label("Type:"); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.chart, Chart::GaussBars, "Histogram"); + ui.selectable_value(&mut self.chart, Chart::StackedBars, "Stacked Bar Chart"); + ui.selectable_value(&mut self.chart, Chart::BoxPlot, "Box Plot"); + }); + ui.label("Orientation:"); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.vertical, true, "Vertical"); + ui.selectable_value(&mut self.vertical, false, "Horizontal"); + }); + match self.chart { + Chart::GaussBars => self.bar_gauss(ui), + Chart::StackedBars => self.bar_stacked(ui), + Chart::BoxPlot => self.box_plot(ui), + } + } +} + #[derive(PartialEq, Eq)] enum Panel { Lines, Markers, Legend, + Charts, Items, Interaction, } impl Default for Panel { fn default() -> Self { - Self::Lines + Self::Charts } } @@ -450,6 +636,7 @@ pub struct PlotDemo { line_demo: LineDemo, marker_demo: MarkerDemo, legend_demo: LegendDemo, + charts_demo: ChartsDemo, items_demo: ItemsDemo, interaction_demo: InteractionDemo, open_panel: Panel, @@ -492,6 +679,7 @@ impl super::View for PlotDemo { ui.selectable_value(&mut self.open_panel, Panel::Lines, "Lines"); ui.selectable_value(&mut self.open_panel, Panel::Markers, "Markers"); ui.selectable_value(&mut self.open_panel, Panel::Legend, "Legend"); + 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"); }); @@ -507,6 +695,9 @@ impl super::View for PlotDemo { Panel::Legend => { ui.add(&mut self.legend_demo); } + Panel::Charts => { + ui.add(&mut self.charts_demo); + } Panel::Items => { ui.add(&mut self.items_demo); } diff --git a/emath/src/rect.rs b/emath/src/rect.rs index aec425021d4..a67af7a4bb0 100644 --- a/emath/src/rect.rs +++ b/emath/src/rect.rs @@ -309,6 +309,27 @@ impl Rect { self.width() * self.height() } + #[inline] + pub fn distance_sq_to_pos(&self, pos: Pos2) -> f32 { + let dx = if self.min.x > pos.x { + self.min.x - pos.x + } else if pos.x > self.max.x { + pos.x - self.max.x + } else { + 0.0 + }; + + let dy = if self.min.y > pos.y { + self.min.y - pos.y + } else if pos.y > self.max.y { + pos.y - self.max.y + } else { + 0.0 + }; + + dx * dx + dy * dy + } + #[inline(always)] pub fn x_range(&self) -> RangeInclusive { self.min.x..=self.max.x