Customize Plot label and cursor texts (#1235)

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
Sven Niederberger 2022-02-15 17:12:29 +01:00 committed by GitHub
parent cfad28936d
commit 8f8eb5d4a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 124 additions and 34 deletions

View file

@ -24,6 +24,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w
* Added linked axis support for plots via `plot::LinkedAxisGroup` ([#1184](https://github.com/emilk/egui/pull/1184)).
* Added `Response::on_hover_text_at_pointer` as a convenience akin to `Response::on_hover_text` ([1179](https://github.com/emilk/egui/pull/1179)).
* Added `ui.weak(text)`.
* Added plot pointer coordinates with `Plot::coordinates_formatter`. ([#1235](https://github.com/emilk/egui/pull/1235)).
* Added `Slider::step_by` ([1255](https://github.com/emilk/egui/pull/1225)).
* Added ability to scroll an UI into view without specifying an alignment ([1247](https://github.com/emilk/egui/pull/1247))
@ -47,6 +48,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w
* Replaced Frame's `margin: Vec2` with `margin: Margin`, allowing for different margins on opposing sides ([#1219](https://github.com/emilk/egui/pull/1219)).
* `Plot::highlight` now takes a `bool` argument ([#1159](https://github.com/emilk/egui/pull/1159)).
* `ScrollArea::show` now returns a `ScrollAreaOutput`, so you might need to add `.inner` after the call to it ([#1166](https://github.com/emilk/egui/pull/1166)).
* Renamed `Plot::custom_label_func` to `Plot::label_formatter` ([#1235](https://github.com/emilk/egui/pull/1235)).
* Tooltips that don't fit the window don't flicker anymore ([#1240](https://github.com/emilk/egui/pull/1240)).
* `Areas::layer_id_at` ignores non interatable layers (i.e. Tooltips) ([#1240](https://github.com/emilk/egui/pull/1240)).

View file

@ -7,7 +7,7 @@ use epaint::Mesh;
use crate::*;
use super::{CustomLabelFuncRef, PlotBounds, ScreenTransform};
use super::{LabelFormatter, PlotBounds, ScreenTransform};
use rect_elem::*;
use values::{ClosestElem, PlotGeometry};
@ -66,7 +66,7 @@ pub(super) trait PlotItem {
elem: ClosestElem,
shapes: &mut Vec<Shape>,
plot: &PlotConfig<'_>,
custom_label_func: &CustomLabelFuncRef,
label_formatter: &LabelFormatter,
) {
let points = match self.geometry() {
PlotGeometry::Points(points) => points,
@ -89,7 +89,7 @@ pub(super) trait PlotItem {
let pointer = plot.transform.position_from_value(&value);
shapes.push(Shape::circle_filled(pointer, 3.0, line_color));
rulers_at_value(pointer, value, self.name(), plot, shapes, custom_label_func);
rulers_at_value(pointer, value, self.name(), plot, shapes, label_formatter);
}
}
@ -1380,7 +1380,7 @@ impl PlotItem for BarChart {
elem: ClosestElem,
shapes: &mut Vec<Shape>,
plot: &PlotConfig<'_>,
_: &CustomLabelFuncRef,
_: &LabelFormatter,
) {
let bar = &self.bars[elem.index];
@ -1522,7 +1522,7 @@ impl PlotItem for BoxPlot {
elem: ClosestElem,
shapes: &mut Vec<Shape>,
plot: &PlotConfig<'_>,
_: &CustomLabelFuncRef,
_: &LabelFormatter,
) {
let box_plot = &self.boxes[elem.index];
@ -1643,7 +1643,7 @@ pub(super) fn rulers_at_value(
name: &str,
plot: &PlotConfig<'_>,
shapes: &mut Vec<Shape>,
custom_label_func: &CustomLabelFuncRef,
label_formatter: &LabelFormatter,
) {
let line_color = rulers_color(plot.ui);
if plot.show_x {
@ -1663,7 +1663,7 @@ pub(super) fn rulers_at_value(
let scale = plot.transform.dvalue_dpos();
let x_decimals = ((-scale[0].abs().log10()).ceil().at_least(0.0) as usize).at_most(6);
let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6);
if let Some(custom_label) = custom_label_func {
if let Some(custom_label) = label_formatter {
custom_label(name, &value)
} else if plot.show_x && plot.show_y {
format!(

View file

@ -1,6 +1,6 @@
//! Simple plotting library.
use std::{cell::RefCell, rc::Rc};
use std::{cell::RefCell, ops::RangeInclusive, rc::Rc};
use crate::*;
use epaint::ahash::AHashSet;
@ -20,12 +20,44 @@ mod items;
mod legend;
mod transform;
type CustomLabelFunc = dyn Fn(&str, &Value) -> String;
type CustomLabelFuncRef = Option<Box<CustomLabelFunc>>;
type AxisFormatterFn = dyn Fn(f64) -> String;
type LabelFormatterFn = dyn Fn(&str, &Value) -> String;
type LabelFormatter = Option<Box<LabelFormatterFn>>;
type AxisFormatterFn = dyn Fn(f64, &RangeInclusive<f64>) -> String;
type AxisFormatter = Option<Box<AxisFormatterFn>>;
/// Specifies the coordinates formatting when passed to [`Plot::coordinates_formatter`].
pub struct CoordinatesFormatter {
function: Box<dyn Fn(&Value, &PlotBounds) -> String>,
}
impl CoordinatesFormatter {
/// Create a new formatter based on the pointer coordinate and the plot bounds.
pub fn new(function: impl Fn(&Value, &PlotBounds) -> String + 'static) -> Self {
Self {
function: Box::new(function),
}
}
/// Show a fixed number of decimal places.
pub fn with_decimals(num_decimals: usize) -> Self {
Self {
function: Box::new(move |value, _| {
format!("x: {:.d$}\ny: {:.d$}", value.x, value.y, d = num_decimals)
}),
}
}
fn format(&self, value: &Value, bounds: &PlotBounds) -> String {
(self.function)(value, bounds)
}
}
impl Default for CoordinatesFormatter {
fn default() -> Self {
Self::with_decimals(3)
}
}
// ----------------------------------------------------------------------------
/// Information about the plot that has to persist between frames.
@ -146,7 +178,8 @@ pub struct Plot {
show_x: bool,
show_y: bool,
custom_label_func: CustomLabelFuncRef,
label_formatter: LabelFormatter,
coordinates_formatter: Option<(Corner, CoordinatesFormatter)>,
axis_formatters: [AxisFormatter; 2],
legend_config: Option<Legend>,
show_background: bool,
@ -177,7 +210,8 @@ impl Plot {
show_x: true,
show_y: true,
custom_label_func: None,
label_formatter: None,
coordinates_formatter: None,
axis_formatters: [None, None], // [None; 2] requires Copy
legend_config: None,
show_background: true,
@ -284,7 +318,7 @@ impl Plot {
/// });
/// let line = Line::new(Values::from_values_iter(sin));
/// Plot::new("my_plot").view_aspect(2.0)
/// .custom_label_func(|name, value| {
/// .label_formatter(|name, value| {
/// if !name.is_empty() {
/// format!("{}: {:.*}%", name, 1, value.y).to_string()
/// } else {
@ -294,34 +328,50 @@ impl Plot {
/// .show(ui, |plot_ui| plot_ui.line(line));
/// # });
/// ```
pub fn custom_label_func(
pub fn label_formatter(
mut self,
custom_label_func: impl Fn(&str, &Value) -> String + 'static,
label_formatter: impl Fn(&str, &Value) -> String + 'static,
) -> Self {
self.custom_label_func = Some(Box::new(custom_label_func));
self.label_formatter = Some(Box::new(label_formatter));
self
}
/// Provide a function to customize the labels for the X axis.
/// Show the pointer coordinates in the plot.
pub fn coordinates_formatter(
mut self,
position: Corner,
formatter: CoordinatesFormatter,
) -> Self {
self.coordinates_formatter = Some((position, formatter));
self
}
/// Provide a function to customize the labels for the X axis based on the current visible value range.
///
/// This is useful for custom input domains, e.g. date/time.
///
/// If axis labels should not appear for certain values or beyond a certain zoom/resolution,
/// the formatter function can return empty strings. This is also useful if your domain is
/// discrete (e.g. only full days in a calendar).
pub fn x_axis_formatter(mut self, func: impl Fn(f64) -> String + 'static) -> Self {
pub fn x_axis_formatter(
mut self,
func: impl Fn(f64, &RangeInclusive<f64>) -> String + 'static,
) -> Self {
self.axis_formatters[0] = Some(Box::new(func));
self
}
/// Provide a function to customize the labels for the Y axis.
/// Provide a function to customize the labels for the Y axis based on the current value range.
///
/// This is useful for custom value representation, e.g. percentage or units.
///
/// If axis labels should not appear for certain values or beyond a certain zoom/resolution,
/// the formatter function can return empty strings. This is also useful if your Y values are
/// discrete (e.g. only integers).
pub fn y_axis_formatter(mut self, func: impl Fn(f64) -> String + 'static) -> Self {
pub fn y_axis_formatter(
mut self,
func: impl Fn(f64, &RangeInclusive<f64>) -> String + 'static,
) -> Self {
self.axis_formatters[1] = Some(Box::new(func));
self
}
@ -388,7 +438,8 @@ impl Plot {
view_aspect,
mut show_x,
mut show_y,
custom_label_func,
label_formatter,
coordinates_formatter,
axis_formatters,
legend_config,
show_background,
@ -630,7 +681,8 @@ impl Plot {
items,
show_x,
show_y,
custom_label_func,
label_formatter,
coordinates_formatter,
axis_formatters,
show_axes,
transform: transform.clone(),
@ -849,7 +901,8 @@ struct PreparedPlot {
items: Vec<Box<dyn PlotItem>>,
show_x: bool,
show_y: bool,
custom_label_func: CustomLabelFuncRef,
label_formatter: LabelFormatter,
coordinates_formatter: Option<(Corner, CoordinatesFormatter)>,
axis_formatters: [AxisFormatter; 2],
show_axes: [bool; 2],
transform: ScreenTransform,
@ -877,7 +930,24 @@ impl PreparedPlot {
self.hover(ui, pointer, &mut shapes);
}
ui.painter().sub_region(*transform.frame()).extend(shapes);
let painter = ui.painter().sub_region(*transform.frame());
painter.extend(shapes);
if let Some((corner, formatter)) = self.coordinates_formatter.as_ref() {
if let Some(pointer) = response.hover_pos() {
let font_id = TextStyle::Monospace.resolve(ui.style());
let coordinate = transform.value_from_position(pointer);
let text = formatter.format(&coordinate, transform.bounds());
let padded_frame = transform.frame().shrink(4.0);
let (anchor, position) = match corner {
Corner::LeftTop => (Align2::LEFT_TOP, padded_frame.left_top()),
Corner::RightTop => (Align2::RIGHT_TOP, padded_frame.right_top()),
Corner::LeftBottom => (Align2::LEFT_BOTTOM, padded_frame.left_bottom()),
Corner::RightBottom => (Align2::RIGHT_BOTTOM, padded_frame.right_bottom()),
};
painter.text(position, anchor, text, font_id, ui.visuals().text_color());
}
}
}
fn paint_axis(&self, ui: &Ui, axis: usize, shapes: &mut Vec<Shape>) {
@ -888,6 +958,11 @@ impl PreparedPlot {
} = self;
let bounds = transform.bounds();
let axis_range = match axis {
0 => bounds.range_x(),
1 => bounds.range_y(),
_ => panic!("Axis {} does not exist.", axis),
};
let font_id = TextStyle::Body.resolve(ui.style());
@ -947,7 +1022,7 @@ impl PreparedPlot {
let color = color_from_alpha(ui, text_alpha);
let text: String = if let Some(formatter) = axis_formatters[axis].as_deref() {
formatter(value_main)
formatter(value_main, &axis_range)
} else {
emath::round_to_decimals(value_main, 5).to_string() // hack
};
@ -982,7 +1057,7 @@ impl PreparedPlot {
transform,
show_x,
show_y,
custom_label_func,
label_formatter,
items,
..
} = self;
@ -1012,10 +1087,10 @@ impl PreparedPlot {
};
if let Some((item, elem)) = closest {
item.on_hover(elem, shapes, &plot, custom_label_func);
item.on_hover(elem, shapes, &plot, label_formatter);
} else {
let value = transform.value_from_position(pointer);
items::rulers_at_value(pointer, value, "", &plot, shapes, custom_label_func);
items::rulers_at_value(pointer, value, "", &plot, shapes, label_formatter);
}
}
}

View file

@ -120,6 +120,10 @@ impl PlotBounds {
self.min[0]..=self.max[0]
}
pub(crate) fn range_y(&self) -> RangeInclusive<f64> {
self.min[1]..=self.max[1]
}
pub(crate) fn make_x_symmetrical(&mut self) {
let x_abs = self.min[0].abs().max(self.max[0].abs());
self.min[0] = -x_abs;

View file

@ -2,8 +2,9 @@ use std::f64::consts::TAU;
use egui::*;
use plot::{
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, Corner, HLine, Legend, Line, LineStyle,
MarkerShape, Plot, PlotImage, Points, Polygon, Text, VLine, Value, Values,
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine,
Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, Points, Polygon, Text, VLine, Value,
Values,
};
#[derive(PartialEq)]
@ -14,6 +15,7 @@ struct LineDemo {
circle_center: Pos2,
square: bool,
proportional: bool,
coordinates: bool,
line_style: LineStyle,
}
@ -26,6 +28,7 @@ impl Default for LineDemo {
circle_center: Pos2::new(0.0, 0.0),
square: false,
proportional: true,
coordinates: true,
line_style: LineStyle::Solid,
}
}
@ -41,6 +44,7 @@ impl LineDemo {
square,
proportional,
line_style,
coordinates,
..
} = self;
@ -76,6 +80,8 @@ impl LineDemo {
.on_hover_text("Always keep the viewport square.");
ui.checkbox(proportional, "Proportional data axes")
.on_hover_text("Tick are the same size on both axes.");
ui.checkbox(coordinates, "Show coordinates")
.on_hover_text("Can take a custom formatting function.");
ComboBox::from_label("Line style")
.selected_text(line_style.to_string())
@ -151,6 +157,9 @@ impl Widget for &mut LineDemo {
if self.proportional {
plot = plot.data_aspect(1.0);
}
if self.coordinates {
plot = plot.coordinates_formatter(Corner::LeftBottom, CoordinatesFormatter::default());
}
plot.show(ui, |plot_ui| {
plot_ui.line(self.circle());
plot_ui.line(self.sin());
@ -595,7 +604,7 @@ impl ChartsDemo {
.name("Set 4")
.stack_on(&[&chart1, &chart2, &chart3]);
let mut x_fmt: fn(f64) -> String = |val| {
let mut x_fmt: fn(f64, &std::ops::RangeInclusive<f64>) -> String = |val, _range| {
if val >= 0.0 && val <= 4.0 && is_approx_integer(val) {
// Only label full days from 0 to 4
format!("Day {}", val)
@ -605,7 +614,7 @@ impl ChartsDemo {
}
};
let mut y_fmt: fn(f64) -> String = |val| {
let mut y_fmt: fn(f64, &std::ops::RangeInclusive<f64>) -> String = |val, _range| {
let percent = 100.0 * val;
if is_approx_integer(percent) && !is_approx_zero(percent) {