Skip to content

Commit

Permalink
Plot: Legend improvements (#410)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
EmbersArc and emilk authored Jun 7, 2021
1 parent ece25ee commit 02db9ee
Show file tree
Hide file tree
Showing 5 changed files with 324 additions and 110 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions egui/src/widgets/plot/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub(super) trait PlotItem {
fn name(&self) -> &str;
fn color(&self) -> Color32;
fn highlight(&mut self);
fn highlighted(&self) -> bool;
}

// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -556,4 +563,8 @@ impl PlotItem for Points {
fn highlight(&mut self) {
self.highlight = true;
}

fn highlighted(&self) -> bool {
self.highlight
}
}
230 changes: 193 additions & 37 deletions egui/src/widgets/plot/legend.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit 02db9ee

Please sign in to comment.