1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Move egui_plot to its own repo (#4828)

* Part of https://github.com/emilk/egui/issues/4705

`egui_plot` can now be found at https://github.com/emilk/egui_plot
This commit is contained in:
Emil Ernerfeldt
2024-07-15 18:45:19 +02:00
committed by GitHub
parent 1384410cb4
commit cb9f30482f
36 changed files with 11 additions and 8196 deletions

View File

@@ -1,92 +0,0 @@
# Changelog for egui_plot
All notable changes to the `egui_plot` integration will be noted in this file.
This file is updated upon each release.
Changes since the last release can be found at <https://github.com/emilk/egui/compare/latest...HEAD> or by running the `scripts/generate_changelog.py` script.
## 0.28.1 - 2024-07-05
Nothing new
## 0.28.0 - 2024-07-03
### ⭐ Added
* Hide all other series when alt-clicking in the legend [#4549](https://github.com/emilk/egui/pull/4549) by [@abey79](https://github.com/abey79)
### 🔧 Changed
* `Plot::Items:allow_hover` give possibility to masked the interaction on hovered item [#2558](https://github.com/emilk/egui/pull/2558) by [@haricot](https://github.com/haricot)
* Expose `ClosestElem` and `PlotConfig` [#4380](https://github.com/emilk/egui/pull/4380) by [@Narcha](https://github.com/Narcha)
* Introduce lifetime to `egui_plot::Plot` to replace `'static` fields [#4435](https://github.com/emilk/egui/pull/4435) by [@Fabus1184](https://github.com/Fabus1184)
* Plot now respects the `interact_radius` set in the UI's style [#4520](https://github.com/emilk/egui/pull/4520) by [@YgorSouza](https://github.com/YgorSouza)
* Improve behavior of plot auto-bounds with reduced data [#4632](https://github.com/emilk/egui/pull/4632) by [@abey79](https://github.com/abey79)
* Improve default formatter of tick-marks [#4738](https://github.com/emilk/egui/pull/4738) by [@emilk](https://github.com/emilk)
### 🐛 Fixed
* Disable interaction for `ScrollArea` and `Plot` when UI is disabled [#4457](https://github.com/emilk/egui/pull/4457) by [@varphone](https://github.com/varphone)
* Make sure plot size is positive [#4429](https://github.com/emilk/egui/pull/4429) by [@rustbasic](https://github.com/rustbasic)
* Use `f64` for translate [#4637](https://github.com/emilk/egui/pull/4637) by [@Its-Just-Nans](https://github.com/Its-Just-Nans)
* Clamp plot zoom values to valid range [#4695](https://github.com/emilk/egui/pull/4695) by [@Its-Just-Nans](https://github.com/Its-Just-Nans)
* Fix plot bounds of empty plots [#4741](https://github.com/emilk/egui/pull/4741) by [@emilk](https://github.com/emilk)
## 0.27.2 - 2024-04-02
* Allow zoom/pan a plot as long as it contains the mouse cursor [#4292](https://github.com/emilk/egui/pull/4292)
* Prevent plot from resetting one axis while zooming/dragging the other [#4252](https://github.com/emilk/egui/pull/4252) (thanks [@YgorSouza](https://github.com/YgorSouza)!)
* egui_plot: Fix the same plot tick label being painted multiple times [#4307](https://github.com/emilk/egui/pull/4307)
## 0.27.1 - 2024-03-29
* Nothing new
## 0.27.0 - 2024-03-26
* Add `sense` option to `Plot` [#4052](https://github.com/emilk/egui/pull/4052) (thanks [@AmesingFlank](https://github.com/AmesingFlank)!)
* Plot widget - allow disabling scroll for x and y separately [#4051](https://github.com/emilk/egui/pull/4051) (thanks [@YgorSouza](https://github.com/YgorSouza)!)
* Fix panic when the base step size is set to 0 [#4078](https://github.com/emilk/egui/pull/4078) (thanks [@abey79](https://github.com/abey79)!)
* Expose `PlotGeometry` in public API [#4193](https://github.com/emilk/egui/pull/4193) (thanks [@dwuertz](https://github.com/dwuertz)!)
## 0.26.2 - 2024-02-14
* Nothing new
## 0.26.1 - 2024-02-11
* Nothing new
## 0.26.0 - 2024-02-05
* Make `egui_plot::PlotMemory` public [#3871](https://github.com/emilk/egui/pull/3871)
* Customizable spacing of grid and axis label spacing [#3896](https://github.com/emilk/egui/pull/3896)
* Change default plot line thickness from 1.0 to 1.5 [#3918](https://github.com/emilk/egui/pull/3918)
* Automatically expand plot axes thickness to fit their labels [#3921](https://github.com/emilk/egui/pull/3921)
* Plot items now have optional id which is returned in the plot's response when hovered [#3920](https://github.com/emilk/egui/pull/3920) (thanks [@Wumpf](https://github.com/Wumpf)!)
* Parallel tessellation with opt-in `rayon` feature [#3934](https://github.com/emilk/egui/pull/3934)
* Make `egui_plot::PlotItem` a public trait [#3943](https://github.com/emilk/egui/pull/3943)
* Fix clip rect for plot items [#3955](https://github.com/emilk/egui/pull/3955) (thanks [@YgorSouza](https://github.com/YgorSouza)!)
## 0.25.0 - 2024-01-08
* Fix plot auto-bounds unset by default [#3722](https://github.com/emilk/egui/pull/3722) (thanks [@abey79](https://github.com/abey79)!)
* Add methods to zoom a `Plot` programmatically [#2714](https://github.com/emilk/egui/pull/2714) (thanks [@YgorSouza](https://github.com/YgorSouza)!)
* Add a public API for overriding plot legend traces' visibilities [#3534](https://github.com/emilk/egui/pull/3534) (thanks [@jayzhudev](https://github.com/jayzhudev)!)
## 0.24.1 - 2024-12-03
* Fix plot auto-bounds default [#3722](https://github.com/emilk/egui/pull/3722) (thanks [@abey79](https://github.com/abey79)!)
## 0.24.0 - 2023-11-23
* Add `emath::Vec2b`, replacing `egui_plot::AxisBools` [#3543](https://github.com/emilk/egui/pull/3543)
* Add `auto_bounds/set_auto_bounds` to `PlotUi` [#3587](https://github.com/emilk/egui/pull/3587) [#3586](https://github.com/emilk/egui/pull/3586) (thanks [@abey79](https://github.com/abey79)!)
* Update MSRV to Rust 1.72 [#3595](https://github.com/emilk/egui/pull/3595)
## 0.23.0 - 2023-09-27 - Initial release, after being forked out from `egui`
* Draw axis labels and ticks outside of plotting window [#2284](https://github.com/emilk/egui/pull/2284) (thanks [@JohannesProgrammiert](https://github.com/JohannesProgrammiert)!)
* Add `PlotUi::response()` to replace `plot_clicked()` etc [#3223](https://github.com/emilk/egui/pull/3223)
* Add rotation feature to plot images [#3121](https://github.com/emilk/egui/pull/3121) (thanks [@ThundR67](https://github.com/ThundR67)!)
* Plot items: Image rotation and size in plot coordinates, polygon fill color [#3182](https://github.com/emilk/egui/pull/3182) (thanks [@s-nie](https://github.com/s-nie)!)
* Add method to specify `tip_size` of plot arrows [#3138](https://github.com/emilk/egui/pull/3138) (thanks [@nagua](https://github.com/nagua)!)
* Better handle additive colors in plots [#3387](https://github.com/emilk/egui/pull/3387)
* Fix auto_bounds when only one axis has restricted navigation [#3171](https://github.com/emilk/egui/pull/3171) (thanks [@KoffeinFlummi](https://github.com/KoffeinFlummi)!)
* Fix plot formatter not taking closures [#3260](https://github.com/emilk/egui/pull/3260) (thanks [@Wumpf](https://github.com/Wumpf)!)

View File

@@ -1,47 +0,0 @@
[package]
name = "egui_plot"
version.workspace = true
authors = [
"Emil Ernerfeldt <emil.ernerfeldt@gmail.com>", # https://github.com/emilk
"Jan Haller <bromeon@gmail.com>", # https://github.com/Bromeon
"Sven Niederberger <s-niederberger@outlook.com>", # https://github.com/EmbersArc
]
description = "Immediate mode plotting for the egui GUI library"
edition.workspace = true
rust-version.workspace = true
homepage = "https://github.com/emilk/egui"
license.workspace = true
readme = "README.md"
repository = "https://github.com/emilk/egui"
categories = ["visualization", "gui"]
keywords = ["egui", "plot", "plotting"]
include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"]
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
[lib]
[features]
default = []
## Allow serialization using [`serde`](https://docs.rs/serde).
serde = ["dep:serde", "egui/serde"]
[dependencies]
egui = { workspace = true, default-features = false }
emath = { workspace = true, default-features = false }
ahash.workspace = true
#! ### Optional dependencies
## Enable this when generating docs.
document-features = { workspace = true, optional = true }
serde = { workspace = true, optional = true }

View File

@@ -1,11 +1 @@
# egui_plot
[![Latest version](https://img.shields.io/crates/v/egui_plot.svg)](https://crates.io/crates/egui_plot)
[![Documentation](https://docs.rs/egui_plot/badge.svg)](https://docs.rs/egui_plot)
[![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/)
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)
Immediate mode plotting for [`egui`](https://github.com/emilk/egui).
[**Looking for a maintainer!**](https://github.com/emilk/egui/issues/4705)
`egui_plot` has been moved to <https://github.com/emilk/egui_plot>

View File

@@ -1,398 +0,0 @@
use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};
use egui::{
emath::{remap_clamp, Rot2},
epaint::TextShape,
Pos2, Rangef, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, WidgetText,
};
use super::{transform::PlotTransform, GridMark};
pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, &RangeInclusive<f64>) -> String + 'a;
/// X or Y axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
/// Horizontal X-Axis
X = 0,
/// Vertical Y-axis
Y = 1,
}
impl From<Axis> for usize {
#[inline]
fn from(value: Axis) -> Self {
match value {
Axis::X => 0,
Axis::Y => 1,
}
}
}
/// Placement of the horizontal X-Axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VPlacement {
Top,
Bottom,
}
/// Placement of the vertical Y-Axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HPlacement {
Left,
Right,
}
/// Placement of an axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
/// Bottom for X-axis, or left for Y-axis.
LeftBottom,
/// Top for x-axis and right for y-axis.
RightTop,
}
impl From<HPlacement> for Placement {
#[inline]
fn from(placement: HPlacement) -> Self {
match placement {
HPlacement::Left => Self::LeftBottom,
HPlacement::Right => Self::RightTop,
}
}
}
impl From<Placement> for HPlacement {
#[inline]
fn from(placement: Placement) -> Self {
match placement {
Placement::LeftBottom => Self::Left,
Placement::RightTop => Self::Right,
}
}
}
impl From<VPlacement> for Placement {
#[inline]
fn from(placement: VPlacement) -> Self {
match placement {
VPlacement::Top => Self::RightTop,
VPlacement::Bottom => Self::LeftBottom,
}
}
}
impl From<Placement> for VPlacement {
#[inline]
fn from(placement: Placement) -> Self {
match placement {
Placement::LeftBottom => Self::Bottom,
Placement::RightTop => Self::Top,
}
}
}
/// Axis configuration.
///
/// Used to configure axis label and ticks.
#[derive(Clone)]
pub struct AxisHints<'a> {
pub(super) label: WidgetText,
pub(super) formatter: Arc<AxisFormatterFn<'a>>,
pub(super) min_thickness: f32,
pub(super) placement: Placement,
pub(super) label_spacing: Rangef,
}
// TODO(JohannesProgrammiert): this just a guess. It might cease to work if a user changes font size.
const LINE_HEIGHT: f32 = 12.0;
impl<'a> AxisHints<'a> {
/// Initializes a default axis configuration for the X axis.
pub fn new_x() -> Self {
Self::new(Axis::X)
}
/// Initializes a default axis configuration for the Y axis.
pub fn new_y() -> Self {
Self::new(Axis::Y)
}
/// Initializes a default axis configuration for the specified axis.
///
/// `label` is empty.
/// `formatter` is default float to string formatter.
pub fn new(axis: Axis) -> Self {
Self {
label: Default::default(),
formatter: Arc::new(Self::default_formatter),
min_thickness: 14.0,
placement: Placement::LeftBottom,
label_spacing: match axis {
Axis::X => Rangef::new(60.0, 80.0), // labels can get pretty wide
Axis::Y => Rangef::new(20.0, 30.0), // text isn't very high
},
}
}
/// Specify custom formatter for ticks.
///
/// The first parameter of `formatter` is the raw tick value as `f64`.
/// The second parameter of `formatter` is the currently shown range on this axis.
pub fn formatter(
mut self,
fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> String + 'a,
) -> Self {
self.formatter = Arc::new(fmt);
self
}
fn default_formatter(mark: GridMark, _range: &RangeInclusive<f64>) -> String {
// Example: If the step to the next tick is `0.01`, we should use 2 decimals of precision:
let num_decimals = -mark.step_size.log10().round() as usize;
emath::format_with_decimals_in_range(mark.value, num_decimals..=num_decimals)
}
/// Specify axis label.
///
/// The default is 'x' for x-axes and 'y' for y-axes.
#[inline]
pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
self.label = label.into();
self
}
/// Specify minimum thickness of the axis
#[inline]
pub fn min_thickness(mut self, min_thickness: f32) -> Self {
self.min_thickness = min_thickness;
self
}
/// Specify maximum number of digits for ticks.
#[inline]
#[deprecated = "Use `min_thickness` instead"]
pub fn max_digits(self, digits: usize) -> Self {
self.min_thickness(12.0 * digits as f32)
}
/// Specify the placement of the axis.
///
/// For X-axis, use [`VPlacement`].
/// For Y-axis, use [`HPlacement`].
#[inline]
pub fn placement(mut self, placement: impl Into<Placement>) -> Self {
self.placement = placement.into();
self
}
/// Set the minimum spacing between labels
///
/// When labels get closer together than the given minimum, then they become invisible.
/// When they get further apart than the max, they are at full opacity.
///
/// Labels can never be closer together than the [`crate::Plot::grid_spacing`] setting.
#[inline]
pub fn label_spacing(mut self, range: impl Into<Rangef>) -> Self {
self.label_spacing = range.into();
self
}
pub(super) fn thickness(&self, axis: Axis) -> f32 {
match axis {
Axis::X => self.min_thickness.max(if self.label.is_empty() {
1.0 * LINE_HEIGHT
} else {
3.0 * LINE_HEIGHT
}),
Axis::Y => {
self.min_thickness
+ if self.label.is_empty() {
0.0
} else {
LINE_HEIGHT
}
}
}
}
}
#[derive(Clone)]
pub(super) struct AxisWidget<'a> {
pub range: RangeInclusive<f64>,
pub hints: AxisHints<'a>,
/// The region where we draw the axis labels.
pub rect: Rect,
pub transform: Option<PlotTransform>,
pub steps: Arc<Vec<GridMark>>,
}
impl<'a> AxisWidget<'a> {
/// if `rect` has width or height == 0, it will be automatically calculated from ticks and text.
pub fn new(hints: AxisHints<'a>, rect: Rect) -> Self {
Self {
range: (0.0..=0.0),
hints,
rect,
transform: None,
steps: Default::default(),
}
}
/// Returns the actual thickness of the axis.
pub fn ui(self, ui: &mut Ui, axis: Axis) -> (Response, f32) {
let response = ui.allocate_rect(self.rect, Sense::hover());
if !ui.is_rect_visible(response.rect) {
return (response, 0.0);
}
let visuals = ui.style().visuals.clone();
{
let text = self.hints.label;
let galley = text.into_galley(
ui,
Some(TextWrapMode::Extend),
f32::INFINITY,
TextStyle::Body,
);
let text_color = visuals
.override_text_color
.unwrap_or_else(|| ui.visuals().text_color());
let angle: f32 = match axis {
Axis::X => 0.0,
Axis::Y => -std::f32::consts::TAU * 0.25,
};
// select text_pos and angle depending on placement and orientation of widget
let text_pos = match self.hints.placement {
Placement::LeftBottom => match axis {
Axis::X => {
let pos = response.rect.center_bottom();
Pos2 {
x: pos.x - galley.size().x / 2.0,
y: pos.y - galley.size().y * 1.25,
}
}
Axis::Y => {
let pos = response.rect.left_center();
Pos2 {
x: pos.x,
y: pos.y + galley.size().x / 2.0,
}
}
},
Placement::RightTop => match axis {
Axis::X => {
let pos = response.rect.center_top();
Pos2 {
x: pos.x - galley.size().x / 2.0,
y: pos.y + galley.size().y * 0.25,
}
}
Axis::Y => {
let pos = response.rect.right_center();
Pos2 {
x: pos.x - galley.size().y * 1.5,
y: pos.y + galley.size().x / 2.0,
}
}
},
};
ui.painter()
.add(TextShape::new(text_pos, galley, text_color).with_angle(angle));
}
let font_id = TextStyle::Body.resolve(ui.style());
let Some(transform) = self.transform else {
return (response, 0.0);
};
let label_spacing = self.hints.label_spacing;
let mut thickness: f32 = 0.0;
// Add tick labels:
for step in self.steps.iter() {
let text = (self.hints.formatter)(*step, &self.range);
if !text.is_empty() {
let spacing_in_points =
(transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32;
if spacing_in_points <= label_spacing.min {
// Labels are too close together - don't paint them.
continue;
}
// Fade in labels as they get further apart:
let strength = remap_clamp(spacing_in_points, label_spacing, 0.0..=1.0);
let text_color = super::color_from_strength(ui, strength);
let galley = ui
.painter()
.layout_no_wrap(text, font_id.clone(), text_color);
if spacing_in_points < galley.size()[axis as usize] {
continue; // the galley won't fit (likely too wide on the X axis).
}
match axis {
Axis::X => {
thickness = thickness.max(galley.size().y);
let projected_point = super::PlotPoint::new(step.value, 0.0);
let center_x = transform.position_from_point(&projected_point).x;
let y = match VPlacement::from(self.hints.placement) {
VPlacement::Bottom => self.rect.min.y,
VPlacement::Top => self.rect.max.y - galley.size().y,
};
let pos = Pos2::new(center_x - galley.size().x / 2.0, y);
ui.painter().add(TextShape::new(pos, galley, text_color));
}
Axis::Y => {
thickness = thickness.max(galley.size().x);
let projected_point = super::PlotPoint::new(0.0, step.value);
let center_y = transform.position_from_point(&projected_point).y;
match HPlacement::from(self.hints.placement) {
HPlacement::Left => {
let angle = 0.0; // TODO(emilk): allow users to rotate text
if angle == 0.0 {
let x = self.rect.max.x - galley.size().x;
let pos = Pos2::new(x, center_y - galley.size().y / 2.0);
ui.painter().add(TextShape::new(pos, galley, text_color));
} else {
let right = Pos2::new(
self.rect.max.x,
center_y - galley.size().y / 2.0,
);
let width = galley.size().x;
let left =
right - Rot2::from_angle(angle) * Vec2::new(width, 0.0);
ui.painter().add(
TextShape::new(left, galley, text_color).with_angle(angle),
);
}
}
HPlacement::Right => {
let x = self.rect.min.x;
let pos = Pos2::new(x, center_y - galley.size().y / 2.0);
ui.painter().add(TextShape::new(pos, galley, text_color));
}
};
}
};
}
}
(response, thickness)
}
}

View File

@@ -1,197 +0,0 @@
use egui::emath::NumExt;
use egui::epaint::{Color32, RectShape, Rounding, Shape, Stroke};
use super::{add_rulers_and_text, highlighted_color, Orientation, PlotConfig, RectElement};
use crate::{BarChart, Cursor, PlotPoint, PlotTransform};
/// 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) -> Self {
Self {
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)]
#[inline]
pub fn name(mut self, name: impl ToString) -> Self {
self.name = name.to_string();
self
}
/// Add a custom stroke.
#[inline]
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = stroke.into();
self
}
/// Add a custom fill color.
#[inline]
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.
#[inline]
pub fn base_offset(mut self, offset: f64) -> Self {
self.base_offset = Some(offset);
self
}
/// Set the bar width.
#[inline]
pub fn width(mut self, width: f64) -> Self {
self.bar_width = width;
self
}
/// Set orientation of the element as vertical. Argument axis is X.
#[inline]
pub fn vertical(mut self) -> Self {
self.orientation = Orientation::Vertical;
self
}
/// Set orientation of the element as horizontal. Argument axis is Y.
#[inline]
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: &PlotTransform,
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::new(rect, Rounding::ZERO, fill, stroke));
shapes.push(rect);
}
pub(super) fn add_rulers_and_text(
&self,
parent: &BarChart,
plot: &PlotConfig<'_>,
shapes: &mut Vec<Shape>,
cursors: &mut Vec<Cursor>,
) {
let text: Option<String> = parent
.element_formatter
.as_ref()
.map(|fmt| fmt(self, parent));
add_rulers_and_text(self, plot, text, shapes, cursors);
}
}
impl RectElement for Bar {
fn name(&self) -> &str {
self.name.as_str()
}
fn bounds_min(&self) -> PlotPoint {
self.point_at(self.argument - self.bar_width / 2.0, self.lower())
}
fn bounds_max(&self) -> PlotPoint {
self.point_at(self.argument + self.bar_width / 2.0, self.upper())
}
fn values_with_ruler(&self) -> Vec<PlotPoint> {
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: &PlotTransform) -> String {
let scale = transform.dvalue_dpos();
let scale = match self.orientation {
Orientation::Horizontal => scale[0],
Orientation::Vertical => scale[1],
};
let decimals = ((-scale.abs().log10()).ceil().at_least(0.0) as usize).at_most(6);
crate::format_number(self.value, decimals)
}
}

View File

@@ -1,296 +0,0 @@
use egui::emath::NumExt as _;
use egui::epaint::{Color32, RectShape, Rounding, Shape, Stroke};
use crate::{BoxPlot, Cursor, PlotPoint, PlotTransform};
use super::{add_rulers_and_text, highlighted_color, Orientation, PlotConfig, RectElement};
/// 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)]
#[inline]
pub fn name(mut self, name: impl ToString) -> Self {
self.name = name.to_string();
self
}
/// Add a custom stroke.
#[inline]
pub fn stroke(mut self, stroke: impl Into<Stroke>) -> Self {
self.stroke = stroke.into();
self
}
/// Add a custom fill color.
#[inline]
pub fn fill(mut self, color: impl Into<Color32>) -> Self {
self.fill = color.into();
self
}
/// Set the box width.
#[inline]
pub fn box_width(mut self, width: f64) -> Self {
self.box_width = width;
self
}
/// Set the whisker width.
#[inline]
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.
#[inline]
pub fn vertical(mut self) -> Self {
self.orientation = Orientation::Vertical;
self
}
/// Set orientation of the element as horizontal. Argument axis is Y.
#[inline]
pub fn horizontal(mut self) -> Self {
self.orientation = Orientation::Horizontal;
self
}
pub(super) fn add_shapes(
&self,
transform: &PlotTransform,
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::new(rect, Rounding::ZERO, fill, stroke));
shapes.push(rect);
let line_between = |v1, v2| {
Shape::line_segment(
[
transform.position_from_point(&v1),
transform.position_from_point(&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>,
cursors: &mut Vec<Cursor>,
) {
let text: Option<String> = parent
.element_formatter
.as_ref()
.map(|fmt| fmt(self, parent));
add_rulers_and_text(self, plot, text, shapes, cursors);
}
}
impl RectElement for BoxElem {
fn name(&self) -> &str {
self.name.as_str()
}
fn bounds_min(&self) -> PlotPoint {
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) -> PlotPoint {
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<PlotPoint> {
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) -> PlotPoint {
self.point_at(self.argument, self.spread.upper_whisker)
}
fn default_values_format(&self, transform: &PlotTransform) -> String {
let scale = transform.dvalue_dpos();
let scale = match self.orientation {
Orientation::Horizontal => scale[0],
Orientation::Vertical => scale[1],
};
let y_decimals = ((-scale.abs().log10()).ceil().at_least(0.0) as usize)
.at_most(6)
.at_least(1);
format!(
"Max = {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
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
use egui::emath::NumExt as _;
use egui::epaint::{Color32, Rgba, Stroke};
use crate::transform::{PlotBounds, PlotTransform};
use super::{Orientation, PlotPoint};
/// 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) -> PlotPoint;
fn bounds_max(&self) -> PlotPoint;
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<PlotPoint> {
// 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<PlotPoint>;
/// 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) -> PlotPoint {
match self.orientation() {
Orientation::Horizontal => PlotPoint::new(value, argument),
Orientation::Vertical => PlotPoint::new(argument, value),
}
}
/// Right top of the rectangle (position of text)
fn corner_value(&self) -> PlotPoint {
//self.point_at(self.position + self.width / 2.0, value)
PlotPoint {
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: &PlotTransform) -> String;
}
// ----------------------------------------------------------------------------
// Helper functions
pub(super) fn highlighted_color(mut stroke: Stroke, fill: Color32) -> (Stroke, Color32) {
stroke.width *= 2.0;
let mut fill = Rgba::from(fill);
if fill.is_additive() {
// Make slightly brighter
fill = 1.3 * fill;
} else {
// Make more opaque:
let fill_alpha = (2.0 * fill.a()).at_most(1.0);
fill = fill.to_opaque().multiply(fill_alpha);
}
(stroke, fill.into())
}

View File

@@ -1,434 +0,0 @@
use std::ops::{Bound, RangeBounds, RangeInclusive};
use egui::{Pos2, Shape, Stroke, Vec2};
use crate::transform::PlotBounds;
/// A point coordinate in 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 PlotPoint {
/// 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 From<[f64; 2]> for PlotPoint {
#[inline]
fn from([x, y]: [f64; 2]) -> Self {
Self { x, y }
}
}
impl PlotPoint {
#[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)
}
}
// ----------------------------------------------------------------------------
/// Solid, dotted, dashed, etc.
#[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 {
Self::Solid => {
if highlight {
stroke.width *= 2.0;
}
shapes.push(Shape::line(line, stroke));
}
Self::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));
}
Self::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 std::fmt::Display for LineStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Solid => write!(f, "Solid"),
Self::Dotted { spacing } => write!(f, "Dotted({spacing} px)"),
Self::Dashed { length } => write!(f, "Dashed({length} px)"),
}
}
}
// ----------------------------------------------------------------------------
/// Determines whether a plot element is vertically or horizontally oriented.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Orientation {
Horizontal,
Vertical,
}
impl Default for Orientation {
fn default() -> Self {
Self::Vertical
}
}
// ----------------------------------------------------------------------------
/// Represents many [`PlotPoint`]s.
///
/// These can be an owned `Vec` or generated with a function.
pub enum PlotPoints {
Owned(Vec<PlotPoint>),
Generator(ExplicitGenerator),
// Borrowed(&[PlotPoint]), // TODO(EmbersArc): Lifetimes are tricky in this case.
}
impl Default for PlotPoints {
fn default() -> Self {
Self::Owned(Vec::new())
}
}
impl From<[f64; 2]> for PlotPoints {
fn from(coordinate: [f64; 2]) -> Self {
Self::new(vec![coordinate])
}
}
impl From<Vec<[f64; 2]>> for PlotPoints {
fn from(coordinates: Vec<[f64; 2]>) -> Self {
Self::new(coordinates)
}
}
impl FromIterator<[f64; 2]> for PlotPoints {
fn from_iter<T: IntoIterator<Item = [f64; 2]>>(iter: T) -> Self {
Self::Owned(iter.into_iter().map(|point| point.into()).collect())
}
}
impl PlotPoints {
pub fn new(points: Vec<[f64; 2]>) -> Self {
Self::from_iter(points)
}
pub fn points(&self) -> &[PlotPoint] {
match self {
Self::Owned(points) => points.as_slice(),
Self::Generator(_) => &[],
}
}
/// 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::Generator(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
};
(0..points)
.map(|i| {
let t = start + i as f64 * increment;
function(t).into()
})
.collect()
}
/// From a series of y-values.
/// The x-values will be the indices of these values
pub fn from_ys_f32(ys: &[f32]) -> Self {
ys.iter()
.enumerate()
.map(|(i, &y)| [i as f64, y as f64])
.collect()
}
/// From a series of y-values.
/// The x-values will be the indices of these values
pub fn from_ys_f64(ys: &[f64]) -> Self {
ys.iter().enumerate().map(|(i, &y)| [i as f64, y]).collect()
}
/// Returns true if there are no data points available and there is no function to generate any.
pub(crate) fn is_empty(&self) -> bool {
match self {
Self::Owned(points) => points.is_empty(),
Self::Generator(_) => false,
}
}
/// 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 Self::Generator(generator) = self {
*self = Self::range_intersection(&x_range, &generator.x_range)
.map(|intersection| {
let increment =
(intersection.end() - intersection.start()) / (generator.points - 1) as f64;
(0..generator.points)
.map(|i| {
let x = intersection.start() + i as f64 * increment;
let y = (generator.function)(x);
[x, y]
})
.collect()
})
.unwrap_or_default();
}
}
/// 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_some(start..=end)
}
pub(super) fn bounds(&self) -> PlotBounds {
match self {
Self::Owned(points) => {
let mut bounds = PlotBounds::NOTHING;
for point in points {
bounds.extend_with(point);
}
bounds
}
Self::Generator(generator) => generator.estimate_bounds(),
}
}
}
// ----------------------------------------------------------------------------
/// Circle, Diamond, Square, Cross, …
#[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 ExactSizeIterator<Item = Self> {
[
Self::Circle,
Self::Diamond,
Self::Square,
Self::Cross,
Self::Plus,
Self::Up,
Self::Down,
Self::Left,
Self::Right,
Self::Asterisk,
]
.iter()
.copied()
}
}
// ----------------------------------------------------------------------------
/// Query the points of the plot, for geometric relations like closest checks
pub enum PlotGeometry<'a> {
/// No geometry based on single elements (examples: text, image, horizontal/vertical line)
None,
/// Point values (X-Y graphs)
Points(&'a [PlotPoint]),
/// 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.
pub struct ExplicitGenerator {
function: Box<dyn Fn(f64) -> f64>,
x_range: RangeInclusive<f64>,
points: usize,
}
impl ExplicitGenerator {
fn estimate_bounds(&self) -> PlotBounds {
let mut bounds = PlotBounds::NOTHING;
let mut add_x = |x: f64| {
// avoid infinities, as we cannot auto-bound on them!
if x.is_finite() {
bounds.extend_with_x(x);
}
let y = (self.function)(x);
if y.is_finite() {
bounds.extend_with_y(y);
}
};
let min_x = *self.x_range.start();
let max_x = *self.x_range.end();
add_x(min_x);
add_x(max_x);
if min_x.is_finite() && max_x.is_finite() {
// Sample some points in the interval:
const N: u32 = 8;
for i in 1..N {
let t = i as f64 / (N - 1) as f64;
let x = crate::lerp(min_x..=max_x, t);
add_x(x);
}
} else {
// Try adding some points anyway:
for x in [-1, 0, 1] {
let x = x as f64;
if min_x <= x && x <= max_x {
add_x(x);
}
}
}
bounds
}
}
// ----------------------------------------------------------------------------
/// Result of [`super::PlotItem::find_closest()`] search, identifies an element inside the item for immediate use
pub 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,324 +0,0 @@
use std::{collections::BTreeMap, string::String};
use crate::*;
use super::items::PlotItem;
/// Where to place the plot legend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Corner {
LeftTop,
RightTop,
LeftBottom,
RightBottom,
}
impl Corner {
pub fn all() -> impl Iterator<Item = Self> {
[
Self::LeftTop,
Self::RightTop,
Self::LeftBottom,
Self::RightBottom,
]
.iter()
.copied()
}
}
/// The configuration for a plot legend.
#[derive(Clone, PartialEq)]
pub struct Legend {
pub text_style: TextStyle,
pub background_alpha: f32,
pub position: Corner,
/// Used for overriding the `hidden_items` set in [`LegendWidget`].
hidden_items: Option<ahash::HashSet<String>>,
}
impl Default for Legend {
fn default() -> Self {
Self {
text_style: TextStyle::Body,
background_alpha: 0.75,
position: Corner::RightTop,
hidden_items: None,
}
}
}
impl Legend {
/// Which text style to use for the legend. Default: `TextStyle::Body`.
#[inline]
pub fn text_style(mut self, style: TextStyle) -> Self {
self.text_style = style;
self
}
/// The alpha of the legend background. Default: `0.75`.
#[inline]
pub fn background_alpha(mut self, alpha: f32) -> Self {
self.background_alpha = alpha;
self
}
/// In which corner to place the legend. Default: `Corner::RightTop`.
#[inline]
pub fn position(mut self, corner: Corner) -> Self {
self.position = corner;
self
}
/// Specifies hidden items in the legend configuration to override the existing ones. This
/// allows the legend traces' visibility to be controlled from the application code.
#[inline]
pub fn hidden_items<I>(mut self, hidden_items: I) -> Self
where
I: IntoIterator<Item = String>,
{
self.hidden_items = Some(hidden_items.into_iter().collect());
self
}
}
#[derive(Clone)]
struct LegendEntry {
color: Color32,
checked: bool,
hovered: bool,
}
impl LegendEntry {
fn new(color: Color32, checked: bool) -> Self {
Self {
color,
checked,
hovered: false,
}
}
fn ui(&self, ui: &mut Ui, text: String, text_style: &TextStyle) -> Response {
let Self {
color,
checked,
hovered: _,
} = self;
let font_id = text_style.resolve(ui.style());
let galley = ui.fonts(|f| f.layout_delayed_color(text, font_id, f32::INFINITY));
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());
response.widget_info(|| {
WidgetInfo::selected(
WidgetType::Checkbox,
ui.is_enabled(),
*checked,
galley.text(),
)
});
let visuals = ui.style().interact(&response);
let label_on_the_left = ui.layout().horizontal_placement() == Align::RIGHT;
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(epaint::CircleShape {
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(epaint::Shape::circle_filled(
icon_rect.center(),
icon_size * 0.4,
fill,
));
}
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());
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: &ahash::HashSet<String>, // Existing hidden items in the plot memory.
) -> Option<Self> {
// If `config.hidden_items` is not `None`, it is used.
let hidden_items = config.hidden_items.as_ref().unwrap_or(hidden_items);
// 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_owned())
.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_some(Self {
rect,
entries,
config,
})
}
// Get the names of the hidden items.
pub fn hidden_items(&self) -> ahash::HashSet<String> {
self.entries
.iter()
.filter(|(_, entry)| !entry.checked)
.map(|(name, _)| name.clone())
.collect()
}
// Get the name of the hovered items.
pub fn hovered_item_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 = 4.0;
let legend_rect = rect.shrink(legend_pad);
let mut legend_ui = ui.child_ui(legend_rect, layout, None);
legend_ui
.scope(|ui| {
let background_frame = Frame {
inner_margin: vec2(8.0, 4.0).into(),
rounding: ui.style().visuals.window_rounding,
shadow: epaint::Shadow::NONE,
fill: ui.style().visuals.extreme_bg_color,
stroke: ui.style().visuals.window_stroke(),
..Default::default()
}
.multiply_with_opacity(config.background_alpha);
background_frame
.show(ui, |ui| {
let mut focus_on_item = None;
let response_union = entries
.iter_mut()
.map(|(name, entry)| {
let response = entry.ui(ui, name.clone(), &config.text_style);
// Handle interactions. Alt-clicking must be deferred to end of loop
// since it may affect all entries.
handle_interaction_on_legend_item(&response, entry);
if response.clicked() && ui.input(|r| r.modifiers.alt) {
focus_on_item = Some(name.clone());
}
response
})
.reduce(|r1, r2| r1.union(r2))
.unwrap();
if let Some(focus_on_item) = focus_on_item {
handle_focus_on_legend_item(&focus_on_item, entries);
}
response_union
})
.inner
})
.inner
}
}
/// Handle per-entry interactions.
fn handle_interaction_on_legend_item(response: &Response, entry: &mut LegendEntry) {
entry.checked ^= response.clicked_by(PointerButton::Primary);
entry.hovered = response.hovered();
}
/// Handle alt-click interaction (which may affect all entries).
fn handle_focus_on_legend_item(
clicked_entry_name: &str,
entries: &mut BTreeMap<String, LegendEntry>,
) {
// if all other items are already hidden, we show everything
let is_focus_item_only_visible = entries
.iter()
.all(|(name, entry)| !entry.checked || (clicked_entry_name == name));
// either show everything or show only the focus item
for (name, entry) in entries.iter_mut() {
entry.checked = is_focus_item_only_visible || clicked_entry_name == name;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
use std::collections::BTreeMap;
use egui::{Context, Id, Pos2, Vec2b};
use crate::{PlotBounds, PlotTransform};
/// Information about the plot that has to persist between frames.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone)]
pub struct PlotMemory {
/// Indicates if the plot uses automatic bounds.
///
/// This is set to `false` whenever the user modifies
/// the bounds, for example by moving or zooming.
pub auto_bounds: Vec2b,
/// Display string of the hovered legend item if any.
pub hovered_legend_item: Option<String>,
/// Which items _not_ to show?
pub hidden_items: ahash::HashSet<String>,
/// The transform from last frame.
pub(crate) transform: PlotTransform,
/// Allows to remember the first click position when performing a boxed zoom
pub(crate) last_click_pos_for_zoom: Option<Pos2>,
/// The thickness of each of the axes the previous frame.
///
/// This is used in the next frame to make the axes thicker
/// in order to fit the labels, if necessary.
pub(crate) x_axis_thickness: BTreeMap<usize, f32>,
pub(crate) y_axis_thickness: BTreeMap<usize, f32>,
}
impl PlotMemory {
#[inline]
pub fn transform(&self) -> PlotTransform {
self.transform
}
#[inline]
pub fn set_transform(&mut self, t: PlotTransform) {
self.transform = t;
}
/// Plot-space bounds.
#[inline]
pub fn bounds(&self) -> &PlotBounds {
self.transform.bounds()
}
/// Plot-space bounds.
#[inline]
pub fn set_bounds(&mut self, bounds: PlotBounds) {
self.transform.set_bounds(bounds);
}
}
#[cfg(feature = "serde")]
impl PlotMemory {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data_mut(|d| d.get_persisted(id))
}
pub fn store(self, ctx: &Context, id: Id) {
ctx.data_mut(|d| d.insert_persisted(id, self));
}
}
#[cfg(not(feature = "serde"))]
impl PlotMemory {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data_mut(|d| d.get_temp(id))
}
pub fn store(self, ctx: &Context, id: Id) {
ctx.data_mut(|d| d.insert_temp(id, self));
}
}

View File

@@ -1,235 +0,0 @@
use crate::*;
/// Provides methods to interact with a plot while building it. It is the single argument of the closure
/// provided to [`Plot::show`]. See [`Plot`] for an example of how to use it.
pub struct PlotUi {
pub(crate) ctx: Context,
pub(crate) items: Vec<Box<dyn PlotItem>>,
pub(crate) next_auto_color_idx: usize,
pub(crate) last_plot_transform: PlotTransform,
pub(crate) last_auto_bounds: Vec2b,
pub(crate) response: Response,
pub(crate) bounds_modifications: Vec<BoundsModification>,
}
impl PlotUi {
fn auto_color(&mut self) -> Color32 {
let i = self.next_auto_color_idx;
self.next_auto_color_idx += 1;
let golden_ratio = (5.0_f32.sqrt() - 1.0) / 2.0; // 0.61803398875
let h = i as f32 * golden_ratio;
Hsva::new(h, 0.85, 0.5, 1.0).into() // TODO(emilk): OkLab or some other perspective color space
}
pub fn ctx(&self) -> &Context {
&self.ctx
}
/// The plot bounds as they were in the last frame. If called on the first frame and the bounds were not
/// further specified in the plot builder, this will return bounds centered on the origin. The bounds do
/// not change until the plot is drawn.
pub fn plot_bounds(&self) -> PlotBounds {
*self.last_plot_transform.bounds()
}
/// Set the plot bounds. Can be useful for implementing alternative plot navigation methods.
pub fn set_plot_bounds(&mut self, plot_bounds: PlotBounds) {
self.bounds_modifications
.push(BoundsModification::Set(plot_bounds));
}
/// Move the plot bounds. Can be useful for implementing alternative plot navigation methods.
pub fn translate_bounds(&mut self, delta_pos: Vec2) {
self.bounds_modifications
.push(BoundsModification::Translate(delta_pos));
}
/// Whether the plot axes were in auto-bounds mode in the last frame. If called on the first
/// frame, this is the [`Plot`]'s default auto-bounds mode.
pub fn auto_bounds(&self) -> Vec2b {
self.last_auto_bounds
}
/// Set the auto-bounds mode for the plot axes.
pub fn set_auto_bounds(&mut self, auto_bounds: Vec2b) {
self.bounds_modifications
.push(BoundsModification::AutoBounds(auto_bounds));
}
/// Can be used to check if the plot was hovered or clicked.
pub fn response(&self) -> &Response {
&self.response
}
/// Scale the plot bounds around a position in plot coordinates.
///
/// Can be useful for implementing alternative plot navigation methods.
///
/// The plot bounds are divided by `zoom_factor`, therefore:
/// - `zoom_factor < 1.0` zooms out, i.e., increases the visible range to show more data.
/// - `zoom_factor > 1.0` zooms in, i.e., reduces the visible range to show more detail.
pub fn zoom_bounds(&mut self, zoom_factor: Vec2, center: PlotPoint) {
self.bounds_modifications
.push(BoundsModification::Zoom(zoom_factor, center));
}
/// Scale the plot bounds around the hovered position, if any.
///
/// Can be useful for implementing alternative plot navigation methods.
///
/// The plot bounds are divided by `zoom_factor`, therefore:
/// - `zoom_factor < 1.0` zooms out, i.e., increases the visible range to show more data.
/// - `zoom_factor > 1.0` zooms in, i.e., reduces the visible range to show more detail.
pub fn zoom_bounds_around_hovered(&mut self, zoom_factor: Vec2) {
if let Some(hover_pos) = self.pointer_coordinate() {
self.zoom_bounds(zoom_factor, hover_pos);
}
}
/// The pointer position in plot coordinates. Independent of whether the pointer is in the plot area.
pub fn pointer_coordinate(&self) -> Option<PlotPoint> {
// We need to subtract the drag delta to keep in sync with the frame-delayed screen transform:
let last_pos = self.ctx().input(|i| i.pointer.latest_pos())? - self.response.drag_delta();
let value = self.plot_from_screen(last_pos);
Some(value)
}
/// The pointer drag delta in plot coordinates.
pub fn pointer_coordinate_drag_delta(&self) -> Vec2 {
let delta = self.response.drag_delta();
let dp_dv = self.last_plot_transform.dpos_dvalue();
Vec2::new(delta.x / dp_dv[0] as f32, delta.y / dp_dv[1] as f32)
}
/// Read the transform between plot coordinates and screen coordinates.
pub fn transform(&self) -> &PlotTransform {
&self.last_plot_transform
}
/// Transform the plot coordinates to screen coordinates.
pub fn screen_from_plot(&self, position: PlotPoint) -> Pos2 {
self.last_plot_transform.position_from_point(&position)
}
/// Transform the screen coordinates to plot coordinates.
pub fn plot_from_screen(&self, position: Pos2) -> PlotPoint {
self.last_plot_transform.value_from_position(position)
}
/// Add an arbitrary item.
pub fn add(&mut self, item: impl PlotItem + 'static) {
self.items.push(Box::new(item));
}
/// Add a data line.
pub fn line(&mut self, mut line: Line) {
if line.series.is_empty() {
return;
};
// Give the stroke an automatic color if no color has been assigned.
if line.stroke.color == Color32::TRANSPARENT {
line.stroke.color = self.auto_color();
}
self.items.push(Box::new(line));
}
/// Add a polygon. The polygon has to be convex.
pub fn polygon(&mut self, mut polygon: Polygon) {
if polygon.series.is_empty() {
return;
};
// Give the stroke an automatic color if no color has been assigned.
if polygon.stroke.color == Color32::TRANSPARENT {
polygon.stroke.color = self.auto_color();
}
self.items.push(Box::new(polygon));
}
/// Add a text.
pub fn text(&mut self, text: Text) {
if text.text.is_empty() {
return;
};
self.items.push(Box::new(text));
}
/// Add data points.
pub fn points(&mut self, mut points: Points) {
if points.series.is_empty() {
return;
};
// Give the points an automatic color if no color has been assigned.
if points.color == Color32::TRANSPARENT {
points.color = self.auto_color();
}
self.items.push(Box::new(points));
}
/// Add arrows.
pub fn arrows(&mut self, mut arrows: Arrows) {
if arrows.origins.is_empty() || arrows.tips.is_empty() {
return;
};
// Give the arrows an automatic color if no color has been assigned.
if arrows.color == Color32::TRANSPARENT {
arrows.color = self.auto_color();
}
self.items.push(Box::new(arrows));
}
/// Add an image.
pub fn image(&mut self, image: PlotImage) {
self.items.push(Box::new(image));
}
/// Add a horizontal line.
/// Can be useful e.g. to show min/max bounds or similar.
/// Always fills the full width of the plot.
pub fn hline(&mut self, mut hline: HLine) {
if hline.stroke.color == Color32::TRANSPARENT {
hline.stroke.color = self.auto_color();
}
self.items.push(Box::new(hline));
}
/// Add a vertical line.
/// Can be useful e.g. to show min/max bounds or similar.
/// Always fills the full height of the plot.
pub fn vline(&mut self, mut vline: VLine) {
if vline.stroke.color == Color32::TRANSPARENT {
vline.stroke.color = self.auto_color();
}
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));
}
}

View File

@@ -1,509 +0,0 @@
use std::ops::RangeInclusive;
use super::PlotPoint;
use crate::*;
/// 2D bounding box of f64 precision.
///
/// The range of data values we show.
#[derive(Clone, Copy, PartialEq, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PlotBounds {
pub(crate) min: [f64; 2],
pub(crate) max: [f64; 2],
}
impl PlotBounds {
pub const NOTHING: Self = Self {
min: [f64::INFINITY; 2],
max: [-f64::INFINITY; 2],
};
#[inline]
pub fn from_min_max(min: [f64; 2], max: [f64; 2]) -> Self {
Self { min, max }
}
#[inline]
pub fn min(&self) -> [f64; 2] {
self.min
}
#[inline]
pub fn max(&self) -> [f64; 2] {
self.max
}
#[inline]
pub fn new_symmetrical(half_extent: f64) -> Self {
Self {
min: [-half_extent; 2],
max: [half_extent; 2],
}
}
#[inline]
pub fn is_finite(&self) -> bool {
self.min[0].is_finite()
&& self.min[1].is_finite()
&& self.max[0].is_finite()
&& self.max[1].is_finite()
}
#[inline]
pub fn is_finite_x(&self) -> bool {
self.min[0].is_finite() && self.max[0].is_finite()
}
#[inline]
pub fn is_finite_y(&self) -> bool {
self.min[1].is_finite() && self.max[1].is_finite()
}
#[inline]
pub fn is_valid(&self) -> bool {
self.is_finite() && self.width() > 0.0 && self.height() > 0.0
}
#[inline]
pub fn is_valid_x(&self) -> bool {
self.is_finite_x() && self.width() > 0.0
}
#[inline]
pub fn is_valid_y(&self) -> bool {
self.is_finite_y() && self.height() > 0.0
}
#[inline]
pub fn width(&self) -> f64 {
self.max[0] - self.min[0]
}
#[inline]
pub fn height(&self) -> f64 {
self.max[1] - self.min[1]
}
#[inline]
pub fn center(&self) -> PlotPoint {
[
(self.min[0] + self.max[0]) / 2.0,
(self.min[1] + self.max[1]) / 2.0,
]
.into()
}
/// Expand to include the given (x,y) value
#[inline]
pub fn extend_with(&mut self, value: &PlotPoint) {
self.extend_with_x(value.x);
self.extend_with_y(value.y);
}
/// Expand to include the given x coordinate
#[inline]
pub fn extend_with_x(&mut self, x: f64) {
self.min[0] = self.min[0].min(x);
self.max[0] = self.max[0].max(x);
}
/// Expand to include the given y coordinate
#[inline]
pub fn extend_with_y(&mut self, y: f64) {
self.min[1] = self.min[1].min(y);
self.max[1] = self.max[1].max(y);
}
#[inline]
fn clamp_to_finite(&mut self) {
for d in 0..2 {
self.min[d] = self.min[d].clamp(f64::MIN, f64::MAX);
if self.min[d].is_nan() {
self.min[d] = 0.0;
}
self.max[d] = self.max[d].clamp(f64::MIN, f64::MAX);
if self.max[d].is_nan() {
self.max[d] = 0.0;
}
}
}
#[inline]
pub fn expand_x(&mut self, pad: f64) {
if pad.is_finite() {
self.min[0] -= pad;
self.max[0] += pad;
self.clamp_to_finite();
}
}
#[inline]
pub fn expand_y(&mut self, pad: f64) {
if pad.is_finite() {
self.min[1] -= pad;
self.max[1] += pad;
self.clamp_to_finite();
}
}
#[inline]
pub fn merge_x(&mut self, other: &Self) {
self.min[0] = self.min[0].min(other.min[0]);
self.max[0] = self.max[0].max(other.max[0]);
}
#[inline]
pub fn merge_y(&mut self, other: &Self) {
self.min[1] = self.min[1].min(other.min[1]);
self.max[1] = self.max[1].max(other.max[1]);
}
#[inline]
pub fn set_x(&mut self, other: &Self) {
self.min[0] = other.min[0];
self.max[0] = other.max[0];
}
#[inline]
pub fn set_x_center_width(&mut self, x: f64, width: f64) {
self.min[0] = x - width / 2.0;
self.max[0] = x + width / 2.0;
}
#[inline]
pub fn set_y(&mut self, other: &Self) {
self.min[1] = other.min[1];
self.max[1] = other.max[1];
}
#[inline]
pub fn set_y_center_height(&mut self, y: f64, height: f64) {
self.min[1] = y - height / 2.0;
self.max[1] = y + height / 2.0;
}
#[inline]
pub fn merge(&mut self, other: &Self) {
self.min[0] = self.min[0].min(other.min[0]);
self.min[1] = self.min[1].min(other.min[1]);
self.max[0] = self.max[0].max(other.max[0]);
self.max[1] = self.max[1].max(other.max[1]);
}
#[inline]
pub fn translate_x(&mut self, delta: f64) {
if delta.is_finite() {
self.min[0] += delta;
self.max[0] += delta;
self.clamp_to_finite();
}
}
#[inline]
pub fn translate_y(&mut self, delta: f64) {
if delta.is_finite() {
self.min[1] += delta;
self.max[1] += delta;
self.clamp_to_finite();
}
}
#[inline]
pub fn translate(&mut self, delta: (f64, f64)) {
self.translate_x(delta.0);
self.translate_y(delta.1);
}
#[inline]
pub fn zoom(&mut self, zoom_factor: Vec2, center: PlotPoint) {
self.min[0] = center.x + (self.min[0] - center.x) / (zoom_factor.x as f64);
self.max[0] = center.x + (self.max[0] - center.x) / (zoom_factor.x as f64);
self.min[1] = center.y + (self.min[1] - center.y) / (zoom_factor.y as f64);
self.max[1] = center.y + (self.max[1] - center.y) / (zoom_factor.y as f64);
}
#[inline]
pub fn add_relative_margin_x(&mut self, margin_fraction: Vec2) {
let width = self.width().max(0.0);
self.expand_x(margin_fraction.x as f64 * width);
}
#[inline]
pub fn add_relative_margin_y(&mut self, margin_fraction: Vec2) {
let height = self.height().max(0.0);
self.expand_y(margin_fraction.y as f64 * height);
}
#[inline]
pub fn range_x(&self) -> RangeInclusive<f64> {
self.min[0]..=self.max[0]
}
#[inline]
pub fn range_y(&self) -> RangeInclusive<f64> {
self.min[1]..=self.max[1]
}
#[inline]
pub fn make_x_symmetrical(&mut self) {
let x_abs = self.min[0].abs().max(self.max[0].abs());
self.min[0] = -x_abs;
self.max[0] = x_abs;
}
#[inline]
pub fn make_y_symmetrical(&mut self) {
let y_abs = self.min[1].abs().max(self.max[1].abs());
self.min[1] = -y_abs;
self.max[1] = y_abs;
}
}
/// Contains the screen rectangle and the plot bounds and provides methods to transform between them.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug)]
pub struct PlotTransform {
/// The screen rectangle.
frame: Rect,
/// The plot bounds.
bounds: PlotBounds,
/// Whether to always center the x-range of the bounds.
x_centered: bool,
/// Whether to always center the y-range of the bounds.
y_centered: bool,
}
impl PlotTransform {
pub fn new(frame: Rect, bounds: PlotBounds, x_centered: bool, y_centered: bool) -> Self {
debug_assert!(
0.0 <= frame.width() && 0.0 <= frame.height(),
"Bad plot frame: {frame:?}"
);
// Since the current Y bounds an affect the final X bounds and vice versa, we need to keep
// the original version of the `bounds` before we start modifying it.
let mut new_bounds = bounds;
// Sanitize bounds.
//
// When a given bound axis is "thin" (e.g. width or height is 0) but finite, we center the
// bounds around that value. If the other axis is "fat", we reuse its extent for the thin
// axis, and default to +/- 1.0 otherwise.
if !bounds.is_finite_x() {
new_bounds.set_x(&PlotBounds::new_symmetrical(1.0));
} else if bounds.width() <= 0.0 {
new_bounds.set_x_center_width(
bounds.center().x,
if bounds.is_valid_y() {
bounds.height()
} else {
1.0
},
);
};
if !bounds.is_finite_y() {
new_bounds.set_y(&PlotBounds::new_symmetrical(1.0));
} else if bounds.height() <= 0.0 {
new_bounds.set_y_center_height(
bounds.center().y,
if bounds.is_valid_x() {
bounds.width()
} else {
1.0
},
);
};
// Scale axes so that the origin is in the center.
if x_centered {
new_bounds.make_x_symmetrical();
};
if y_centered {
new_bounds.make_y_symmetrical();
};
debug_assert!(
new_bounds.is_valid(),
"Bad final plot bounds: {new_bounds:?}"
);
Self {
frame,
bounds: new_bounds,
x_centered,
y_centered,
}
}
/// ui-space rectangle.
#[inline]
pub fn frame(&self) -> &Rect {
&self.frame
}
/// Plot-space bounds.
#[inline]
pub fn bounds(&self) -> &PlotBounds {
&self.bounds
}
#[inline]
pub fn set_bounds(&mut self, bounds: PlotBounds) {
self.bounds = bounds;
}
pub fn translate_bounds(&mut self, mut delta_pos: (f64, f64)) {
if self.x_centered {
delta_pos.0 = 0.;
}
if self.y_centered {
delta_pos.1 = 0.;
}
delta_pos.0 *= self.dvalue_dpos()[0];
delta_pos.1 *= self.dvalue_dpos()[1];
self.bounds.translate((delta_pos.0, delta_pos.1));
}
/// Zoom by a relative factor with the given screen position as center.
pub fn zoom(&mut self, zoom_factor: Vec2, center: Pos2) {
let center = self.value_from_position(center);
let mut new_bounds = self.bounds;
new_bounds.zoom(zoom_factor, center);
if new_bounds.is_valid() {
self.bounds = new_bounds;
}
}
pub fn position_from_point_x(&self, value: f64) -> f32 {
remap(
value,
self.bounds.min[0]..=self.bounds.max[0],
(self.frame.left() as f64)..=(self.frame.right() as f64),
) as f32
}
pub fn position_from_point_y(&self, value: f64) -> f32 {
remap(
value,
self.bounds.min[1]..=self.bounds.max[1],
(self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis!
) as f32
}
/// Screen/ui position from point on plot.
pub fn position_from_point(&self, value: &PlotPoint) -> Pos2 {
pos2(
self.position_from_point_x(value.x),
self.position_from_point_y(value.y),
)
}
/// Plot point from screen/ui position.
pub fn value_from_position(&self, pos: Pos2) -> PlotPoint {
let x = remap(
pos.x as f64,
(self.frame.left() as f64)..=(self.frame.right() as f64),
self.bounds.range_x(),
);
let y = remap(
pos.y as f64,
(self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis!
self.bounds.range_y(),
);
PlotPoint::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: &PlotPoint, value2: &PlotPoint) -> Rect {
let pos1 = self.position_from_point(value1);
let pos2 = self.position_from_point(value2);
let mut rect = Rect::NOTHING;
rect.extend_with(pos1);
rect.extend_with(pos2);
rect
}
/// delta position / delta value = how many ui points per step in the X axis in "plot space"
pub fn dpos_dvalue_x(&self) -> f64 {
self.frame.width() as f64 / self.bounds.width()
}
/// delta position / delta value = how many ui points per step in the Y axis in "plot space"
pub fn dpos_dvalue_y(&self) -> f64 {
-self.frame.height() as f64 / self.bounds.height() // negated y axis!
}
/// delta position / delta value = how many ui points per step in "plot space"
pub fn dpos_dvalue(&self) -> [f64; 2] {
[self.dpos_dvalue_x(), self.dpos_dvalue_y()]
}
/// delta value / delta position = how much ground do we cover in "plot space" per ui point?
pub fn dvalue_dpos(&self) -> [f64; 2] {
[1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()]
}
/// scale.x/scale.y ratio.
///
/// If 1.0, it means the scale factor is the same in both axes.
fn aspect(&self) -> f64 {
let rw = self.frame.width() as f64;
let rh = self.frame.height() as f64;
(self.bounds.width() / rw) / (self.bounds.height() / rh)
}
/// Sets the aspect ratio by expanding the x- or y-axis.
///
/// This never contracts, so we don't miss out on any data.
pub(crate) fn set_aspect_by_expanding(&mut self, aspect: f64) {
let current_aspect = self.aspect();
let epsilon = 1e-5;
if (current_aspect - aspect).abs() < epsilon {
// Don't make any changes when the aspect is already almost correct.
return;
}
if current_aspect < aspect {
self.bounds
.expand_x((aspect / current_aspect - 1.0) * self.bounds.width() * 0.5);
} else {
self.bounds
.expand_y((current_aspect / aspect - 1.0) * self.bounds.height() * 0.5);
}
}
/// Sets the aspect ratio by changing either the X or Y axis (callers choice).
pub(crate) fn set_aspect_by_changing_axis(&mut self, aspect: f64, axis: Axis) {
let current_aspect = self.aspect();
let epsilon = 1e-5;
if (current_aspect - aspect).abs() < epsilon {
// Don't make any changes when the aspect is already almost correct.
return;
}
match axis {
Axis::X => {
self.bounds
.expand_x((aspect / current_aspect - 1.0) * self.bounds.width() * 0.5);
}
Axis::Y => {
self.bounds
.expand_y((current_aspect / aspect - 1.0) * self.bounds.height() * 0.5);
}
}
}
}