From a505d01090fb2991dd1f6bf1461997bb2e0d4142 Mon Sep 17 00:00:00 2001 From: Sven Niederberger Date: Sat, 24 Apr 2021 14:26:54 +0200 Subject: [PATCH] Plot refactor (#331) * drag and zoom support for plots * update doctest * use impl ToString * revert back to Into until #302 is solved * Apply suggestions from code review Co-authored-by: ilya sheprut * use persistence feature for PlotMemory * * split plot into multiple files * add curve from function * move more functionality into ScreenTransform struct * changes from code review in base branch * let user specify a range for generated functions * rename file * minor changes * improve generator functionality * improve callback and add parametric callback * minor changes * add documentation * fix merge issues * changes based on review * rename folder * make plot.rs the mod.rs file * remove mod.rs * rename file * namespace changes * fix doctest * Update egui/src/widgets/plot/items.rs Co-authored-by: Emil Ernerfeldt Co-authored-by: ilya sheprut Co-authored-by: Emil Ernerfeldt --- egui/src/widgets/plot/items.rs | 223 +++++++++ egui/src/widgets/{plot.rs => plot/mod.rs} | 563 ++++++---------------- egui/src/widgets/plot/transform.rs | 248 ++++++++++ egui_demo_lib/src/apps/demo/plot_demo.rs | 38 +- 4 files changed, 635 insertions(+), 437 deletions(-) create mode 100644 egui/src/widgets/plot/items.rs rename egui/src/widgets/{plot.rs => plot/mod.rs} (58%) create mode 100644 egui/src/widgets/plot/transform.rs diff --git a/egui/src/widgets/plot/items.rs b/egui/src/widgets/plot/items.rs new file mode 100644 index 00000000..e954355f --- /dev/null +++ b/egui/src/widgets/plot/items.rs @@ -0,0 +1,223 @@ +//! Contains items that can be added to a plot. + +use std::ops::RangeInclusive; + +use super::transform::Bounds; +use crate::*; + +/// 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(), + } + } +} + +// ---------------------------------------------------------------------------- + +/// A horizontal line in a plot, filling the full width +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HLine { + pub(crate) y: f64, + pub(crate) stroke: Stroke, +} + +impl HLine { + pub fn new(y: impl Into, stroke: impl Into) -> Self { + Self { + y: y.into(), + stroke: stroke.into(), + } + } +} + +/// A vertical line in a plot, filling the full width +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VLine { + pub(crate) x: f64, + pub(crate) stroke: Stroke, +} + +impl VLine { + pub fn new(x: impl Into, stroke: impl Into) -> Self { + Self { + x: x.into(), + stroke: stroke.into(), + } + } +} + +// ---------------------------------------------------------------------------- + +/// 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, +} + +// ---------------------------------------------------------------------------- + +/// A series of values forming a path. +pub struct Curve { + pub(crate) values: Vec, + generator: Option, + pub(crate) bounds: Bounds, + pub(crate) stroke: Stroke, + pub(crate) name: String, +} + +impl Curve { + fn empty() -> Self { + Self { + values: Vec::new(), + generator: None, + bounds: Bounds::NOTHING, + stroke: Stroke::new(2.0, Color32::TRANSPARENT), + name: Default::default(), + } + } + + pub fn from_values(values: Vec) -> Self { + let mut bounds = Bounds::NOTHING; + for value in &values { + bounds.extend_with(value); + } + Self { + values, + bounds, + ..Self::empty() + } + } + + pub fn from_values_iter(iter: impl Iterator) -> Self { + Self::from_values(iter.collect()) + } + + /// Draw a curve 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, + points: usize, + ) -> Self { + let mut bounds = Bounds::NOTHING; + if x_range.start().is_finite() && x_range.end().is_finite() { + bounds.min[0] = *x_range.start(); + bounds.max[0] = *x_range.end(); + } + + let generator = ExplicitGenerator { + function: Box::new(function), + x_range, + points, + }; + + Self { + generator: Some(generator), + bounds, + ..Self::empty() + } + } + + /// Draw a curve based on a function `(x,y)=f(t)`, a range for t and the number of points. + pub fn from_parametric_callback( + function: impl Fn(f64) -> (f64, f64), + t_range: RangeInclusive, + points: usize, + ) -> Self { + let increment = (t_range.end() - t_range.start()) / (points - 1) as f64; + let values = (0..points).map(|i| { + let t = t_range.start() + i as f64 * increment; + let (x, y) = function(t); + Value { x, y } + }); + Self::from_values_iter(values) + } + + /// Returns true if there are no data points available and there is no function to generate any. + pub(crate) fn no_data(&self) -> bool { + self.generator.is_none() && self.values.is_empty() + } + + /// 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) + } + + /// If initialized with a generator function, this will generate `n` evenly spaced points in the + /// given range. + pub(crate) 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(); + } + } + } + + /// 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) + } + + /// Add a stroke. + pub fn stroke(mut self, stroke: impl Into) -> Self { + self.stroke = stroke.into(); + self + } + + /// Stroke width. A high value means the plot thickens. + pub fn width(mut self, width: f32) -> Self { + self.stroke.width = width; + 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 + } + + /// Name of this curve. + #[allow(clippy::needless_pass_by_value)] + pub fn name(mut self, name: impl ToString) -> Self { + self.name = name.to_string(); + self + } +} diff --git a/egui/src/widgets/plot.rs b/egui/src/widgets/plot/mod.rs similarity index 58% rename from egui/src/widgets/plot.rs rename to egui/src/widgets/plot/mod.rs index 6ae23455..16a8b5b8 100644 --- a/egui/src/widgets/plot.rs +++ b/egui/src/widgets/plot/mod.rs @@ -1,230 +1,14 @@ //! Simple plotting library. -#![allow(clippy::comparison_chain)] +mod items; +mod transform; -use color::Hsva; +pub use items::{Curve, Value}; +use items::{HLine, VLine}; +use transform::{Bounds, ScreenTransform}; use crate::*; - -// ---------------------------------------------------------------------------- - -/// 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(), - } - } -} - -// ---------------------------------------------------------------------------- - -/// 2D bounding box of f64 precision. -/// The range of data values we show. -#[derive(Clone, Copy, PartialEq, Debug)] -#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] -struct Bounds { - min: [f64; 2], - max: [f64; 2], -} - -impl Bounds { - pub const NOTHING: Self = Self { - min: [f64::INFINITY; 2], - max: [-f64::INFINITY; 2], - }; - - pub fn width(&self) -> f64 { - self.max[0] - self.min[0] - } - - pub fn height(&self) -> f64 { - self.max[1] - self.min[1] - } - - pub fn is_finite(&self) -> bool { - self.min[0].is_finite() - && self.min[1].is_finite() - && self.max[0].is_finite() - && 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); - } - - /// Expand to include the given x coordinate - pub fn extend_with_x(&mut self, x: f64) { - self.min[0] = self.min[0].min(x); - self.max[0] = self.max[0].max(x); - } - - /// Expand to include the given y coordinate - pub fn extend_with_y(&mut self, y: f64) { - self.min[1] = self.min[1].min(y); - self.max[1] = self.max[1].max(y); - } - - pub fn expand_x(&mut self, pad: f64) { - self.min[0] -= pad; - self.max[0] += pad; - } - - pub fn expand_y(&mut self, pad: f64) { - self.min[1] -= pad; - self.max[1] += pad; - } - - 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); - } -} - -// ---------------------------------------------------------------------------- - -/// A horizontal line in a plot, filling the full width -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct HLine { - y: f64, - stroke: Stroke, -} - -impl HLine { - pub fn new(y: impl Into, stroke: impl Into) -> Self { - Self { - y: y.into(), - stroke: stroke.into(), - } - } -} - -/// A vertical line in a plot, filling the full width -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct VLine { - x: f64, - stroke: Stroke, -} - -impl VLine { - pub fn new(x: impl Into, stroke: impl Into) -> Self { - Self { - x: x.into(), - stroke: stroke.into(), - } - } -} - -// ---------------------------------------------------------------------------- - -/// A series of values forming a path. -#[derive(Clone, PartialEq)] -pub struct Curve { - values: Vec, - bounds: Bounds, - stroke: Stroke, - name: String, -} - -impl Curve { - pub fn from_values(values: Vec) -> Self { - let mut bounds = Bounds::NOTHING; - for value in &values { - bounds.extend_with(value); - } - Self { - values, - bounds, - stroke: Stroke::new(2.0, Color32::TRANSPARENT), - name: Default::default(), - } - } - - pub fn from_values_iter(iter: impl Iterator) -> Self { - Self::from_values(iter.collect()) - } - - /// 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) - } - - pub fn stroke(mut self, stroke: impl Into) -> Self { - self.stroke = stroke.into(); - self - } - - /// Stroke width. A high value means the plot thickens. - pub fn width(mut self, width: f32) -> Self { - self.stroke.width = width; - 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 - } - - /// Name of this curve. - #[allow(clippy::needless_pass_by_value)] - pub fn name(mut self, name: impl ToString) -> Self { - self.name = name.to_string(); - self - } -} +use color::Hsva; // ---------------------------------------------------------------------------- @@ -254,7 +38,6 @@ struct PlotMemory { /// Plot::new("Test Plot").curve(curve).view_aspect(2.0) /// ); /// ``` -#[derive(Clone, PartialEq)] pub struct Plot { name: String, next_auto_color_idx: usize, @@ -263,8 +46,11 @@ pub struct Plot { hlines: Vec, vlines: Vec, - symmetrical_x_bounds: bool, - symmetrical_y_bounds: bool, + center_x_axis: bool, + center_y_axis: bool, + allow_zoom: bool, + allow_drag: bool, + min_auto_bounds: Bounds, margin_fraction: Vec2, min_size: Vec2, @@ -273,8 +59,6 @@ pub struct Plot { data_aspect: Option, view_aspect: Option, - min_auto_bounds: Bounds, - show_x: bool, show_y: bool, } @@ -290,8 +74,11 @@ impl Plot { hlines: Default::default(), vlines: Default::default(), - symmetrical_x_bounds: false, - symmetrical_y_bounds: false, + center_x_axis: false, + center_y_axis: false, + allow_zoom: true, + allow_drag: true, + min_auto_bounds: Bounds::NOTHING, margin_fraction: Vec2::splat(0.05), min_size: Vec2::splat(64.0), @@ -300,8 +87,6 @@ impl Plot { data_aspect: None, view_aspect: None, - min_auto_bounds: Bounds::NOTHING, - show_x: true, show_y: true, } @@ -320,7 +105,7 @@ impl Plot { /// Add a data curve. /// You can add multiple curves. pub fn curve(mut self, mut curve: Curve) -> Self { - if !curve.values.is_empty() { + if !curve.no_data() { self.auto_color(&mut curve.stroke.color); self.curves.push(curve); } @@ -345,35 +130,10 @@ impl Plot { 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 { - self.symmetrical_x_bounds = symmetrical_x_bounds; - self - } - - /// If true, the y-bounds will be symmetrical, so that the y=0 zero line - /// is always in the center. - pub fn symmetrical_y_bounds(mut self, symmetrical_y_bounds: bool) -> Self { - self.symmetrical_y_bounds = symmetrical_y_bounds; - 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. + /// For instance, it can be useful to set this to `1.0` for when the two axes show the same + /// unit. + /// By default the plot window's aspect ratio is used. pub fn data_aspect(mut self, data_aspect: f32) -> Self { self.data_aspect = Some(data_aspect); self @@ -419,6 +179,56 @@ impl Plot { self.show_y = show_y; self } + + #[deprecated = "Renamed center_x_axis"] + pub fn symmetrical_x_axis(mut self, on: bool) -> Self { + self.center_x_axis = on; + self + } + + #[deprecated = "Renamed center_y_axis"] + pub fn symmetrical_y_axis(mut self, on: bool) -> Self { + self.center_y_axis = on; + self + } + + /// Always keep the x-axis centered. Default: `false`. + pub fn center_x_axis(mut self, on: bool) -> Self { + self.center_x_axis = on; + self + } + + /// Always keep the y-axis centered. Default: `false`. + pub fn center_y_axis(mut self, on: bool) -> Self { + self.center_y_axis = on; + self + } + + /// Whether to allow zooming in the plot. Default: `true`. + pub fn allow_zoom(mut self, on: bool) -> Self { + self.allow_zoom = on; + self + } + + /// Whether to allow dragging in the plot to move the bounds. Default: `true`. + pub fn allow_drag(mut self, on: bool) -> Self { + self.allow_drag = on; + self + } + + /// Expand bounds to include the given x value. + /// For instance, to always show the y axis, call `plot.include_x(0.0)`. + 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 + } } impl Widget for Plot { @@ -426,11 +236,14 @@ impl Widget for Plot { let Self { name, next_auto_color_idx: _, - curves, + mut curves, hlines, vlines, - symmetrical_x_bounds, - symmetrical_y_bounds, + center_x_axis, + center_y_axis, + allow_zoom, + allow_drag, + min_auto_bounds, margin_fraction, width, height, @@ -439,7 +252,6 @@ impl Widget for Plot { view_aspect, show_x, show_y, - min_auto_bounds, } = self; let plot_id = ui.make_persistent_id(name); @@ -480,42 +292,7 @@ impl Widget for Plot { let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); - 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()); - bounds.min[0] = -x_abs; - bounds.max[0] = x_abs; - }; - if symmetrical_y_bounds { - let y_abs = bounds.min[1].abs().max(bounds.max[1].abs()); - bounds.min[1] = -y_abs; - bounds.max[1] = y_abs; - }; - - 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); - - 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 if current_data_aspect > data_aspect + epsilon { - bounds.expand_y((current_data_aspect / data_aspect - 1.0) * bounds.height() * 0.5); - } - } - - // Background: + // Background ui.painter().add(Shape::Rect { rect, corner_radius: 2.0, @@ -523,12 +300,44 @@ impl Widget for Plot { stroke: ui.visuals().window_stroke(), }); - 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; - } + auto_bounds |= response.double_clicked_by(PointerButton::Primary); + + // 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)); + curves.iter().for_each(|curve| bounds.merge(&curve.bounds)); + bounds.add_relative_margin(margin_fraction); + } + // Make sure they are not empty. + if !bounds.is_valid() { + bounds = Bounds::new_symmetrical(1.0); + } + + // Scale axes so that the origin is in the center. + if center_x_axis { + bounds.make_x_symmetrical(); + }; + if center_y_axis { + bounds.make_y_symmetrical() + }; + + let mut transform = ScreenTransform::new(rect, bounds, center_x_axis, center_y_axis); + + // Enforce equal aspect ratio. + if let Some(data_aspect) = data_aspect { + transform.set_aspect(data_aspect as f64); + } + + // Dragging + if allow_drag && response.dragged_by(PointerButton::Primary) { + transform.translate_bounds(-response.drag_delta()); + auto_bounds = false; + } + + // Zooming + if allow_zoom { if let Some(hover_pos) = response.hover_pos() { let scroll_delta = ui.input().scroll_delta[1]; if scroll_delta != 0. { @@ -536,117 +345,35 @@ impl Widget for Plot { auto_bounds = false; } } - - ui.memory().id_data.insert( - plot_id, - PlotMemory { - bounds: *transform.bounds(), - auto_bounds, - }, - ); - - let prepared = Prepared { - curves, - hlines, - vlines, - show_x, - show_y, - transform, - }; - prepared.ui(ui, &response); } + // Initialize values from functions. + curves + .iter_mut() + .for_each(|curve| curve.generate_points(transform.bounds().range_x())); + + ui.memory().id_data.insert( + plot_id, + PlotMemory { + bounds: *transform.bounds(), + auto_bounds, + }, + ); + + let prepared = Prepared { + curves, + hlines, + vlines, + show_x, + show_y, + transform, + }; + prepared.ui(ui, &response); + response.on_hover_cursor(CursorIcon::Crosshair) } } -/// 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, -} - -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, - self.bounds.min[0]..=self.bounds.max[0], - (self.rect.left() as f64)..=(self.rect.right() as f64), - ); - let y = remap( - value.y, - self.bounds.min[1]..=self.bounds.max[1], - (self.rect.bottom() as f64)..=(self.rect.top() as f64), // negated y axis! - ); - 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() - } - - /// delta position / delta value - fn dpos_dvalue_y(&self) -> f64 { - -self.rect.height() as f64 / self.bounds.height() // negated y axis! - } - - /// delta position / delta value - fn dpos_dvalue(&self) -> [f64; 2] { - [self.dpos_dvalue_x(), self.dpos_dvalue_y()] - } - - /// delta value / delta position - fn dvalue_dpos(&self) -> [f64; 2] { - [1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()] - } -} - struct Prepared { curves: Vec, hlines: Vec, @@ -706,7 +433,7 @@ impl Prepared { self.hover(ui, pointer, &mut shapes); } - ui.painter().sub_region(*transform.rect()).extend(shapes); + ui.painter().sub_region(*transform.frame()).extend(shapes); } fn paint_axis(&self, ui: &Ui, axis: usize, shapes: &mut Vec) { @@ -763,8 +490,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] = transform.rect.min[1 - axis]; - p1[1 - axis] = transform.rect.max[1 - axis]; + p0[1 - axis] = transform.frame().min[1 - axis]; + p1[1 - axis] = transform.frame().max[1 - axis]; shapes.push(Shape::line_segment([p0, p1], Stroke::new(1.0, color))); } } @@ -777,8 +504,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(transform.rect.max[1 - axis] - galley.size[1 - axis] - 2.0) - .at_least(transform.rect.min[1 - axis] + 1.0); + .at_most(transform.frame().max[1 - axis] - galley.size[1 - axis] - 2.0) + .at_least(transform.frame().min[1 - axis] + 1.0); shapes.push(Shape::Text { pos: text_pos, @@ -825,7 +552,7 @@ impl Prepared { } } - let line_color = line_color(ui, Strength::Strong); + let line_color = line_color(ui, Strength::Middle); let value = if let Some(value) = closest_value { let position = transform.position_from_value(value); @@ -836,7 +563,7 @@ impl Prepared { }; let pointer = transform.position_from_value(&value); - let rect = transform.rect(); + let rect = transform.frame(); if *show_x { // vertical line diff --git a/egui/src/widgets/plot/transform.rs b/egui/src/widgets/plot/transform.rs new file mode 100644 index 00000000..f57f6d81 --- /dev/null +++ b/egui/src/widgets/plot/transform.rs @@ -0,0 +1,248 @@ +use std::ops::RangeInclusive; + +use super::items::Value; +use crate::*; + +/// 2D bounding box of f64 precision. +/// The range of data values we show. +#[derive(Clone, Copy, PartialEq, Debug)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +pub(crate) struct Bounds { + pub min: [f64; 2], + pub max: [f64; 2], +} + +impl Bounds { + pub const NOTHING: Self = Self { + min: [f64::INFINITY; 2], + max: [-f64::INFINITY; 2], + }; + + pub fn new_symmetrical(half_extent: f64) -> Self { + Self { + min: [-half_extent; 2], + max: [half_extent; 2], + } + } + + pub fn is_finite(&self) -> bool { + self.min[0].is_finite() + && self.min[1].is_finite() + && self.max[0].is_finite() + && self.max[1].is_finite() + } + + pub fn is_valid(&self) -> bool { + self.is_finite() && self.width() > 0.0 && self.height() > 0.0 + } + + pub fn width(&self) -> f64 { + self.max[0] - self.min[0] + } + + pub fn height(&self) -> f64 { + self.max[1] - self.min[1] + } + + pub fn extend_with(&mut self, value: &Value) { + self.extend_with_x(value.x); + self.extend_with_y(value.y); + } + + /// Expand to include the given x coordinate + pub fn extend_with_x(&mut self, x: f64) { + self.min[0] = self.min[0].min(x); + self.max[0] = self.max[0].max(x); + } + + /// Expand to include the given y coordinate + pub fn extend_with_y(&mut self, y: f64) { + self.min[1] = self.min[1].min(y); + self.max[1] = self.max[1].max(y); + } + + pub fn expand_x(&mut self, pad: f64) { + self.min[0] -= pad; + self.max[0] += pad; + } + + pub fn expand_y(&mut self, pad: f64) { + self.min[1] -= pad; + self.max[1] += pad; + } + + 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().max(0.0); + let height = self.height().max(0.0); + self.expand_x(margin_fraction.x as f64 * width); + self.expand_y(margin_fraction.y as f64 * height); + } + + pub fn range_x(&self) -> RangeInclusive { + self.min[0]..=self.max[0] + } + + pub fn make_x_symmetrical(&mut self) { + let x_abs = self.min[0].abs().max(self.max[0].abs()); + self.min[0] = -x_abs; + self.max[0] = x_abs; + } + + pub fn make_y_symmetrical(&mut self) { + let y_abs = self.min[1].abs().max(self.max[1].abs()); + self.min[1] = -y_abs; + self.max[1] = y_abs; + } +} + +/// Contains the screen rectangle and the plot bounds and provides methods to transform them. +pub(crate) struct ScreenTransform { + /// The screen rectangle. + frame: Rect, + /// The plot bounds. + bounds: Bounds, + /// Whether to always center the x-range of the bounds. + x_centered: bool, + /// Whether to always center the y-range of the bounds. + y_centered: bool, +} + +impl ScreenTransform { + pub fn new(frame: Rect, bounds: Bounds, x_centered: bool, y_centered: bool) -> Self { + Self { + frame, + bounds, + x_centered, + y_centered, + } + } + + pub fn frame(&self) -> &Rect { + &self.frame + } + + pub fn bounds(&self) -> &Bounds { + &self.bounds + } + + pub fn translate_bounds(&mut self, mut delta_pos: Vec2) { + if self.x_centered { + delta_pos.x = 0.; + } + if self.y_centered { + delta_pos.y = 0.; + } + 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. + pub fn zoom(&mut self, delta: f32, mut center: Pos2) { + if self.x_centered { + center.x = self.frame.center().x as f32; + } + if self.y_centered { + center.y = self.frame.center().y as f32; + } + let delta = delta.clamp(-1., 1.); + let frame_width = self.frame.width(); + let frame_height = self.frame.height(); + let bounds_width = self.bounds.width() as f32; + let bounds_height = self.bounds.height() as f32; + let t_x = (center.x - self.frame.min[0]) / frame_width; + let t_y = (self.frame.max[1] - center.y) / frame_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; + } + + pub fn position_from_value(&self, value: &Value) -> Pos2 { + let x = remap( + value.x, + self.bounds.min[0]..=self.bounds.max[0], + (self.frame.left() as f64)..=(self.frame.right() as f64), + ); + let y = remap( + value.y, + self.bounds.min[1]..=self.bounds.max[1], + (self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis! + ); + pos2(x as f32, y as f32) + } + + pub fn value_from_position(&self, pos: Pos2) -> Value { + let x = remap( + pos.x as f64, + (self.frame.left() as f64)..=(self.frame.right() as f64), + self.bounds.min[0]..=self.bounds.max[0], + ); + let y = remap( + pos.y as f64, + (self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis! + self.bounds.min[1]..=self.bounds.max[1], + ); + Value::new(x, y) + } + + /// delta position / delta value + pub fn dpos_dvalue_x(&self) -> f64 { + self.frame.width() as f64 / self.bounds.width() + } + + /// delta position / delta value + pub fn dpos_dvalue_y(&self) -> f64 { + -self.frame.height() as f64 / self.bounds.height() // negated y axis! + } + + /// delta position / delta value + pub fn dpos_dvalue(&self) -> [f64; 2] { + [self.dpos_dvalue_x(), self.dpos_dvalue_y()] + } + + /// delta value / delta position + pub fn dvalue_dpos(&self) -> [f64; 2] { + [1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()] + } + + pub fn get_aspect(&self) -> f64 { + let rw = self.frame.width() as f64; + let rh = self.frame.height() as f64; + (self.bounds.width() / rw) / (self.bounds.height() / rh) + } + + pub fn set_aspect(&mut self, aspect: f64) { + let epsilon = 1e-5; + let current_aspect = self.get_aspect(); + if current_aspect < aspect - epsilon { + self.bounds + .expand_x((aspect / current_aspect - 1.0) * self.bounds.width() * 0.5); + } else if current_aspect > aspect + epsilon { + self.bounds + .expand_y((current_aspect / aspect - 1.0) * self.bounds.height() * 0.5); + } + } +} diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index 3b17953f..58c7abd2 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -6,7 +6,7 @@ use std::f64::consts::TAU; pub struct PlotDemo { animate: bool, time: f64, - circle_radius: f32, + circle_radius: f64, circle_center: Pos2, square: bool, proportional: bool, @@ -17,7 +17,7 @@ impl Default for PlotDemo { Self { animate: true, time: 0.0, - circle_radius: 0.5, + circle_radius: 1.5, circle_center: Pos2::new(0.0, 0.0), square: false, proportional: true, @@ -96,7 +96,7 @@ impl PlotDemo { let n = 512; let circle = (0..=n).map(|i| { let t = remap(i as f64, 0.0..=(n as f64), 0.0..=TAU); - let r = self.circle_radius as f64; + let r = self.circle_radius; Value::new( r * t.cos() + self.circle_center.x as f64, r * t.sin() + self.circle_center.y as f64, @@ -108,25 +108,25 @@ impl PlotDemo { } fn sin(&self) -> Curve { - let n = 512; - let circle = (0..=n).map(|i| { - let t = remap(i as f64, 0.0..=(n as f64), -TAU..=TAU); - Value::new(t / 5.0, 0.5 * (self.time + t).sin()) - }); - Curve::from_values_iter(circle) - .color(Color32::from_rgb(200, 100, 100)) - .name("0.5 * sin(x / 5)") + let time = self.time; + Curve::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)) + .name("0.5 * sin(2x) * sin(t)") } fn thingy(&self) -> Curve { - let n = 512; - let complex_curve = (0..=n).map(|i| { - let t = remap(i as f64, 0.0..=(n as f64), 0.0..=TAU); - Value::new((2.0 * t + self.time).sin(), (3.0 * t).sin()) - }); - Curve::from_values_iter(complex_curve) - .color(Color32::from_rgb(100, 150, 250)) - .name("x = sin(2t), y = sin(3t)") + let time = self.time; + Curve::from_parametric_callback( + move |t| ((2.0 * t + time).sin(), (3.0 * t).sin()), + 0.0..=TAU, + 512, + ) + .color(Color32::from_rgb(100, 150, 250)) + .name("x = sin(2t), y = sin(3t)") } }