Skip to content

Commit cfb81d3

Browse files
michalsustrazerupi
andauthored
feat: Add spans for plots (#196)
This PR adds supports for spans in plots on either axis. This allows for example to highlight a region of interest or draw states in the background of plots. Added items to the public API ============================= ``` +pub struct egui_plot::Span +impl egui_plot::Span +pub fn egui_plot::PlotUi<'a>::span(&mut self, span: egui_plot::Span) ``` --------- Co-authored-by: Mathieu David <mathieudavid@mathieudavid.org>
1 parent f9a4787 commit cfb81d3

File tree

16 files changed

+575
-0
lines changed

16 files changed

+575
-0
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,7 @@ dependencies = [
910910
"linked_axes",
911911
"log",
912912
"markers",
913+
"plot_span",
913914
"save_plot",
914915
"stacked_bar",
915916
"wasm-bindgen-futures",
@@ -2736,6 +2737,16 @@ version = "0.3.32"
27362737
source = "registry+https://github.com/rust-lang/crates.io-index"
27372738
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
27382739

2740+
[[package]]
2741+
name = "plot_span"
2742+
version = "0.1.0"
2743+
dependencies = [
2744+
"eframe",
2745+
"egui_plot",
2746+
"env_logger",
2747+
"examples_utils",
2748+
]
2749+
27392750
[[package]]
27402751
name = "png"
27412752
version = "0.18.0"

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ legend_sort = { version = "0.1.0", path = "examples/legend_sort" }
3535
lines = { version = "0.1.0", path = "examples/lines" }
3636
linked_axes = { version = "0.1.0", path = "examples/linked_axes" }
3737
markers = { version = "0.1.0", path = "examples/markers" }
38+
plot_span = { version = "0.1.0", path = "examples/plot_span" }
3839
save_plot = { version = "0.1.0", path = "examples/save_plot" }
3940
stacked_bar = { version = "0.1.0", path = "examples/stacked_bar" }
4041

demo/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ legend_sort.workspace = true
4141
lines.workspace = true
4242
linked_axes.workspace = true
4343
markers.workspace = true
44+
plot_span.workspace = true
4445
save_plot.workspace = true
4546
stacked_bar.workspace = true
4647

demo/src/app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ impl DemoGallery {
4747
Box::new(lines::LineExample::default()),
4848
Box::new(linked_axes::LinkedAxesExample::default()),
4949
Box::new(markers::MarkerDemo::default()),
50+
Box::new(plot_span::PlotSpanDemo::default()),
5051
Box::new(save_plot::SavePlotExample::default()),
5152
Box::new(stacked_bar::StackedBarExample::default()),
5253
];

egui_plot/src/items/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub use points::Points;
3030
pub use polygon::Polygon;
3131
use rect_elem::RectElement;
3232
pub use series::Line;
33+
pub use span::Span;
3334
pub use text::Text;
3435
pub use values::ClosestElem;
3536
pub use values::LineStyle;
@@ -54,6 +55,7 @@ mod points;
5455
mod polygon;
5556
mod rect_elem;
5657
mod series;
58+
mod span;
5759
mod text;
5860
mod values;
5961

egui_plot/src/items/span.rs

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use std::f32::consts::PI;
2+
use std::ops::RangeInclusive;
3+
4+
use egui::Align2;
5+
use egui::Color32;
6+
use egui::Pos2;
7+
use egui::Rect;
8+
use egui::Shape;
9+
use egui::Stroke;
10+
use egui::TextStyle;
11+
use egui::Ui;
12+
use egui::Vec2;
13+
use egui::epaint::PathStroke;
14+
use egui::epaint::TextShape;
15+
use egui::pos2;
16+
use emath::TSTransform;
17+
18+
use super::LineStyle;
19+
use super::PlotBounds;
20+
use super::PlotGeometry;
21+
use super::PlotItem;
22+
use super::PlotItemBase;
23+
use super::PlotPoint;
24+
use super::PlotTransform;
25+
use super::rect_elem::highlighted_color;
26+
use crate::Axis;
27+
use crate::utils::find_name_candidate;
28+
29+
/// Padding between the label of the span and both the edge of the view and the
30+
/// span borders. For example, for a horizontal span, this is the padding
31+
/// between the top of the span label and the top edge of the plot view, but
32+
/// also the margin between the left/right edges of the span and the span label.
33+
const LABEL_PADDING: f32 = 4.0;
34+
35+
/// A span covering a range on either axis.
36+
#[derive(Clone, Debug, PartialEq)]
37+
pub struct Span {
38+
base: PlotItemBase,
39+
axis: Axis,
40+
range: RangeInclusive<f64>,
41+
fill: Color32,
42+
border_stroke: Stroke,
43+
border_style: LineStyle,
44+
label_align: Align2,
45+
}
46+
47+
impl Span {
48+
/// Create a new span covering the provided range on the X axis by default.
49+
pub fn new(name: impl Into<String>, range: impl Into<RangeInclusive<f64>>) -> Self {
50+
Self {
51+
base: PlotItemBase::new(name.into()),
52+
axis: Axis::X,
53+
range: range.into(),
54+
fill: Color32::TRANSPARENT,
55+
border_stroke: Stroke::new(1.0, Color32::TRANSPARENT),
56+
border_style: LineStyle::Solid,
57+
label_align: Align2::CENTER_TOP,
58+
}
59+
}
60+
61+
/// Select which axis the span applies to. This also sets the label
62+
/// alignment. If you want a different label alignment, you need to set
63+
/// it by calling `label_align` after this call.
64+
#[inline]
65+
pub fn axis(mut self, axis: Axis) -> Self {
66+
self.axis = axis;
67+
match axis {
68+
Axis::X => self.label_align = Align2::CENTER_TOP,
69+
Axis::Y => self.label_align = Align2::LEFT_CENTER,
70+
}
71+
self
72+
}
73+
74+
/// Set the range.
75+
#[inline]
76+
pub fn range(mut self, range: impl Into<RangeInclusive<f64>>) -> Self {
77+
self.range = range.into();
78+
self
79+
}
80+
81+
/// Set the background fill color for the span.
82+
#[inline]
83+
pub fn fill(mut self, color: impl Into<Color32>) -> Self {
84+
self.fill = color.into();
85+
self
86+
}
87+
88+
/// Set the stroke used for both span borders.
89+
#[inline]
90+
pub fn border(mut self, stroke: impl Into<Stroke>) -> Self {
91+
self.border_stroke = stroke.into();
92+
self
93+
}
94+
95+
/// Convenience for updating the span border width.
96+
#[inline]
97+
pub fn border_width(mut self, width: impl Into<f32>) -> Self {
98+
self.border_stroke.width = width.into();
99+
self
100+
}
101+
102+
/// Convenience for updating the span border color.
103+
#[inline]
104+
pub fn border_color(mut self, color: impl Into<Color32>) -> Self {
105+
self.border_stroke.color = color.into();
106+
self
107+
}
108+
109+
/// Set the style for the span borders. Defaults to `LineStyle::Solid`.
110+
#[inline]
111+
pub fn border_style(mut self, style: LineStyle) -> Self {
112+
self.border_style = style;
113+
self
114+
}
115+
116+
/// Set the label alignment within the span.
117+
/// This should be called after any calls to `axis` as that would overwrite
118+
/// the label alignment
119+
#[inline]
120+
pub fn label_align(mut self, align: Align2) -> Self {
121+
self.label_align = align;
122+
self
123+
}
124+
125+
#[inline]
126+
pub(crate) fn fill_color(&self) -> Color32 {
127+
self.fill
128+
}
129+
130+
#[inline]
131+
pub(crate) fn border_color_value(&self) -> Color32 {
132+
self.border_stroke.color
133+
}
134+
135+
fn range_sorted(&self) -> (f64, f64) {
136+
let start = *self.range.start();
137+
let end = *self.range.end();
138+
if start <= end { (start, end) } else { (end, start) }
139+
}
140+
141+
fn hline_points(value: f64, transform: &PlotTransform) -> Vec<Pos2> {
142+
vec![
143+
transform.position_from_point(&PlotPoint::new(transform.bounds().min[0], value)),
144+
transform.position_from_point(&PlotPoint::new(transform.bounds().max[0], value)),
145+
]
146+
}
147+
148+
fn vline_points(value: f64, transform: &PlotTransform) -> Vec<Pos2> {
149+
vec![
150+
transform.position_from_point(&PlotPoint::new(value, transform.bounds().min[1])),
151+
transform.position_from_point(&PlotPoint::new(value, transform.bounds().max[1])),
152+
]
153+
}
154+
155+
fn draw_border(&self, value: f64, stroke: Stroke, transform: &PlotTransform, shapes: &mut Vec<Shape>) {
156+
if stroke.color == Color32::TRANSPARENT || stroke.width <= 0.0 || !value.is_finite() {
157+
return;
158+
}
159+
160+
let line = match self.axis {
161+
Axis::X => Self::vline_points(value, transform),
162+
Axis::Y => Self::hline_points(value, transform),
163+
};
164+
165+
self.border_style
166+
.style_line(line, PathStroke::new(stroke.width, stroke.color), false, shapes);
167+
}
168+
169+
fn available_width_for_name(&self, rect: &Rect) -> f32 {
170+
match self.axis {
171+
Axis::X => (rect.width() - 2.0 * LABEL_PADDING).max(0.0),
172+
Axis::Y => (rect.height() - 2.0 * LABEL_PADDING).max(0.0),
173+
}
174+
}
175+
176+
fn draw_name(&self, ui: &Ui, transform: &PlotTransform, shapes: &mut Vec<Shape>, span_rect: &Rect) {
177+
let frame = *transform.frame();
178+
let visible_rect = span_rect.intersect(frame);
179+
180+
let available_width = self.available_width_for_name(&visible_rect);
181+
if available_width <= 0.0 {
182+
return;
183+
}
184+
185+
let font_id = TextStyle::Body.resolve(ui.style());
186+
let text_color = ui.visuals().text_color();
187+
let painter = ui.painter();
188+
189+
let name = find_name_candidate(&self.base.name, available_width, painter, &font_id);
190+
191+
let galley = painter.layout_no_wrap(name, font_id, text_color);
192+
193+
if galley.is_empty() {
194+
return;
195+
}
196+
197+
// Place text center point at origin and rotate for Y-axis.
198+
let mut text_shape = match self.axis {
199+
Axis::X => TextShape::new(pos2(-galley.size().x / 2.0, -galley.size().y / 2.0), galley, text_color),
200+
201+
// For spans on the Y axis we rotate the text by 90° around its center point
202+
Axis::Y => TextShape::new(pos2(-galley.size().x / 2.0, -galley.size().y / 2.0), galley, text_color)
203+
.with_angle_and_anchor(-PI / 2.0, Align2::CENTER_CENTER),
204+
};
205+
206+
// Take into account the rotation of the text when calculating its position
207+
let text_rect = text_shape.visual_bounding_rect();
208+
let (width, height) = (text_rect.width(), text_rect.height());
209+
210+
// Calculate the position of the text based on the label alignment
211+
let text_pos_x = match self.label_align {
212+
Align2::LEFT_CENTER | Align2::LEFT_TOP | Align2::LEFT_BOTTOM => visible_rect.left() + LABEL_PADDING,
213+
Align2::CENTER_CENTER | Align2::CENTER_TOP | Align2::CENTER_BOTTOM => visible_rect.center().x - width / 2.0,
214+
Align2::RIGHT_CENTER | Align2::RIGHT_TOP | Align2::RIGHT_BOTTOM => {
215+
visible_rect.right() - LABEL_PADDING - width
216+
}
217+
};
218+
219+
let text_pos_y = match self.label_align {
220+
Align2::LEFT_TOP | Align2::CENTER_TOP | Align2::RIGHT_TOP => visible_rect.top() + LABEL_PADDING,
221+
Align2::LEFT_CENTER | Align2::CENTER_CENTER | Align2::RIGHT_CENTER => {
222+
visible_rect.center().y - height / 2.0
223+
}
224+
Align2::LEFT_BOTTOM | Align2::CENTER_BOTTOM | Align2::RIGHT_BOTTOM => {
225+
visible_rect.bottom() - LABEL_PADDING - height
226+
}
227+
};
228+
229+
// Make sure to add half the width/height since the text position is at the
230+
// center of the text shape
231+
let text_pos = pos2(text_pos_x + width / 2.0, text_pos_y + height / 2.0);
232+
233+
text_shape.transform(TSTransform::from_translation(Vec2::new(text_pos.x, text_pos.y)));
234+
235+
shapes.push(text_shape.into());
236+
}
237+
}
238+
239+
impl PlotItem for Span {
240+
fn shapes(&self, ui: &Ui, transform: &PlotTransform, shapes: &mut Vec<Shape>) {
241+
let (range_min, range_max) = self.range_sorted();
242+
243+
let mut stroke = self.border_stroke;
244+
let mut fill = self.fill;
245+
if self.base.highlight {
246+
(stroke, fill) = highlighted_color(stroke, fill);
247+
}
248+
249+
let range_min = range_min.clamp(
250+
transform.bounds().min[self.axis as usize],
251+
transform.bounds().max[self.axis as usize],
252+
);
253+
let range_max = range_max.clamp(
254+
transform.bounds().min[self.axis as usize],
255+
transform.bounds().max[self.axis as usize],
256+
);
257+
258+
let span_rect = match self.axis {
259+
Axis::X => transform.rect_from_values(
260+
&PlotPoint::new(range_min, transform.bounds().min[1]),
261+
&PlotPoint::new(range_max, transform.bounds().max[1]),
262+
),
263+
Axis::Y => transform.rect_from_values(
264+
&PlotPoint::new(transform.bounds().min[0], range_min),
265+
&PlotPoint::new(transform.bounds().max[0], range_max),
266+
),
267+
};
268+
269+
if fill != Color32::TRANSPARENT && span_rect.is_positive() {
270+
shapes.push(Shape::rect_filled(span_rect, 0.0, fill));
271+
}
272+
273+
let mut border_values = vec![range_min, range_max];
274+
if (range_max - range_min).abs() <= f64::EPSILON {
275+
border_values.truncate(1);
276+
}
277+
278+
for value in border_values {
279+
self.draw_border(value, stroke, transform, shapes);
280+
}
281+
282+
self.draw_name(ui, transform, shapes, &span_rect);
283+
}
284+
285+
fn initialize(&mut self, _x_range: RangeInclusive<f64>) {}
286+
287+
fn color(&self) -> Color32 {
288+
if self.fill != Color32::TRANSPARENT {
289+
self.fill
290+
} else {
291+
self.border_stroke.color
292+
}
293+
}
294+
295+
fn geometry(&self) -> PlotGeometry<'_> {
296+
PlotGeometry::None
297+
}
298+
299+
fn bounds(&self) -> PlotBounds {
300+
PlotBounds::NOTHING
301+
}
302+
303+
fn base(&self) -> &PlotItemBase {
304+
&self.base
305+
}
306+
307+
fn base_mut(&mut self) -> &mut PlotItemBase {
308+
&mut self.base
309+
}
310+
}

egui_plot/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod memory;
1515
mod plot;
1616
mod plot_ui;
1717
mod transform;
18+
mod utils;
1819

1920
use std::cmp::Ordering;
2021
use std::ops::RangeInclusive;
@@ -56,6 +57,7 @@ pub use crate::items::PlotPoint;
5657
pub use crate::items::PlotPoints;
5758
pub use crate::items::Points;
5859
pub use crate::items::Polygon;
60+
pub use crate::items::Span;
5961
pub use crate::items::Text;
6062
pub use crate::items::VLine;
6163
pub use crate::legend::ColorConflictHandling;

0 commit comments

Comments
 (0)