1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-30 05:10:03 -04:00

Break out plotting to own crate egui_plot (#3282)

This replaces `egui::plot` with the new crate `egui_plot`
This commit is contained in:
Emil Ernerfeldt
2023-08-27 17:22:49 +02:00
committed by GitHub
parent 87f12d782e
commit 7b169ec13d
30 changed files with 134 additions and 57 deletions

View File

@@ -0,0 +1,319 @@
use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};
use egui::emath::{remap_clamp, round_to_decimals, Pos2, Rect};
use egui::epaint::{Shape, Stroke, TextShape};
use crate::{Response, Sense, TextStyle, Ui, WidgetText};
use super::{transform::PlotTransform, GridMark};
pub(super) type AxisFormatterFn = dyn Fn(f64, usize, &RangeInclusive<f64>) -> String;
/// X or Y axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
/// Horizontal X-Axis
X,
/// Vertical Y-axis
Y,
}
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 => Placement::LeftBottom,
HPlacement::Right => Placement::RightTop,
}
}
}
impl From<VPlacement> for Placement {
#[inline]
fn from(placement: VPlacement) -> Self {
match placement {
VPlacement::Top => Placement::RightTop,
VPlacement::Bottom => Placement::LeftBottom,
}
}
}
/// Axis configuration.
///
/// Used to configure axis label and ticks.
#[derive(Clone)]
pub struct AxisHints {
pub(super) label: WidgetText,
pub(super) formatter: Arc<AxisFormatterFn>,
pub(super) digits: usize,
pub(super) placement: Placement,
}
// TODO: this just a guess. It might cease to work if a user changes font size.
const LINE_HEIGHT: f32 = 12.0;
impl Default for AxisHints {
/// Initializes a default axis configuration for the specified axis.
///
/// `label` is empty.
/// `formatter` is default float to string formatter.
/// maximum `digits` on tick label is 5.
fn default() -> Self {
Self {
label: Default::default(),
formatter: Arc::new(Self::default_formatter),
digits: 5,
placement: Placement::LeftBottom,
}
}
}
impl AxisHints {
/// Specify custom formatter for ticks.
///
/// The first parameter of `formatter` is the raw tick value as `f64`.
/// The second parameter is the maximum number of characters that fit into y-labels.
/// The second parameter of `formatter` is the currently shown range on this axis.
pub fn formatter(
mut self,
fmt: impl Fn(f64, usize, &RangeInclusive<f64>) -> String + 'static,
) -> Self {
self.formatter = Arc::new(fmt);
self
}
fn default_formatter(tick: f64, max_digits: usize, _range: &RangeInclusive<f64>) -> String {
if tick.abs() > 10.0_f64.powf(max_digits as f64) {
let tick_rounded = tick as isize;
return format!("{tick_rounded:+e}");
}
let tick_rounded = round_to_decimals(tick, max_digits);
if tick.abs() < 10.0_f64.powf(-(max_digits as f64)) && tick != 0.0 {
return format!("{tick_rounded:+e}");
}
tick_rounded.to_string()
}
/// Specify axis label.
///
/// The default is 'x' for x-axes and 'y' for y-axes.
pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
self.label = label.into();
self
}
/// Specify maximum number of digits for ticks.
///
/// This is considered by the default tick formatter and affects the width of the y-axis
pub fn max_digits(mut self, digits: usize) -> Self {
self.digits = digits;
self
}
/// Specify the placement of the axis.
///
/// For X-axis, use [`VPlacement`].
/// For Y-axis, use [`HPlacement`].
pub fn placement(mut self, placement: impl Into<Placement>) -> Self {
self.placement = placement.into();
self
}
pub(super) fn thickness(&self, axis: Axis) -> f32 {
match axis {
Axis::X => {
if self.label.is_empty() {
1.0 * LINE_HEIGHT
} else {
3.0 * LINE_HEIGHT
}
}
Axis::Y => {
if self.label.is_empty() {
(self.digits as f32) * LINE_HEIGHT
} else {
(self.digits as f32 + 1.0) * LINE_HEIGHT
}
}
}
}
}
#[derive(Clone)]
pub(super) struct AxisWidget {
pub(super) range: RangeInclusive<f64>,
pub(super) hints: AxisHints,
pub(super) rect: Rect,
pub(super) transform: Option<PlotTransform>,
pub(super) steps: Arc<Vec<GridMark>>,
}
impl AxisWidget {
/// if `rect` as width or height == 0, is will be automatically calculated from ticks and text.
pub(super) fn new(hints: AxisHints, rect: Rect) -> Self {
Self {
range: (0.0..=0.0),
hints,
rect,
transform: None,
steps: Default::default(),
}
}
pub fn ui(self, ui: &mut Ui, axis: Axis) -> Response {
let response = ui.allocate_rect(self.rect, Sense::hover());
if ui.is_rect_visible(response.rect) {
let visuals = ui.style().visuals.clone();
let text = self.hints.label;
let galley = text.into_galley(ui, Some(false), 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,
}
}
},
};
let shape = TextShape {
pos: text_pos,
galley: galley.galley,
underline: Stroke::NONE,
override_text_color: Some(text_color),
angle,
};
ui.painter().add(shape);
// --- add ticks ---
let font_id = TextStyle::Body.resolve(ui.style());
let transform = match self.transform {
Some(t) => t,
None => return response,
};
for step in self.steps.iter() {
let text = (self.hints.formatter)(step.value, self.hints.digits, &self.range);
if !text.is_empty() {
const MIN_TEXT_SPACING: f32 = 20.0;
const FULL_CONTRAST_SPACING: f32 = 40.0;
let spacing_in_points =
(transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32;
if spacing_in_points <= MIN_TEXT_SPACING {
continue;
}
let line_strength = remap_clamp(
spacing_in_points,
MIN_TEXT_SPACING..=FULL_CONTRAST_SPACING,
0.0..=1.0,
);
let line_color = super::color_from_strength(ui, line_strength);
let galley = ui
.painter()
.layout_no_wrap(text, font_id.clone(), line_color);
let text_pos = match axis {
Axis::X => {
let y = match self.hints.placement {
Placement::LeftBottom => self.rect.min.y,
Placement::RightTop => self.rect.max.y - galley.size().y,
};
let projected_point = super::PlotPoint::new(step.value, 0.0);
Pos2 {
x: transform.position_from_point(&projected_point).x
- galley.size().x / 2.0,
y,
}
}
Axis::Y => {
let x = match self.hints.placement {
Placement::LeftBottom => self.rect.max.x - galley.size().x,
Placement::RightTop => self.rect.min.x,
};
let projected_point = super::PlotPoint::new(0.0, step.value);
Pos2 {
x,
y: transform.position_from_point(&projected_point).y
- galley.size().y / 2.0,
}
}
};
ui.painter().add(Shape::galley(text_pos, galley));
}
}
}
response
}
}

