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 89806f95..1e39051a 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -18,6 +18,7 @@ use super::{Cursor, LabelFormatter, PlotBounds, PlotTransform}; pub use bar::Bar; pub use box_elem::{BoxElem, BoxSpread}; +pub use span::Span; pub use values::{ ClosestElem, LineStyle, MarkerShape, Orientation, PlotGeometry, PlotPoint, PlotPoints, }; @@ -25,6 +26,7 @@ pub use values::{ mod bar; mod box_elem; mod rect_elem; +mod span; mod values; const DEFAULT_FILL_ALPHA: f32 = 0.05; diff --git a/egui_plot/src/items/span.rs b/egui_plot/src/items/span.rs new file mode 100644 index 00000000..0ebcab99 --- /dev/null +++ b/egui_plot/src/items/span.rs @@ -0,0 +1,336 @@ +use crate::Axis; +use std::{f32::consts::PI, ops::RangeInclusive}; + +use egui::{ + Color32, FontId, Painter, Pos2, Rect, Shape, Stroke, TextStyle, Ui, + epaint::{PathStroke, TextShape}, + pos2, +}; + +use super::{ + LineStyle, PlotBounds, PlotGeometry, PlotItem, PlotItemBase, PlotPoint, PlotTransform, + rect_elem::highlighted_color, +}; + +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, +} + +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, + } + } + + /// Select which axis the span applies to. + #[inline] + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + 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 + } + + #[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 { + 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), + } + } + + // If the span is too small to display the full name, find the longest name + // with "..." appended that we can display within the span + fn find_name_candidate(&self, width: f32, painter: &Painter, font_id: &FontId) -> String { + let name = self.base.name.as_str(); + 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 + } + + 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 = self.find_name_candidate(available_width, painter, &font_id); + + let galley = painter.layout_no_wrap(name, font_id, text_color); + + if galley.is_empty() { + return; + } + + let text_pos = match self.axis { + Axis::X => pos2( + visible_rect.center().x - galley.size().x / 2.0, + visible_rect.top() + LABEL_PADDING, + ), + Axis::Y => pos2( + visible_rect.left() + LABEL_PADDING, + visible_rect.center().y + galley.size().x / 2.0, + ), + }; + + let text_shape = match self.axis { + Axis::X => TextShape::new(text_pos, galley, text_color), + + // For spans on the Y axis we rotate the text by 90° around its center point + Axis::Y => TextShape::new(text_pos, galley, text_color).with_angle(-PI / 2.0), + }; + + 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(); + + if !range_min.is_finite() || !range_max.is_finite() { + return; + } + + let mut stroke = self.border_stroke; + let mut fill = self.fill; + if self.base.highlight { + (stroke, fill) = highlighted_color(stroke, fill); + } + + 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 { + let mut bounds = PlotBounds::NOTHING; + let (min, max) = self.range_sorted(); + + match self.axis { + Axis::X => { + bounds.extend_with_x(min); + bounds.extend_with_x(max); + } + Axis::Y => { + bounds.extend_with_y(min); + bounds.extend_with_y(max); + } + } + + bounds + } + + 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 506453f3..98cf0d15 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -30,7 +30,7 @@ pub use crate::{ items::{ Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, ClosestElem, HLine, Line, LineStyle, MarkerShape, Orientation, PlotConfig, PlotGeometry, PlotImage, PlotItem, PlotItemBase, - PlotPoint, PlotPoints, Points, Polygon, Text, VLine, + PlotPoint, PlotPoints, Points, Polygon, Span, Text, VLine, }, legend::{ColorConflictHandling, Corner, Legend}, memory::PlotMemory, diff --git a/egui_plot/src/plot_ui.rs b/egui_plot/src/plot_ui.rs index 61eb159c..f0ca697c 100644 --- a/egui_plot/src/plot_ui.rs +++ b/egui_plot/src/plot_ui.rs @@ -2,7 +2,7 @@ use std::ops::RangeInclusive; use egui::{Color32, Pos2, Response, Vec2, Vec2b, epaint::Hsva}; -use crate::{BoundsModification, PlotBounds, PlotItem, PlotPoint, PlotTransform}; +use crate::{BoundsModification, PlotBounds, PlotItem, PlotPoint, PlotTransform, Span}; #[expect(unused_imports)] // for links in docstrings use crate::Plot; @@ -231,6 +231,28 @@ 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/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..639109e0 --- /dev/null +++ b/examples/plot_span/src/main.rs @@ -0,0 +1,69 @@ +#![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, 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); + 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); + 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 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); + }); + }); + } +}