Add bar charts and box plots (#863)

Changes:
* New `BarChart` and `BoxPlot` diagrams
* New `FloatOrd` trait for total ordering of float types
* Refactoring of existing plot items

Co-authored-by: niladic <git@nil.choron.cc>
This commit is contained in:
Jan Haller 2021-11-29 18:39:58 +01:00 committed by GitHub
parent 224d4d6d26
commit 1088d950e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1813 additions and 420 deletions

View file

@ -8,6 +8,7 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w
## Unreleased
### Added ⭐
* Add bar charts and box plots ([#863](https://github.com/emilk/egui/pull/863)).
* Add context menus: See `Ui::menu_button` and `Response::context_menu` ([#543](https://github.com/emilk/egui/pull/543)).
* You can now read and write the cursor of a `TextEdit` ([#848](https://github.com/emilk/egui/pull/848)).
* Most widgets containing text (`Label`, `Button` etc) now supports rich text ([#855](https://github.com/emilk/egui/pull/855)).

View file

@ -0,0 +1,64 @@
//! Total order on floating point types, assuming absence of NaN.
//! Can be used for sorting, min/max computation, and other collection algorithms.
use std::cmp::Ordering;
/// Totally orderable floating-point value
/// For not `f32` is supported; could be made generic if necessary.
pub(crate) struct OrderedFloat(f32);
impl Eq for OrderedFloat {}
impl PartialEq<Self> for OrderedFloat {
#[inline]
fn eq(&self, other: &Self) -> bool {
// NaNs are considered equal (equivalent when it comes to ordering
if self.0.is_nan() {
other.0.is_nan()
} else {
self.0 == other.0
}
}
}
impl PartialOrd<Self> for OrderedFloat {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match self.0.partial_cmp(&other.0) {
Some(ord) => Some(ord),
None => Some(self.0.is_nan().cmp(&other.0.is_nan())),
}
}
}
impl Ord for OrderedFloat {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
match self.partial_cmp(other) {
Some(ord) => ord,
None => unreachable!(),
}
}
}
/// Extension trait to provide `ord` method
pub(crate) trait FloatOrd {
/// Type to provide total order, useful as key in sorted contexts.
fn ord(self) -> OrderedFloat;
}
impl FloatOrd for f32 {
#[inline]
fn ord(self) -> OrderedFloat {
OrderedFloat(self)
}
}
// TODO ordering may break down at least significant digits due to f64 -> f32 conversion
// Possible solutions: generic OrderedFloat<T>, always OrderedFloat(f64)
impl FloatOrd for f64 {
#[inline]
fn ord(self) -> OrderedFloat {
OrderedFloat(self as f32)
}
}

View file

@ -2,6 +2,7 @@
pub mod cache;
pub(crate) mod fixed_cache;
pub(crate) mod float_ord;
mod history;
pub mod id_type_map;
pub mod undoer;

View file

@ -0,0 +1,190 @@
use crate::emath::NumExt;
use crate::epaint::{Color32, RectShape, Shape, Stroke};
use super::{add_rulers_and_text, highlighted_color, Orientation, PlotConfig, RectElement};
use crate::plot::{BarChart, ScreenTransform, Value};
/// One bar in a [`BarChart`]. Potentially floating, allowing stacked bar charts.
/// Width can be changed to allow variable-width histograms.
#[derive(Clone, Debug, PartialEq)]
pub struct Bar {
/// Name of plot element in the diagram (annotated by default formatter)
pub name: String,
/// Which direction the bar faces in the diagram
pub orientation: Orientation,
/// Position on the argument (input) axis -- X if vertical, Y if horizontal
pub argument: f64,
/// Position on the value (output) axis -- Y if vertical, X if horizontal
pub value: f64,
/// For stacked bars, this denotes where the bar starts. None if base axis
pub base_offset: Option<f64>,
/// Thickness of the bar
pub bar_width: f64,
/// Line width and color
pub stroke: Stroke,
/// Fill color
pub fill: Color32,
}
impl Bar {
/// Create a bar. Its `orientation` is set by its [`BarChart`] parent.
///
/// - `argument`: Position on the argument axis (X if vertical, Y if horizontal).
/// - `value`: Height of the bar (if vertical).
///
/// By default the bar is vertical and its base is at zero.
pub fn new(argument: f64, height: f64) -> Bar {
Bar {
argument,
value: height,
orientation: Orientation::default(),
name: Default::default(),
base_offset: None,
bar_width: 0.5,
stroke: Stroke::new(1.0, Color32::TRANSPARENT),
fill: Color32::TRANSPARENT,
}
}
/// Name of this bar chart element.
#[allow(clippy::needless_pass_by_value)]
pub fn name(mut self, name: impl ToString) -> Self {
self.name = name.to_string();
self
}
/// Add a custom stroke.
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = stroke.into();
self
}
/// Add a custom fill color.
pub fn fill(mut self, color: impl Into<Color32>) -> Self {
self.fill = color.into();
self
}
/// Offset the base of the bar.
/// This offset is on the Y axis for a vertical bar
/// and on the X axis for a horizontal bar.
pub fn base_offset(mut self, offset: f64) -> Self {
self.base_offset = Some(offset);
self
}
/// Set the bar width.
pub fn width(mut self, width: f64) -> Self {
self.bar_width = width;
self
}
/// Set orientation of the element as vertical. Argument axis is X.
pub fn vertical(mut self) -> Self {
self.orientation = Orientation::Vertical;
self
}
/// Set orientation of the element as horizontal. Argument axis is Y.
pub fn horizontal(mut self) -> Self {
self.orientation = Orientation::Horizontal;
self
}
pub(super) fn lower(&self) -> f64 {
if self.value.is_sign_positive() {
self.base_offset.unwrap_or(0.0)
} else {
self.base_offset.map_or(self.value, |o| o + self.value)
}
}
pub(super) fn upper(&self) -> f64 {
if self.value.is_sign_positive() {
self.base_offset.map_or(self.value, |o| o + self.value)
} else {
self.base_offset.unwrap_or(0.0)
}
}
pub(super) fn add_shapes(
&self,
transform: &ScreenTransform,
highlighted: bool,
shapes: &mut Vec<Shape>,
) {
let (stroke, fill) = if highlighted {
highlighted_color(self.stroke, self.fill)
} else {
(self.stroke, self.fill)
};
let rect = transform.rect_from_values(&self.bounds_min(), &self.bounds_max());
let rect = Shape::Rect(RectShape {
rect,
corner_radius: 0.0,
fill,
stroke,
});
shapes.push(rect);
}
pub(super) fn add_rulers_and_text(
&self,
parent: &BarChart,
plot: &PlotConfig<'_>,
shapes: &mut Vec<Shape>,
) {
let text: Option<String> = parent
.element_formatter
.as_ref()
.map(|fmt| fmt(self, parent));
add_rulers_and_text(self, plot, text, shapes);
}
}
impl RectElement for Bar {
fn name(&self) -> &str {
self.name.as_str()
}
fn bounds_min(&self) -> Value {
self.point_at(self.argument - self.bar_width / 2.0, self.lower())
}
fn bounds_max(&self) -> Value {
self.point_at(self.argument + self.bar_width / 2.0, self.upper())
}
fn values_with_ruler(&self) -> Vec<Value> {
let base = self.base_offset.unwrap_or(0.0);
let value_center = self.point_at(self.argument, base + self.value);
let mut ruler_positions = vec![value_center];
if let Some(offset) = self.base_offset {
ruler_positions.push(self.point_at(self.argument, offset));
}
ruler_positions
}
fn orientation(&self) -> Orientation {
self.orientation
}
fn default_values_format(&self, transform: &ScreenTransform) -> String {
let scale = transform.dvalue_dpos();
let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6);
format!("\n{:.*}", y_decimals, self.value)
}
}

View file

@ -0,0 +1,286 @@
use crate::emath::NumExt;
use crate::epaint::{Color32, RectShape, Shape, Stroke};
use super::{add_rulers_and_text, highlighted_color, Orientation, PlotConfig, RectElement};
use crate::plot::{BoxPlot, ScreenTransform, Value};
/// Contains the values of a single box in a box plot.
#[derive(Clone, Debug, PartialEq)]
pub struct BoxSpread {
/// Value of lower whisker (typically minimum).
///
/// The whisker is not drawn if `lower_whisker >= quartile1`.
pub lower_whisker: f64,
/// Value of lower box threshold (typically 25% quartile)
pub quartile1: f64,
/// Value of middle line in box (typically median)
pub median: f64,
/// Value of upper box threshold (typically 75% quartile)
pub quartile3: f64,
/// Value of upper whisker (typically maximum)
///
/// The whisker is not drawn if `upper_whisker <= quartile3`.
pub upper_whisker: f64,
}
impl BoxSpread {
pub fn new(
lower_whisker: f64,
quartile1: f64,
median: f64,
quartile3: f64,
upper_whisker: f64,
) -> Self {
Self {
lower_whisker,
quartile1,
median,
quartile3,
upper_whisker,
}
}
}
/// A box in a [`BoxPlot`] diagram. This is a low level graphical element; it will not compute quartiles and whiskers,
/// letting one use their preferred formula. Use [`Points`][`super::Points`] to draw the outliers.
#[derive(Clone, Debug, PartialEq)]
pub struct BoxElem {
/// Name of plot element in the diagram (annotated by default formatter).
pub name: String,
/// Which direction the box faces in the diagram.
pub orientation: Orientation,
/// Position on the argument (input) axis -- X if vertical, Y if horizontal.
pub argument: f64,
/// Values of the box
pub spread: BoxSpread,
/// Thickness of the box
pub box_width: f64,
/// Width of the whisker at minimum/maximum
pub whisker_width: f64,
/// Line width and color
pub stroke: Stroke,
/// Fill color
pub fill: Color32,
}
impl BoxElem {
/// Create a box element. Its `orientation` is set by its [`BoxPlot`] parent.
///
/// Check [`BoxElem`] fields for detailed description.
pub fn new(argument: f64, spread: BoxSpread) -> Self {
Self {
argument,
orientation: Orientation::default(),
name: String::default(),
spread,
box_width: 0.25,
whisker_width: 0.15,
stroke: Stroke::new(1.0, Color32::TRANSPARENT),
fill: Color32::TRANSPARENT,
}
}
/// Name of this box element.
#[allow(clippy::needless_pass_by_value)]
pub fn name(mut self, name: impl ToString) -> Self {
self.name = name.to_string();
self
}
/// Add a custom stroke.
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = stroke.into();
self
}
/// Add a custom fill color.
pub fn fill(mut self, color: impl Into<Color32>) -> Self {
self.fill = color.into();
self
}
/// Set the box width.
pub fn box_width(mut self, width: f64) -> Self {
self.box_width = width;
self
}
/// Set the whisker width.
pub fn whisker_width(mut self, width: f64) -> Self {
self.whisker_width = width;
self
}
/// Set orientation of the element as vertical. Argument axis is X.
pub fn vertical(mut self) -> Self {
self.orientation = Orientation::Vertical;
self
}
/// Set orientation of the element as horizontal. Argument axis is Y.
pub fn horizontal(mut self) -> Self {
self.orientation = Orientation::Horizontal;
self
}
pub(super) fn add_shapes(
&self,
transform: &ScreenTransform,
highlighted: bool,
shapes: &mut Vec<Shape>,
) {
let (stroke, fill) = if highlighted {
highlighted_color(self.stroke, self.fill)
} else {
(self.stroke, self.fill)
};
let rect = transform.rect_from_values(
&self.point_at(self.argument - self.box_width / 2.0, self.spread.quartile1),
&self.point_at(self.argument + self.box_width / 2.0, self.spread.quartile3),
);
let rect = Shape::Rect(RectShape {
rect,
corner_radius: 0.0,
fill,
stroke,
});
shapes.push(rect);
let line_between = |v1, v2| {
Shape::line_segment(
[
transform.position_from_value(&v1),
transform.position_from_value(&v2),
],
stroke,
)
};
let median = line_between(
self.point_at(self.argument - self.box_width / 2.0, self.spread.median),
self.point_at(self.argument + self.box_width / 2.0, self.spread.median),
);
shapes.push(median);
if self.spread.upper_whisker > self.spread.quartile3 {
let high_whisker = line_between(
self.point_at(self.argument, self.spread.quartile3),
self.point_at(self.argument, self.spread.upper_whisker),
);
shapes.push(high_whisker);
if self.box_width > 0.0 {
let high_whisker_end = line_between(
self.point_at(
self.argument - self.whisker_width / 2.0,
self.spread.upper_whisker,
),
self.point_at(
self.argument + self.whisker_width / 2.0,
self.spread.upper_whisker,
),
);
shapes.push(high_whisker_end);
}
}
if self.spread.lower_whisker < self.spread.quartile1 {
let low_whisker = line_between(
self.point_at(self.argument, self.spread.quartile1),
self.point_at(self.argument, self.spread.lower_whisker),
);
shapes.push(low_whisker);
if self.box_width > 0.0 {
let low_whisker_end = line_between(
self.point_at(
self.argument - self.whisker_width / 2.0,
self.spread.lower_whisker,
),
self.point_at(
self.argument + self.whisker_width / 2.0,
self.spread.lower_whisker,
),
);
shapes.push(low_whisker_end);
}
}
}
pub(super) fn add_rulers_and_text(
&self,
parent: &BoxPlot,
plot: &PlotConfig<'_>,
shapes: &mut Vec<Shape>,
) {
let text: Option<String> = parent
.element_formatter
.as_ref()
.map(|fmt| fmt(self, parent));
add_rulers_and_text(self, plot, text, shapes);
}
}
impl RectElement for BoxElem {
fn name(&self) -> &str {
self.name.as_str()
}
fn bounds_min(&self) -> Value {
let argument = self.argument - self.box_width.max(self.whisker_width) / 2.0;
let value = self.spread.lower_whisker;
self.point_at(argument, value)
}
fn bounds_max(&self) -> Value {
let argument = self.argument + self.box_width.max(self.whisker_width) / 2.0;
let value = self.spread.upper_whisker;
self.point_at(argument, value)
}
fn values_with_ruler(&self) -> Vec<Value> {
let median = self.point_at(self.argument, self.spread.median);
let q1 = self.point_at(self.argument, self.spread.quartile1);
let q3 = self.point_at(self.argument, self.spread.quartile3);
let upper = self.point_at(self.argument, self.spread.upper_whisker);
let lower = self.point_at(self.argument, self.spread.lower_whisker);
vec![median, q1, q3, upper, lower]
}
fn orientation(&self) -> Orientation {
self.orientation
}
fn corner_value(&self) -> Value {
self.point_at(self.argument, self.spread.upper_whisker)
}
fn default_values_format(&self, transform: &ScreenTransform) -> String {
let scale = transform.dvalue_dpos();
let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).at_most(6);
format!(
"\nMax = {max:.decimals$}\
\nQuartile 3 = {q3:.decimals$}\
\nMedian = {med:.decimals$}\
\nQuartile 1 = {q1:.decimals$}\
\nMin = {min:.decimals$}",
max = self.spread.upper_whisker,
q3 = self.spread.quartile3,
med = self.spread.median,
q1 = self.spread.quartile1,
min = self.spread.lower_whisker,
decimals = y_decimals
)
}
}

View file

@ -1,131 +1,89 @@
//! Contains items that can be added to a plot.
use std::ops::{Bound, RangeBounds, RangeInclusive};
use std::ops::RangeInclusive;
use epaint::Mesh;
use super::transform::{PlotBounds, ScreenTransform};
use crate::util::float_ord::FloatOrd;
use crate::*;
use super::{PlotBounds, ScreenTransform};
use rect_elem::*;
use values::*;
pub use bar::Bar;
pub use box_elem::{BoxElem, BoxSpread};
pub use values::{LineStyle, MarkerShape, Value, Values};
mod bar;
mod box_elem;
mod rect_elem;
mod values;
const DEFAULT_FILL_ALPHA: f32 = 0.05;
/// A value in the value-space of the plot.
///
/// Uses f64 for improved accuracy to enable plotting
/// large values (e.g. unix time on x axis).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Value {
/// This is often something monotonically increasing, such as time, but doesn't have to be.
/// Goes from left to right.
pub x: f64,
/// Goes from bottom to top (inverse of everything else in egui!).
pub y: f64,
/// Container to pass-through several parameters related to plot visualization
pub(super) struct PlotConfig<'a> {
pub ui: &'a Ui,
pub transform: &'a ScreenTransform,
pub show_x: bool,
pub show_y: bool,
}
impl Value {
#[inline(always)]
pub fn new(x: impl Into<f64>, y: impl Into<f64>) -> Self {
Self {
x: x.into(),
y: y.into(),
/// Trait shared by things that can be drawn in the plot.
pub(super) trait PlotItem {
fn get_shapes(&self, ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec<Shape>);
fn initialize(&mut self, x_range: RangeInclusive<f64>);
fn name(&self) -> &str;
fn color(&self) -> Color32;
fn highlight(&mut self);
fn highlighted(&self) -> bool;
fn geometry(&self) -> PlotGeometry<'_>;
fn get_bounds(&self) -> PlotBounds;
fn find_closest(&self, point: Pos2, transform: &ScreenTransform) -> Option<ClosestElem> {
match self.geometry() {
PlotGeometry::None => None,
PlotGeometry::Points(points) => points
.iter()
.enumerate()
.map(|(index, value)| {
let pos = transform.position_from_value(value);
let dist_sq = point.distance_sq(pos);
ClosestElem { index, dist_sq }
})
.min_by_key(|e| e.dist_sq.ord()),
PlotGeometry::Rects => {
panic!("If the PlotItem is made of rects, it should implement find_closest()")
}
}
}
#[inline(always)]
pub fn to_pos2(self) -> Pos2 {
Pos2::new(self.x as f32, self.y as f32)
fn on_hover(&self, elem: ClosestElem, shapes: &mut Vec<Shape>, plot: &PlotConfig<'_>) {
let points = match self.geometry() {
PlotGeometry::Points(points) => points,
PlotGeometry::None => {
panic!("If the PlotItem has no geometry, on_hover() must not be called")
}
PlotGeometry::Rects => {
panic!("If the PlotItem is made of rects, it should implement on_hover()")
}
};
#[inline(always)]
pub fn to_vec2(self) -> Vec2 {
Vec2::new(self.x as f32, self.y as f32)
}
}
let line_color = if plot.ui.visuals().dark_mode {
Color32::from_gray(100).additive()
} else {
Color32::from_black_alpha(180)
};
// ----------------------------------------------------------------------------
// this method is only called, if the value is in the result set of find_closest()
let value = points[elem.index];
let pointer = plot.transform.position_from_value(&value);
shapes.push(Shape::circle_filled(pointer, 3.0, line_color));
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum LineStyle {
Solid,
Dotted { spacing: f32 },
Dashed { length: f32 },
}
impl LineStyle {
pub fn dashed_loose() -> Self {
Self::Dashed { length: 10.0 }
}
pub fn dashed_dense() -> Self {
Self::Dashed { length: 5.0 }
}
pub fn dotted_loose() -> Self {
Self::Dotted { spacing: 10.0 }
}
pub fn dotted_dense() -> Self {
Self::Dotted { spacing: 5.0 }
}
fn style_line(
&self,
line: Vec<Pos2>,
mut stroke: Stroke,
highlight: bool,
shapes: &mut Vec<Shape>,
) {
match line.len() {
0 => {}
1 => {
let mut radius = stroke.width / 2.0;
if highlight {
radius *= 2f32.sqrt();
}
shapes.push(Shape::circle_filled(line[0], radius, stroke.color));
}
_ => {
match self {
LineStyle::Solid => {
if highlight {
stroke.width *= 2.0;
}
shapes.push(Shape::line(line, stroke));
}
LineStyle::Dotted { spacing } => {
// Take the stroke width for the radius even though it's not "correct", otherwise
// the dots would become too small.
let mut radius = stroke.width;
if highlight {
radius *= 2f32.sqrt();
}
shapes.extend(Shape::dotted_line(&line, stroke.color, *spacing, radius));
}
LineStyle::Dashed { length } => {
if highlight {
stroke.width *= 2.0;
}
let golden_ratio = (5.0_f32.sqrt() - 1.0) / 2.0; // 0.61803398875
shapes.extend(Shape::dashed_line(
&line,
stroke,
*length,
length * golden_ratio,
));
}
}
}
}
}
}
impl ToString for LineStyle {
fn to_string(&self) -> String {
match self {
LineStyle::Solid => "Solid".into(),
LineStyle::Dotted { spacing } => format!("Dotted{}Px", spacing),
LineStyle::Dashed { length } => format!("Dashed{}Px", length),
}
rulers_at_value(pointer, value, self.name(), plot, shapes);
}
}
@ -229,8 +187,8 @@ impl PlotItem for HLine {
self.highlight
}
fn values(&self) -> Option<&Values> {
None
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::None
}
fn get_bounds(&self) -> PlotBounds {
@ -339,8 +297,8 @@ impl PlotItem for VLine {
self.highlight
}
fn values(&self) -> Option<&Values> {
None
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::None
}
fn get_bounds(&self) -> PlotBounds {
@ -351,203 +309,6 @@ impl PlotItem for VLine {
}
}
/// Trait shared by things that can be drawn in the plot.
pub(super) trait PlotItem {
fn get_shapes(&self, ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec<Shape>);
fn initialize(&mut self, x_range: RangeInclusive<f64>);
fn name(&self) -> &str;
fn color(&self) -> Color32;
fn highlight(&mut self);
fn highlighted(&self) -> bool;
fn values(&self) -> Option<&Values>;
fn get_bounds(&self) -> PlotBounds;
}
// ----------------------------------------------------------------------------
/// Describes a function y = f(x) with an optional range for x and a number of points.
struct ExplicitGenerator {
function: Box<dyn Fn(f64) -> f64>,
x_range: RangeInclusive<f64>,
points: usize,
}
pub struct Values {
pub(super) values: Vec<Value>,
generator: Option<ExplicitGenerator>,
}
impl Default for Values {
fn default() -> Self {
Self {
values: Vec::new(),
generator: None,
}
}
}
impl Values {
pub fn from_values(values: Vec<Value>) -> Self {
Self {
values,
generator: None,
}
}
pub fn from_values_iter(iter: impl Iterator<Item = Value>) -> Self {
Self::from_values(iter.collect())
}
/// Draw a line based on a function `y=f(x)`, a range (which can be infinite) for x and the number of points.
pub fn from_explicit_callback(
function: impl Fn(f64) -> f64 + 'static,
x_range: impl RangeBounds<f64>,
points: usize,
) -> Self {
let start = match x_range.start_bound() {
Bound::Included(x) | Bound::Excluded(x) => *x,
Bound::Unbounded => f64::NEG_INFINITY,
};
let end = match x_range.end_bound() {
Bound::Included(x) | Bound::Excluded(x) => *x,
Bound::Unbounded => f64::INFINITY,
};
let x_range = start..=end;
let generator = ExplicitGenerator {
function: Box::new(function),
x_range,
points,
};
Self {
values: Vec::new(),
generator: Some(generator),
}
}
/// Draw a line based on a function `(x,y)=f(t)`, a range for t and the number of points.
/// The range may be specified as start..end or as start..=end.
pub fn from_parametric_callback(
function: impl Fn(f64) -> (f64, f64),
t_range: impl RangeBounds<f64>,
points: usize,
) -> Self {
let start = match t_range.start_bound() {
Bound::Included(x) => x,
Bound::Excluded(_) => unreachable!(),
Bound::Unbounded => panic!("The range for parametric functions must be bounded!"),
};
let end = match t_range.end_bound() {
Bound::Included(x) | Bound::Excluded(x) => x,
Bound::Unbounded => panic!("The range for parametric functions must be bounded!"),
};
let last_point_included = matches!(t_range.end_bound(), Bound::Included(_));
let increment = if last_point_included {
(end - start) / (points - 1) as f64
} else {
(end - start) / points as f64
};
let values = (0..points).map(|i| {
let t = start + i as f64 * increment;
let (x, y) = function(t);
Value { x, y }
});
Self::from_values_iter(values)
}
/// From a series of y-values.
/// The x-values will be the indices of these values
pub fn from_ys_f32(ys: &[f32]) -> Self {
let values: Vec<Value> = ys
.iter()
.enumerate()
.map(|(i, &y)| Value {
x: i as f64,
y: y as f64,
})
.collect();
Self::from_values(values)
}
/// Returns true if there are no data points available and there is no function to generate any.
pub(super) fn is_empty(&self) -> bool {
self.generator.is_none() && self.values.is_empty()
}
/// If initialized with a generator function, this will generate `n` evenly spaced points in the
/// given range.
pub(super) fn generate_points(&mut self, x_range: RangeInclusive<f64>) {
if let Some(generator) = self.generator.take() {
if let Some(intersection) = Self::range_intersection(&x_range, &generator.x_range) {
let increment =
(intersection.end() - intersection.start()) / (generator.points - 1) as f64;
self.values = (0..generator.points)
.map(|i| {
let x = intersection.start() + i as f64 * increment;
let y = (generator.function)(x);
Value { x, y }
})
.collect();
}
}
}
/// Returns the intersection of two ranges if they intersect.
fn range_intersection(
range1: &RangeInclusive<f64>,
range2: &RangeInclusive<f64>,
) -> Option<RangeInclusive<f64>> {
let start = range1.start().max(*range2.start());
let end = range1.end().min(*range2.end());
(start < end).then(|| start..=end)
}
pub(super) fn get_bounds(&self) -> PlotBounds {
let mut bounds = PlotBounds::NOTHING;
self.values
.iter()
.for_each(|value| bounds.extend_with(value));
bounds
}
}
// ----------------------------------------------------------------------------
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum MarkerShape {
Circle,
Diamond,
Square,
Cross,
Plus,
Up,
Down,
Left,
Right,
Asterisk,
}
impl MarkerShape {
/// Get a vector containing all marker shapes.
pub fn all() -> impl Iterator<Item = MarkerShape> {
[
Self::Circle,
Self::Diamond,
Self::Square,
Self::Cross,
Self::Plus,
Self::Up,
Self::Down,
Self::Left,
Self::Right,
Self::Asterisk,
]
.iter()
.copied()
}
}
/// A series of values forming a path.
pub struct Line {
pub(super) series: Values,
@ -706,8 +467,8 @@ impl PlotItem for Line {
self.highlight
}
fn values(&self) -> Option<&Values> {
Some(&self.series)
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::Points(&self.series.values)
}
fn get_bounds(&self) -> PlotBounds {
@ -836,8 +597,8 @@ impl PlotItem for Polygon {
self.highlight
}
fn values(&self) -> Option<&Values> {
Some(&self.series)
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::Points(&self.series.values)
}
fn get_bounds(&self) -> PlotBounds {
@ -949,8 +710,8 @@ impl PlotItem for Text {
self.highlight
}
fn values(&self) -> Option<&Values> {
None
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::None
}
fn get_bounds(&self) -> PlotBounds {
@ -1182,8 +943,8 @@ impl PlotItem for Points {
self.highlight
}
fn values(&self) -> Option<&Values> {
Some(&self.series)
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::Points(&self.series.values)
}
fn get_bounds(&self) -> PlotBounds {
@ -1297,8 +1058,8 @@ impl PlotItem for Arrows {
self.highlight
}
fn values(&self) -> Option<&Values> {
Some(&self.origins)
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::Points(&self.origins.values)
}
fn get_bounds(&self) -> PlotBounds {
@ -1427,8 +1188,8 @@ impl PlotItem for PlotImage {
self.highlight
}
fn values(&self) -> Option<&Values> {
None
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::None
}
fn get_bounds(&self) -> PlotBounds {
@ -1446,3 +1207,477 @@ impl PlotItem for PlotImage {
bounds
}
}
// ----------------------------------------------------------------------------
/// A bar chart.
pub struct BarChart {
pub(super) bars: Vec<Bar>,
pub(super) default_color: Color32,
pub(super) name: String,
/// A custom element formatter
pub(super) element_formatter: Option<Box<dyn Fn(&Bar, &BarChart) -> String>>,
highlight: bool,
}
impl BarChart {
/// Create a bar chart. It defaults to vertically oriented elements.
pub fn new(bars: Vec<Bar>) -> BarChart {
BarChart {
bars,
default_color: Color32::TRANSPARENT,
name: String::new(),
element_formatter: None,
highlight: false,
}
}
/// Set the default color. It is set on all elements that do not already have a specific color.
/// This is the color that shows up in the legend.
/// It can be overridden at the bar level (see [[`Bar`]]).
/// Default is `Color32::TRANSPARENT` which means a color will be auto-assigned.
pub fn color(mut self, color: impl Into<Color32>) -> Self {
let plot_color = color.into();
self.default_color = plot_color;
self.bars.iter_mut().for_each(|b| {
if b.fill == Color32::TRANSPARENT && b.stroke.color == Color32::TRANSPARENT {
b.fill = plot_color.linear_multiply(0.2);
b.stroke.color = plot_color;
}
});
self
}
/// Name of this chart.
///
/// This name will show up in the plot legend, if legends are turned on. Multiple charts 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();
self
}
/// Set all elements to be in a vertical orientation.
/// Argument axis will be X and bar values will be on the Y axis.
pub fn vertical(mut self) -> Self {
self.bars.iter_mut().for_each(|b| {
b.orientation = Orientation::Vertical;
});
self
}
/// Set all elements to be in a horizontal orientation.
/// Argument axis will be Y and bar values will be on the X axis.
pub fn horizontal(mut self) -> Self {
self.bars.iter_mut().for_each(|b| {
b.orientation = Orientation::Horizontal;
});
self
}
/// Set the width (thickness) of all its elements.
pub fn width(mut self, width: f64) -> Self {
self.bars.iter_mut().for_each(|b| {
b.bar_width = width;
});
self
}
/// Highlight all plot elements.
pub fn highlight(mut self) -> Self {
self.highlight = true;
self
}
/// Add a custom way to format an element.
/// Can be used to display a set number of decimals or custom labels.
pub fn element_formatter(mut self, formatter: Box<dyn Fn(&Bar, &BarChart) -> String>) -> Self {
self.element_formatter = Some(formatter);
self
}
/// Stacks the bars on top of another chart.
/// Positive values are stacked on top of other positive values.
/// Negative values are stacked below other negative values.
pub fn stack_on(mut self, others: &[&BarChart]) -> Self {
for (index, bar) in self.bars.iter_mut().enumerate() {
let new_base_offset = if bar.value.is_sign_positive() {
others
.iter()
.filter_map(|other_chart| other_chart.bars.get(index).map(|bar| bar.upper()))
.max_by_key(|value| value.ord())
} else {
others
.iter()
.filter_map(|other_chart| other_chart.bars.get(index).map(|bar| bar.lower()))
.min_by_key(|value| value.ord())
};
if let Some(value) = new_base_offset {
bar.base_offset = Some(value);
}
}
self
}
}
impl PlotItem for BarChart {
fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec<Shape>) {
self.bars.iter().for_each(|b| {
b.add_shapes(transform, self.highlight, shapes);
});
}
fn initialize(&mut self, _x_range: RangeInclusive<f64>) {
// nothing to do
}
fn name(&self) -> &str {
self.name.as_str()
}
fn color(&self) -> Color32 {
self.default_color
}
fn highlight(&mut self) {
self.highlight = true;
}
fn highlighted(&self) -> bool {
self.highlight
}
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::Rects
}
fn get_bounds(&self) -> PlotBounds {
let mut bounds = PlotBounds::NOTHING;
self.bars.iter().for_each(|b| {
bounds.merge(&b.bounds());
});
bounds
}
fn find_closest(&self, point: Pos2, transform: &ScreenTransform) -> Option<ClosestElem> {
find_closest_rect(&self.bars, point, transform)
}
fn on_hover(&self, elem: ClosestElem, shapes: &mut Vec<Shape>, plot: &PlotConfig<'_>) {
let bar = &self.bars[elem.index];
bar.add_shapes(plot.transform, true, shapes);
bar.add_rulers_and_text(self, plot, shapes);
}
}
/// A diagram containing a series of [`BoxElem`] elements.
pub struct BoxPlot {
pub(super) boxes: Vec<BoxElem>,
pub(super) default_color: Color32,
pub(super) name: String,
/// A custom element formatter
pub(super) element_formatter: Option<Box<dyn Fn(&BoxElem, &BoxPlot) -> String>>,
highlight: bool,
}
impl BoxPlot {
/// Create a plot containing multiple `boxes`. It defaults to vertically oriented elements.
pub fn new(boxes: Vec<BoxElem>) -> Self {
Self {
boxes,
default_color: Color32::TRANSPARENT,
name: String::new(),
element_formatter: None,
highlight: false,
}
}
/// Set the default color. It is set on all elements that do not already have a specific color.
/// This is the color that shows up in the legend.
/// It can be overridden at the element level (see [`BoxElem`]).
/// Default is `Color32::TRANSPARENT` which means a color will be auto-assigned.
pub fn color(mut self, color: impl Into<Color32>) -> Self {
let plot_color = color.into();
self.default_color = plot_color;
self.boxes.iter_mut().for_each(|box_elem| {
if box_elem.fill == Color32::TRANSPARENT
&& box_elem.stroke.color == Color32::TRANSPARENT
{
box_elem.fill = plot_color.linear_multiply(0.2);
box_elem.stroke.color = plot_color;
}
});
self
}
/// Name of this box plot diagram.
///
/// This name will show up in the plot legend, if legends are turned on. Multiple series 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();
self
}
/// Set all elements to be in a vertical orientation.
/// Argument axis will be X and values will be on the Y axis.
pub fn vertical(mut self) -> Self {
self.boxes.iter_mut().for_each(|box_elem| {
box_elem.orientation = Orientation::Vertical;
});
self
}
/// Set all elements to be in a horizontal orientation.
/// Argument axis will be Y and values will be on the X axis.
pub fn horizontal(mut self) -> Self {
self.boxes.iter_mut().for_each(|box_elem| {
box_elem.orientation = Orientation::Horizontal;
});
self
}
/// Highlight all plot elements.
pub fn highlight(mut self) -> Self {
self.highlight = true;
self
}
/// Add a custom way to format an element.
/// Can be used to display a set number of decimals or custom labels.
pub fn element_formatter(
mut self,
formatter: Box<dyn Fn(&BoxElem, &BoxPlot) -> String>,
) -> Self {
self.element_formatter = Some(formatter);
self
}
}
impl PlotItem for BoxPlot {
fn get_shapes(&self, _ui: &mut Ui, transform: &ScreenTransform, shapes: &mut Vec<Shape>) {
self.boxes.iter().for_each(|b| {
b.add_shapes(transform, self.highlight, shapes);
});
}
fn initialize(&mut self, _x_range: RangeInclusive<f64>) {
// nothing to do
}
fn name(&self) -> &str {
self.name.as_str()
}
fn color(&self) -> Color32 {
self.default_color
}
fn highlight(&mut self) {
self.highlight = true;
}
fn highlighted(&self) -> bool {
self.highlight
}
fn geometry(&self) -> PlotGeometry<'_> {
PlotGeometry::Rects
}
fn get_bounds(&self) -> PlotBounds {
let mut bounds = PlotBounds::NOTHING;
self.boxes.iter().for_each(|b| {
bounds.merge(&b.bounds());
});
bounds
}
fn find_closest(&self, point: Pos2, transform: &ScreenTransform) -> Option<ClosestElem> {
find_closest_rect(&self.boxes, point, transform)
}
fn on_hover(&self, elem: ClosestElem, shapes: &mut Vec<Shape>, plot: &PlotConfig<'_>) {
let box_plot = &self.boxes[elem.index];
box_plot.add_shapes(plot.transform, true, shapes);
box_plot.add_rulers_and_text(self, plot, shapes);
}
}
// ----------------------------------------------------------------------------
// Helper functions
fn rulers_color(ui: &Ui) -> Color32 {
if ui.visuals().dark_mode {
Color32::from_gray(100).additive()
} else {
Color32::from_black_alpha(180)
}
}
fn vertical_line(pointer: Pos2, transform: &ScreenTransform, line_color: Color32) -> Shape {
let frame = transform.frame();
Shape::line_segment(
[
pos2(pointer.x, frame.top()),
pos2(pointer.x, frame.bottom()),
],
(1.0, line_color),
)
}
fn horizontal_line(pointer: Pos2, transform: &ScreenTransform, line_color: Color32) -> Shape {
let frame = transform.frame();
Shape::line_segment(
[
pos2(frame.left(), pointer.y),
pos2(frame.right(), pointer.y),
],
(1.0, line_color),
)
}
fn add_rulers_and_text(
elem: &dyn RectElement,
plot: &PlotConfig<'_>,
text: Option<String>,
shapes: &mut Vec<Shape>,
) {
let orientation = elem.orientation();
let show_argument = plot.show_x && orientation == Orientation::Vertical
|| plot.show_y && orientation == Orientation::Horizontal;
let show_values = plot.show_y && orientation == Orientation::Vertical
|| plot.show_x && orientation == Orientation::Horizontal;
let line_color = rulers_color(plot.ui);
// Rulers for argument (usually vertical)
if show_argument {
let push_argument_ruler = |argument: Value, shapes: &mut Vec<Shape>| {
let position = plot.transform.position_from_value(&argument);
let line = match orientation {
Orientation::Horizontal => horizontal_line(position, plot.transform, line_color),
Orientation::Vertical => vertical_line(position, plot.transform, line_color),
};
shapes.push(line);
};
for pos in elem.arguments_with_ruler() {
push_argument_ruler(pos, shapes);
}
}
// Rulers for values (usually horizontal)
if show_values {
let push_value_ruler = |value: Value, shapes: &mut Vec<Shape>| {
let position = plot.transform.position_from_value(&value);
let line = match orientation {
Orientation::Horizontal => vertical_line(position, plot.transform, line_color),
Orientation::Vertical => horizontal_line(position, plot.transform, line_color),
};
shapes.push(line);
};
for pos in elem.values_with_ruler() {
push_value_ruler(pos, shapes);
}
}
// Text
let text = text.unwrap_or({
let mut text = elem.name().to_string(); // could be empty
if show_values {
text.push_str(&elem.default_values_format(plot.transform));
}
text
});
let corner_value = elem.corner_value();
shapes.push(Shape::text(
plot.ui.fonts(),
plot.transform.position_from_value(&corner_value) + vec2(3.0, -2.0),
Align2::LEFT_BOTTOM,
text,
TextStyle::Body,
plot.ui.visuals().text_color(),
));
}
/// Draws a cross of horizontal and vertical ruler at the `pointer` position.
/// `value` is used to for text displaying X/Y coordinates.
#[allow(clippy::too_many_arguments)]
pub(super) fn rulers_at_value(
pointer: Pos2,
value: Value,
name: &str,
plot: &PlotConfig<'_>,
shapes: &mut Vec<Shape>,
) {
let line_color = rulers_color(plot.ui);
if plot.show_x {
shapes.push(vertical_line(pointer, plot.transform, line_color));
}
if plot.show_y {
shapes.push(horizontal_line(pointer, plot.transform, line_color));
}
let mut prefix = String::new();
if !name.is_empty() {
prefix = format!("{}\n", name);
}
let text = {
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 plot.show_x && plot.show_y {
format!(
"{}x = {:.*}\ny = {:.*}",
prefix, x_decimals, value.x, y_decimals, value.y
)
} else if plot.show_x {
format!("{}x = {:.*}", prefix, x_decimals, value.x)
} else if plot.show_y {
format!("{}y = {:.*}", prefix, y_decimals, value.y)
} else {
unreachable!()
}
};
shapes.push(Shape::text(
plot.ui.fonts(),
pointer + vec2(3.0, -2.0),
Align2::LEFT_BOTTOM,
text,
TextStyle::Body,
plot.ui.visuals().text_color(),
));
}
fn find_closest_rect<'a, T>(
rects: impl IntoIterator<Item = &'a T>,
point: Pos2,
transform: &ScreenTransform,
) -> Option<ClosestElem>
where
T: 'a + RectElement,
{
rects
.into_iter()
.enumerate()
.map(|(index, bar)| {
let bar_rect: Rect = transform.rect_from_values(&bar.bounds_min(), &bar.bounds_max());
let dist_sq = bar_rect.distance_sq_to_pos(point);
ClosestElem { index, dist_sq }
})
.min_by_key(|e| e.dist_sq.ord())
}

View file

@ -0,0 +1,61 @@
use super::{Orientation, Value};
use crate::plot::transform::{PlotBounds, ScreenTransform};
use epaint::emath::NumExt;
use epaint::{Color32, Rgba, Stroke};
/// Trait that abstracts from rectangular 'Value'-like elements, such as bars or boxes
pub(super) trait RectElement {
fn name(&self) -> &str;
fn bounds_min(&self) -> Value;
fn bounds_max(&self) -> Value;
fn bounds(&self) -> PlotBounds {
let mut bounds = PlotBounds::NOTHING;
bounds.extend_with(&self.bounds_min());
bounds.extend_with(&self.bounds_max());
bounds
}
/// At which argument (input; usually X) there is a ruler (usually vertical)
fn arguments_with_ruler(&self) -> Vec<Value> {
// Default: one at center
vec![self.bounds().center()]
}
/// At which value (output; usually Y) there is a ruler (usually horizontal)
fn values_with_ruler(&self) -> Vec<Value>;
/// The diagram's orientation (vertical/horizontal)
fn orientation(&self) -> Orientation;
/// Get X/Y-value for (argument, value) pair, taking into account orientation
fn point_at(&self, argument: f64, value: f64) -> Value {
match self.orientation() {
Orientation::Horizontal => Value::new(value, argument),
Orientation::Vertical => Value::new(argument, value),
}
}
/// Right top of the rectangle (position of text)
fn corner_value(&self) -> Value {
//self.point_at(self.position + self.width / 2.0, value)
Value {
x: self.bounds_max().x,
y: self.bounds_max().y,
}
}
/// Debug formatting for hovered-over value, if none is specified by the user
fn default_values_format(&self, transform: &ScreenTransform) -> String;
}
// ----------------------------------------------------------------------------
// Helper functions
pub(super) fn highlighted_color(mut stroke: Stroke, fill: Color32) -> (Stroke, Color32) {
stroke.width *= 2.0;
let fill = Rgba::from(fill);
let fill_alpha = (2.0 * fill.a()).at_most(1.0);
let fill = fill.to_opaque().multiply(fill_alpha);
(stroke, fill.into())
}

View file

@ -0,0 +1,352 @@
use epaint::{Pos2, Shape, Stroke, Vec2};
use std::ops::{Bound, RangeBounds, RangeInclusive};
use crate::plot::transform::PlotBounds;
/// A value in the value-space of the plot.
///
/// Uses f64 for improved accuracy to enable plotting
/// large values (e.g. unix time on x axis).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Value {
/// This is often something monotonically increasing, such as time, but doesn't have to be.
/// Goes from left to right.
pub x: f64,
/// Goes from bottom to top (inverse of everything else in egui!).
pub y: f64,
}
impl Value {
#[inline(always)]
pub fn new(x: impl Into<f64>, y: impl Into<f64>) -> Self {
Self {
x: x.into(),
y: y.into(),
}
}
#[inline(always)]
pub fn to_pos2(self) -> Pos2 {
Pos2::new(self.x as f32, self.y as f32)
}
#[inline(always)]
pub fn to_vec2(self) -> Vec2 {
Vec2::new(self.x as f32, self.y as f32)
}
}
// ----------------------------------------------------------------------------
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum LineStyle {
Solid,
Dotted { spacing: f32 },
Dashed { length: f32 },
}
impl LineStyle {
pub fn dashed_loose() -> Self {
Self::Dashed { length: 10.0 }
}
pub fn dashed_dense() -> Self {
Self::Dashed { length: 5.0 }
}
pub fn dotted_loose() -> Self {
Self::Dotted { spacing: 10.0 }
}
pub fn dotted_dense() -> Self {
Self::Dotted { spacing: 5.0 }
}
pub(super) fn style_line(
&self,
line: Vec<Pos2>,
mut stroke: Stroke,
highlight: bool,
shapes: &mut Vec<Shape>,
) {
match line.len() {
0 => {}
1 => {
let mut radius = stroke.width / 2.0;
if highlight {
radius *= 2f32.sqrt();
}
shapes.push(Shape::circle_filled(line[0], radius, stroke.color));
}
_ => {
match self {
LineStyle::Solid => {
if highlight {
stroke.width *= 2.0;
}
shapes.push(Shape::line(line, stroke));
}
LineStyle::Dotted { spacing } => {
// Take the stroke width for the radius even though it's not "correct", otherwise
// the dots would become too small.
let mut radius = stroke.width;
if highlight {
radius *= 2f32.sqrt();
}
shapes.extend(Shape::dotted_line(&line, stroke.color, *spacing, radius));
}
LineStyle::Dashed { length } => {
if highlight {
stroke.width *= 2.0;
}
let golden_ratio = (5.0_f32.sqrt() - 1.0) / 2.0; // 0.61803398875
shapes.extend(Shape::dashed_line(
&line,
stroke,
*length,
length * golden_ratio,
));
}
}
}
}
}
}
impl ToString for LineStyle {
fn to_string(&self) -> String {
match self {
LineStyle::Solid => "Solid".into(),
LineStyle::Dotted { spacing } => format!("Dotted{}Px", spacing),
LineStyle::Dashed { length } => format!("Dashed{}Px", length),
}
}
}
// ----------------------------------------------------------------------------
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Orientation {
Horizontal,
Vertical,
}
impl Default for Orientation {
fn default() -> Self {
Self::Vertical
}
}
// ----------------------------------------------------------------------------
pub struct Values {
pub(super) values: Vec<Value>,
generator: Option<ExplicitGenerator>,
}
impl Default for Values {
fn default() -> Self {
Self {
values: Vec::new(),
generator: None,
}
}
}
impl Values {
pub fn from_values(values: Vec<Value>) -> Self {
Self {
values,
generator: None,
}
}
pub fn from_values_iter(iter: impl Iterator<Item = Value>) -> Self {
Self::from_values(iter.collect())
}
/// Draw a line based on a function `y=f(x)`, a range (which can be infinite) for x and the number of points.
pub fn from_explicit_callback(
function: impl Fn(f64) -> f64 + 'static,
x_range: impl RangeBounds<f64>,
points: usize,
) -> Self {
let start = match x_range.start_bound() {
Bound::Included(x) | Bound::Excluded(x) => *x,
Bound::Unbounded => f64::NEG_INFINITY,
};
let end = match x_range.end_bound() {
Bound::Included(x) | Bound::Excluded(x) => *x,
Bound::Unbounded => f64::INFINITY,
};
let x_range = start..=end;
let generator = ExplicitGenerator {
function: Box::new(function),
x_range,
points,
};
Self {
values: Vec::new(),
generator: Some(generator),
}
}
/// Draw a line based on a function `(x,y)=f(t)`, a range for t and the number of points.
/// The range may be specified as start..end or as start..=end.
pub fn from_parametric_callback(
function: impl Fn(f64) -> (f64, f64),
t_range: impl RangeBounds<f64>,
points: usize,
) -> Self {
let start = match t_range.start_bound() {
Bound::Included(x) => x,
Bound::Excluded(_) => unreachable!(),
Bound::Unbounded => panic!("The range for parametric functions must be bounded!"),
};
let end = match t_range.end_bound() {
Bound::Included(x) | Bound::Excluded(x) => x,
Bound::Unbounded => panic!("The range for parametric functions must be bounded!"),
};
let last_point_included = matches!(t_range.end_bound(), Bound::Included(_));
let increment = if last_point_included {
(end - start) / (points - 1) as f64
} else {
(end - start) / points as f64
};
let values = (0..points).map(|i| {
let t = start + i as f64 * increment;
let (x, y) = function(t);
Value { x, y }
});
Self::from_values_iter(values)
}
/// From a series of y-values.
/// The x-values will be the indices of these values
pub fn from_ys_f32(ys: &[f32]) -> Self {
let values: Vec<Value> = ys
.iter()
.enumerate()
.map(|(i, &y)| Value {
x: i as f64,
y: y as f64,
})
.collect();
Self::from_values(values)
}
/// Returns true if there are no data points available and there is no function to generate any.
pub(crate) fn is_empty(&self) -> bool {
self.generator.is_none() && self.values.is_empty()
}
/// If initialized with a generator function, this will generate `n` evenly spaced points in the
/// given range.
pub(super) fn generate_points(&mut self, x_range: RangeInclusive<f64>) {
if let Some(generator) = self.generator.take() {
if let Some(intersection) = Self::range_intersection(&x_range, &generator.x_range) {
let increment =
(intersection.end() - intersection.start()) / (generator.points - 1) as f64;
self.values = (0..generator.points)
.map(|i| {
let x = intersection.start() + i as f64 * increment;
let y = (generator.function)(x);
Value { x, y }
})
.collect();
}
}
}
/// Returns the intersection of two ranges if they intersect.
fn range_intersection(
range1: &RangeInclusive<f64>,
range2: &RangeInclusive<f64>,
) -> Option<RangeInclusive<f64>> {
let start = range1.start().max(*range2.start());
let end = range1.end().min(*range2.end());
(start < end).then(|| start..=end)
}
pub(super) fn get_bounds(&self) -> PlotBounds {
let mut bounds = PlotBounds::NOTHING;
self.values
.iter()
.for_each(|value| bounds.extend_with(value));
bounds
}
}
// ----------------------------------------------------------------------------
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum MarkerShape {
Circle,
Diamond,
Square,
Cross,
Plus,
Up,
Down,
Left,
Right,
Asterisk,
}
impl MarkerShape {
/// Get a vector containing all marker shapes.
pub fn all() -> impl Iterator<Item = MarkerShape> {
[
Self::Circle,
Self::Diamond,
Self::Square,
Self::Cross,
Self::Plus,
Self::Up,
Self::Down,
Self::Left,
Self::Right,
Self::Asterisk,
]
.iter()
.copied()
}
}
// ----------------------------------------------------------------------------
/// Query the values of the plot, for geometric relations like closest checks
pub(crate) enum PlotGeometry<'a> {
/// No geometry based on single elements (examples: text, image, horizontal/vertical line)
None,
/// Point values (X-Y graphs)
Points(&'a [Value]),
/// Rectangles (examples: boxes or bars)
// Has currently no data, as it would require copying rects or iterating a list of pointers.
// Instead, geometry-based functions are directly implemented in the respective PlotItem impl.
Rects,
}
// ----------------------------------------------------------------------------
/// Describes a function y = f(x) with an optional range for x and a number of points.
struct ExplicitGenerator {
function: Box<dyn Fn(f64) -> f64>,
x_range: RangeInclusive<f64>,
points: usize,
}
// ----------------------------------------------------------------------------
/// Result of [`PlotItem::find_closest()`] search, identifies an element inside the item for immediate use
pub(crate) struct ClosestElem {
/// Position of hovered-over value (or bar/box-plot/...) in PlotItem
pub index: usize,
/// Squared distance from the mouse cursor (needed to compare against other PlotItems, which might be nearer)
pub dist_sq: f32,
}

View file

@ -1,23 +1,23 @@
//! Simple plotting library.
use crate::util::float_ord::FloatOrd;
use crate::*;
use color::Hsva;
use epaint::ahash::AHashSet;
use items::PlotItem;
use legend::LegendWidget;
use transform::{PlotBounds, ScreenTransform};
pub use items::{
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, HLine, Line, LineStyle, MarkerShape,
PlotImage, Points, Polygon, Text, VLine, Value, Values,
};
pub use legend::{Corner, Legend};
mod items;
mod legend;
mod transform;
use items::PlotItem;
pub use items::{
Arrows, HLine, Line, LineStyle, MarkerShape, PlotImage, Points, Polygon, Text, VLine, Value,
Values,
};
use legend::LegendWidget;
pub use legend::{Corner, Legend};
pub use transform::PlotBounds;
use transform::ScreenTransform;
use crate::*;
use color::Hsva;
use epaint::ahash::AHashSet;
// ----------------------------------------------------------------------------
/// Information about the plot that has to persist between frames.
@ -581,6 +581,32 @@ impl PlotUi {
}
self.items.push(Box::new(vline));
}
/// Add a box plot diagram.
pub fn box_plot(&mut self, mut box_plot: BoxPlot) {
if box_plot.boxes.is_empty() {
return;
}
// Give the elements an automatic color if no color has been assigned.
if box_plot.default_color == Color32::TRANSPARENT {
box_plot = box_plot.color(self.auto_color());
}
self.items.push(Box::new(box_plot));
}
/// Add a bar chart.
pub fn bar_chart(&mut self, mut chart: BarChart) {
if chart.bars.is_empty() {
return;
}
// Give the elements an automatic color if no color has been assigned.
if chart.default_color == Color32::TRANSPARENT {
chart = chart.color(self.auto_color());
}
self.items.push(Box::new(chart));
}
}
struct PreparedPlot {
@ -713,88 +739,31 @@ impl PreparedPlot {
return;
}
let interact_radius: f32 = 16.0;
let mut closest_value = None;
let mut closest_item = None;
let mut closest_dist_sq = interact_radius.powi(2);
for item in items {
if let Some(values) = item.values() {
for value in &values.values {
let pos = transform.position_from_value(value);
let dist_sq = pointer.distance_sq(pos);
if dist_sq < closest_dist_sq {
closest_dist_sq = dist_sq;
closest_value = Some(value);
closest_item = Some(item.name());
}
}
}
}
let interact_radius_sq: f32 = (16.0f32).powi(2);
let mut prefix = String::new();
if let Some(name) = closest_item {
if !name.is_empty() {
prefix = format!("{}\n", name);
}
}
let candidates = items.iter().filter_map(|item| {
let item = &**item;
let closest = item.find_closest(pointer, transform);
let line_color = if ui.visuals().dark_mode {
Color32::from_gray(100).additive()
} else {
Color32::from_black_alpha(180)
Some(item).zip(closest)
});
let closest = candidates
.min_by_key(|(_, elem)| elem.dist_sq.ord())
.filter(|(_, elem)| elem.dist_sq <= interact_radius_sq);
let plot = items::PlotConfig {
ui,
transform,
show_x: *show_x,
show_y: *show_y,
};
let value = if let Some(value) = closest_value {
let position = transform.position_from_value(value);
shapes.push(Shape::circle_filled(position, 3.0, line_color));
*value
if let Some((item, elem)) = closest {
item.on_hover(elem, shapes, &plot);
} else {
transform.value_from_position(pointer)
};
let pointer = transform.position_from_value(&value);
let rect = transform.frame();
if *show_x {
// vertical line
shapes.push(Shape::line_segment(
[pos2(pointer.x, rect.top()), pos2(pointer.x, rect.bottom())],
(1.0, line_color),
));
let value = transform.value_from_position(pointer);
items::rulers_at_value(pointer, value, "", &plot, shapes);
}
if *show_y {
// horizontal line
shapes.push(Shape::line_segment(
[pos2(rect.left(), pointer.y), pos2(rect.right(), pointer.y)],
(1.0, line_color),
));
}
let text = {
let scale = 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 *show_x && *show_y {
format!(
"{}x = {:.*}\ny = {:.*}",
prefix, x_decimals, value.x, y_decimals, value.y
)
} else if *show_x {
format!("{}x = {:.*}", prefix, x_decimals, value.x)
} else if *show_y {
format!("{}y = {:.*}", prefix, y_decimals, value.y)
} else {
unreachable!()
}
};
shapes.push(Shape::text(
ui.fonts(),
pointer + vec2(3.0, -2.0),
Align2::LEFT_BOTTOM,
text,
TextStyle::Body,
ui.visuals().text_color(),
));
}
}

View file

@ -52,6 +52,14 @@ impl PlotBounds {
self.max[1] - self.min[1]
}
pub fn center(&self) -> Value {
Value {
x: (self.min[0] + self.max[0]) / 2.0,
y: (self.min[1] + self.max[1]) / 2.0,
}
}
/// Expand to include the given (x,y) value
pub(crate) fn extend_with(&mut self, value: &Value) {
self.extend_with_x(value.x);
self.extend_with_y(value.y);
@ -225,6 +233,20 @@ impl ScreenTransform {
Value::new(x, y)
}
/// Transform a rectangle of plot values to a screen-coordinate rectangle.
///
/// This typically means that the rect is mirrored vertically (top becomes bottom and vice versa),
/// since the plot's coordinate system has +Y up, while egui has +Y down.
pub fn rect_from_values(&self, value1: &Value, value2: &Value) -> Rect {
let pos1 = self.position_from_value(value1);
let pos2 = self.position_from_value(value2);
let mut rect = Rect::NOTHING;
rect.extend_with(pos1);
rect.extend_with(pos2);
rect
}
/// delta position / delta value
pub fn dpos_dvalue_x(&self) -> f64 {
self.frame.width() as f64 / self.bounds.width()

View file

@ -1,9 +1,10 @@
use std::f64::consts::TAU;
use egui::*;
use plot::{
Arrows, Corner, HLine, Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, Points, Polygon,
Text, VLine, Value, Values,
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, Corner, HLine, Legend, Line, LineStyle,
MarkerShape, Plot, PlotImage, Points, Polygon, Text, VLine, Value, Values,
};
use std::f64::consts::TAU;
#[derive(PartialEq)]
struct LineDemo {
@ -430,18 +431,203 @@ impl Widget for &mut InteractionDemo {
}
}
#[derive(PartialEq, Eq)]
enum Chart {
GaussBars,
StackedBars,
BoxPlot,
}
impl Default for Chart {
fn default() -> Self {
Self::GaussBars
}
}
#[derive(PartialEq)]
struct ChartsDemo {
chart: Chart,
vertical: bool,
}
impl Default for ChartsDemo {
fn default() -> Self {
Self {
vertical: true,
chart: Chart::default(),
}
}
}
impl ChartsDemo {
fn bar_gauss(&self, ui: &mut Ui) -> Response {
let mut chart = BarChart::new(
(-395..=395)
.step_by(10)
.map(|x| x as f64 * 0.01)
.map(|x| {
(
x,
(-x * x / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt(),
)
})
// The 10 factor here is purely for a nice 1:1 aspect ratio
.map(|(x, f)| Bar::new(x, f * 10.0).width(0.095))
.collect(),
)
.color(Color32::LIGHT_BLUE)
.name("Normal Distribution");
if !self.vertical {
chart = chart.horizontal();
}
Plot::new("Normal Distribution Demo")
.legend(Legend::default())
.data_aspect(1.0)
.show(ui, |plot_ui| plot_ui.bar_chart(chart))
.response
}
fn bar_stacked(&self, ui: &mut Ui) -> Response {
let mut chart1 = BarChart::new(vec![
Bar::new(0.5, 1.0).name("Day 1"),
Bar::new(1.5, 3.0).name("Day 2"),
Bar::new(2.5, 1.0).name("Day 3"),
Bar::new(3.5, 2.0).name("Day 4"),
Bar::new(4.5, 4.0).name("Day 5"),
])
.width(0.7)
.name("Set 1");
let mut chart2 = BarChart::new(vec![
Bar::new(0.5, 1.0),
Bar::new(1.5, 1.5),
Bar::new(2.5, 0.1),
Bar::new(3.5, 0.7),
Bar::new(4.5, 0.8),
])
.width(0.7)
.name("Set 2")
.stack_on(&[&chart1]);
let mut chart3 = BarChart::new(vec![
Bar::new(0.5, -0.5),
Bar::new(1.5, 1.0),
Bar::new(2.5, 0.5),
Bar::new(3.5, -1.0),
Bar::new(4.5, 0.3),
])
.width(0.7)
.name("Set 3")
.stack_on(&[&chart1, &chart2]);
let mut chart4 = BarChart::new(vec![
Bar::new(0.5, 0.5),
Bar::new(1.5, 1.0),
Bar::new(2.5, 0.5),
Bar::new(3.5, -0.5),
Bar::new(4.5, -0.5),
])
.width(0.7)
.name("Set 4")
.stack_on(&[&chart1, &chart2, &chart3]);
if !self.vertical {
chart1 = chart1.horizontal();
chart2 = chart2.horizontal();
chart3 = chart3.horizontal();
chart4 = chart4.horizontal();
}
Plot::new("Stacked Bar Chart Demo")
.legend(Legend::default())
.data_aspect(1.0)
.show(ui, |plot_ui| {
plot_ui.bar_chart(chart1);
plot_ui.bar_chart(chart2);
plot_ui.bar_chart(chart3);
plot_ui.bar_chart(chart4);
})
.response
}
fn box_plot(&self, ui: &mut Ui) -> Response {
let yellow = Color32::from_rgb(248, 252, 168);
let mut box1 = BoxPlot::new(vec![
BoxElem::new(0.5, BoxSpread::new(1.5, 2.2, 2.5, 2.6, 3.1)).name("Day 1"),
BoxElem::new(2.5, BoxSpread::new(0.4, 1.0, 1.1, 1.4, 2.1)).name("Day 2"),
BoxElem::new(4.5, BoxSpread::new(1.7, 2.0, 2.2, 2.5, 2.9)).name("Day 3"),
])
.name("Experiment A");
let mut box2 = BoxPlot::new(vec![
BoxElem::new(1.0, BoxSpread::new(0.2, 0.5, 1.0, 2.0, 2.7)).name("Day 1"),
BoxElem::new(3.0, BoxSpread::new(1.5, 1.7, 2.1, 2.9, 3.3))
.name("Day 2: interesting")
.stroke(Stroke::new(1.5, yellow))
.fill(yellow.linear_multiply(0.2)),
BoxElem::new(5.0, BoxSpread::new(1.3, 2.0, 2.3, 2.9, 4.0)).name("Day 3"),
])
.name("Experiment B");
let mut box3 = BoxPlot::new(vec![
BoxElem::new(1.5, BoxSpread::new(2.1, 2.2, 2.6, 2.8, 3.0)).name("Day 1"),
BoxElem::new(3.5, BoxSpread::new(1.3, 1.5, 1.9, 2.2, 2.4)).name("Day 2"),
BoxElem::new(5.5, BoxSpread::new(0.2, 0.4, 1.0, 1.3, 1.5)).name("Day 3"),
])
.name("Experiment C");
if !self.vertical {
box1 = box1.horizontal();
box2 = box2.horizontal();
box3 = box3.horizontal();
}
Plot::new("Box Plot Demo")
.legend(Legend::default())
.show(ui, |plot_ui| {
plot_ui.box_plot(box1);
plot_ui.box_plot(box2);
plot_ui.box_plot(box3);
})
.response
}
}
impl Widget for &mut ChartsDemo {
fn ui(self, ui: &mut Ui) -> Response {
ui.label("Type:");
ui.horizontal(|ui| {
ui.selectable_value(&mut self.chart, Chart::GaussBars, "Histogram");
ui.selectable_value(&mut self.chart, Chart::StackedBars, "Stacked Bar Chart");
ui.selectable_value(&mut self.chart, Chart::BoxPlot, "Box Plot");
});
ui.label("Orientation:");
ui.horizontal(|ui| {
ui.selectable_value(&mut self.vertical, true, "Vertical");
ui.selectable_value(&mut self.vertical, false, "Horizontal");
});
match self.chart {
Chart::GaussBars => self.bar_gauss(ui),
Chart::StackedBars => self.bar_stacked(ui),
Chart::BoxPlot => self.box_plot(ui),
}
}
}
#[derive(PartialEq, Eq)]
enum Panel {
Lines,
Markers,
Legend,
Charts,
Items,
Interaction,
}
impl Default for Panel {
fn default() -> Self {
Self::Lines
Self::Charts
}
}
@ -450,6 +636,7 @@ pub struct PlotDemo {
line_demo: LineDemo,
marker_demo: MarkerDemo,
legend_demo: LegendDemo,
charts_demo: ChartsDemo,
items_demo: ItemsDemo,
interaction_demo: InteractionDemo,
open_panel: Panel,
@ -492,6 +679,7 @@ impl super::View for PlotDemo {
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.selectable_value(&mut self.open_panel, Panel::Charts, "Charts");
ui.selectable_value(&mut self.open_panel, Panel::Items, "Items");
ui.selectable_value(&mut self.open_panel, Panel::Interaction, "Interaction");
});
@ -507,6 +695,9 @@ impl super::View for PlotDemo {
Panel::Legend => {
ui.add(&mut self.legend_demo);
}
Panel::Charts => {
ui.add(&mut self.charts_demo);
}
Panel::Items => {
ui.add(&mut self.items_demo);
}

View file

@ -309,6 +309,27 @@ impl Rect {
self.width() * self.height()
}
#[inline]
pub fn distance_sq_to_pos(&self, pos: Pos2) -> f32 {
let dx = if self.min.x > pos.x {
self.min.x - pos.x
} else if pos.x > self.max.x {
pos.x - self.max.x
} else {
0.0
};
let dy = if self.min.y > pos.y {
self.min.y - pos.y
} else if pos.y > self.max.y {
pos.y - self.max.y
} else {
0.0
};
dx * dx + dy * dy
}
#[inline(always)]
pub fn x_range(&self) -> RangeInclusive<f32> {
self.min.x..=self.max.x