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:
Sven Niederberger 2021-05-07 10:32:17 +02:00 committed by GitHub
parent d862ff66ac
commit 838f3e4ff2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 202 additions and 29 deletions

View 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
}
}

View file

@ -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
}
}
}

View file

@ -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,

View file

@ -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);
}