diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc4bfe9e0c..b39ceffa761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w * Added linked axis support for plots via `plot::LinkedAxisGroup` ([#1184](https://github.com/emilk/egui/pull/1184)). * Added `Response::on_hover_text_at_pointer` as a convenience akin to `Response::on_hover_text` ([1179](https://github.com/emilk/egui/pull/1179)). * Added `ui.weak(text)`. +* Added plot pointer coordinates with `Plot::coordinates_formatter`. ([#1235](https://github.com/emilk/egui/pull/1235)). * Added `Slider::step_by` ([1255](https://github.com/emilk/egui/pull/1225)). ### Changed 🔧 @@ -46,6 +47,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w * Replaced Frame's `margin: Vec2` with `margin: Margin`, allowing for different margins on opposing sides ([#1219](https://github.com/emilk/egui/pull/1219)). * `Plot::highlight` now takes a `bool` argument ([#1159](https://github.com/emilk/egui/pull/1159)). * `ScrollArea::show` now returns a `ScrollAreaOutput`, so you might need to add `.inner` after the call to it ([#1166](https://github.com/emilk/egui/pull/1166)). +* Renamed `Plot::custom_label_func` to `Plot::label_formatter` ([#1235](https://github.com/emilk/egui/pull/1235)). * Tooltips that don't fit the window don't flicker anymore ([#1240](https://github.com/emilk/egui/pull/1240)). * `Areas::layer_id_at` ignores non interatable layers (i.e. Tooltips) ([#1240](https://github.com/emilk/egui/pull/1240)). diff --git a/egui/src/widgets/plot/items/mod.rs b/egui/src/widgets/plot/items/mod.rs index 983d29df095..2a00a285d67 100644 --- a/egui/src/widgets/plot/items/mod.rs +++ b/egui/src/widgets/plot/items/mod.rs @@ -7,7 +7,7 @@ use epaint::Mesh; use crate::*; -use super::{CustomLabelFuncRef, PlotBounds, ScreenTransform}; +use super::{LabelFormatter, PlotBounds, ScreenTransform}; use rect_elem::*; use values::{ClosestElem, PlotGeometry}; @@ -66,7 +66,7 @@ pub(super) trait PlotItem { elem: ClosestElem, shapes: &mut Vec, plot: &PlotConfig<'_>, - custom_label_func: &CustomLabelFuncRef, + label_formatter: &LabelFormatter, ) { let points = match self.geometry() { PlotGeometry::Points(points) => points, @@ -89,7 +89,7 @@ pub(super) trait PlotItem { 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, custom_label_func); + rulers_at_value(pointer, value, self.name(), plot, shapes, label_formatter); } } @@ -1380,7 +1380,7 @@ impl PlotItem for BarChart { elem: ClosestElem, shapes: &mut Vec, plot: &PlotConfig<'_>, - _: &CustomLabelFuncRef, + _: &LabelFormatter, ) { let bar = &self.bars[elem.index]; @@ -1522,7 +1522,7 @@ impl PlotItem for BoxPlot { elem: ClosestElem, shapes: &mut Vec, plot: &PlotConfig<'_>, - _: &CustomLabelFuncRef, + _: &LabelFormatter, ) { let box_plot = &self.boxes[elem.index]; @@ -1643,7 +1643,7 @@ pub(super) fn rulers_at_value( name: &str, plot: &PlotConfig<'_>, shapes: &mut Vec, - custom_label_func: &CustomLabelFuncRef, + label_formatter: &LabelFormatter, ) { let line_color = rulers_color(plot.ui); if plot.show_x { @@ -1663,7 +1663,7 @@ pub(super) fn rulers_at_value( 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 let Some(custom_label) = custom_label_func { + if let Some(custom_label) = label_formatter { custom_label(name, &value) } else if plot.show_x && plot.show_y { format!( diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 647c1b4152a..b6f080960a6 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -1,6 +1,6 @@ //! Simple plotting library. -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, ops::RangeInclusive, rc::Rc}; use crate::*; use epaint::ahash::AHashSet; @@ -20,12 +20,44 @@ mod items; mod legend; mod transform; -type CustomLabelFunc = dyn Fn(&str, &Value) -> String; -type CustomLabelFuncRef = Option>; - -type AxisFormatterFn = dyn Fn(f64) -> String; +type LabelFormatterFn = dyn Fn(&str, &Value) -> String; +type LabelFormatter = Option>; +type AxisFormatterFn = dyn Fn(f64, &RangeInclusive) -> String; type AxisFormatter = Option>; +/// Specifies the coordinates formatting when passed to [`Plot::coordinates_formatter`]. +pub struct CoordinatesFormatter { + function: Box String>, +} + +impl CoordinatesFormatter { + /// Create a new formatter based on the pointer coordinate and the plot bounds. + pub fn new(function: impl Fn(&Value, &PlotBounds) -> String + 'static) -> Self { + Self { + function: Box::new(function), + } + } + + /// Show a fixed number of decimal places. + pub fn with_decimals(num_decimals: usize) -> Self { + Self { + function: Box::new(move |value, _| { + format!("x: {:.d$}\ny: {:.d$}", value.x, value.y, d = num_decimals) + }), + } + } + + fn format(&self, value: &Value, bounds: &PlotBounds) -> String { + (self.function)(value, bounds) + } +} + +impl Default for CoordinatesFormatter { + fn default() -> Self { + Self::with_decimals(3) + } +} + // ---------------------------------------------------------------------------- /// Information about the plot that has to persist between frames. @@ -146,7 +178,8 @@ pub struct Plot { show_x: bool, show_y: bool, - custom_label_func: CustomLabelFuncRef, + label_formatter: LabelFormatter, + coordinates_formatter: Option<(Corner, CoordinatesFormatter)>, axis_formatters: [AxisFormatter; 2], legend_config: Option, show_background: bool, @@ -177,7 +210,8 @@ impl Plot { show_x: true, show_y: true, - custom_label_func: None, + label_formatter: None, + coordinates_formatter: None, axis_formatters: [None, None], // [None; 2] requires Copy legend_config: None, show_background: true, @@ -284,7 +318,7 @@ impl Plot { /// }); /// let line = Line::new(Values::from_values_iter(sin)); /// Plot::new("my_plot").view_aspect(2.0) - /// .custom_label_func(|name, value| { + /// .label_formatter(|name, value| { /// if !name.is_empty() { /// format!("{}: {:.*}%", name, 1, value.y).to_string() /// } else { @@ -294,34 +328,50 @@ impl Plot { /// .show(ui, |plot_ui| plot_ui.line(line)); /// # }); /// ``` - pub fn custom_label_func( + pub fn label_formatter( + mut self, + label_formatter: impl Fn(&str, &Value) -> String + 'static, + ) -> Self { + self.label_formatter = Some(Box::new(label_formatter)); + self + } + + /// Show the pointer coordinates in the plot. + pub fn coordinates_formatter( mut self, - custom_label_func: impl Fn(&str, &Value) -> String + 'static, + position: Corner, + formatter: CoordinatesFormatter, ) -> Self { - self.custom_label_func = Some(Box::new(custom_label_func)); + self.coordinates_formatter = Some((position, formatter)); self } - /// Provide a function to customize the labels for the X axis. + /// Provide a function to customize the labels for the X axis based on the current visible value range. /// /// This is useful for custom input domains, e.g. date/time. /// /// If axis labels should not appear for certain values or beyond a certain zoom/resolution, /// the formatter function can return empty strings. This is also useful if your domain is /// discrete (e.g. only full days in a calendar). - pub fn x_axis_formatter(mut self, func: impl Fn(f64) -> String + 'static) -> Self { + pub fn x_axis_formatter( + mut self, + func: impl Fn(f64, &RangeInclusive) -> String + 'static, + ) -> Self { self.axis_formatters[0] = Some(Box::new(func)); self } - /// Provide a function to customize the labels for the Y axis. + /// Provide a function to customize the labels for the Y axis based on the current value range. /// /// This is useful for custom value representation, e.g. percentage or units. /// /// If axis labels should not appear for certain values or beyond a certain zoom/resolution, /// the formatter function can return empty strings. This is also useful if your Y values are /// discrete (e.g. only integers). - pub fn y_axis_formatter(mut self, func: impl Fn(f64) -> String + 'static) -> Self { + pub fn y_axis_formatter( + mut self, + func: impl Fn(f64, &RangeInclusive) -> String + 'static, + ) -> Self { self.axis_formatters[1] = Some(Box::new(func)); self } @@ -388,7 +438,8 @@ impl Plot { view_aspect, mut show_x, mut show_y, - custom_label_func, + label_formatter, + coordinates_formatter, axis_formatters, legend_config, show_background, @@ -630,7 +681,8 @@ impl Plot { items, show_x, show_y, - custom_label_func, + label_formatter, + coordinates_formatter, axis_formatters, show_axes, transform: transform.clone(), @@ -849,7 +901,8 @@ struct PreparedPlot { items: Vec>, show_x: bool, show_y: bool, - custom_label_func: CustomLabelFuncRef, + label_formatter: LabelFormatter, + coordinates_formatter: Option<(Corner, CoordinatesFormatter)>, axis_formatters: [AxisFormatter; 2], show_axes: [bool; 2], transform: ScreenTransform, @@ -877,7 +930,24 @@ impl PreparedPlot { self.hover(ui, pointer, &mut shapes); } - ui.painter().sub_region(*transform.frame()).extend(shapes); + let painter = ui.painter().sub_region(*transform.frame()); + painter.extend(shapes); + + if let Some((corner, formatter)) = self.coordinates_formatter.as_ref() { + if let Some(pointer) = response.hover_pos() { + let font_id = TextStyle::Monospace.resolve(ui.style()); + let coordinate = transform.value_from_position(pointer); + let text = formatter.format(&coordinate, transform.bounds()); + let padded_frame = transform.frame().shrink(4.0); + let (anchor, position) = match corner { + Corner::LeftTop => (Align2::LEFT_TOP, padded_frame.left_top()), + Corner::RightTop => (Align2::RIGHT_TOP, padded_frame.right_top()), + Corner::LeftBottom => (Align2::LEFT_BOTTOM, padded_frame.left_bottom()), + Corner::RightBottom => (Align2::RIGHT_BOTTOM, padded_frame.right_bottom()), + }; + painter.text(position, anchor, text, font_id, ui.visuals().text_color()); + } + } } fn paint_axis(&self, ui: &Ui, axis: usize, shapes: &mut Vec) { @@ -888,6 +958,11 @@ impl PreparedPlot { } = self; let bounds = transform.bounds(); + let axis_range = match axis { + 0 => bounds.range_x(), + 1 => bounds.range_y(), + _ => panic!("Axis {} does not exist.", axis), + }; let font_id = TextStyle::Body.resolve(ui.style()); @@ -947,7 +1022,7 @@ impl PreparedPlot { let color = color_from_alpha(ui, text_alpha); let text: String = if let Some(formatter) = axis_formatters[axis].as_deref() { - formatter(value_main) + formatter(value_main, &axis_range) } else { emath::round_to_decimals(value_main, 5).to_string() // hack }; @@ -982,7 +1057,7 @@ impl PreparedPlot { transform, show_x, show_y, - custom_label_func, + label_formatter, items, .. } = self; @@ -1012,10 +1087,10 @@ impl PreparedPlot { }; if let Some((item, elem)) = closest { - item.on_hover(elem, shapes, &plot, custom_label_func); + item.on_hover(elem, shapes, &plot, label_formatter); } else { let value = transform.value_from_position(pointer); - items::rulers_at_value(pointer, value, "", &plot, shapes, custom_label_func); + items::rulers_at_value(pointer, value, "", &plot, shapes, label_formatter); } } } diff --git a/egui/src/widgets/plot/transform.rs b/egui/src/widgets/plot/transform.rs index c208bc6e2c2..759a26d80ce 100644 --- a/egui/src/widgets/plot/transform.rs +++ b/egui/src/widgets/plot/transform.rs @@ -120,6 +120,10 @@ impl PlotBounds { self.min[0]..=self.max[0] } + pub(crate) fn range_y(&self) -> RangeInclusive { + self.min[1]..=self.max[1] + } + pub(crate) fn make_x_symmetrical(&mut self) { let x_abs = self.min[0].abs().max(self.max[0].abs()); self.min[0] = -x_abs; diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index fb0ab761c18..bf845f094ec 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -2,8 +2,9 @@ use std::f64::consts::TAU; use egui::*; use plot::{ - Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, Corner, HLine, Legend, Line, LineStyle, - MarkerShape, Plot, PlotImage, Points, Polygon, Text, VLine, Value, Values, + Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine, + Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, Points, Polygon, Text, VLine, Value, + Values, }; #[derive(PartialEq)] @@ -14,6 +15,7 @@ struct LineDemo { circle_center: Pos2, square: bool, proportional: bool, + coordinates: bool, line_style: LineStyle, } @@ -26,6 +28,7 @@ impl Default for LineDemo { circle_center: Pos2::new(0.0, 0.0), square: false, proportional: true, + coordinates: true, line_style: LineStyle::Solid, } } @@ -41,6 +44,7 @@ impl LineDemo { square, proportional, line_style, + coordinates, .. } = self; @@ -76,6 +80,8 @@ impl LineDemo { .on_hover_text("Always keep the viewport square."); ui.checkbox(proportional, "Proportional data axes") .on_hover_text("Tick are the same size on both axes."); + ui.checkbox(coordinates, "Show coordinates") + .on_hover_text("Can take a custom formatting function."); ComboBox::from_label("Line style") .selected_text(line_style.to_string()) @@ -151,6 +157,9 @@ impl Widget for &mut LineDemo { if self.proportional { plot = plot.data_aspect(1.0); } + if self.coordinates { + plot = plot.coordinates_formatter(Corner::LeftBottom, CoordinatesFormatter::default()); + } plot.show(ui, |plot_ui| { plot_ui.line(self.circle()); plot_ui.line(self.sin()); @@ -595,7 +604,7 @@ impl ChartsDemo { .name("Set 4") .stack_on(&[&chart1, &chart2, &chart3]); - let mut x_fmt: fn(f64) -> String = |val| { + let mut x_fmt: fn(f64, &std::ops::RangeInclusive) -> String = |val, _range| { if val >= 0.0 && val <= 4.0 && is_approx_integer(val) { // Only label full days from 0 to 4 format!("Day {}", val) @@ -605,7 +614,7 @@ impl ChartsDemo { } }; - let mut y_fmt: fn(f64) -> String = |val| { + let mut y_fmt: fn(f64, &std::ops::RangeInclusive) -> String = |val, _range| { let percent = 100.0 * val; if is_approx_integer(percent) && !is_approx_zero(percent) {