Plot: Legend improvements (#410)

* initial work on markers

* clippy fix

* simplify marker

* use option for color

* prepare for more demo plots

* more improvements for markers

* some small adjustments

* better highlighting

* don't draw transparent lines

* use transparent color instead of option

* don't brighten curves when highlighting

* Initial changes to lengend:
* Font options
* Position options
* Internal cleanup

* draw legend on top of curves

* update changelog

* fix legend checkboxes

* simplify legend

* remove unnecessary derives

* remove config from legend entries

* avoid allocations and use line_segment

* compare against transparent color

* create new Points primitive

* fix doctest

* some cleanup and fix hover

* common interface for lines and points

* clippy fixes

* reduce visibilities

* update legend

* clippy fix

* change instances of "curve" to "item"

* change visibility

* Update egui/src/widgets/plot/mod.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* Update egui/src/widgets/plot/mod.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* Update egui_demo_lib/src/apps/demo/plot_demo.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* Update egui_demo_lib/src/apps/demo/plot_demo.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* changes based on review

* add legend to demo

* fix test

* move highlighted items to front

* dynamic plot size

* add legend again

* remove height

* clippy fix

* update changelog

* minor changes

* Update egui/src/widgets/plot/legend.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* Update egui/src/widgets/plot/legend.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* Update egui/src/widgets/plot/legend.rs

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>

* changes based on review

* add functions to mutate legend config

* use horizontal_align

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
Sven Niederberger 2021-06-07 22:36:13 +02:00 committed by GitHub
parent ece25ee7f3
commit 02db9ee583
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 325 additions and 111 deletions

View File

@ -8,6 +8,7 @@ NOTE: [`eframe`](eframe/CHANGELOG.md), [`egui_web`](egui_web/CHANGELOG.md) and [
## Unreleased
### Added ⭐
* [Plot legend improvements](https://github.com/emilk/egui/pull/410).
* [Line markers for plots](https://github.com/emilk/egui/pull/363).
* Add right and bottom panels (`SidePanel::right` and `Panel::bottom`).
* Add resizable panels.

View File

@ -69,6 +69,7 @@ pub(super) trait PlotItem {
fn name(&self) -> &str;
fn color(&self) -> Color32;
fn highlight(&mut self);
fn highlighted(&self) -> bool;
}
// ----------------------------------------------------------------------------
@ -273,7 +274,8 @@ impl Line {
/// Name of this line.
///
/// This name will show up in the plot legend, if legends are turned on.
/// This name will show up in the plot legend, if legends are turned on. Multiple lines may
/// share the same name, in which case they will also share an entry in the legend.
#[allow(clippy::needless_pass_by_value)]
pub fn name(mut self, name: impl ToString) -> Self {
self.name = name.to_string();
@ -327,6 +329,10 @@ impl PlotItem for Line {
fn highlight(&mut self) {
self.highlight = true;
}
fn highlighted(&self) -> bool {
self.highlight
}
}
/// A set of points.
@ -386,9 +392,10 @@ impl Points {
self
}
/// Name of this series of markers.
/// Name of this set of points.
///
/// This name will show up in the plot legend, if legends are turned on.
/// This name will show up in the plot legend, if legends are turned on. Multiple sets of points
/// may share the same name, in which case they will also share an entry in the legend.
#[allow(clippy::needless_pass_by_value)]
pub fn name(mut self, name: impl ToString) -> Self {
self.name = name.to_string();
@ -556,4 +563,8 @@ impl PlotItem for Points {
fn highlight(&mut self) {
self.highlight = true;
}
fn highlighted(&self) -> bool {
self.highlight
}
}

View File

@ -1,81 +1,237 @@
use std::string::String;
use std::{
collections::{BTreeMap, HashSet},
string::String,
};
use crate::*;
pub(crate) struct LegendEntry {
pub text: String,
pub color: Color32,
pub checked: bool,
pub hovered: bool,
use super::items::PlotItem;
/// Where to place the plot legend.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Corner {
LeftTop,
RightTop,
LeftBottom,
RightBottom,
}
impl Corner {
pub fn all() -> impl Iterator<Item = Corner> {
[
Corner::LeftTop,
Corner::RightTop,
Corner::LeftBottom,
Corner::RightBottom,
]
.iter()
.copied()
}
}
/// The configuration for a plot legend.
#[derive(Clone, Copy, PartialEq)]
pub struct Legend {
pub text_style: TextStyle,
pub position: Corner,
}
impl Default for Legend {
fn default() -> Self {
Self {
text_style: TextStyle::Body,
position: Corner::RightTop,
}
}
}
impl Legend {
pub fn text_style(mut self, style: TextStyle) -> Self {
self.text_style = style;
self
}
pub fn position(mut self, corner: Corner) -> Self {
self.position = corner;
self
}
}
#[derive(Clone)]
struct LegendEntry {
color: Color32,
checked: bool,
hovered: bool,
}
impl LegendEntry {
pub fn new(text: String, color: Color32, checked: bool) -> Self {
fn new(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,
fn ui(&mut self, ui: &mut Ui, text: String) -> Response {
let Self {
color,
..
checked,
hovered,
} = 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 galley = ui.fonts().layout_no_wrap(ui.style().body_text_style, text);
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 icon_size = galley.size.y;
let icon_spacing = icon_size / 5.0;
let total_extra = vec2(icon_size + icon_spacing, 0.0);
let desired_size = total_extra + galley.size;
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 label_on_the_left = ui.layout().horizontal_align() == Align::RIGHT;
let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect);
let icon_position_x = if label_on_the_left {
rect.right() - icon_size / 2.0
} else {
rect.left() + icon_size / 2.0
};
let icon_position = pos2(icon_position_x, rect.center().y);
let icon_rect = Rect::from_center_size(icon_position, vec2(icon_size, icon_size));
let painter = ui.painter();
painter.add(Shape::Circle {
center: big_icon_rect.center(),
radius: big_icon_rect.width() / 2.0 + visuals.expansion,
center: icon_rect.center(),
radius: icon_size * 0.5,
fill: visuals.bg_fill,
stroke: visuals.bg_stroke,
});
if *checked {
let fill = if *color == Color32::TRANSPARENT {
ui.visuals().noninteractive().fg_stroke.color
} else {
*color
};
painter.add(Shape::Circle {
center: small_icon_rect.center(),
radius: small_icon_rect.width() * 0.8,
fill: *color,
center: icon_rect.center(),
radius: icon_size * 0.4,
fill,
stroke: Default::default(),
});
}
let text_position = pos2(
rect.left() + padding.x + icon_width + icon_spacing,
rect.center().y - 0.5 * galley.size.y,
);
let text_position_x = if label_on_the_left {
rect.right() - icon_size - icon_spacing - galley.size.x
} else {
rect.left() + icon_size + icon_spacing
};
let text_position = pos2(text_position_x, 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();
*checked ^= response.clicked_by(PointerButton::Primary);
*hovered = response.hovered();
response
}
}
#[derive(Clone)]
pub(super) struct LegendWidget {
rect: Rect,
entries: BTreeMap<String, LegendEntry>,
config: Legend,
}
impl LegendWidget {
/// Create a new legend from items, the names of items that are hidden and the style of the
/// text. Returns `None` if the legend has no entries.
pub(super) fn try_new(
rect: Rect,
config: Legend,
items: &[Box<dyn PlotItem>],
hidden_items: &HashSet<String>,
) -> Option<Self> {
// Collect the legend entries. If multiple items have the same name, they share a
// checkbox. If their colors don't match, we pick a neutral color for the checkbox.
let mut entries: BTreeMap<String, LegendEntry> = BTreeMap::new();
items
.iter()
.filter(|item| !item.name().is_empty())
.for_each(|item| {
entries
.entry(item.name().to_string())
.and_modify(|entry| {
if entry.color != item.color() {
// Multiple items with different colors
entry.color = Color32::TRANSPARENT;
}
})
.or_insert_with(|| {
let color = item.color();
let checked = !hidden_items.contains(item.name());
LegendEntry::new(color, checked)
});
});
(!entries.is_empty()).then(|| Self {
rect,
entries,
config,
})
}
// Get the names of the hidden items.
pub fn get_hidden_items(&self) -> HashSet<String> {
self.entries
.iter()
.filter(|(_, entry)| !entry.checked)
.map(|(name, _)| name.clone())
.collect()
}
// Get the name of the hovered items.
pub fn get_hovered_entry_name(&self) -> Option<String> {
self.entries
.iter()
.find(|(_, entry)| entry.hovered)
.map(|(name, _)| name.to_string())
}
}
impl Widget for &mut LegendWidget {
fn ui(self, ui: &mut Ui) -> Response {
let LegendWidget {
rect,
entries,
config,
} = self;
let main_dir = match config.position {
Corner::LeftTop | Corner::RightTop => Direction::TopDown,
Corner::LeftBottom | Corner::RightBottom => Direction::BottomUp,
};
let cross_align = match config.position {
Corner::LeftTop | Corner::LeftBottom => Align::LEFT,
Corner::RightTop | Corner::RightBottom => Align::RIGHT,
};
let layout = Layout::from_main_dir_and_cross_align(main_dir, cross_align);
let legend_pad = 2.0;
let legend_rect = rect.shrink(legend_pad);
let mut legend_ui = ui.child_ui(legend_rect, layout);
legend_ui
.scope(|ui| {
ui.style_mut().body_text_style = config.text_style;
entries
.iter_mut()
.map(|(name, entry)| entry.ui(ui, name.clone()))
.reduce(|r1, r2| r1.union(r2))
.unwrap()
})
.inner
}
}

View File

@ -4,12 +4,13 @@ mod items;
mod legend;
mod transform;
use std::collections::{BTreeMap, HashSet};
use std::collections::HashSet;
use items::PlotItem;
pub use items::{HLine, VLine};
pub use items::{Line, MarkerShape, Points, Value, Values};
use legend::LegendEntry;
use legend::LegendWidget;
pub use legend::{Corner, Legend};
use transform::{Bounds, ScreenTransform};
use crate::*;
@ -23,6 +24,7 @@ use color::Hsva;
struct PlotMemory {
bounds: Bounds,
auto_bounds: bool,
hovered_entry: Option<String>,
hidden_items: HashSet<String>,
}
@ -67,7 +69,7 @@ pub struct Plot {
show_x: bool,
show_y: bool,
show_legend: bool,
legend_config: Option<Legend>,
}
impl Plot {
@ -96,7 +98,7 @@ impl Plot {
show_x: true,
show_y: true,
show_legend: true,
legend_config: None,
}
}
@ -262,9 +264,16 @@ impl Plot {
self
}
#[deprecated = "Use `Plot::legend` instead"]
/// Whether to show a legend including all named items. Default: `true`.
pub fn show_legend(mut self, show: bool) -> Self {
self.show_legend = show;
self.legend_config = show.then(Legend::default);
self
}
/// Show a legend including all named items.
pub fn legend(mut self, legend: Legend) -> Self {
self.legend_config = Some(legend);
self
}
}
@ -290,7 +299,7 @@ impl Widget for Plot {
view_aspect,
mut show_x,
mut show_y,
show_legend,
legend_config,
} = self;
let plot_id = ui.make_persistent_id(name);
@ -300,6 +309,7 @@ impl Widget for Plot {
.get_mut_or_insert_with(plot_id, || PlotMemory {
bounds: min_auto_bounds,
auto_bounds: !min_auto_bounds.is_valid(),
hovered_entry: None,
hidden_items: HashSet::new(),
})
.clone();
@ -307,6 +317,7 @@ impl Widget for Plot {
let PlotMemory {
mut bounds,
mut auto_bounds,
mut hovered_entry,
mut hidden_items,
} = memory;
@ -345,65 +356,25 @@ impl Widget for Plot {
stroke: ui.visuals().window_stroke(),
});
// --- Legend ---
if show_legend {
// Collect the legend entries. If multiple items 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();
let neutral_color = ui.visuals().noninteractive().fg_stroke.color;
items
.iter()
.filter(|item| !item.name().is_empty())
.for_each(|item| {
let checked = !hidden_items.contains(item.name());
let text = item.name();
legend_entries
.entry(item.name().to_string())
.and_modify(|entry| {
if entry.color != item.color() {
entry.color = neutral_color
}
})
.or_insert_with(|| {
LegendEntry::new(text.to_string(), item.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 items.
hidden_items = legend_entries
.values()
.filter(|entry| !entry.checked)
.map(|entry| entry.text.clone())
.collect();
// Highlight the hovered items.
legend_entries
.values()
.filter(|entry| entry.hovered)
.for_each(|entry| {
items.iter_mut().for_each(|item| {
if item.name() == entry.text {
item.highlight();
}
});
});
// Remove deselected items.
items.retain(|item| !hidden_items.contains(item.name()));
// Legend
let legend = legend_config
.and_then(|config| LegendWidget::try_new(rect, config, &items, &hidden_items));
// Don't show hover cursor when hovering over legend.
if hovered_entry.is_some() {
show_x = false;
show_y = false;
}
// ---
// Remove the deselected items.
items.retain(|item| !hidden_items.contains(item.name()));
// Highlight the hovered items.
if let Some(hovered_name) = &hovered_entry {
items
.iter_mut()
.filter(|entry| entry.name() == hovered_name)
.for_each(|entry| entry.highlight());
}
// Move highlighted items to front.
items.sort_by_key(|item| item.highlighted());
auto_bounds |= response.double_clicked_by(PointerButton::Primary);
@ -482,11 +453,18 @@ impl Widget for Plot {
};
prepared.ui(ui, &response);
if let Some(mut legend) = legend {
ui.add(&mut legend);
hidden_items = legend.get_hidden_items();
hovered_entry = legend.get_hovered_entry_name();
}
ui.memory().id_data.insert(
plot_id,
PlotMemory {
bounds,
auto_bounds,
hovered_entry,
hidden_items,
},
);

View File

@ -1,5 +1,5 @@
use egui::plot::{Line, MarkerShape, Plot, Points, Value, Values};
use egui::*;
use plot::{Corner, Legend, Line, MarkerShape, Plot, Points, Value, Values};
use std::f64::consts::TAU;
#[derive(PartialEq)]
@ -9,7 +9,6 @@ struct LineDemo {
circle_radius: f64,
circle_center: Pos2,
square: bool,
legend: bool,
proportional: bool,
}
@ -21,7 +20,6 @@ impl Default for LineDemo {
circle_radius: 1.5,
circle_center: Pos2::new(0.0, 0.0),
square: false,
legend: true,
proportional: true,
}
}
@ -35,7 +33,6 @@ impl LineDemo {
circle_radius,
circle_center,
square,
legend,
proportional,
..
} = self;
@ -69,7 +66,6 @@ impl LineDemo {
ui.style_mut().wrap = Some(false);
ui.checkbox(animate, "animate");
ui.checkbox(square, "square view");
ui.checkbox(legend, "legend");
ui.checkbox(proportional, "proportional data axes");
});
});
@ -124,7 +120,7 @@ impl Widget for &mut LineDemo {
.line(self.circle())
.line(self.sin())
.line(self.thingy())
.show_legend(self.legend);
.legend(Legend::default());
if self.square {
plot = plot.view_aspect(1.0);
}
@ -200,7 +196,9 @@ impl Widget for &mut MarkerDemo {
}
});
let mut markers_plot = Plot::new("Markers Demo").data_aspect(1.0);
let mut markers_plot = Plot::new("Markers Demo")
.data_aspect(1.0)
.legend(Legend::default());
for marker in self.markers() {
markers_plot = markers_plot.points(marker);
}
@ -208,10 +206,75 @@ impl Widget for &mut MarkerDemo {
}
}
#[derive(PartialEq)]
struct LegendDemo {
config: Legend,
}
impl Default for LegendDemo {
fn default() -> Self {
Self {
config: Legend::default(),
}
}
}
impl LegendDemo {
fn line_with_slope(slope: f64) -> Line {
Line::new(Values::from_explicit_callback(
move |x| slope * x,
f64::NEG_INFINITY..=f64::INFINITY,
100,
))
}
fn sin() -> Line {
Line::new(Values::from_explicit_callback(
move |x| x.sin(),
f64::NEG_INFINITY..=f64::INFINITY,
100,
))
}
fn cos() -> Line {
Line::new(Values::from_explicit_callback(
move |x| x.cos(),
f64::NEG_INFINITY..=f64::INFINITY,
100,
))
}
}
impl Widget for &mut LegendDemo {
fn ui(self, ui: &mut Ui) -> Response {
let LegendDemo { config } = self;
ui.label("Text Style:");
ui.horizontal(|ui| {
TextStyle::all().for_each(|style| {
ui.selectable_value(&mut config.text_style, style, format!("{:?}", style));
});
});
ui.label("Position:");
ui.horizontal(|ui| {
Corner::all().for_each(|position| {
ui.selectable_value(&mut config.position, position, format!("{:?}", position));
});
});
let legend_plot = Plot::new("Legend Demo")
.line(LegendDemo::line_with_slope(0.5).name("lines"))
.line(LegendDemo::line_with_slope(1.0).name("lines"))
.line(LegendDemo::line_with_slope(2.0).name("lines"))
.line(LegendDemo::sin().name("sin(x)"))
.line(LegendDemo::cos().name("cos(x)"))
.legend(*config)
.data_aspect(1.0);
ui.add(legend_plot)
}
}
#[derive(PartialEq, Eq)]
enum Panel {
Lines,
Markers,
Legend,
}
impl Default for Panel {
@ -224,6 +287,7 @@ impl Default for Panel {
pub struct PlotDemo {
line_demo: LineDemo,
marker_demo: MarkerDemo,
legend_demo: LegendDemo,
open_panel: Panel,
}
@ -261,6 +325,7 @@ impl super::View for PlotDemo {
ui.horizontal(|ui| {
ui.selectable_value(&mut self.open_panel, Panel::Lines, "Lines");
ui.selectable_value(&mut self.open_panel, Panel::Markers, "Markers");
ui.selectable_value(&mut self.open_panel, Panel::Legend, "Legend");
});
ui.separator();
@ -271,6 +336,9 @@ impl super::View for PlotDemo {
Panel::Markers => {
ui.add(&mut self.marker_demo);
}
Panel::Legend => {
ui.add(&mut self.legend_demo);
}
}
}
}