diff --git a/Cargo.lock b/Cargo.lock index b1e11f8e..87962134 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2597,6 +2597,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plot_span" +version = "0.1.0" +dependencies = [ + "eframe", + "egui_plot", + "env_logger", +] + [[package]] name = "png" version = "0.18.0" diff --git a/egui_plot/src/items/mod.rs b/egui_plot/src/items/mod.rs index b4e7c3a0..5cda76fe 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -28,6 +28,7 @@ pub use points::Points; pub use polygon::Polygon; use rect_elem::RectElement; pub use series::Line; +pub use span::Span; pub use text::Text; pub use values::ClosestElem; pub use values::LineStyle; @@ -51,6 +52,7 @@ mod points; mod polygon; mod rect_elem; mod series; +mod span; mod text; mod values; diff --git a/egui_plot/src/items/span.rs b/egui_plot/src/items/span.rs new file mode 100644 index 00000000..40e4b68a --- /dev/null +++ b/egui_plot/src/items/span.rs @@ -0,0 +1,294 @@ +use crate::{Axis, utils::find_name_candidate}; +use std::{f32::consts::PI, ops::RangeInclusive}; + +use egui::{ + Align2, Color32, Pos2, Rect, Shape, Stroke, TextStyle, Ui, Vec2, + epaint::{PathStroke, TextShape}, + pos2, +}; +use emath::TSTransform; + +use super::{ + LineStyle, PlotBounds, PlotGeometry, PlotItem, PlotItemBase, PlotPoint, PlotTransform, rect_elem::highlighted_color, +}; + +/// Padding between the label of the span and both the edge of the view and the span borders. +/// For example, for a horizontal span, this is the padding between the top of the span label +/// and the top edge of the plot view, but also the margin between the left/right edges of the span +/// and the span label. +const LABEL_PADDING: f32 = 4.0; + +/// A span covering a range on either axis. +#[derive(Clone, Debug, PartialEq)] +pub struct Span { + base: PlotItemBase, + axis: Axis, + range: RangeInclusive, + fill: Color32, + border_stroke: Stroke, + border_style: LineStyle, + label_align: Align2, +} + +impl Span { + /// Create a new span covering the provided range on the X axis by default. + pub fn new(name: impl Into, range: impl Into>) -> Self { + Self { + base: PlotItemBase::new(name.into()), + axis: Axis::X, + range: range.into(), + fill: Color32::TRANSPARENT, + border_stroke: Stroke::new(1.0, Color32::TRANSPARENT), + border_style: LineStyle::Solid, + label_align: Align2::CENTER_TOP, + } + } + + /// Select which axis the span applies to. This also sets the label alignment. + /// If you want a different label alignment, you need to set it by calling `label_align` after + /// this call. + #[inline] + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + match axis { + Axis::X => self.label_align = Align2::CENTER_TOP, + Axis::Y => self.label_align = Align2::LEFT_CENTER, + } + self + } + + /// Set the range. + #[inline] + pub fn range(mut self, range: impl Into>) -> Self { + self.range = range.into(); + self + } + + /// Set the background fill color for the span. + #[inline] + pub fn fill(mut self, color: impl Into) -> Self { + self.fill = color.into(); + self + } + + /// Set the stroke used for both span borders. + #[inline] + pub fn border(mut self, stroke: impl Into) -> Self { + self.border_stroke = stroke.into(); + self + } + + /// Convenience for updating the span border width. + #[inline] + pub fn border_width(mut self, width: impl Into) -> Self { + self.border_stroke.width = width.into(); + self + } + + /// Convenience for updating the span border color. + #[inline] + pub fn border_color(mut self, color: impl Into) -> Self { + self.border_stroke.color = color.into(); + self + } + + /// Set the style for the span borders. Defaults to `LineStyle::Solid`. + #[inline] + pub fn border_style(mut self, style: LineStyle) -> Self { + self.border_style = style; + self + } + + /// Set the label alignment within the span. + /// This should be called after any calls to `axis` as that would overwrite the label alignment + #[inline] + pub fn label_align(mut self, align: Align2) -> Self { + self.label_align = align; + self + } + + #[inline] + pub(crate) fn fill_color(&self) -> Color32 { + self.fill + } + + #[inline] + pub(crate) fn border_color_value(&self) -> Color32 { + self.border_stroke.color + } + + fn range_sorted(&self) -> (f64, f64) { + let start = *self.range.start(); + let end = *self.range.end(); + if start <= end { (start, end) } else { (end, start) } + } + + fn hline_points(value: f64, transform: &PlotTransform) -> Vec { + vec![ + transform.position_from_point(&PlotPoint::new(transform.bounds().min[0], value)), + transform.position_from_point(&PlotPoint::new(transform.bounds().max[0], value)), + ] + } + + fn vline_points(value: f64, transform: &PlotTransform) -> Vec { + vec![ + transform.position_from_point(&PlotPoint::new(value, transform.bounds().min[1])), + transform.position_from_point(&PlotPoint::new(value, transform.bounds().max[1])), + ] + } + + fn draw_border(&self, value: f64, stroke: Stroke, transform: &PlotTransform, shapes: &mut Vec) { + if stroke.color == Color32::TRANSPARENT || stroke.width <= 0.0 || !value.is_finite() { + return; + } + + let line = match self.axis { + Axis::X => Self::vline_points(value, transform), + Axis::Y => Self::hline_points(value, transform), + }; + + self.border_style + .style_line(line, PathStroke::new(stroke.width, stroke.color), false, shapes); + } + + fn available_width_for_name(&self, rect: &Rect) -> f32 { + match self.axis { + Axis::X => (rect.width() - 2.0 * LABEL_PADDING).max(0.0), + Axis::Y => (rect.height() - 2.0 * LABEL_PADDING).max(0.0), + } + } + + fn draw_name(&self, ui: &Ui, transform: &PlotTransform, shapes: &mut Vec, span_rect: &Rect) { + let frame = *transform.frame(); + let visible_rect = span_rect.intersect(frame); + + let available_width = self.available_width_for_name(&visible_rect); + if available_width <= 0.0 { + return; + } + + let font_id = TextStyle::Body.resolve(ui.style()); + let text_color = ui.visuals().text_color(); + let painter = ui.painter(); + + let name = find_name_candidate(&self.base.name, available_width, painter, &font_id); + + let galley = painter.layout_no_wrap(name, font_id, text_color); + + if galley.is_empty() { + return; + } + + // Place text center point at origin and rotate for Y-axis. + let mut text_shape = match self.axis { + Axis::X => TextShape::new(pos2(-galley.size().x / 2.0, -galley.size().y / 2.0), galley, text_color), + + // For spans on the Y axis we rotate the text by 90° around its center point + Axis::Y => TextShape::new(pos2(-galley.size().x / 2.0, -galley.size().y / 2.0), galley, text_color) + .with_angle_and_anchor(-PI / 2.0, Align2::CENTER_CENTER), + }; + + // Take into account the rotation of the text when calculating its position + let text_rect = text_shape.visual_bounding_rect(); + let (width, height) = (text_rect.width(), text_rect.height()); + + // Calculate the position of the text based on the label alignment + let text_pos_x = match self.label_align { + Align2::LEFT_CENTER | Align2::LEFT_TOP | Align2::LEFT_BOTTOM => visible_rect.left() + LABEL_PADDING, + Align2::CENTER_CENTER | Align2::CENTER_TOP | Align2::CENTER_BOTTOM => visible_rect.center().x - width / 2.0, + Align2::RIGHT_CENTER | Align2::RIGHT_TOP | Align2::RIGHT_BOTTOM => { + visible_rect.right() - LABEL_PADDING - width + } + }; + + let text_pos_y = match self.label_align { + Align2::LEFT_TOP | Align2::CENTER_TOP | Align2::RIGHT_TOP => visible_rect.top() + LABEL_PADDING, + Align2::LEFT_CENTER | Align2::CENTER_CENTER | Align2::RIGHT_CENTER => { + visible_rect.center().y - height / 2.0 + } + Align2::LEFT_BOTTOM | Align2::CENTER_BOTTOM | Align2::RIGHT_BOTTOM => { + visible_rect.bottom() - LABEL_PADDING - height + } + }; + + // Make sure to add half the width/height since the text position is at the center of the text shape + let text_pos = pos2(text_pos_x + width / 2.0, text_pos_y + height / 2.0); + + text_shape.transform(TSTransform::from_translation(Vec2::new(text_pos.x, text_pos.y))); + + shapes.push(text_shape.into()); + } +} + +impl PlotItem for Span { + fn shapes(&self, ui: &Ui, transform: &PlotTransform, shapes: &mut Vec) { + let (range_min, range_max) = self.range_sorted(); + + let mut stroke = self.border_stroke; + let mut fill = self.fill; + if self.base.highlight { + (stroke, fill) = highlighted_color(stroke, fill); + } + + let range_min = range_min.clamp( + transform.bounds().min[self.axis as usize], + transform.bounds().max[self.axis as usize], + ); + let range_max = range_max.clamp( + transform.bounds().min[self.axis as usize], + transform.bounds().max[self.axis as usize], + ); + + let span_rect = match self.axis { + Axis::X => transform.rect_from_values( + &PlotPoint::new(range_min, transform.bounds().min[1]), + &PlotPoint::new(range_max, transform.bounds().max[1]), + ), + Axis::Y => transform.rect_from_values( + &PlotPoint::new(transform.bounds().min[0], range_min), + &PlotPoint::new(transform.bounds().max[0], range_max), + ), + }; + + if fill != Color32::TRANSPARENT && span_rect.is_positive() { + shapes.push(Shape::rect_filled(span_rect, 0.0, fill)); + } + + let mut border_values = vec![range_min, range_max]; + if (range_max - range_min).abs() <= f64::EPSILON { + border_values.truncate(1); + } + + for value in border_values { + self.draw_border(value, stroke, transform, shapes); + } + + self.draw_name(ui, transform, shapes, &span_rect); + } + + fn initialize(&mut self, _x_range: RangeInclusive) {} + + fn color(&self) -> Color32 { + if self.fill != Color32::TRANSPARENT { + self.fill + } else { + self.border_stroke.color + } + } + + fn geometry(&self) -> PlotGeometry<'_> { + PlotGeometry::None + } + + fn bounds(&self) -> PlotBounds { + PlotBounds::NOTHING + } + + fn base(&self) -> &PlotItemBase { + &self.base + } + + fn base_mut(&mut self) -> &mut PlotItemBase { + &mut self.base + } +} diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 21d45c05..1cf75d9a 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -15,6 +15,7 @@ mod memory; mod plot; mod plot_ui; mod transform; +mod utils; use std::cmp::Ordering; use std::ops::RangeInclusive; @@ -54,6 +55,7 @@ pub use crate::items::PlotPoint; pub use crate::items::PlotPoints; pub use crate::items::Points; pub use crate::items::Polygon; +pub use crate::items::Span; pub use crate::items::Text; pub use crate::items::VLine; pub use crate::legend::ColorConflictHandling; diff --git a/egui_plot/src/plot_ui.rs b/egui_plot/src/plot_ui.rs index b86809d3..2a92c169 100644 --- a/egui_plot/src/plot_ui.rs +++ b/egui_plot/src/plot_ui.rs @@ -14,6 +14,7 @@ use crate::PlotBounds; use crate::PlotItem; use crate::PlotPoint; use crate::PlotTransform; +use crate::Span; /// Provides methods to interact with a plot while building it. It is the single /// argument of the closure provided to [`Plot::show`]. See [`Plot`] for an @@ -249,6 +250,26 @@ impl<'a> PlotUi<'a> { self.items.push(Box::new(vline)); } + /// Add an axis-aligned span. + /// + /// Spans fill the space between two values on one axis. If both the fill and border colors + /// are transparent, a color is auto-assigned. + pub fn span(&mut self, mut span: Span) { + let fill_is_transparent = span.fill_color() == Color32::TRANSPARENT; + let border_is_transparent = span.border_color_value() == Color32::TRANSPARENT; + + // If no color was provided, automatically assign a color to the span + if fill_is_transparent && border_is_transparent { + let auto_color = self.auto_color(); + span = span.fill(auto_color.gamma_multiply(0.15)).border_color(auto_color); + } else if border_is_transparent && !fill_is_transparent { + let fill_color = span.fill_color(); + span = span.border_color(fill_color); + } + + self.items.push(Box::new(span)); + } + /// Add a box plot diagram. pub fn box_plot(&mut self, mut box_plot: crate::BoxPlot) { if box_plot.boxes.is_empty() { diff --git a/egui_plot/src/utils.rs b/egui_plot/src/utils.rs new file mode 100644 index 00000000..4ade70d7 --- /dev/null +++ b/egui_plot/src/utils.rs @@ -0,0 +1,56 @@ +use egui::{Color32, FontId, Painter}; + +// Utility function to find a truncated candidate to fit a text label into a given width. +// If the width is large enough for the text, a string with the full text will be returned. +// If the width is too small to display the full text, it finds the longest text with "..." +// appended at the end that we can display within the given width. +// If the width is too small to display the first character followed by "..." then we return an +// empty string. +pub(crate) fn find_name_candidate(name: &str, width: f32, painter: &Painter, font_id: &FontId) -> String { + let galley = painter.layout_no_wrap(name.to_owned(), font_id.clone(), Color32::BLACK); + + if galley.size().x <= width || name.is_empty() { + return name.to_owned(); + } + + // If we don't have enough space for the name to be displayed in the span, we search + // for the longest candidate that fits, where a candidate is a truncated version of the + // name followed by "...". + let chars: Vec = name.chars().collect(); + + // First test the minimum candidate which is the first letter followed by "..." + let mut min_candidate = chars[0].to_string(); + min_candidate.push_str("..."); + let galley = painter.layout_no_wrap(min_candidate.clone(), font_id.clone(), Color32::BLACK); + if galley.size().x > width { + return String::new(); + } + + // Then do a binary search to find the longest possible candidate + let mut low = 1; + let mut high = chars.len(); + let mut best = String::new(); + + while low <= high { + let mid = usize::midpoint(low, high); + let mut candidate: String = chars[..mid].iter().collect(); + candidate.push_str("..."); + + let candidate_width = painter + .layout_no_wrap(candidate.clone(), font_id.clone(), Color32::BLACK) + .size() + .x; + + if candidate_width <= width { + best = candidate; + low = mid + 1; + } else { + high = mid.saturating_sub(1); + if high == 0 { + break; + } + } + } + + best +} diff --git a/examples/plot_span/Cargo.toml b/examples/plot_span/Cargo.toml new file mode 100644 index 00000000..f40ee502 --- /dev/null +++ b/examples/plot_span/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "plot_span" +version = "0.1.0" +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", +] } diff --git a/examples/plot_span/README.md b/examples/plot_span/README.md new file mode 100644 index 00000000..e958c9a9 --- /dev/null +++ b/examples/plot_span/README.md @@ -0,0 +1,5 @@ +This example shows how to add spans to the plot. + +```sh +cargo run -p plot_span +``` diff --git a/examples/plot_span/src/main.rs b/examples/plot_span/src/main.rs new file mode 100644 index 00000000..159dacb9 --- /dev/null +++ b/examples/plot_span/src/main.rs @@ -0,0 +1,72 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +#![expect(rustdoc::missing_crate_level_docs)] // it's an example + +use eframe::{ + egui::{self, Align2, Color32}, + epaint::Hsva, +}; +use egui_plot::{Legend, Line, Plot, PlotPoints, Span}; + +fn main() -> eframe::Result { + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]), + ..Default::default() + }; + eframe::run_native( + "My egui App with a plot", + options, + Box::new(|_cc| Ok(Box::new(MyApp {}))), + ) +} + +#[derive(Default)] +struct MyApp {} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + Plot::new("My Plot").legend(Legend::default()).show(ui, |plot_ui| { + let span = Span::new("Span 1", -10.0..=-5.0) + .border_style(egui_plot::LineStyle::Dashed { length: 50.0 }) + .border_width(3.0); + plot_ui.span(span); + + let span = Span::new("Span 2", 0.0..=1.0); + plot_ui.span(span); + + let span = Span::new("Span 3", 5.0..=6.0).axis(egui_plot::Axis::Y); + plot_ui.span(span); + + let color4: Color32 = Hsva::new(0.1, 0.85, 0.5, 0.15).into(); + let span4 = Span::new("Span 4", 5.0..=5.5) + .border_width(0.0) + .fill(color4) + .label_align(Align2::LEFT_BOTTOM); + plot_ui.span(span4.clone()); + + let color5: Color32 = Hsva::new(0.3, 0.85, 0.5, 0.15).into(); + let span5 = Span::new("Span 5", 5.5..=6.5) + .border_width(0.0) + .fill(color5) + .label_align(Align2::LEFT_BOTTOM); + plot_ui.span(span5.clone()); + + let span = span4.clone().range(6.5..=8.0); + plot_ui.span(span); + + let span = span5.clone().range(8.0..=10.0); + plot_ui.span(span); + + let span = Span::new("Infinite span", 10.0..=f64::INFINITY); + plot_ui.span(span); + + let sine_points = PlotPoints::from_explicit_callback(|x| x.sin(), .., 5000); + let sine_line = Line::new("Sine", sine_points).name("Sine"); + + plot_ui.line(sine_line); + }); + }); + } +}