View File

@@ -0,0 +1,190 @@
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) -> 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: &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

@@ -0,0 +1,289 @@
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)]
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: &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

@@ -0,0 +1,65 @@
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 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,433 @@
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)
}
}
// ----------------------------------------------------------------------------
#[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{spacing}Px"),
LineStyle::Dashed { length } => format!("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: 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 {
PlotPoints::Owned(points) => points.as_slice(),
PlotPoints::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;
let (x, y) = function(t);
[x, y]
})
.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 {
PlotPoints::Owned(points) => points.is_empty(),
PlotPoints::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 {
PlotPoints::Owned(points) => {
let mut bounds = PlotBounds::NOTHING;
for point in points {
bounds.extend_with(point);
}
bounds
}
PlotPoints::Generator(generator) => generator.estimate_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 ExactSizeIterator<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 points 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 [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(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

@@ -0,0 +1,258 @@
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 = Corner> {
[
Corner::LeftTop,
Corner::RightTop,
Corner::LeftBottom,
Corner::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,
}
impl Default for Legend {
fn default() -> Self {
Self {
text_style: TextStyle::Body,
background_alpha: 0.75,
position: Corner::RightTop,
}
}
}
impl Legend {
/// Which text style to use for the legend. Default: `TextStyle::Body`.
pub fn text_style(mut self, style: TextStyle) -> Self {
self.text_style = style;
self
}
/// The alpha of the legend background. Default: `0.75`.
pub fn background_alpha(mut self, alpha: f32) -> Self {
self.background_alpha = alpha;
self
}
/// In which corner to place the legend. Default: `Corner::RightTop`.
pub fn position(mut self, corner: Corner) -> Self {
self.position = corner;
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(&mut 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, *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_with_color(text_position, galley, visuals.text_color());
*checked ^= response.clicked_by(PointerButton::Primary);
*hovered = response.hovered();
response
}
}
#[derive(Clone)]
pub(super) struct LegendWidget {
rect: Rect,
entries: BTreeMap<String, LegendEntry>,
config: Legend,
}
impl LegendWidget {
/// Create a new legend from items, the names of items that are hidden and the style of the
/// text. Returns `None` if the legend has no entries.
pub(super) fn try_new(
rect: Rect,
config: Legend,
items: &[Box<dyn PlotItem>],
hidden_items: &ahash::HashSet<String>,
) -> Option<Self> {
// Collect the legend entries. If multiple items have the same name, they share a
// checkbox. If their colors don't match, we pick a neutral color for the checkbox.
let mut entries: BTreeMap<String, LegendEntry> = BTreeMap::new();
items
.iter()
.filter(|item| !item.name().is_empty())
.for_each(|item| {
entries
.entry(item.name().to_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_entry_name(&self) -> Option<String> {
self.entries
.iter()
.find(|(_, entry)| entry.hovered)
.map(|(name, _)| name.to_string())
}
}
impl Widget for &mut LegendWidget {
fn ui(self, ui: &mut Ui) -> Response {
let LegendWidget {
rect,
entries,
config,
} = self;
let main_dir = match config.position {
Corner::LeftTop | Corner::RightTop => Direction::TopDown,
Corner::LeftBottom | Corner::RightBottom => Direction::BottomUp,
};
let cross_align = match config.position {
Corner::LeftTop | Corner::LeftBottom => Align::LEFT,
Corner::RightTop | Corner::RightBottom => Align::RIGHT,
};
let layout = Layout::from_main_dir_and_cross_align(main_dir, cross_align);
let legend_pad = 4.0;
let legend_rect = rect.shrink(legend_pad);
let mut legend_ui = ui.child_ui(legend_rect, layout);
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| {
entries
.iter_mut()
.map(|(name, entry)| entry.ui(ui, name.clone(), &config.text_style))
.reduce(|r1, r2| r1.union(r2))
.unwrap()
})
.inner
})
.inner
}
}

1913
crates/egui_plot/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
use epaint::Pos2;
use crate::{Context, Id};
use super::{transform::ScreenTransform, AxisBools};
/// Information about the plot that has to persist between frames.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone)]
pub(super) struct PlotMemory {
/// Indicates if the user has modified the bounds, for example by moving or zooming,
/// or if the bounds should be calculated based by included point or auto bounds.
pub(super) bounds_modified: AxisBools,
pub(super) hovered_entry: Option<String>,
pub(super) hidden_items: ahash::HashSet<String>,
pub(super) last_screen_transform: ScreenTransform,
/// Allows to remember the first click position when performing a boxed zoom
pub(super) last_click_pos_for_zoom: Option<Pos2>,
}
impl PlotMemory {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.data().get_persisted(id)
}
pub fn store(self, ctx: &Context, id: Id) {
ctx.data().insert_persisted(id, self);
}
}

View File

@@ -0,0 +1,387 @@
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],
};
pub fn from_min_max(min: [f64; 2], max: [f64; 2]) -> Self {
Self { min, max }
}
pub fn min(&self) -> [f64; 2] {
self.min
}
pub fn max(&self) -> [f64; 2] {
self.max
}
pub(crate) fn new_symmetrical(half_extent: f64) -> Self {
Self {
min: [-half_extent; 2],
max: [half_extent; 2],
}
}
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()
}
pub fn is_finite_x(&self) -> bool {
self.min[0].is_finite() && self.max[0].is_finite()
}
pub fn is_finite_y(&self) -> bool {
self.min[1].is_finite() && self.max[1].is_finite()
}
pub fn is_valid(&self) -> bool {
self.is_finite() && self.width() > 0.0 && self.height() > 0.0
}
pub fn is_valid_x(&self) -> bool {
self.is_finite_x() && self.width() > 0.0
}
pub fn is_valid_y(&self) -> bool {
self.is_finite_y() && self.height() > 0.0
}
pub fn width(&self) -> f64 {
self.max[0] - self.min[0]
}
pub fn height(&self) -> f64 {
self.max[1] - self.min[1]
}
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
pub(crate) 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
pub(crate) 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
pub(crate) fn extend_with_y(&mut self, y: f64) {
self.min[1] = self.min[1].min(y);
self.max[1] = self.max[1].max(y);
}
pub(crate) fn expand_x(&mut self, pad: f64) {
self.min[0] -= pad;
self.max[0] += pad;
}
pub(crate) fn expand_y(&mut self, pad: f64) {
self.min[1] -= pad;
self.max[1] += pad;
}
pub(crate) fn merge_x(&mut self, other: &PlotBounds) {
self.min[0] = self.min[0].min(other.min[0]);
self.max[0] = self.max[0].max(other.max[0]);
}
pub(crate) fn merge_y(&mut self, other: &PlotBounds) {
self.min[1] = self.min[1].min(other.min[1]);
self.max[1] = self.max[1].max(other.max[1]);
}
pub(crate) fn set_x(&mut self, other: &PlotBounds) {
self.min[0] = other.min[0];
self.max[0] = other.max[0];
}
pub(crate) fn set_y(&mut self, other: &PlotBounds) {
self.min[1] = other.min[1];
self.max[1] = other.max[1];
}
pub(crate) fn merge(&mut self, other: &PlotBounds) {
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]);
}
pub(crate) fn translate_x(&mut self, delta: f64) {
self.min[0] += delta;
self.max[0] += delta;
}
pub(crate) fn translate_y(&mut self, delta: f64) {
self.min[1] += delta;
self.max[1] += delta;
}
pub(crate) fn translate(&mut self, delta: Vec2) {
self.translate_x(delta.x as f64);
self.translate_y(delta.y as f64);
}
pub(crate) 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);
}
pub(crate) 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);
}
pub(crate) fn range_x(&self) -> RangeInclusive<f64> {
self.min[0]..=self.max[0]
}
pub(crate) fn range_y(&self) -> RangeInclusive<f64> {
self.min[1]..=self.max[1]
}
pub(crate) fn make_x_symmetrical(&mut self) {
let x_abs = self.min[0].abs().max(self.max[0].abs());
self.min[0] = -x_abs;
self.max[0] = x_abs;
}
pub(crate) 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, mut bounds: PlotBounds, x_centered: bool, y_centered: bool) -> Self {
// Make sure they are not empty.
if !bounds.is_valid_x() {
bounds.set_x(&PlotBounds::new_symmetrical(1.0));
}
if !bounds.is_valid_y() {
bounds.set_y(&PlotBounds::new_symmetrical(1.0));
}
// Scale axes so that the origin is in the center.
if x_centered {
bounds.make_x_symmetrical();
};
if y_centered {
bounds.make_y_symmetrical();
};
Self {
frame,
bounds,
x_centered,
y_centered,
}
}
/// ui-space rectangle.
pub fn frame(&self) -> &Rect {
&self.frame
}
/// Plot-space bounds.
pub fn bounds(&self) -> &PlotBounds {
&self.bounds
}
pub(crate) fn set_bounds(&mut self, bounds: PlotBounds) {
self.bounds = bounds;
}
pub(crate) fn translate_bounds(&mut self, mut delta_pos: Vec2) {
if self.x_centered {
delta_pos.x = 0.;
}
if self.y_centered {
delta_pos.y = 0.;
}
delta_pos.x *= self.dvalue_dpos()[0] as f32;
delta_pos.y *= self.dvalue_dpos()[1] as f32;
self.bounds.translate(delta_pos);
}
/// Zoom by a relative factor with the given screen position as center.
pub(crate) fn zoom(&mut self, zoom_factor: Vec2, center: Pos2) {
let center = self.value_from_position(center);
let mut new_bounds = self.bounds;
new_bounds.min[0] = center.x + (new_bounds.min[0] - center.x) / (zoom_factor.x as f64);
new_bounds.max[0] = center.x + (new_bounds.max[0] - center.x) / (zoom_factor.x as f64);
new_bounds.min[1] = center.y + (new_bounds.min[1] - center.y) / (zoom_factor.y as f64);
new_bounds.max[1] = center.y + (new_bounds.max[1] - center.y) / (zoom_factor.y as f64);
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.min[0]..=self.bounds.max[0],
);
let y = remap(
pos.y as f64,
(self.frame.bottom() as f64)..=(self.frame.top() as f64), // negated y axis!
self.bounds.min[1]..=self.bounds.max[1],
);
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
pub fn dpos_dvalue_x(&self) -> f64 {
self.frame.width() as f64 / self.bounds.width()
}
/// delta position / delta value
pub fn dpos_dvalue_y(&self) -> f64 {
-self.frame.height() as f64 / self.bounds.height() // negated y axis!
}
/// delta position / delta value
pub fn dpos_dvalue(&self) -> [f64; 2] {
[self.dpos_dvalue_x(), self.dpos_dvalue_y()]
}
/// delta value / delta position
pub fn dvalue_dpos(&self) -> [f64; 2] {
[1.0 / self.dpos_dvalue_x(), 1.0 / self.dpos_dvalue_y()]
}
/// width / height aspect ratio
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, change_x: bool) {
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 change_x {
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);
}
}
}