Add plot legends (#349)
* add plot legends * don't show crosshairs when hovering over legend * add a toggle for the legend * changes based on review * improve legend behavior when curves share names
This commit is contained in:
parent
d862ff66ac
commit
838f3e4ff2
4 changed files with 202 additions and 29 deletions
81
egui/src/widgets/plot/legend.rs
Normal file
81
egui/src/widgets/plot/legend.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use std::string::String;
|
||||
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct LegendEntry {
|
||||
pub text: String,
|
||||
pub color: Color32,
|
||||
pub checked: bool,
|
||||
pub hovered: bool,
|
||||
}
|
||||
|
||||
impl LegendEntry {
|
||||
pub fn new(text: String, color: Color32, checked: bool) -> Self {
|
||||
Self {
|
||||
text,
|
||||
color,
|
||||
checked,
|
||||
hovered: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &mut LegendEntry {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let LegendEntry {
|
||||
checked,
|
||||
text,
|
||||
color,
|
||||
..
|
||||
} = self;
|
||||
let icon_width = ui.spacing().icon_width;
|
||||
let icon_spacing = ui.spacing().icon_spacing;
|
||||
let padding = vec2(2.0, 2.0);
|
||||
let total_extra = padding + vec2(icon_width + icon_spacing, 0.0) + padding;
|
||||
|
||||
let text_style = TextStyle::Button;
|
||||
let galley = ui.fonts().layout_no_wrap(text_style, text.clone());
|
||||
|
||||
let mut desired_size = total_extra + galley.size;
|
||||
desired_size = desired_size.at_least(ui.spacing().interact_size);
|
||||
desired_size.y = desired_size.y.at_least(icon_width);
|
||||
|
||||
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
|
||||
let rect = rect.shrink2(padding);
|
||||
|
||||
response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, &galley.text));
|
||||
|
||||
let visuals = ui.style().interact(&response);
|
||||
|
||||
let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect);
|
||||
|
||||
let painter = ui.painter();
|
||||
|
||||
painter.add(Shape::Circle {
|
||||
center: big_icon_rect.center(),
|
||||
radius: big_icon_rect.width() / 2.0 + visuals.expansion,
|
||||
fill: visuals.bg_fill,
|
||||
stroke: visuals.bg_stroke,
|
||||
});
|
||||
|
||||
if *checked {
|
||||
painter.add(Shape::Circle {
|
||||
center: small_icon_rect.center(),
|
||||
radius: small_icon_rect.width() * 0.8,
|
||||
fill: *color,
|
||||
stroke: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let text_position = pos2(
|
||||
rect.left() + padding.x + icon_width + icon_spacing,
|
||||
rect.center().y - 0.5 * galley.size.y,
|
||||
);
|
||||
painter.galley(text_position, galley, visuals.text_color());
|
||||
|
||||
self.checked ^= response.clicked_by(PointerButton::Primary);
|
||||
self.hovered = response.hovered();
|
||||
|
||||
response
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
//! Simple plotting library.
|
||||
|
||||
mod items;
|
||||
mod legend;
|
||||
mod transform;
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
pub use items::{Curve, Value};
|
||||
use items::{HLine, VLine};
|
||||
use transform::{Bounds, ScreenTransform};
|
||||
|
@ -10,6 +13,8 @@ use transform::{Bounds, ScreenTransform};
|
|||
use crate::*;
|
||||
use color::Hsva;
|
||||
|
||||
use self::legend::LegendEntry;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Information about the plot that has to persist between frames.
|
||||
|
@ -18,6 +23,7 @@ use color::Hsva;
|
|||
struct PlotMemory {
|
||||
bounds: Bounds,
|
||||
auto_bounds: bool,
|
||||
hidden_curves: HashSet<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
@ -61,6 +67,7 @@ pub struct Plot {
|
|||
|
||||
show_x: bool,
|
||||
show_y: bool,
|
||||
show_legend: bool,
|
||||
}
|
||||
|
||||
impl Plot {
|
||||
|
@ -89,6 +96,7 @@ impl Plot {
|
|||
|
||||
show_x: true,
|
||||
show_y: true,
|
||||
show_legend: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -229,6 +237,12 @@ impl Plot {
|
|||
self.min_auto_bounds.extend_with_y(y.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether to show a legend including all named curves. Default: `true`.
|
||||
pub fn show_legend(mut self, show: bool) -> Self {
|
||||
self.show_legend = show;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Plot {
|
||||
|
@ -250,8 +264,9 @@ impl Widget for Plot {
|
|||
min_size,
|
||||
data_aspect,
|
||||
view_aspect,
|
||||
show_x,
|
||||
show_y,
|
||||
mut show_x,
|
||||
mut show_y,
|
||||
show_legend,
|
||||
} = self;
|
||||
|
||||
let plot_id = ui.make_persistent_id(name);
|
||||
|
@ -260,46 +275,110 @@ impl Widget for Plot {
|
|||
.id_data
|
||||
.get_mut_or_insert_with(plot_id, || PlotMemory {
|
||||
bounds: min_auto_bounds,
|
||||
auto_bounds: true,
|
||||
auto_bounds: !min_auto_bounds.is_valid(),
|
||||
hidden_curves: HashSet::new(),
|
||||
})
|
||||
.clone();
|
||||
|
||||
let PlotMemory {
|
||||
mut bounds,
|
||||
mut auto_bounds,
|
||||
mut hidden_curves,
|
||||
} = memory;
|
||||
|
||||
// Determine the size of the plot in the UI
|
||||
let size = {
|
||||
let width = width.unwrap_or_else(|| {
|
||||
if let (Some(height), Some(aspect)) = (height, view_aspect) {
|
||||
height * aspect
|
||||
} else {
|
||||
ui.available_size_before_wrap_finite().x
|
||||
}
|
||||
});
|
||||
let width = width.at_least(min_size.x);
|
||||
let width = width
|
||||
.unwrap_or_else(|| {
|
||||
if let (Some(height), Some(aspect)) = (height, view_aspect) {
|
||||
height * aspect
|
||||
} else {
|
||||
ui.available_size_before_wrap_finite().x
|
||||
}
|
||||
})
|
||||
.at_least(min_size.x);
|
||||
|
||||
let height = height.unwrap_or_else(|| {
|
||||
if let Some(aspect) = view_aspect {
|
||||
width / aspect
|
||||
} else {
|
||||
ui.available_size_before_wrap_finite().y
|
||||
}
|
||||
});
|
||||
let height = height.at_least(min_size.y);
|
||||
let height = height
|
||||
.unwrap_or_else(|| {
|
||||
if let Some(aspect) = view_aspect {
|
||||
width / aspect
|
||||
} else {
|
||||
ui.available_size_before_wrap_finite().y
|
||||
}
|
||||
})
|
||||
.at_least(min_size.y);
|
||||
vec2(width, height)
|
||||
};
|
||||
|
||||
let (rect, response) = ui.allocate_exact_size(size, Sense::drag());
|
||||
let plot_painter = ui.painter().sub_region(rect);
|
||||
|
||||
// Background
|
||||
ui.painter().add(Shape::Rect {
|
||||
plot_painter.add(Shape::Rect {
|
||||
rect,
|
||||
corner_radius: 2.0,
|
||||
fill: ui.visuals().extreme_bg_color,
|
||||
stroke: ui.visuals().window_stroke(),
|
||||
});
|
||||
|
||||
// --- Legend ---
|
||||
|
||||
if show_legend {
|
||||
// Collect the legend entries. If multiple curves have the same name, they share a
|
||||
// checkbox. If their colors don't match, we pick a neutral color for the checkbox.
|
||||
let mut legend_entries: BTreeMap<String, LegendEntry> = BTreeMap::new();
|
||||
curves
|
||||
.iter()
|
||||
.filter(|curve| !curve.name.is_empty())
|
||||
.for_each(|curve| {
|
||||
let checked = !hidden_curves.contains(&curve.name);
|
||||
let text = curve.name.clone();
|
||||
legend_entries
|
||||
.entry(curve.name.clone())
|
||||
.and_modify(|entry| {
|
||||
if entry.color != curve.stroke.color {
|
||||
entry.color = ui.visuals().noninteractive().fg_stroke.color
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| LegendEntry::new(text, curve.stroke.color, checked));
|
||||
});
|
||||
|
||||
// Show the legend.
|
||||
let mut legend_ui = ui.child_ui(rect, Layout::top_down(Align::LEFT));
|
||||
legend_entries.values_mut().for_each(|entry| {
|
||||
let response = legend_ui.add(entry);
|
||||
if response.hovered() {
|
||||
show_x = false;
|
||||
show_y = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Get the names of the hidden curves.
|
||||
hidden_curves = legend_entries
|
||||
.values()
|
||||
.filter(|entry| !entry.checked)
|
||||
.map(|entry| entry.text.clone())
|
||||
.collect();
|
||||
|
||||
// Highlight the hovered curves.
|
||||
legend_entries
|
||||
.values()
|
||||
.filter(|entry| entry.hovered)
|
||||
.for_each(|entry| {
|
||||
curves
|
||||
.iter_mut()
|
||||
.filter(|curve| curve.name == entry.text)
|
||||
.for_each(|curve| {
|
||||
curve.stroke.width *= 2.0;
|
||||
});
|
||||
});
|
||||
|
||||
// Remove deselected curves.
|
||||
curves.retain(|curve| !hidden_curves.contains(&curve.name));
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
auto_bounds |= response.double_clicked_by(PointerButton::Primary);
|
||||
|
||||
// Set bounds automatically based on content.
|
||||
|
@ -358,13 +437,7 @@ impl Widget for Plot {
|
|||
.iter_mut()
|
||||
.for_each(|curve| curve.generate_points(transform.bounds().range_x()));
|
||||
|
||||
ui.memory().id_data.insert(
|
||||
plot_id,
|
||||
PlotMemory {
|
||||
bounds: *transform.bounds(),
|
||||
auto_bounds,
|
||||
},
|
||||
);
|
||||
let bounds = *transform.bounds();
|
||||
|
||||
let prepared = Prepared {
|
||||
curves,
|
||||
|
@ -376,7 +449,20 @@ impl Widget for Plot {
|
|||
};
|
||||
prepared.ui(ui, &response);
|
||||
|
||||
response.on_hover_cursor(CursorIcon::Crosshair)
|
||||
ui.memory().id_data.insert(
|
||||
plot_id,
|
||||
PlotMemory {
|
||||
bounds,
|
||||
auto_bounds,
|
||||
hidden_curves,
|
||||
},
|
||||
);
|
||||
|
||||
if show_x || show_y {
|
||||
response.on_hover_cursor(CursorIcon::Crosshair)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -118,6 +118,7 @@ impl Bounds {
|
|||
}
|
||||
|
||||
/// Contains the screen rectangle and the plot bounds and provides methods to transform them.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ScreenTransform {
|
||||
/// The screen rectangle.
|
||||
frame: Rect,
|
||||
|
|
|
@ -9,6 +9,7 @@ pub struct PlotDemo {
|
|||
circle_radius: f64,
|
||||
circle_center: Pos2,
|
||||
square: bool,
|
||||
legend: bool,
|
||||
proportional: bool,
|
||||
}
|
||||
|
||||
|
@ -20,6 +21,7 @@ impl Default for PlotDemo {
|
|||
circle_radius: 1.5,
|
||||
circle_center: Pos2::new(0.0, 0.0),
|
||||
square: false,
|
||||
legend: true,
|
||||
proportional: true,
|
||||
}
|
||||
}
|
||||
|
@ -54,6 +56,7 @@ impl PlotDemo {
|
|||
circle_radius,
|
||||
circle_center,
|
||||
square,
|
||||
legend,
|
||||
proportional,
|
||||
} = self;
|
||||
|
||||
|
@ -87,6 +90,7 @@ impl PlotDemo {
|
|||
ui.checkbox(animate, "animate");
|
||||
ui.add_space(8.0);
|
||||
ui.checkbox(square, "square view");
|
||||
ui.checkbox(legend, "legend");
|
||||
ui.checkbox(proportional, "proportional data axes");
|
||||
});
|
||||
});
|
||||
|
@ -145,7 +149,8 @@ impl super::View for PlotDemo {
|
|||
.curve(self.circle())
|
||||
.curve(self.sin())
|
||||
.curve(self.thingy())
|
||||
.min_size(Vec2::new(256.0, 200.0));
|
||||
.show_legend(self.legend)
|
||||
.min_size(Vec2::new(200.0, 200.0));
|
||||
if self.square {
|
||||
plot = plot.view_aspect(1.0);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue