From e14934e2625db22b026fc288ae6718fa989d56f9 Mon Sep 17 00:00:00 2001 From: Sven Niederberger Date: Tue, 8 Jun 2021 00:06:43 +0200 Subject: [PATCH] Added plot items: * Arrows, also called "Quiver plots" in matplotlib etc. * Convex polygons * Text * Images Other changes: * Make HLine/VLine into PlotItems as well. * Add a "fill" property to Line so that we can fill/shade the area between a line and a horizontal reference line. * Add stems to Points, which are lines between the points and a horizontal reference line. * Allow using .. when specifying ranges for values generated by explicit callback functions, as an alias for f64::NEG_INFINITY..f64::INFINITY * Allow using ranges with exclusive end bounds for values generated by parametric callback functions to generate values where the first and last value are not the same. --- egui/src/widgets/plot/items.rs | 825 +++++++++++++++++++++-- egui/src/widgets/plot/mod.rs | 109 +-- egui_demo_lib/src/apps/demo/plot_demo.rs | 105 ++- 3 files changed, 932 insertions(+), 107 deletions(-) diff --git a/egui/src/widgets/plot/items.rs b/egui/src/widgets/plot/items.rs index 9e49c476b8c..b5372f57b3a 100644 --- a/egui/src/widgets/plot/items.rs +++ b/egui/src/widgets/plot/items.rs @@ -1,10 +1,14 @@ //! Contains items that can be added to a plot. -use std::ops::RangeInclusive; +use std::ops::{Bound, RangeBounds, RangeInclusive}; + +use epaint::Mesh; use super::transform::{Bounds, ScreenTransform}; 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 @@ -31,45 +35,175 @@ impl Value { // ---------------------------------------------------------------------------- /// A horizontal line in a plot, filling the full width -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct HLine { pub(super) y: f64, pub(super) stroke: Stroke, + pub(super) name: String, + pub(super) highlight: bool, } impl HLine { - pub fn new(y: impl Into, stroke: impl Into) -> Self { + pub fn new(y: impl Into) -> Self { Self { y: y.into(), - stroke: stroke.into(), + stroke: Stroke::new(1.0, Color32::TRANSPARENT), + name: String::default(), + highlight: false, } } + + /// Name of this horizontal line. + /// + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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 + } +} + +impl PlotItem for HLine { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + let HLine { + y, + mut stroke, + highlight, + .. + } = self; + if *highlight { + stroke.width *= 2.0; + } + let points = [ + transform.position_from_value(&Value::new(transform.bounds().min[0], *y)), + transform.position_from_value(&Value::new(transform.bounds().max[0], *y)), + ]; + shapes.push(Shape::line_segment(points, stroke)); + } + + fn initialize(&mut self, _x_range: RangeInclusive) {} + + fn name(&self) -> &str { + &self.name + } + + fn color(&self) -> Color32 { + self.stroke.color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn values(&self) -> Option<&Values> { + None + } + + fn get_bounds(&self) -> Bounds { + let mut bounds = Bounds::NOTHING; + bounds.min[1] = self.y; + bounds.max[1] = self.y; + bounds + } } /// A vertical line in a plot, filling the full width -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct VLine { pub(super) x: f64, pub(super) stroke: Stroke, + pub(super) name: String, + pub(super) highlight: bool, } impl VLine { - pub fn new(x: impl Into, stroke: impl Into) -> Self { + pub fn new(x: impl Into) -> Self { Self { x: x.into(), - stroke: stroke.into(), + stroke: Stroke::new(1.0, Color32::TRANSPARENT), + name: String::default(), + highlight: false, } } + + /// Name of this vertical line. + /// + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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 + } +} + +impl PlotItem for VLine { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + let VLine { + x, + mut stroke, + highlight, + .. + } = self; + if *highlight { + stroke.width *= 2.0; + } + let points = [ + transform.position_from_value(&Value::new(*x, transform.bounds().min[1])), + transform.position_from_value(&Value::new(*x, transform.bounds().max[1])), + ]; + shapes.push(Shape::line_segment(points, stroke)); + } + + fn initialize(&mut self, _x_range: RangeInclusive) {} + + fn name(&self) -> &str { + &self.name + } + + fn color(&self) -> Color32 { + self.stroke.color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn values(&self) -> Option<&Values> { + None + } + + fn get_bounds(&self) -> Bounds { + let mut bounds = Bounds::NOTHING; + bounds.min[0] = self.x; + bounds.max[0] = self.x; + bounds + } } +/// Trait shared by things that can be drawn in the plot. pub(super) trait PlotItem { - fn get_shapes(&self, transform: &ScreenTransform, shapes: &mut Vec); - fn series(&self) -> &Values; - fn series_mut(&mut self) -> &mut Values; + 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) -> Bounds; } // ---------------------------------------------------------------------------- @@ -110,9 +244,19 @@ impl Values { /// 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: RangeInclusive, + 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, @@ -126,14 +270,29 @@ impl Values { } /// 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: RangeInclusive, + t_range: impl RangeBounds, points: usize, ) -> Self { - let increment = (t_range.end() - t_range.start()) / (points - 1) as f64; + 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 = t_range.start() + i as f64 * increment; + let t = start + i as f64 * increment; let (x, y) = function(t); Value { x, y } }); @@ -236,6 +395,7 @@ pub struct Line { pub(super) stroke: Stroke, pub(super) name: String, pub(super) highlight: bool, + pub(super) fill: Option, } impl Line { @@ -245,6 +405,7 @@ impl Line { stroke: Stroke::new(1.0, Color32::TRANSPARENT), name: Default::default(), highlight: false, + fill: None, } } @@ -261,8 +422,8 @@ impl Line { } /// Stroke width. A high value means the plot thickens. - pub fn width(mut self, width: f32) -> Self { - self.stroke.width = width; + pub fn width(mut self, width: impl Into) -> Self { + self.stroke.width = width.into(); self } @@ -272,10 +433,18 @@ impl Line { self } + /// Fill the area between this line and a given horizontal reference line. + pub fn fill(mut self, y_reference: impl Into) -> Self { + self.fill = Some(y_reference.into()); + self + } + /// Name of this line. /// - /// This name will show up in the plot legend, if legends are turned on. Multiple lines may - /// share the same name, in which case they will also share an entry in the legend. + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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(); @@ -283,17 +452,28 @@ impl Line { } } +/// Returns the x-coordinate of a possible intersection between a line segment from `p1` to `p2` and +/// a horizontal line at the given y-coordinate. +fn y_intersection(p1: &Pos2, p2: &Pos2, y: f32) -> Option { + ((p1.y > y && p2.y < y) || (p1.y < y && p2.y > y)) + .then(|| ((y * (p1.x - p2.x)) - (p1.x * p2.y - p1.y * p2.x)) / (p1.y - p2.y)) +} + impl PlotItem for Line { - fn get_shapes(&self, transform: &ScreenTransform, shapes: &mut Vec) { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { let Self { series, mut stroke, highlight, + mut fill, .. } = self; + let mut fill_alpha = DEFAULT_FILL_ALPHA; + if *highlight { stroke.width *= 2.0; + fill_alpha = (2.0 * fill_alpha).at_most(1.0); } let values_tf: Vec<_> = series @@ -301,8 +481,45 @@ impl PlotItem for Line { .iter() .map(|v| transform.position_from_value(v)) .collect(); + let n_values = values_tf.len(); - let line_shape = if values_tf.len() > 1 { + // Fill the area between the line and a reference line, if required. + if n_values < 2 { + fill = None; + } + if let Some(y_reference) = fill { + let y = transform + .position_from_value(&Value::new(0.0, y_reference)) + .y; + let fill_color = Rgba::from(stroke.color) + .to_opaque() + .multiply(fill_alpha) + .into(); + let mut mesh = Mesh::default(); + let expected_intersections = 20; + mesh.reserve_triangles((n_values - 1) * 2); + mesh.reserve_vertices(n_values * 2 + expected_intersections); + values_tf[0..n_values - 1].windows(2).for_each(|w| { + let i = mesh.vertices.len() as u32; + mesh.colored_vertex(w[0], fill_color); + mesh.colored_vertex(pos2(w[0].x, y), fill_color); + if let Some(x) = y_intersection(&w[0], &w[1], y) { + let point = pos2(x, y); + mesh.colored_vertex(point, fill_color); + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i + 2, i + 3, i + 4); + } else { + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i + 1, i + 2, i + 3); + } + }); + let last = values_tf[n_values - 1]; + mesh.colored_vertex(last, fill_color); + mesh.colored_vertex(pos2(last.x, y), fill_color); + shapes.push(Shape::Mesh(mesh)); + } + + let line_shape = if n_values > 1 { Shape::line(values_tf, stroke) } else { Shape::circle_filled(values_tf[0], stroke.width / 2.0, stroke.color) @@ -310,12 +527,129 @@ impl PlotItem for Line { shapes.push(line_shape); } - fn series(&self) -> &Values { - &self.series + fn initialize(&mut self, x_range: RangeInclusive) { + self.series.generate_points(x_range); + } + + fn name(&self) -> &str { + self.name.as_str() + } + + fn color(&self) -> Color32 { + self.stroke.color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn values(&self) -> Option<&Values> { + Some(&self.series) + } + + fn get_bounds(&self) -> Bounds { + self.series.get_bounds() + } +} + +/// A convex polygon. +pub struct Polygon { + pub(super) series: Values, + pub(super) stroke: Stroke, + pub(super) name: String, + pub(super) highlight: bool, + pub(super) fill_alpha: f32, +} + +impl Polygon { + pub fn new(series: Values) -> Self { + Self { + series, + stroke: Stroke::new(1.0, Color32::TRANSPARENT), + name: Default::default(), + highlight: false, + fill_alpha: DEFAULT_FILL_ALPHA, + } + } + + /// Highlight this polygon in the plot by scaling up the stroke and reducing the fill + /// transparency. + pub fn highlight(mut self) -> Self { + self.highlight = true; + self + } + + /// Add a custom stroke. + pub fn stroke(mut self, stroke: impl Into) -> Self { + self.stroke = stroke.into(); + self + } + + /// Set the stroke width. + pub fn width(mut self, width: impl Into) -> Self { + self.stroke.width = width.into(); + self + } + + /// Stroke color. Default is `Color32::TRANSPARENT` which means a color will be auto-assigned. + pub fn color(mut self, color: impl Into) -> Self { + self.stroke.color = color.into(); + self + } + + /// Alpha of the filled area. + pub fn fill_alpha(mut self, alpha: impl Into) -> Self { + self.fill_alpha = alpha.into(); + self + } + + /// Name of this polygon. + /// + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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 + } +} + +impl PlotItem for Polygon { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + let Self { + series, + mut stroke, + highlight, + mut fill_alpha, + .. + } = self; + + if *highlight { + stroke.width *= 2.0; + fill_alpha = (2.0 * fill_alpha).at_most(1.0); + } + + let values_tf: Vec<_> = series + .values + .iter() + .map(|v| transform.position_from_value(v)) + .collect(); + + let fill = Rgba::from(stroke.color).to_opaque().multiply(fill_alpha); + + let shape = Shape::convex_polygon(values_tf, fill, stroke); + + shapes.push(shape); } - fn series_mut(&mut self) -> &mut Values { - &mut self.series + fn initialize(&mut self, x_range: RangeInclusive) { + self.series.generate_points(x_range); } fn name(&self) -> &str { @@ -333,6 +667,134 @@ impl PlotItem for Line { fn highlighted(&self) -> bool { self.highlight } + + fn values(&self) -> Option<&Values> { + Some(&self.series) + } + + fn get_bounds(&self) -> Bounds { + self.series.get_bounds() + } +} + +/// Text inside the plot. +pub struct Text { + pub(super) text: String, + pub(super) style: TextStyle, + pub(super) position: Value, + pub(super) name: String, + pub(super) highlight: bool, + pub(super) color: Color32, + pub(super) anchor: Align2, +} + +impl Text { + #[allow(clippy::needless_pass_by_value)] + pub fn new(position: Value, text: impl ToString) -> Self { + Self { + text: text.to_string(), + style: TextStyle::Small, + position, + name: Default::default(), + highlight: false, + color: Color32::TRANSPARENT, + anchor: Align2::CENTER_CENTER, + } + } + + /// Highlight this text in the plot by drawing a rectangle around it. + pub fn highlight(mut self) -> Self { + self.highlight = true; + self + } + + /// Text style. Default is `TextStyle::Small`. + pub fn style(mut self, style: TextStyle) -> Self { + self.style = style; + self + } + + /// Text color. Default is `Color32::TRANSPARENT` which means a color will be auto-assigned. + pub fn color(mut self, color: impl Into) -> Self { + self.color = color.into(); + self + } + + /// Anchor position of the text. Default is `Align2::CENTER_CENTER`. + pub fn anchor(mut self, anchor: Align2) -> Self { + self.anchor = anchor; + self + } + + /// Name of this text. + /// + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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 + } +} + +impl PlotItem for Text { + fn get_shapes(&self, ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + let color = if self.color == Color32::TRANSPARENT { + ui.style().visuals.text_color() + } else { + self.color + }; + let pos = transform.position_from_value(&self.position); + let galley = ui + .fonts() + .layout_multiline(self.style, self.text.clone(), f32::INFINITY); + let rect = self + .anchor + .anchor_rect(Rect::from_min_size(pos, galley.size)); + shapes.push(Shape::Text { + pos: rect.min, + galley, + color, + fake_italics: false, + }); + if self.highlight { + shapes.push(Shape::rect_stroke( + rect.expand(2.0), + 1.0, + Stroke::new(0.5, color), + )); + } + } + + fn initialize(&mut self, _x_range: RangeInclusive) {} + + fn name(&self) -> &str { + self.name.as_str() + } + + fn color(&self) -> Color32 { + self.color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn values(&self) -> Option<&Values> { + None + } + + fn get_bounds(&self) -> Bounds { + let mut bounds = Bounds::NOTHING; + bounds.extend_with(&self.position); + bounds + } } /// A set of points. @@ -347,6 +809,7 @@ pub struct Points { pub(super) radius: f32, pub(super) name: String, pub(super) highlight: bool, + pub(super) stems: Option, } impl Points { @@ -359,6 +822,7 @@ impl Points { radius: 1.0, name: Default::default(), highlight: false, + stems: None, } } @@ -375,8 +839,8 @@ impl Points { } /// Set the marker's color. - pub fn color(mut self, color: Color32) -> Self { - self.color = color; + pub fn color(mut self, color: impl Into) -> Self { + self.color = color.into(); self } @@ -386,16 +850,24 @@ impl Points { self } + /// Whether to add stems between the markers and a horizontal reference line. + pub fn stems(mut self, y_reference: impl Into) -> Self { + self.stems = Some(y_reference.into()); + self + } + /// Set the maximum extent of the marker around its position. - pub fn radius(mut self, radius: f32) -> Self { - self.radius = radius; + pub fn radius(mut self, radius: impl Into) -> Self { + self.radius = radius.into(); self } /// Name of this set of points. /// - /// This name will show up in the plot legend, if legends are turned on. Multiple sets of points - /// may share the same name, in which case they will also share an entry in the legend. + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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(); @@ -404,7 +876,7 @@ impl Points { } impl PlotItem for Points { - fn get_shapes(&self, transform: &ScreenTransform, shapes: &mut Vec) { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { let sqrt_3 = 3f32.sqrt(); let frac_sqrt_3_2 = 3f32.sqrt() / 2.0; let frac_1_sqrt_2 = 1.0 / 2f32.sqrt(); @@ -416,19 +888,27 @@ impl PlotItem for Points { filled, mut radius, highlight, + stems, .. } = self; - if *highlight { - radius *= 2f32.sqrt(); - } - let stroke_size = radius / 5.0; let default_stroke = Stroke::new(stroke_size, *color); - let stroke = (!filled).then(|| default_stroke).unwrap_or_default(); + let mut stem_stroke = default_stroke; + let stroke = (!filled) + .then(|| default_stroke) + .unwrap_or_else(Stroke::none); let fill = filled.then(|| *color).unwrap_or_default(); + if *highlight { + radius *= 2f32.sqrt(); + stem_stroke.width *= 2.0; + } + + let y_reference = + stems.map(|y| transform.position_from_value(&Value::new(0.0, y)).y as f32); + series .values .iter() @@ -436,6 +916,11 @@ impl PlotItem for Points { .for_each(|center| { let tf = |dx: f32, dy: f32| -> Pos2 { center + radius * vec2(dx, dy) }; + if let Some(y) = y_reference { + let stem = Shape::line_segment([center, pos2(center.x, y)], stem_stroke); + shapes.push(stem); + } + match shape { MarkerShape::Circle => { shapes.push(Shape::Circle { @@ -544,12 +1029,123 @@ impl PlotItem for Points { }); } - fn series(&self) -> &Values { - &self.series + fn initialize(&mut self, x_range: RangeInclusive) { + self.series.generate_points(x_range); + } + + fn name(&self) -> &str { + self.name.as_str() + } + + fn color(&self) -> Color32 { + self.color + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight } - fn series_mut(&mut self) -> &mut Values { - &mut self.series + fn values(&self) -> Option<&Values> { + Some(&self.series) + } + + fn get_bounds(&self) -> Bounds { + self.series.get_bounds() + } +} + +/// A set of arrows. +pub struct Arrows { + pub(super) origins: Values, + pub(super) tips: Values, + pub(super) color: Color32, + pub(super) name: String, + pub(super) highlight: bool, +} + +impl Arrows { + pub fn new(origins: Values, tips: Values) -> Self { + Self { + origins, + tips, + color: Color32::TRANSPARENT, + name: Default::default(), + highlight: false, + } + } + + /// Highlight these arrows in the plot. + pub fn highlight(mut self) -> Self { + self.highlight = true; + self + } + + /// Set the arrows' color. + pub fn color(mut self, color: impl Into) -> Self { + self.color = color.into(); + self + } + + /// Name of this set of arrows. + /// + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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 + } +} + +impl PlotItem for Arrows { + fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + use crate::emath::*; + let Self { + origins, + tips, + color, + highlight, + .. + } = self; + let stroke = Stroke::new(if *highlight { 2.0 } else { 1.0 }, *color); + origins + .values + .iter() + .zip(tips.values.iter()) + .map(|(origin, tip)| { + ( + transform.position_from_value(origin), + transform.position_from_value(tip), + ) + }) + .for_each(|(origin, tip)| { + let vector = tip - origin; + let rot = Rot2::from_angle(std::f32::consts::TAU / 10.0); + let tip_length = vector.length() / 4.0; + let tip = origin + vector; + let dir = vector.normalized(); + shapes.push(Shape::line_segment([origin, tip], stroke)); + shapes.push(Shape::line( + vec![ + tip - tip_length * (rot.inverse() * dir), + tip, + tip - tip_length * (rot * dir), + ], + stroke, + )); + }); + } + + fn initialize(&mut self, _x_range: RangeInclusive) { + self.origins + .generate_points(f64::NEG_INFINITY..=f64::INFINITY); + self.tips.generate_points(f64::NEG_INFINITY..=f64::INFINITY); } fn name(&self) -> &str { @@ -567,4 +1163,153 @@ impl PlotItem for Points { fn highlighted(&self) -> bool { self.highlight } + + fn values(&self) -> Option<&Values> { + Some(&self.origins) + } + + fn get_bounds(&self) -> Bounds { + self.origins.get_bounds() + } +} + +/// An image in the plot. +pub struct PlotImage { + pub(super) position: Value, + pub(super) texture_id: TextureId, + pub(super) uv: Rect, + pub(super) size: Vec2, + pub(super) bg_fill: Color32, + pub(super) tint: Color32, + pub(super) highlight: bool, + pub(super) name: String, +} + +impl PlotImage { + /// Create a new image with position and size in plot coordinates. + pub fn new(texture_id: TextureId, position: Value, size: impl Into) -> Self { + Self { + position, + name: Default::default(), + highlight: false, + texture_id, + uv: Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + size: size.into(), + bg_fill: Default::default(), + tint: Color32::WHITE, + } + } + + /// Highlight this image in the plot. + pub fn highlight(mut self) -> Self { + self.highlight = true; + self + } + + /// Select UV range. Default is (0,0) in top-left, (1,1) bottom right. + pub fn uv(mut self, uv: impl Into) -> Self { + self.uv = uv.into(); + self + } + + /// A solid color to put behind the image. Useful for transparent images. + pub fn bg_fill(mut self, bg_fill: impl Into) -> Self { + self.bg_fill = bg_fill.into(); + self + } + + /// Multiply image color with this. Default is WHITE (no tint). + pub fn tint(mut self, tint: impl Into) -> Self { + self.tint = tint.into(); + self + } + + /// Name of this image. + /// + /// This name will show up in the plot legend, if legends are turned on. + /// + /// Multiple plot items 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 + } +} + +impl PlotItem for PlotImage { + fn get_shapes(&self, ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec) { + let Self { + position, + texture_id, + uv, + size, + bg_fill, + tint, + highlight, + .. + } = self; + let rect = { + let left_top = Value::new( + position.x as f32 - size.x / 2.0, + position.y as f32 - size.y / 2.0, + ); + let right_bottom = Value::new( + position.x as f32 + size.x / 2.0, + position.y as f32 + size.y / 2.0, + ); + let left_top_tf = transform.position_from_value(&left_top); + let right_bottom_tf = transform.position_from_value(&right_bottom); + Rect::from_two_pos(left_top_tf, right_bottom_tf) + }; + Image::new(*texture_id, *size) + .bg_fill(*bg_fill) + .tint(*tint) + .uv(*uv) + .paint_at(ui, rect); + if *highlight { + shapes.push(Shape::rect_stroke( + rect, + 0.0, + Stroke::new(1.0, ui.visuals().strong_text_color()), + )) + } + } + + fn initialize(&mut self, _x_range: RangeInclusive) {} + + fn name(&self) -> &str { + self.name.as_str() + } + + fn color(&self) -> Color32 { + Color32::TRANSPARENT + } + + fn highlight(&mut self) { + self.highlight = true; + } + + fn highlighted(&self) -> bool { + self.highlight + } + + fn values(&self) -> Option<&Values> { + None + } + + fn get_bounds(&self) -> Bounds { + let mut bounds = Bounds::NOTHING; + let left_top = Value::new( + self.position.x as f32 - self.size.x / 2.0, + self.position.y as f32 - self.size.y / 2.0, + ); + let right_bottom = Value::new( + self.position.x as f32 + self.size.x / 2.0, + self.position.y as f32 + self.size.y / 2.0, + ); + bounds.extend_with(&left_top); + bounds.extend_with(&right_bottom); + bounds + } } diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 902355eed83..6f7f5d3ce29 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -7,8 +7,8 @@ mod transform; use std::collections::HashSet; use items::PlotItem; +pub use items::{Arrows, Line, MarkerShape, PlotImage, Points, Polygon, Text, Value, Values}; pub use items::{HLine, VLine}; -pub use items::{Line, MarkerShape, Points, Value, Values}; use legend::LegendWidget; pub use legend::{Corner, Legend}; use transform::{Bounds, ScreenTransform}; @@ -51,8 +51,6 @@ pub struct Plot { next_auto_color_idx: usize, items: Vec>, - hlines: Vec, - vlines: Vec, center_x_axis: bool, center_y_axis: bool, @@ -80,8 +78,6 @@ impl Plot { next_auto_color_idx: 0, items: Default::default(), - hlines: Default::default(), - vlines: Default::default(), center_x_axis: false, center_y_axis: false, @@ -111,7 +107,6 @@ impl Plot { } /// Add a data lines. - /// You can add multiple lines. pub fn line(mut self, mut line: Line) -> Self { if line.series.is_empty() { return self; @@ -122,12 +117,34 @@ impl Plot { line.stroke.color = self.auto_color(); } self.items.push(Box::new(line)); + self + } + + /// Add a polygon. The polygon has to be convex. + pub fn polygon(mut self, mut polygon: Polygon) -> Self { + if polygon.series.is_empty() { + return self; + }; + + // Give the stroke an automatic color if no color has been assigned. + if polygon.stroke.color == Color32::TRANSPARENT { + polygon.stroke.color = self.auto_color(); + } + self.items.push(Box::new(polygon)); + self + } + /// Add a text. + pub fn text(mut self, text: Text) -> Self { + if text.text.is_empty() { + return self; + }; + + self.items.push(Box::new(text)); self } /// Add data points. - /// You can add multiple sets of points. pub fn points(mut self, mut points: Points) -> Self { if points.series.is_empty() { return self; @@ -138,7 +155,26 @@ impl Plot { points.color = self.auto_color(); } self.items.push(Box::new(points)); + self + } + + /// Add arrows. + pub fn arrows(mut self, mut arrows: Arrows) -> Self { + if arrows.origins.is_empty() || arrows.tips.is_empty() { + return self; + }; + + // Give the arrows an automatic color if no color has been assigned. + if arrows.color == Color32::TRANSPARENT { + arrows.color = self.auto_color(); + } + self.items.push(Box::new(arrows)); + self + } + /// Add an image. + pub fn image(mut self, image: PlotImage) -> Self { + self.items.push(Box::new(image)); self } @@ -149,7 +185,7 @@ impl Plot { if hline.stroke.color == Color32::TRANSPARENT { hline.stroke.color = self.auto_color(); } - self.hlines.push(hline); + self.items.push(Box::new(hline)); self } @@ -160,7 +196,7 @@ impl Plot { if vline.stroke.color == Color32::TRANSPARENT { vline.stroke.color = self.auto_color(); } - self.vlines.push(vline); + self.items.push(Box::new(vline)); self } @@ -284,8 +320,6 @@ impl Widget for Plot { name, next_auto_color_idx: _, mut items, - hlines, - vlines, center_x_axis, center_y_axis, allow_zoom, @@ -381,11 +415,9 @@ impl Widget for Plot { // Set bounds automatically based on content. if auto_bounds || !bounds.is_valid() { bounds = min_auto_bounds; - hlines.iter().for_each(|line| bounds.extend_with_y(line.y)); - vlines.iter().for_each(|line| bounds.extend_with_x(line.x)); items .iter() - .for_each(|item| bounds.merge(&item.series().get_bounds())); + .for_each(|item| bounds.merge(&item.get_bounds())); bounds.add_relative_margin(margin_fraction); } // Make sure they are not empty. @@ -436,17 +468,14 @@ impl Widget for Plot { } // Initialize values from functions. - items.iter_mut().for_each(|item| { - item.series_mut() - .generate_points(transform.bounds().range_x()) - }); + items + .iter_mut() + .for_each(|item| item.initialize(transform.bounds().range_x())); let bounds = *transform.bounds(); let prepared = Prepared { items, - hlines, - vlines, show_x, show_y, transform, @@ -479,8 +508,6 @@ impl Widget for Plot { struct Prepared { items: Vec>, - hlines: Vec, - vlines: Vec, show_x: bool, show_y: bool, transform: ScreenTransform, @@ -496,26 +523,10 @@ impl Prepared { let transform = &self.transform; - for &hline in &self.hlines { - let HLine { y, stroke } = hline; - let points = [ - transform.position_from_value(&Value::new(transform.bounds().min[0], y)), - transform.position_from_value(&Value::new(transform.bounds().max[0], y)), - ]; - shapes.push(Shape::line_segment(points, stroke)); - } - - for &vline in &self.vlines { - let VLine { x, stroke } = vline; - let points = [ - transform.position_from_value(&Value::new(x, transform.bounds().min[1])), - transform.position_from_value(&Value::new(x, transform.bounds().max[1])), - ]; - shapes.push(Shape::line_segment(points, stroke)); - } - + let mut plot_ui = ui.child_ui(*transform.frame(), Layout::default()); + plot_ui.set_clip_rect(*transform.frame()); for item in &self.items { - item.get_shapes(transform, &mut shapes); + item.get_shapes(&mut plot_ui, transform, &mut shapes); } if let Some(pointer) = response.hover_pos() { @@ -632,13 +643,15 @@ impl Prepared { let mut closest_item = None; let mut closest_dist_sq = interact_radius.powi(2); for item in items { - for value in &item.series().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()); + 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()); + } } } } diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index 6cf73a53431..24fe6225487 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -1,5 +1,8 @@ use egui::*; -use plot::{Corner, Legend, Line, MarkerShape, Plot, Points, Value, Values}; +use plot::{ + Arrows, Corner, HLine, Legend, Line, MarkerShape, Plot, PlotImage, Points, Polygon, Text, + VLine, Value, Values, +}; use std::f64::consts::TAU; #[derive(PartialEq)] @@ -44,7 +47,7 @@ impl LineDemo { ui.add( egui::DragValue::new(circle_radius) .speed(0.1) - .clamp_range(0.0..=f32::INFINITY) + .clamp_range(0.0..=f64::INFINITY) .prefix("r: "), ); ui.horizontal(|ui| { @@ -90,7 +93,7 @@ impl LineDemo { let time = self.time; Line::new(Values::from_explicit_callback( move |x| 0.5 * (2.0 * x).sin() * time.sin(), - f64::NEG_INFINITY..=f64::INFINITY, + .., 512, )) .color(Color32::from_rgb(200, 100, 100)) @@ -187,7 +190,7 @@ impl Widget for &mut MarkerDemo { ui.add( egui::DragValue::new(&mut self.marker_radius) .speed(0.1) - .clamp_range(0.0..=f32::INFINITY) + .clamp_range(0.0..=f64::INFINITY) .prefix("marker radius: "), ); ui.checkbox(&mut self.custom_marker_color, "custom marker color"); @@ -221,25 +224,13 @@ impl Default for LegendDemo { impl LegendDemo { fn line_with_slope(slope: f64) -> Line { - Line::new(Values::from_explicit_callback( - move |x| slope * x, - f64::NEG_INFINITY..=f64::INFINITY, - 100, - )) + Line::new(Values::from_explicit_callback(move |x| slope * x, .., 100)) } fn sin() -> Line { - Line::new(Values::from_explicit_callback( - move |x| x.sin(), - f64::NEG_INFINITY..=f64::INFINITY, - 100, - )) + Line::new(Values::from_explicit_callback(move |x| x.sin(), .., 100)) } fn cos() -> Line { - Line::new(Values::from_explicit_callback( - move |x| x.cos(), - f64::NEG_INFINITY..=f64::INFINITY, - 100, - )) + Line::new(Values::from_explicit_callback(move |x| x.cos(), .., 100)) } } @@ -270,11 +261,82 @@ impl Widget for &mut LegendDemo { ui.add(legend_plot) } } + +#[derive(PartialEq, Default)] +struct ItemsDemo {} + +impl ItemsDemo {} + +impl Widget for &mut ItemsDemo { + fn ui(self, ui: &mut Ui) -> Response { + let n = 100; + let mut sin_values: Vec<_> = (0..=n) + .map(|i| remap(i as f64, 0.0..=n as f64, -TAU..=TAU)) + .map(|i| Value::new(i, i.sin())) + .collect(); + + let line = Line::new(Values::from_values(sin_values.split_off(n / 2))).fill(-1.5); + let polygon = Polygon::new(Values::from_parametric_callback( + |t| (4.0 * t.sin() + 2.0 * t.cos(), 4.0 * t.cos() + 2.0 * t.sin()), + 0.0..TAU, + 100, + )); + let points = Points::new(Values::from_values(sin_values)) + .stems(-1.5) + .radius(1.0); + + let arrows = { + let pos_radius = 8.0; + let tip_radius = 7.0; + let arrow_origins = Values::from_parametric_callback( + |t| (pos_radius * t.sin(), pos_radius * t.cos()), + 0.0..TAU, + 36, + ); + let arrow_tips = Values::from_parametric_callback( + |t| (tip_radius * t.sin(), tip_radius * t.cos()), + 0.0..TAU, + 36, + ); + Arrows::new(arrow_origins, arrow_tips) + }; + let image = PlotImage::new( + TextureId::Egui, + Value::new(0.0, 10.0), + [ + ui.fonts().texture().width as f32 / 100.0, + ui.fonts().texture().height as f32 / 100.0, + ], + ); + + let plot = Plot::new("Items Demo") + .hline(HLine::new(9.0).name("Lines horizontal")) + .hline(HLine::new(-9.0).name("Lines horizontal")) + .vline(VLine::new(9.0).name("Lines vertical")) + .vline(VLine::new(-9.0).name("Lines vertical")) + .line(line.name("Line with fill")) + .polygon(polygon.name("Convex polygon")) + .points(points.name("Points with stems")) + .text(Text::new(Value::new(-3.0, -3.0), "wow").name("Text")) + .text(Text::new(Value::new(-2.0, 2.5), "so graph").name("Text")) + .text(Text::new(Value::new(3.0, 3.0), "much color").name("Text")) + .text(Text::new(Value::new(2.5, -2.0), "such plot").name("Text")) + .image(image.name("Image")) + .arrows(arrows.name("Arrows")) + .legend(Legend::default().position(Corner::RightBottom)) + .show_x(false) + .show_y(false) + .data_aspect(1.0); + ui.add(plot) + } +} + #[derive(PartialEq, Eq)] enum Panel { Lines, Markers, Legend, + Items, } impl Default for Panel { @@ -288,6 +350,7 @@ pub struct PlotDemo { line_demo: LineDemo, marker_demo: MarkerDemo, legend_demo: LegendDemo, + items_demo: ItemsDemo, open_panel: Panel, } @@ -326,6 +389,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::Items, "Items"); }); ui.separator(); @@ -339,6 +403,9 @@ impl super::View for PlotDemo { Panel::Legend => { ui.add(&mut self.legend_demo); } + Panel::Items => { + ui.add(&mut self.items_demo); + } } } }