diff --git a/.gitignore b/.gitignore index 37fc2290..fd3a8d64 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ target_wasm **/tests/snapshots/**/*.new.png **/tests/snapshots/**/*.old.png +**/*.diff.png +**/*.new.png +**/*.old.png # trunk output folder dist diff --git a/Cargo.lock b/Cargo.lock index b286614c..3b6aa6d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -956,6 +956,7 @@ dependencies = [ "plot_span", "save_plot", "stacked_bar", + "userdata_points", "wasm-bindgen-futures", "web-sys", ] @@ -3616,6 +3617,16 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "userdata_points" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_plot", + "env_logger", + "examples_utils", +] + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index b2920474..3581d3c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ performance = { version = "0.1.0", path = "examples/performance" } plot_span = { version = "0.1.0", path = "examples/plot_span" } save_plot = { version = "0.1.0", path = "examples/save_plot" } stacked_bar = { version = "0.1.0", path = "examples/stacked_bar" } +userdata_points = { version = "0.1.0", path = "examples/userdata_points" } ahash = { version = "0.8.12", default-features = false, features = [ "no-rng", # we don't need DOS-protection, so we let users opt-in to it instead @@ -283,4 +284,4 @@ map_unwrap_or = "allow" # this is better on 'allow' [workspace.metadata.cargo-shear] -ignored = ["log", "assertables"] +ignored = ["assertables"] diff --git a/demo/Cargo.toml b/demo/Cargo.toml index a7139d83..301bf7d4 100644 --- a/demo/Cargo.toml +++ b/demo/Cargo.toml @@ -46,6 +46,7 @@ performance.workspace = true plot_span.workspace = true save_plot.workspace = true stacked_bar.workspace = true +userdata_points.workspace = true # native: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/demo/src/app.rs b/demo/src/app.rs index 8a2d1038..86e629f6 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -52,6 +52,7 @@ impl DemoGallery { Box::new(plot_span::PlotSpanDemo::default()), Box::new(save_plot::SavePlotExample::default()), Box::new(stacked_bar::StackedBarExample::default()), + Box::new(userdata_points::UserdataPointsExample::default()), ]; let thumbnail_textures = Self::load_thumbnails(ctx, &examples); diff --git a/egui_plot/src/items/arrows.rs b/egui_plot/src/items/arrows.rs index 75954404..0ba49e78 100644 --- a/egui_plot/src/items/arrows.rs +++ b/egui_plot/src/items/arrows.rs @@ -144,7 +144,7 @@ impl PlotItem for Arrows<'_> { } fn geometry(&self) -> PlotGeometry<'_> { - PlotGeometry::Points(self.origins.points()) + PlotGeometry::Points(self.origins.points(), self.id()) } fn bounds(&self) -> PlotBounds { diff --git a/egui_plot/src/items/mod.rs b/egui_plot/src/items/mod.rs index 99513430..594efbdb 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -150,7 +150,7 @@ pub trait PlotItem { match self.geometry() { PlotGeometry::None => None, - PlotGeometry::Points(points) => points + PlotGeometry::Points(points, _) => points .iter() .enumerate() .map(|(index, value)| { @@ -176,8 +176,8 @@ pub trait PlotItem { plot: &PlotConfig<'_>, label_formatter: &Option>, ) { - let points = match self.geometry() { - PlotGeometry::Points(points) => points, + let (points, id) = match self.geometry() { + PlotGeometry::Points(points, id) => (points, id), PlotGeometry::None => { panic!("If the PlotItem has no geometry, on_hover() must not be called") } @@ -198,7 +198,15 @@ pub trait PlotItem { let pointer = plot.transform.position_from_point(&value); shapes.push(Shape::circle_filled(pointer, 3.0, line_color)); - rulers_and_tooltip_at_value(plot_area_response, value, self.name(), plot, cursors, label_formatter); + rulers_and_tooltip_at_value( + plot_area_response, + value, + Some((id, elem.index)), + self.name(), + plot, + cursors, + label_formatter, + ); } } @@ -272,6 +280,7 @@ fn add_rulers_and_text( pub(super) fn rulers_and_tooltip_at_value( plot_area_response: &egui::Response, value: PlotPoint, + item: Option<(Id, usize)>, name: &str, plot: &PlotConfig<'_>, cursors: &mut Vec, @@ -292,7 +301,7 @@ pub(super) fn rulers_and_tooltip_at_value( return; }; - let text = custom_label(name, &value); + let text = custom_label(name, &value, item); if text.is_empty() { return; } @@ -322,7 +331,7 @@ pub enum PlotGeometry<'a> { None, /// Point values (X-Y graphs) - Points(&'a [PlotPoint]), + Points(&'a [PlotPoint], Id), /// Rectangles (examples: boxes or bars) // Has currently no data, as it would require copying rects or iterating a list of pointers. diff --git a/egui_plot/src/items/points.rs b/egui_plot/src/items/points.rs index 7644a03a..d7c1c9a4 100644 --- a/egui_plot/src/items/points.rs +++ b/egui_plot/src/items/points.rs @@ -256,7 +256,7 @@ impl PlotItem for Points<'_> { } fn geometry(&self) -> PlotGeometry<'_> { - PlotGeometry::Points(self.series.points()) + PlotGeometry::Points(self.series.points(), self.id()) } fn bounds(&self) -> PlotBounds { diff --git a/egui_plot/src/items/polygon.rs b/egui_plot/src/items/polygon.rs index f80e0510..f8c1f244 100644 --- a/egui_plot/src/items/polygon.rs +++ b/egui_plot/src/items/polygon.rs @@ -150,7 +150,7 @@ impl PlotItem for Polygon<'_> { } fn geometry(&self) -> PlotGeometry<'_> { - PlotGeometry::Points(self.series.points()) + PlotGeometry::Points(self.series.points(), self.id()) } fn bounds(&self) -> PlotBounds { diff --git a/egui_plot/src/items/series.rs b/egui_plot/src/items/series.rs index 95ade02e..4c97b767 100644 --- a/egui_plot/src/items/series.rs +++ b/egui_plot/src/items/series.rs @@ -256,7 +256,7 @@ impl PlotItem for Line<'_> { } fn geometry(&self) -> PlotGeometry<'_> { - PlotGeometry::Points(self.series.points()) + PlotGeometry::Points(self.series.points(), self.id()) } fn bounds(&self) -> PlotBounds { diff --git a/egui_plot/src/label.rs b/egui_plot/src/label.rs index c24cc566..2463368c 100644 --- a/egui_plot/src/label.rs +++ b/egui_plot/src/label.rs @@ -1,3 +1,4 @@ +use egui::Id; use emath::NumExt as _; use crate::bounds::PlotPoint; @@ -16,14 +17,19 @@ pub fn format_number(number: f64, num_decimals: usize) -> String { } } -type LabelFormatterFn<'a> = dyn Fn(&str, &PlotPoint) -> String + 'a; +type LabelFormatterFn<'a> = dyn Fn(&str, &PlotPoint, Option<(Id, usize)>) -> String + 'a; /// Optional label formatter function for customizing hover labels. +/// +/// The formatter receives the item name, the hovered point, and an optional +/// `(Id, index)` for the hovered plot item. The `Id` matches the item `id()`, +/// and `index` is the point index within that item. The argument is `None` +/// when the cursor isn't hovering a concrete plot item. pub type LabelFormatter<'a> = Box>; /// Default label formatter that shows the x and y coordinates with 3 decimal /// places. -pub fn default_label_formatter(name: &str, value: &PlotPoint) -> String { +pub fn default_label_formatter(name: &str, value: &PlotPoint, _id_index: Option<(Id, usize)>) -> String { let prefix = if name.is_empty() { String::new() } else { diff --git a/egui_plot/src/plot.rs b/egui_plot/src/plot.rs index 581c185e..6761d844 100644 --- a/egui_plot/src/plot.rs +++ b/egui_plot/src/plot.rs @@ -388,7 +388,11 @@ impl<'a> Plot<'a> { self } - /// Provide a function to customize the on-hover label for the x and y axis + /// Provide a function to customize the on-hover label for the x and y axis. + /// + /// The third argument is `Some((Id, index))` when hovering a plot item, + /// where `Id` is the item's id and `index` is the point index within that + /// item. It is `None` when the cursor isn't hovering a concrete plot item. /// /// ``` /// # egui::__run_test_ui(|ui| { @@ -404,9 +408,13 @@ impl<'a> Plot<'a> { /// let line = Line::new("sin", sin); /// Plot::new("my_plot") /// .view_aspect(2.0) - /// .label_formatter(|name, value| { + /// .label_formatter(|name, value, id_index| { /// if !name.is_empty() { - /// format!("{}: {:.*}%", name, 1, value.y) + /// if let Some((_id, index)) = id_index { + /// format!("{}_{}: {:.*}%", name, index, 1, value.y) + /// } else { + /// format!("{}: {:.*}%", name, 1, value.y) + /// } /// } else { /// "".to_owned() /// } @@ -415,7 +423,10 @@ impl<'a> Plot<'a> { /// # }); /// ``` #[inline] - pub fn label_formatter(mut self, label_formatter: impl Fn(&str, &PlotPoint) -> String + 'a) -> Self { + pub fn label_formatter( + mut self, + label_formatter: impl Fn(&str, &PlotPoint, Option<(Id, usize)>) -> String + 'a, + ) -> Self { self.label_formatter = Some(Box::new(label_formatter)); self } @@ -1572,6 +1583,7 @@ impl<'a> Plot<'a> { items::rulers_and_tooltip_at_value( &plot_ui.response, value, + None, "", &plot, &mut cursors, diff --git a/examples/custom_axes/src/app.rs b/examples/custom_axes/src/app.rs index 24a5eecb..e6e7efcf 100644 --- a/examples/custom_axes/src/app.rs +++ b/examples/custom_axes/src/app.rs @@ -101,7 +101,7 @@ impl CustomAxesExample { } }; - let label_fmt = |_s: &str, val: &PlotPoint| { + let label_fmt = |_s: &str, val: &PlotPoint, _id: Option<(egui::Id, usize)>| { format!( "Day {d}, {h}:{m:02}\n{p:.2}%", d = day(val.x), diff --git a/examples/userdata_points/Cargo.toml b/examples/userdata_points/Cargo.toml new file mode 100644 index 00000000..dfded1fa --- /dev/null +++ b/examples/userdata_points/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "userdata_points" +version = "0.1.0" +authors = ["Nicolas "] +license.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +eframe = { workspace = true, features = ["default"] } +egui_plot.workspace = true +env_logger = { workspace = true, default-features = false, features = [ + "auto-color", + "humantime", +] } +examples_utils.workspace = true + +[package.metadata.cargo-shear] +ignored = ["env_logger"] # used by make_main! macro diff --git a/examples/userdata_points/README.md b/examples/userdata_points/README.md new file mode 100644 index 00000000..6e5f4000 --- /dev/null +++ b/examples/userdata_points/README.md @@ -0,0 +1,3 @@ +# Userdata Points Example + +TODO diff --git a/examples/userdata_points/screenshot.png b/examples/userdata_points/screenshot.png new file mode 100644 index 00000000..b354d579 --- /dev/null +++ b/examples/userdata_points/screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed1d0c68267e42757f83fc5a449118810a422551a15db5836d1763946a56f5ae +size 101505 diff --git a/examples/userdata_points/screenshot_thumb.png b/examples/userdata_points/screenshot_thumb.png new file mode 100644 index 00000000..4dd482ca --- /dev/null +++ b/examples/userdata_points/screenshot_thumb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:609961ce5e208689f1ae9f08d2abad67d8792f84153e160bedd8ef2cd7f1aa5e +size 18425 diff --git a/examples/userdata_points/src/app.rs b/examples/userdata_points/src/app.rs new file mode 100644 index 00000000..d5daef5c --- /dev/null +++ b/examples/userdata_points/src/app.rs @@ -0,0 +1,148 @@ +use eframe::egui::{self, Color32, Id, IdMap, Response}; +use egui_plot::{Corner, Legend, Line, MarkerShape, Plot, Points}; + +pub struct UserdataPointsExample { + sine_points: Vec, + cosine_points: Vec, + damped_points: Vec, +} + +#[derive(Clone)] +struct DemoPoint { + x: f64, + y: f64, + custom_label: String, + importance: f32, +} + +impl Default for UserdataPointsExample { + fn default() -> Self { + // Create multiple datasets with custom metadata + let sine_points = (0..=500) + .map(|i| { + let x = i as f64 / 100.0; + DemoPoint { + x, + y: x.sin(), + custom_label: format!("Sine #{i}"), + importance: (i % 100) as f32 / 100.0, + } + }) + .collect::>(); + let cosine_points = (0..=500) + .map(|i| { + let x = i as f64 / 100.0; + DemoPoint { + x, + y: x.cos(), + custom_label: format!("Cosine #{i}"), + importance: (1.0 - (i % 100) as f32 / 100.0), + } + }) + .collect::>(); + + let damped_points = (0..=500) + .map(|i| { + let x = i as f64 / 100.0; + DemoPoint { + x, + y: (-x * 0.5).exp() * (2.0 * x).sin(), + custom_label: format!("Damped #{i}"), + importance: if i % 50 == 0 { 1.0 } else { 0.3 }, + } + }) + .collect::>(); + Self { + sine_points, + cosine_points, + damped_points, + } + } +} + +impl UserdataPointsExample { + pub fn show_plot(&self, ui: &mut egui::Ui) -> Response { + let sine_id = Id::new("sine_wave"); + let cosine_id = Id::new("cosine_wave"); + let damped_id = Id::new("damped_wave"); + + let mut points_by_id: IdMap<&[DemoPoint]> = IdMap::default(); + points_by_id.insert(sine_id, &self.sine_points); + points_by_id.insert(cosine_id, &self.cosine_points); + points_by_id.insert(damped_id, &self.damped_points); + + Plot::new("Userdata Plot Demo") + .legend(Legend::default().position(Corner::LeftTop)) + .label_formatter(move |name, value, item| { + if let Some((id, index)) = item { + if let Some(points) = points_by_id.get(&id) { + if let Some(point) = points.get(index) { + return format!( + "{}\nPosition: ({:.3}, {:.3})\nLabel: {}\nImportance: {:.1}%", + name, + value.x, + value.y, + point.custom_label, + point.importance * 100.0 + ); + } + } + } + format!("{}\n({:.3}, {:.3})", name, value.x, value.y) + }) + .show(ui, |plot_ui| { + // Sine wave with custom data + plot_ui.line( + Line::new( + "sin(x)", + self.sine_points.iter().map(|p| [p.x, p.y]).collect::>(), + ) + .id(sine_id) + .color(Color32::from_rgb(200, 100, 100)), + ); + + // Cosine wave with custom data + plot_ui.line( + Line::new( + "cos(x)", + self.cosine_points.iter().map(|p| [p.x, p.y]).collect::>(), + ) + .id(cosine_id) + .color(Color32::from_rgb(100, 200, 100)), + ); + + // Damped sine wave with custom data + plot_ui.line( + Line::new( + "e^(-0.5x) ยท sin(2x)", + self.damped_points.iter().map(|p| [p.x, p.y]).collect::>(), + ) + .id(damped_id) + .color(Color32::from_rgb(100, 100, 200)), + ); + + // Add some points with high importance as markers + let important_points: Vec<_> = self + .damped_points + .iter() + .filter(|p| p.importance > 0.9) + .map(|p| [p.x, p.y]) + .collect(); + + if !important_points.is_empty() { + plot_ui.points( + Points::new("Important Points", important_points) + .color(Color32::from_rgb(255, 150, 0)) + .radius(4.0) + .shape(MarkerShape::Diamond), + ); + } + }) + .response + } + + #[expect(clippy::unused_self, reason = "required by the example template")] + pub fn show_controls(&self, ui: &mut egui::Ui) -> Response { + ui.scope(|_ui| {}).response + } +} diff --git a/examples/userdata_points/src/lib.rs b/examples/userdata_points/src/lib.rs new file mode 100644 index 00000000..e8f70c61 --- /dev/null +++ b/examples/userdata_points/src/lib.rs @@ -0,0 +1,41 @@ +#![doc = include_str!("../README.md")] + +use eframe::egui; +use examples_utils::PlotExample; + +mod app; +pub use app::UserdataPointsExample; + +impl PlotExample for UserdataPointsExample { + fn name(&self) -> &'static str { + "userdata_points" + } + + fn title(&self) -> &'static str { + "Example of Userdata Points" + } + + fn description(&self) -> &'static str { + "This demo shows how to attach custom data to plot items and display it in tooltips." + } + + fn tags(&self) -> &'static [&'static str] { + &["performance"] + } + + fn thumbnail_bytes(&self) -> &'static [u8] { + include_bytes!("../screenshot_thumb.png") + } + + fn code_bytes(&self) -> &'static [u8] { + include_bytes!("./app.rs") + } + + fn show_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { + self.show_plot(ui) + } + + fn show_controls(&mut self, ui: &mut egui::Ui) -> egui::Response { + ui.scope(|_ui| {}).response + } +} diff --git a/examples/userdata_points/src/main.rs b/examples/userdata_points/src/main.rs new file mode 100644 index 00000000..29465c91 --- /dev/null +++ b/examples/userdata_points/src/main.rs @@ -0,0 +1,4 @@ +use examples_utils::make_main; +use userdata_points::UserdataPointsExample; + +make_main!(UserdataPointsExample);