Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add plot legends #349

Merged
merged 5 commits into from
May 7, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions egui/src/widgets/plot/legend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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,
}
}
}

struct LegendEntryWidget<'a> {
entry: &'a mut LegendEntry,
}

impl<'a> LegendEntryWidget<'a> {
fn new(entry: &'a mut LegendEntry) -> Self {
Self { entry }
}
}

impl<'a> Widget for LegendEntryWidget<'a> {
EmbersArc marked this conversation as resolved.
Show resolved Hide resolved
fn ui(self, ui: &mut Ui) -> Response {
let LegendEntry {
checked,
text,
color,
..
} = self.entry;
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);

ui.with_layout(Layout::top_down(Align::LEFT), |ui| {
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
let rect = rect.shrink2(padding);

response.widget_info(|| {
WidgetInfo::selected(WidgetType::RadioButton, *checked, &galley.text)
emilk marked this conversation as resolved.
Show resolved Hide resolved
});

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());
response
})
.inner
}
}

pub(crate) struct PlotLegend<'e> {
pub entries: &'e mut [LegendEntry],
max_size: Rect,
}

impl<'e> PlotLegend<'e> {
pub fn new(entries: &'e mut [LegendEntry], max_size: Rect) -> Self {
Self { entries, max_size }
}
}

impl Widget for PlotLegend<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let PlotLegend { entries, max_size } = self;

let mut rect = max_size;

entries
.iter_mut()
.map(|entry| {
let reference = LegendEntryWidget::new(entry);
let response = ui.put(rect, reference);
entry.checked ^= response.clicked_by(PointerButton::Primary);
entry.hovered = response.hovered();
rect.min.y += response.rect.height();
response
})
.reduce(|last, current| last.union(current))
.unwrap()
}
}
123 changes: 99 additions & 24 deletions egui/src/widgets/plot/mod.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
//! Simple plotting library.

mod items;
mod legend;
mod transform;

use std::collections::HashSet;

pub use items::{Curve, Value};
use items::{HLine, VLine};
use transform::{Bounds, ScreenTransform};

use crate::*;
use color::Hsva;

use self::legend::{LegendEntry, PlotLegend};

// ----------------------------------------------------------------------------

/// Information about the plot that has to persist between frames.
Expand All @@ -18,6 +23,7 @@ use color::Hsva;
struct PlotMemory {
bounds: Bounds,
auto_bounds: bool,
hidden_curves: HashSet<String>,
}

// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -61,6 +67,7 @@ pub struct Plot {

show_x: bool,
show_y: bool,
show_legend: bool,
}

impl Plot {
Expand Down Expand Up @@ -89,6 +96,7 @@ impl Plot {

show_x: true,
show_y: true,
show_legend: true,
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -260,50 +275,104 @@ 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 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 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
}
})
.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.
let mut legend_entries: Vec<_> = curves
.iter()
.filter(|curve| !curve.name.is_empty())
.map(|curve| {
let checked = !hidden_curves.contains(&curve.name);
let text = curve.name.clone();
let color = curve.stroke.color;
LegendEntry::new(text, color, checked)
})
.collect();

// Show the legend.
if !legend_entries.is_empty() {
let legend = PlotLegend::new(&mut legend_entries, rect);
let response = legend.ui(ui);
emilk marked this conversation as resolved.
Show resolved Hide resolved
if response.hovered() {
show_x = false;
show_y = false;
}
}

// Get the names of the hidden curves.
hidden_curves = legend_entries
.iter()
.filter(|entry| !entry.checked)
.map(|entry| entry.text.clone())
.collect();

// Highlight the hovered curves.
legend_entries
.iter()
.filter(|entry| entry.hovered)
.for_each(|entry| {
if let Some(curve) = curves.iter_mut().find(|curve| curve.name == entry.text) {
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.
if auto_bounds || !bounds.is_valid() {
if auto_bounds {
emilk marked this conversation as resolved.
Show resolved Hide resolved
bounds = min_auto_bounds;
hlines.iter().for_each(|line| bounds.extend_with_y(line.y));
vlines.iter().for_each(|line| bounds.extend_with_x(line.x));
Expand Down Expand Up @@ -345,8 +414,9 @@ impl Widget for Plot {
transform.zoom(zoom_factor, hover_pos);
auto_bounds = false;
}
let scroll_delta = ui.input().scroll_delta;
let mut scroll_delta = ui.input().scroll_delta;
if scroll_delta != Vec2::ZERO {
scroll_delta.y *= -1.0;
emilk marked this conversation as resolved.
Show resolved Hide resolved
transform.translate_bounds(-scroll_delta);
auto_bounds = false;
}
Expand All @@ -363,6 +433,7 @@ impl Widget for Plot {
PlotMemory {
bounds: *transform.bounds(),
auto_bounds,
hidden_curves,
},
);

Expand All @@ -376,7 +447,11 @@ impl Widget for Plot {
};
prepared.ui(ui, &response);

response.on_hover_cursor(CursorIcon::Crosshair)
if show_x || show_y {
response.on_hover_cursor(CursorIcon::Crosshair)
} else {
response
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion egui_demo_lib/src/apps/demo/plot_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub struct PlotDemo {
circle_radius: f64,
circle_center: Pos2,
square: bool,
legend: bool,
proportional: bool,
}

Expand All @@ -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,
}
}
Expand Down Expand Up @@ -54,6 +56,7 @@ impl PlotDemo {
circle_radius,
circle_center,
square,
legend,
proportional,
} = self;

Expand Down Expand Up @@ -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");
});
});
Expand Down Expand Up @@ -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);
}
Expand Down