diff --git a/egui/src/widgets/plot.rs b/egui/src/widgets/plot.rs index 4435a54a..6ae23455 100644 --- a/egui/src/widgets/plot.rs +++ b/egui/src/widgets/plot.rs @@ -35,7 +35,8 @@ impl Value { /// 2D bounding box of f64 precision. /// The range of data values we show. -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Debug)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] struct Bounds { min: [f64; 2], max: [f64; 2], @@ -62,6 +63,10 @@ impl Bounds { && self.max[1].is_finite() } + pub fn is_valid(&self) -> bool { + self.is_finite() && self.width() > 0.0 && self.height() > 0.0 + } + pub fn extend_with(&mut self, value: &Value) { self.extend_with_x(value.x); self.extend_with_y(value.y); @@ -89,12 +94,34 @@ impl Bounds { self.max[1] += pad; } - pub fn union_mut(&mut self, other: &Bounds) { + pub fn merge(&mut self, other: &Bounds) { self.min[0] = self.min[0].min(other.min[0]); self.min[1] = self.min[1].min(other.min[1]); self.max[0] = self.max[0].max(other.max[0]); self.max[1] = self.max[1].max(other.max[1]); } + + pub fn translate_x(&mut self, delta: f64) { + self.min[0] += delta; + self.max[0] += delta; + } + + pub fn translate_y(&mut self, delta: f64) { + self.min[1] += delta; + self.max[1] += delta; + } + + pub fn translate(&mut self, delta: Vec2) { + self.translate_x(delta.x as f64); + self.translate_y(delta.y as f64); + } + + pub fn add_relative_margin(&mut self, margin_fraction: Vec2) { + let width = self.width(); + let height = self.height(); + self.expand_x(margin_fraction.x as f64 * width); + self.expand_y(margin_fraction.y as f64 * height); + } } // ---------------------------------------------------------------------------- @@ -192,14 +219,25 @@ impl Curve { } /// Name of this curve. - pub fn name(mut self, name: impl Into) -> Self { - self.name = name.into(); + #[allow(clippy::needless_pass_by_value)] + pub fn name(mut self, name: impl ToString) -> Self { + self.name = name.to_string(); self } } // ---------------------------------------------------------------------------- +/// Information about the plot that has to persist between frames. +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +#[derive(Clone)] +struct PlotMemory { + bounds: Bounds, + auto_bounds: bool, +} + +// ---------------------------------------------------------------------------- + /// A 2D plot, e.g. a graph of a function. /// /// `Plot` supports multiple curves. @@ -213,18 +251,18 @@ impl Curve { /// }); /// let curve = Curve::from_values_iter(sin); /// ui.add( -/// Plot::default().curve(curve).view_aspect(2.0) +/// Plot::new("Test Plot").curve(curve).view_aspect(2.0) /// ); /// ``` #[derive(Clone, PartialEq)] pub struct Plot { + name: String, next_auto_color_idx: usize, curves: Vec, hlines: Vec, vlines: Vec, - bounds: Bounds, symmetrical_x_bounds: bool, symmetrical_y_bounds: bool, margin_fraction: Vec2, @@ -235,20 +273,23 @@ pub struct Plot { data_aspect: Option, view_aspect: Option, + min_auto_bounds: Bounds, + show_x: bool, show_y: bool, } -impl Default for Plot { - fn default() -> Self { +impl Plot { + #[allow(clippy::needless_pass_by_value)] + pub fn new(name: impl ToString) -> Self { Self { + name: name.to_string(), next_auto_color_idx: 0, curves: Default::default(), hlines: Default::default(), vlines: Default::default(), - bounds: Bounds::NOTHING, symmetrical_x_bounds: false, symmetrical_y_bounds: false, margin_fraction: Vec2::splat(0.05), @@ -259,13 +300,13 @@ impl Default for Plot { data_aspect: None, view_aspect: None, + min_auto_bounds: Bounds::NOTHING, + show_x: true, show_y: true, } } -} -impl Plot { fn auto_color(&mut self, color: &mut Color32) { if *color == Color32::TRANSPARENT { let i = self.next_auto_color_idx; @@ -279,9 +320,10 @@ impl Plot { /// Add a data curve. /// You can add multiple curves. pub fn curve(mut self, mut curve: Curve) -> Self { - self.auto_color(&mut curve.stroke.color); - self.bounds.union_mut(&curve.bounds); - self.curves.push(curve); + if !curve.values.is_empty() { + self.auto_color(&mut curve.stroke.color); + self.curves.push(curve); + } self } @@ -290,7 +332,6 @@ impl Plot { /// Always fills the full width of the plot. pub fn hline(mut self, mut hline: HLine) -> Self { self.auto_color(&mut hline.stroke.color); - self = self.include_y(hline.y); self.hlines.push(hline); self } @@ -300,24 +341,10 @@ impl Plot { /// Always fills the full height of the plot. pub fn vline(mut self, mut vline: VLine) -> Self { self.auto_color(&mut vline.stroke.color); - self = self.include_x(vline.x); self.vlines.push(vline); self } - /// Expand bounds to include the given x value. - pub fn include_x(mut self, x: impl Into) -> Self { - self.bounds.extend_with_x(x.into()); - self - } - - /// Expand bounds to include the given y value. - /// For instance, to always show the x axis, call `plot.include_y(0.0)`. - pub fn include_y(mut self, y: impl Into) -> Self { - self.bounds.extend_with_y(y.into()); - self - } - /// If true, the x-bounds will be symmetrical, so that the x=0 zero line /// is always in the center. pub fn symmetrical_x_bounds(mut self, symmetrical_x_bounds: bool) -> Self { @@ -332,6 +359,19 @@ impl Plot { self } + /// Expand bounds to include the given x value. + pub fn include_x(mut self, x: impl Into) -> Self { + self.min_auto_bounds.extend_with_x(x.into()); + self + } + + /// Expand bounds to include the given y value. + /// For instance, to always show the x axis, call `plot.include_y(0.0)`. + pub fn include_y(mut self, y: impl Into) -> Self { + self.min_auto_bounds.extend_with_y(y.into()); + self + } + /// width / height ratio of the data. /// For instance, it can be useful to set this to `1.0` for when the two axes show the same unit. pub fn data_aspect(mut self, data_aspect: f32) -> Self { @@ -384,11 +424,11 @@ impl Plot { impl Widget for Plot { fn ui(self, ui: &mut Ui) -> Response { let Self { + name, next_auto_color_idx: _, curves, hlines, vlines, - bounds, symmetrical_x_bounds, symmetrical_y_bounds, margin_fraction, @@ -399,12 +439,25 @@ impl Widget for Plot { view_aspect, show_x, show_y, + min_auto_bounds, } = self; - let size = { - let width = width.map(|w| w.at_least(min_size.x)); - let height = height.map(|w| w.at_least(min_size.y)); + let plot_id = ui.make_persistent_id(name); + let memory = ui + .memory() + .id_data + .get_mut_or_insert_with(plot_id, || PlotMemory { + bounds: min_auto_bounds, + auto_bounds: true, + }) + .clone(); + let PlotMemory { + mut bounds, + mut auto_bounds, + } = memory; + + let size = { let width = width.unwrap_or_else(|| { if let (Some(height), Some(aspect)) = (height, view_aspect) { height * aspect @@ -425,9 +478,17 @@ impl Widget for Plot { vec2(width, height) }; - let (rect, response) = ui.allocate_exact_size(size, Sense::hover()); + let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); - let mut bounds = bounds; + auto_bounds |= response.double_clicked_by(PointerButton::Primary); + + 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)); + curves.iter().for_each(|curve| bounds.merge(&curve.bounds)); + bounds.add_relative_margin(margin_fraction); + } if symmetrical_x_bounds { let x_abs = bounds.min[0].abs().max(bounds.max[0].abs()); @@ -440,17 +501,16 @@ impl Widget for Plot { bounds.max[1] = y_abs; }; - bounds.expand_x(margin_fraction.x as f64 * bounds.width()); - bounds.expand_y(margin_fraction.y as f64 * bounds.height()); - if let Some(data_aspect) = data_aspect { let data_aspect = data_aspect as f64; let rw = rect.width() as f64; let rh = rect.height() as f64; let current_data_aspect = (bounds.width() / rw) / (bounds.height() / rh); - if current_data_aspect < data_aspect { + + let epsilon = 1e-5; + if current_data_aspect < data_aspect - epsilon { bounds.expand_x((data_aspect / current_data_aspect - 1.0) * bounds.width() * 0.5); - } else { + } else if current_data_aspect > data_aspect + epsilon { bounds.expand_y((current_data_aspect / data_aspect - 1.0) * bounds.height() * 0.5); } } @@ -463,15 +523,35 @@ impl Widget for Plot { stroke: ui.visuals().window_stroke(), }); - if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 { + if bounds.is_valid() { + let mut transform = ScreenTransform { bounds, rect }; + if response.dragged_by(PointerButton::Primary) { + transform.shift_bounds(-response.drag_delta()); + auto_bounds = false; + } + if let Some(hover_pos) = response.hover_pos() { + let scroll_delta = ui.input().scroll_delta[1]; + if scroll_delta != 0. { + transform.zoom(-0.01 * scroll_delta, hover_pos); + auto_bounds = false; + } + } + + ui.memory().id_data.insert( + plot_id, + PlotMemory { + bounds: *transform.bounds(), + auto_bounds, + }, + ); + let prepared = Prepared { curves, hlines, vlines, - rect, - bounds, show_x, show_y, + transform, }; prepared.ui(ui, &response); } @@ -480,18 +560,44 @@ impl Widget for Plot { } } -struct Prepared { - curves: Vec, - hlines: Vec, - vlines: Vec, - /// Screen space position of the plot +/// Contains the screen rectangle and the plot bounds and provides methods to transform them. +struct ScreenTransform { + /// The screen rectangle. rect: Rect, + /// The plot bounds. bounds: Bounds, - show_x: bool, - show_y: bool, } -impl Prepared { +impl ScreenTransform { + fn rect(&self) -> &Rect { + &self.rect + } + + fn bounds(&self) -> &Bounds { + &self.bounds + } + + fn shift_bounds(&mut self, mut delta_pos: Vec2) { + delta_pos.x *= self.dvalue_dpos()[0] as f32; + delta_pos.y *= self.dvalue_dpos()[1] as f32; + self.bounds.translate(delta_pos); + } + + /// Zoom by a relative amount with the given screen position as center. + fn zoom(&mut self, delta: f32, center: Pos2) { + let delta = delta.clamp(-1., 1.); + let rect_width = self.rect.width(); + let rect_height = self.rect.height(); + let bounds_width = self.bounds.width() as f32; + let bounds_height = self.bounds.height() as f32; + let t_x = (center.x - self.rect.min[0]) / rect_width; + let t_y = (self.rect.max[1] - center.y) / rect_height; + self.bounds.min[0] -= ((t_x * delta) * bounds_width) as f64; + self.bounds.min[1] -= ((t_y * delta) * bounds_height) as f64; + self.bounds.max[0] += (((1. - t_x) * delta) * bounds_width) as f64; + self.bounds.max[1] += (((1. - t_y) * delta) * bounds_height) as f64; + } + fn position_from_value(&self, value: &Value) -> Pos2 { let x = remap( value.x, @@ -506,6 +612,20 @@ impl Prepared { pos2(x as f32, y as f32) } + fn value_from_position(&self, pos: Pos2) -> Value { + let x = remap( + pos.x as f64, + (self.rect.left() as f64)..=(self.rect.right() as f64), + self.bounds.min[0]..=self.bounds.max[0], + ); + let y = remap( + pos.y as f64, + (self.rect.bottom() as f64)..=(self.rect.top() as f64), // negated y axis! + self.bounds.min[1]..=self.bounds.max[1], + ); + Value::new(x, y) + } + /// delta position / delta value fn dpos_dvalue_x(&self) -> f64 { self.rect.width() as f64 / self.bounds.width() @@ -525,23 +645,22 @@ impl Prepared { fn dvalue_dpos(&self) -> [f64; 2] { [1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()] } +} - fn value_from_position(&self, pos: Pos2) -> Value { - let x = remap( - pos.x as f64, - (self.rect.left() as f64)..=(self.rect.right() as f64), - self.bounds.min[0]..=self.bounds.max[0], - ); - let y = remap( - pos.y as f64, - (self.rect.bottom() as f64)..=(self.rect.top() as f64), // negated y axis! - self.bounds.min[1]..=self.bounds.max[1], - ); - Value::new(x, y) - } +struct Prepared { + curves: Vec, + hlines: Vec, + vlines: Vec, + show_x: bool, + show_y: bool, + transform: ScreenTransform, +} +impl Prepared { fn ui(&self, ui: &mut Ui, response: &Response) { - let mut shapes = Vec::with_capacity(self.hlines.len() + self.curves.len() + 2); + let Self { transform, .. } = self; + + let mut shapes = Vec::new(); for d in 0..2 { self.paint_axis(ui, d, &mut shapes); @@ -550,8 +669,8 @@ impl Prepared { for &hline in &self.hlines { let HLine { y, stroke } = hline; let points = [ - self.position_from_value(&Value::new(self.bounds.min[0], y)), - self.position_from_value(&Value::new(self.bounds.max[0], y)), + 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)); } @@ -559,8 +678,8 @@ impl Prepared { for &vline in &self.vlines { let VLine { x, stroke } = vline; let points = [ - self.position_from_value(&Value::new(x, self.bounds.min[1])), - self.position_from_value(&Value::new(x, self.bounds.max[1])), + 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)); } @@ -568,39 +687,41 @@ impl Prepared { for curve in &self.curves { let stroke = curve.stroke; let values = &curve.values; - if values.len() == 1 { - let point = self.position_from_value(&values[0]); - shapes.push(Shape::circle_filled( - point, - stroke.width / 2.0, - stroke.color, - )); - } else if values.len() > 1 { - shapes.push(Shape::line( - values.iter().map(|v| self.position_from_value(v)).collect(), + let shape = if values.len() == 1 { + let point = transform.position_from_value(&values[0]); + Shape::circle_filled(point, stroke.width / 2.0, stroke.color) + } else { + Shape::line( + values + .iter() + .map(|v| transform.position_from_value(v)) + .collect(), stroke, - )); - } + ) + }; + shapes.push(shape); } if let Some(pointer) = response.hover_pos() { self.hover(ui, pointer, &mut shapes); } - ui.painter().sub_region(self.rect).extend(shapes); + ui.painter().sub_region(*transform.rect()).extend(shapes); } fn paint_axis(&self, ui: &Ui, axis: usize, shapes: &mut Vec) { - let bounds = self.bounds; + let Self { transform, .. } = self; + + let bounds = transform.bounds(); let text_style = TextStyle::Body; let base: f64 = 10.0; let min_label_spacing_in_points = 60.0; // TODO: large enough for a wide label - let step_size = self.dvalue_dpos()[axis] * min_label_spacing_in_points; + let step_size = transform.dvalue_dpos()[axis] * min_label_spacing_in_points; let step_size = base.powi(step_size.abs().log(base).ceil() as i32); - let step_size_in_points = (self.dpos_dvalue()[axis] * step_size) as f32; + let step_size_in_points = (transform.dpos_dvalue()[axis] * step_size) as f32; // Where on the cross-dimension to show the label values let value_cross = 0.0_f64.clamp(bounds.min[1 - axis], bounds.max[1 - axis]); @@ -616,7 +737,7 @@ impl Prepared { } else { Value::new(value_cross, value_main) }; - let pos_in_gui = self.position_from_value(&value); + let pos_in_gui = transform.position_from_value(&value); { // Grid: subdivide each label tick in `n` grid lines: @@ -642,8 +763,8 @@ impl Prepared { pos_in_gui[axis] += step_size_in_points * (i as f32) / (n as f32); let mut p0 = pos_in_gui; let mut p1 = pos_in_gui; - p0[1 - axis] = self.rect.min[1 - axis]; - p1[1 - axis] = self.rect.max[1 - axis]; + p0[1 - axis] = transform.rect.min[1 - axis]; + p1[1 - axis] = transform.rect.max[1 - axis]; shapes.push(Shape::line_segment([p0, p1], Stroke::new(1.0, color))); } } @@ -656,8 +777,8 @@ impl Prepared { // Make sure we see the labels, even if the axis is off-screen: text_pos[1 - axis] = text_pos[1 - axis] - .at_most(self.rect.max[1 - axis] - galley.size[1 - axis] - 2.0) - .at_least(self.rect.min[1 - axis] + 1.0); + .at_most(transform.rect.max[1 - axis] - galley.size[1 - axis] - 2.0) + .at_least(transform.rect.min[1 - axis] + 1.0); shapes.push(Shape::Text { pos: text_pos, @@ -669,7 +790,15 @@ impl Prepared { } fn hover(&self, ui: &Ui, pointer: Pos2, shapes: &mut Vec) { - if !self.show_x && !self.show_y { + let Self { + transform, + show_x, + show_y, + curves, + .. + } = self; + + if !show_x && !show_y { return; } @@ -677,9 +806,9 @@ impl Prepared { let mut closest_value = None; let mut closest_curve = None; let mut closest_dist_sq = interact_radius.powi(2); - for curve in &self.curves { + for curve in curves { for value in &curve.values { - let pos = self.position_from_value(value); + 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; @@ -699,24 +828,24 @@ impl Prepared { let line_color = line_color(ui, Strength::Strong); let value = if let Some(value) = closest_value { - let position = self.position_from_value(value); + let position = transform.position_from_value(value); shapes.push(Shape::circle_filled(position, 3.0, line_color)); *value } else { - self.value_from_position(pointer) + transform.value_from_position(pointer) }; - let pointer = self.position_from_value(&value); + let pointer = transform.position_from_value(&value); - let rect = self.rect; + let rect = transform.rect(); - if self.show_x { + 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 self.show_y { + if *show_y { // horizontal line shapes.push(Shape::line_segment( [pos2(rect.left(), pointer.y), pos2(rect.right(), pointer.y)], @@ -725,17 +854,17 @@ impl Prepared { } let text = { - let scale = self.dvalue_dpos(); + 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 self.show_x && self.show_y { + if *show_x && *show_y { format!( "{}x = {:.*}\ny = {:.*}", prefix, x_decimals, value.x, y_decimals, value.y ) - } else if self.show_x { + } else if *show_x { format!("{}x = {:.*}", prefix, x_decimals, value.x) - } else if self.show_y { + } else if *show_y { format!("{}y = {:.*}", prefix, y_decimals, value.y) } else { unreachable!() diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index 10d48841..3b17953f 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -139,7 +139,7 @@ impl super::View for PlotDemo { self.time += ui.input().unstable_dt.at_most(1.0 / 30.0) as f64; }; - let mut plot = Plot::default() + let mut plot = Plot::new("Demo Plot") .curve(self.circle()) .curve(self.sin()) .curve(self.thingy()) diff --git a/egui_demo_lib/src/apps/demo/widget_gallery.rs b/egui_demo_lib/src/apps/demo/widget_gallery.rs index 661fa4bd..0561657d 100644 --- a/egui_demo_lib/src/apps/demo/widget_gallery.rs +++ b/egui_demo_lib/src/apps/demo/widget_gallery.rs @@ -227,7 +227,7 @@ fn example_plot() -> egui::plot::Plot { let x = egui::remap(i as f64, 0.0..=(n as f64), -TAU..=TAU); egui::plot::Value::new(x, x.sin()) })); - egui::plot::Plot::default() + egui::plot::Plot::new("Example Plot") .curve(curve) .height(32.0) .data_aspect(1.0)